diff --git a/packages/plugins/@hera/plugin-core/.npmignore b/packages/plugins/@hera/plugin-core/.npmignore new file mode 100644 index 000000000..65f5e8779 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/.npmignore @@ -0,0 +1,2 @@ +/node_modules +/src diff --git a/packages/plugins/@hera/plugin-core/README.md b/packages/plugins/@hera/plugin-core/README.md new file mode 100644 index 000000000..08f6aa565 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/README.md @@ -0,0 +1 @@ +# @hera/plugin-core diff --git a/packages/plugins/@hera/plugin-core/client.d.ts b/packages/plugins/@hera/plugin-core/client.d.ts new file mode 100644 index 000000000..6c459cbac --- /dev/null +++ b/packages/plugins/@hera/plugin-core/client.d.ts @@ -0,0 +1,2 @@ +export * from './dist/client'; +export { default } from './dist/client'; diff --git a/packages/plugins/@hera/plugin-core/client.js b/packages/plugins/@hera/plugin-core/client.js new file mode 100644 index 000000000..b6e3be70e --- /dev/null +++ b/packages/plugins/@hera/plugin-core/client.js @@ -0,0 +1 @@ +module.exports = require('./dist/client/index.js'); diff --git a/packages/plugins/@hera/plugin-core/package.json b/packages/plugins/@hera/plugin-core/package.json new file mode 100644 index 000000000..19ccaf382 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/package.json @@ -0,0 +1,38 @@ +{ + "name": "@hera/plugin-core", + "version": "1.3.0-alpha.8", + "displayName": "Hera platform", + "displayName.zh-CN": "赫拉平台", + "description": "Hera platform as nocobase plugin.", + "description.zh-CN": "提供标准赫拉平台能力", + "main": "dist/server/index.js", + "devDependencies": { + "@formily/antd-v5": "1.x", + "@formily/core": "^2.2.27", + "@formily/react": "^2.2.27", + "@formily/shared": "^2.2.27", + "@react-pdf/renderer": "^3.3.2", + "antd": "5.12.8", + "copy-to-clipboard": "^3.3.3", + "exceljs": "^4.4.0", + "fs-extra": "^11.1.1", + "mathjs2": "npm:mathjs@^10.6.0", + "qrcode": "^1.5.1", + "dayjs": "^1.11.8", + "qrcode.react": "^3.1.0", + "react": "^18.2.0", + "react-pdf": "^7.5.1", + "vitest": "0.x", + "signature_pad": "4.1.7", + "react-router-dom": "^6.11.2", + "redis": "^4.6.11" + }, + "peerDependencies": { + "@nocobase/client": "0.x", + "@nocobase/server": "0.x", + "@nocobase/test": "0.x" + }, + "keywords": [ + "System management" + ] +} diff --git a/packages/plugins/@hera/plugin-core/server.d.ts b/packages/plugins/@hera/plugin-core/server.d.ts new file mode 100644 index 000000000..c41081ddc --- /dev/null +++ b/packages/plugins/@hera/plugin-core/server.d.ts @@ -0,0 +1,2 @@ +export * from './dist/server'; +export { default } from './dist/server'; diff --git a/packages/plugins/@hera/plugin-core/server.js b/packages/plugins/@hera/plugin-core/server.js new file mode 100644 index 000000000..972842039 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/server.js @@ -0,0 +1 @@ +module.exports = require('./dist/server/index.js'); diff --git a/packages/plugins/@hera/plugin-core/src/__tests__/utils.test.ts b/packages/plugins/@hera/plugin-core/src/__tests__/utils.test.ts new file mode 100644 index 000000000..7fb9c08b4 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/__tests__/utils.test.ts @@ -0,0 +1,15 @@ +import format, { formatCurrency } from '../utils/currencyUtils'; + +describe('utils', () => { + beforeEach(async () => {}); + + afterEach(() => {}); + + describe('currencyUtils', () => { + it('formatCurrency', async () => { + expect(formatCurrency(100, 2)).toBe('¥100.00'); + expect(formatCurrency(null, 2)).toBe('¥0.00'); + expect(formatCurrency(undefined, 2)).toBe('¥0.00'); + }); + }); +}); diff --git a/packages/plugins/@hera/plugin-core/src/client/components/Action.Designer.tsx b/packages/plugins/@hera/plugin-core/src/client/components/Action.Designer.tsx new file mode 100644 index 000000000..93bf09366 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/Action.Designer.tsx @@ -0,0 +1,86 @@ +import { ISchema, useFieldSchema } from '@formily/react'; +import { SchemaSettingsModalItem, useDesignable } from '@nocobase/client'; +import { useTranslation } from '../locale'; +import React from 'react'; + +export function AfterSuccess() { + const { dn } = useDesignable(); + const { t } = useTranslation(); + const fieldSchema = useFieldSchema(); + const component = fieldSchema.parent.parent['x-component']; + const schema = { + type: 'object', + title: t('After successful submission'), + properties: { + successMessage: { + title: t('Popup message'), + 'x-decorator': 'FormItem', + 'x-component': 'Input.TextArea', + 'x-component-props': {}, + }, + dataClear: { + title: t('Clear data method'), + enum: [ + { label: t('Automatic clear'), value: false }, + { label: t('Manually clear'), value: true }, + ], + 'x-decorator': 'FormItem', + 'x-component': 'Radio.Group', + 'x-component-props': {}, + }, + manualClose: { + title: t('Popup close method'), + enum: [ + { label: t('Automatic close'), value: false }, + { label: t('Manually close'), value: true }, + ], + 'x-decorator': 'FormItem', + 'x-component': 'Radio.Group', + 'x-component-props': {}, + }, + redirecting: { + title: t('Then'), + enum: [ + { label: t('Stay on current page'), value: false }, + { label: t('Redirect to'), value: true }, + ], + 'x-decorator': 'FormItem', + 'x-component': 'Radio.Group', + 'x-component-props': {}, + 'x-reactions': { + target: 'redirectTo', + fulfill: { + state: { + visible: '{{!!$self.value}}', + }, + }, + }, + }, + redirectTo: { + title: t('Link'), + 'x-decorator': 'FormItem', + 'x-component': 'Input', + 'x-component-props': {}, + }, + }, + }; + if (!(component as string).includes('Form')) { + delete schema.properties.dataClear; + } + return ( + { + fieldSchema['x-action-settings']['onSuccess'] = onSuccess; + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-action-settings': fieldSchema['x-action-settings'], + }, + }); + }} + /> + ); +} diff --git a/packages/plugins/@hera/plugin-core/src/client/components/AssociatedField.tsx b/packages/plugins/@hera/plugin-core/src/client/components/AssociatedField.tsx new file mode 100644 index 000000000..456262c86 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/AssociatedField.tsx @@ -0,0 +1,149 @@ +import _ from 'lodash'; +import { ISchema, RecursionField, connect, observer, useField, useFieldSchema, useForm } from '@formily/react'; +import { + ActionContextProvider, + CollectionProvider_deprecated, + FormProvider, + RecordProvider, + useDesignable, + useRequest, +} from '@nocobase/client'; +import React, { useCallback, useEffect, useState } from 'react'; + +const viewerSchema: ISchema = { + type: 'void', + title: '{{ t("View record") }}', + 'x-component': 'AssociationField.Viewer', + 'x-component-props': { + className: 'nb-action-popup', + }, + properties: { + tabs: { + type: 'void', + 'x-component': 'Tabs', + 'x-component-props': {}, + 'x-initializer': 'TabPaneInitializers', + properties: { + tab1: { + type: 'void', + title: '{{t("Details")}}', + 'x-component': 'Tabs.TabPane', + 'x-designer': 'Tabs.Designer', + 'x-component-props': {}, + properties: { + grid: { + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'RecordBlockInitializers', + properties: {}, + }, + }, + }, + }, + }, + }, +}; + +export const useInsertSchema = (component) => { + const fieldSchema = useFieldSchema(); + const { insertAfterBegin } = useDesignable(); + const insert = useCallback( + (ss) => { + const schema = fieldSchema.reduceProperties((buf, s) => { + if (s['x-component'] === 'AssociationField.' + component) { + return s; + } + return buf; + }, null); + if (!schema) { + insertAfterBegin(_.cloneDeep(ss)); + } + }, + [component], + ); + return insert; +}; + +const AssociatedFieldImplement = observer((props) => { + const { collection, fieldExp, dateFieldExp, sourceCollection, sourceField } = props; + const [visible, setVisible] = useState(false); + const insertViewer = useInsertSchema('Viewer'); + const field = useField(); + const form = useForm(); + const fieldSchema = useFieldSchema(); + const { designable } = useDesignable(); + const fieldName = fieldExp?.replace(/{{(.*?)}}/, '$1'); + const dateFieldName = dateFieldExp?.replace(/{{(.*?)}}/, '$1'); + + const { data, loading, run } = useRequest<{ data: any }>( + { + url: `/${sourceCollection}:get`, + params: { + appends: [sourceField], + filter: { + contract_id: form.values[fieldName]?.id, + start_date: { $lte: form.values[dateFieldName] }, + end_date: { $gte: form.values[dateFieldName] }, + }, + }, + }, + { + manual: true, + }, + ); + useEffect(() => { + if (form.values[fieldName]?.id && form.values[dateFieldName]) { + run(); + } + }, [form.values[fieldName]?.id, form.values[dateFieldName]]); + + if (loading || !data) { + return null; + } + if (!data.data?.[sourceField + '_id']) { + form.setValues({ + // FIXME should add base path + [fieldSchema.name]: null, + }); + return null; + } + const record = data.data[sourceField]; + form.setValues({ + // FIXME should add base path + [fieldSchema.name]: record, + }); + return ( + + { + e.stopPropagation(); + e.preventDefault(); + if (designable) { + insertViewer(viewerSchema); + } + setVisible(true); + }} + > + {record.name} + + + + + + { + return s['x-component'] === 'AssociationField.Viewer'; + }} + /> + + + + + + ); +}); + +export const AssociatedField = connect(AssociatedFieldImplement); diff --git a/packages/plugins/@hera/plugin-core/src/client/components/CalcResult.tsx b/packages/plugins/@hera/plugin-core/src/client/components/CalcResult.tsx new file mode 100644 index 000000000..f7c678b45 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/CalcResult.tsx @@ -0,0 +1,191 @@ +import { onFormValuesChange } from '@formily/core'; +import { useField, useFieldSchema, useForm, useFormEffects } from '@formily/react'; +import { Input } from '@nocobase/client'; +import { Descriptions, DescriptionsProps } from 'antd'; +import _ from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { evaluate } from 'mathjs2'; + +const transformFormula = (formula: string) => { + if (!formula) return []; + const formulaArray = formula.split(/([+\-*/?:()%])/).filter((item) => item); + return formulaArray; +}; + +export const CalcResult = (props) => { + // 公式,单位,前缀,后缀,小数点位数,面板逻辑代码 + const { formula, prefix, suffix, decimal, panel } = props; + const form = useForm(); + const fieldSchema = useFieldSchema(); + const field = useField(); + const path: any = field.path.entire; + const fieldPath = path?.replace(`.${fieldSchema.name}`, ''); + const defaultValue = fieldSchema.name === 'subtotal' ? '¥0.00' : []; + const [value, setValue] = useState(defaultValue); + + const transformFormulaArray = transformFormula(formula); + + let calculateData = []; + + const newFormulaArray = (data): [string, object] => { + calculateData = []; + let count = 0; + const scopes = {}; + if (transformFormulaArray.length === 0) return; + for (let i = 0; i < transformFormulaArray.length; i++) { + const item = transformFormulaArray[i]; + if (!item) continue; + const isNumber = !isNaN(Number(item)); + const symbol = ['+', '-', '*', '/', '?', ':', '(', ')', '%'].includes(item); + if (!isNumber && !symbol) { + let value; + // 举例: ${fieldObj}.fieldName + const pattern = /\${(.*?)}/g; + if (item.match(pattern)?.length) { + const target = item.match(pattern)[0].replace(/\${|}/g, ''); + // 以.分割字符串 + const targetField = item.split('.')[1]; + + if (path === fieldPath) { + // @ts-ignore + value = _.chain(data).get(target).get(targetField, 0).value(); + } else { + // @ts-ignore + value = _.chain(data).get(fieldPath).get(target).get(targetField, 0).value(); + } + } else { + if (path === fieldPath) { + // @ts-ignore + value = _.chain(data).get(item, 0).value(); + } else { + // @ts-ignore + value = _.chain(data).get(fieldPath).get(item, 0).value(); + } + } + if (!value) { + value = 0; + } + count += 1; + const varName = 'var' + count; + const varValue = value; + scopes[varName] = varValue; + calculateData.push(varName); + } else { + calculateData.push(item); + } + } + return [calculateData.join(''), scopes]; + }; + const fun = () => { + if (!panel && transformFormulaArray.length) { + const [code, scopes] = newFormulaArray(form.values); + let result; + try { + const pre = prefix || ''; + const suf = suffix || ''; + const res = evaluate(code, scopes); + const main = isNaN(res) ? res : Number(res).toFixed(decimal || 0); + result = pre + main + suf; + } catch (error) { + result = `${code}`; + console.warn('code: ' + code + ' scopes: ' + scopes + 'error: ' + result + ' error message ' + error.message); + } + setValue(result.toString()); + } else if (panel) { + let items = []; + // ====================字段配置举例================== + const exampleTemplate = ` + let total = 0; + let allWeight = 0; + const products = {}; + for (let i = 0; i < form.values.items.length; i++) { + const item = form.values.items[i]; + // 计算 合计金额 + const count = item?.count || 0; + const unitPrice = item?.unit_price || 0; + // 换算数量 + let scale = 1; + if (item && item.product && item.product?.ratio) { + scale = item.product?.ratio; + } + total += count * scale * unitPrice; + // 计算 理论重量 + let weight = 1; + if (item && item.product && item.product?.weight) { + weight = item.product.weight || 1; + } + + allWeight += weight * count; + // 计算 产品分类 + if (item && item.product) { + if (products[item.product.name]) { + products[item.product.name].count += count * scale; + } else { + products[item.product.name] = { + count: count * scale, + unit: item.product?.category?.conversion_unit || item.product?.category?.unit || '', + }; + } + } + } + // 生成产品分类的数据 + const weight = { + key: '1', + label: '理论重量', + children: (allWeight / 1000).toFixed(3) + '吨', + }; + // const totalPrice = { + // key: '2', + // label: '合计', + // children: '¥' + total.toFixed(2), + // } + // ,, totalPrice + items.push(weight); + + if (Object.keys(products).length > 0) { + for (const key in products) { + if (Object.prototype.hasOwnProperty.call(products, key)) { + const value = products[key]; + items.push({ + key: key, + label: key, + children: value.count.toFixed(3) + value.unit, + }); + } + } + } + `; + try { + eval(panel); + } catch (error) { + items = []; + items.push({ + key: '1', + label: '数据异常', + children: '请检查字段配置内容,error:' + error, + }); + } + const showItems = items.map((item) => { + return { + label: item.label, + children:

{item.children}

, + }; + }); + setValue(showItems); + } + }; + useEffect(() => { + fun(); + }, []); + + useFormEffects(() => { + onFormValuesChange((form) => { + fun(); + }); + }); + if (typeof value === 'string') { + return ; + } else { + return ; + } +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/components/CustomAssociatedField.tsx b/packages/plugins/@hera/plugin-core/src/client/components/CustomAssociatedField.tsx new file mode 100644 index 000000000..bef631c6f --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/CustomAssociatedField.tsx @@ -0,0 +1,16 @@ +import { SchemaComponent } from '@nocobase/client'; +import React from 'react'; + +export const CustomAssociatedField = (props) => { + if (!props.component) return; + return ( + + ); +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/components/CustomComponentDispatcher.tsx b/packages/plugins/@hera/plugin-core/src/client/components/CustomComponentDispatcher.tsx new file mode 100644 index 000000000..690186b2e --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/CustomComponentDispatcher.tsx @@ -0,0 +1,75 @@ +import { SchemaComponent, SchemaSettings, useApp, useDesignable, usePlugin } from '@nocobase/client'; +import React from 'react'; +import { useTranslation } from '../locale'; +import { useField, useFieldSchema } from '@formily/react'; +import { Field } from '@nocobase/database'; +import { CUSTOM_COMPONENT_TYPE_FORM_ITEM } from '..'; +import { useCustomComponent } from '../hooks/useCustomComponent'; + +export const CustomComponentStub = (props) => { + return
请选择组件
; +}; + +export const CustomComponentDispatcher = (props) => { + if (!props.component) return; + return ( + + ); +}; + +export const customComponentDispatcherSettings = new SchemaSettings({ + name: 'customComponentDispatcherSettings', + items: [ + { + name: 'component', + type: 'select', + useComponentProps() { + const formItemComponents = useCustomComponent(CUSTOM_COMPONENT_TYPE_FORM_ITEM); + const { t } = useTranslation(); + const field = useField(); + const fieldSchema = useFieldSchema(); + const { dn } = useDesignable(); + return { + title: t('component'), + value: field.componentProps?.component || 'CustomComponentStub', + options: [ + { + label: '未选择组件', + value: 'CustomComponentStub', + }, + ...formItemComponents, + ], + onChange(component) { + const schema = { + ['x-uid']: fieldSchema['x-uid'], + }; + fieldSchema['x-component-props']['component'] = component; + schema['x-component-props'] = fieldSchema['x-component-props']; + field.componentProps.component = component; + dn.emit('patch', { + schema, + }); + }, + }; + }, + }, + { + name: 'divider', + type: 'divider', + }, + { + name: 'remove', + type: 'remove', + componentProps: { + removeParentsIfNoChildren: true, + }, + }, + ], +}); diff --git a/packages/plugins/@hera/plugin-core/src/client/components/CustomField.tsx b/packages/plugins/@hera/plugin-core/src/client/components/CustomField.tsx new file mode 100644 index 000000000..474a6b17c --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/CustomField.tsx @@ -0,0 +1,16 @@ +import { SchemaComponent } from '@nocobase/client'; +import React from 'react'; + +export const CustomField = (props) => { + if (!props.component) return; + return ( + + ); +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/components/Expression.tsx b/packages/plugins/@hera/plugin-core/src/client/components/Expression.tsx new file mode 100644 index 000000000..2991977b0 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/Expression.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { useAssociatedFields, useCollectionManager_deprecated, useCompile, Variable } from '@nocobase/client'; + +export const Expression = (props) => { + const { value = '', useCurrentFields, onChange } = props; + const compile = useCompile(); + const { interfaces, getCollectionFields } = useCollectionManager_deprecated(); + + const fields = useCurrentFields?.() ?? []; + const associatedFields = useAssociatedFields(); + console.log(associatedFields, 'associatedFields', fields); + + const options = fields.map((field) => ({ + label: compile(field.uiSchema.title), + value: field.name, + children: + getCollectionFields(field.target) + ?.filter((subField) => subField.uiSchema) + .map((subField) => ({ + label: subField.uiSchema ? compile(subField.uiSchema.title) : '', + value: subField.name, + })) ?? [], + })); + + return ; +}; + +export default Expression; diff --git a/packages/plugins/@hera/plugin-core/src/client/components/ExtendedActionDesigner.tsx b/packages/plugins/@hera/plugin-core/src/client/components/ExtendedActionDesigner.tsx new file mode 100644 index 000000000..2831ac6c6 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/ExtendedActionDesigner.tsx @@ -0,0 +1,51 @@ +import { useFieldSchema } from '@formily/react'; +import { SchemaSettingsSwitchItem, useDesignable } from '@nocobase/client'; +import React from 'react'; +import { useTranslation } from '../locale'; + +// 添加跳转页面选项 +export const SessionSubmit = () => { + const { dn } = useDesignable(); + const { t } = useTranslation(); + const fieldSchema = useFieldSchema(); + return ( + { + fieldSchema['x-action-settings'].sessionSubmit = value; + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-action-settings': { + ...fieldSchema['x-action-settings'], + }, + }, + }); + }} + /> + ); +}; + +export function SessionUpdate() { + const { dn } = useDesignable(); + const { t } = useTranslation(); + const fieldSchema = useFieldSchema(); + return ( + { + fieldSchema['x-action-settings'].sessionUpdate = value; + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-action-settings': { + ...fieldSchema['x-action-settings'], + }, + }, + }); + }} + /> + ); +} diff --git a/packages/plugins/@hera/plugin-core/src/client/components/ExtendedMenuDesigner.tsx b/packages/plugins/@hera/plugin-core/src/client/components/ExtendedMenuDesigner.tsx new file mode 100644 index 000000000..356aef800 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/ExtendedMenuDesigner.tsx @@ -0,0 +1,416 @@ +import { TreeSelect } from '@formily/antd-v5'; +import { Field, onFieldChange } from '@formily/core'; +import { ISchema, Schema, useField, useFieldSchema } from '@formily/react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { message } from 'antd'; +import { saveAs } from 'file-saver'; +import { + GeneralSchemaDesigner, + SchemaSettingsDivider, + SchemaSettingsItem, + SchemaSettingsModalItem, + SchemaSettingsRemove, + SchemaSettingsSubMenu, + createDesignable, + findByUid, + useAPIClient, + useDesignable, +} from '@nocobase/client'; + +const toItems = (properties = {}) => { + const items = []; + for (const key in properties) { + if (Object.prototype.hasOwnProperty.call(properties, key)) { + const element = properties[key]; + const item = { + label: element.title, + value: `${element['x-uid']}||${element['x-component']}`, + }; + if (element.properties) { + const children = toItems(element.properties); + if (children?.length) { + item['children'] = children; + } + } + items.push(item); + } + } + return items; +}; + +const findMenuSchema = (fieldSchema: Schema) => { + let parent = fieldSchema.parent; + while (parent) { + if (parent['x-component'] === 'Menu') { + return parent; + } + parent = parent.parent; + } +}; + +const InsertMenuItems = (props) => { + const { eventKey, title, insertPosition } = props; + const { t } = useTranslation(); + const { dn } = useDesignable(); + const fieldSchema = useFieldSchema(); + const isSubMenu = fieldSchema['x-component'] === 'Menu.SubMenu'; + const api = useAPIClient(); + if (!isSubMenu && insertPosition === 'beforeEnd') { + return null; + } + const serverHooks = [ + { + type: 'onSelfCreate', + method: 'bindMenuToRole', + }, + { + type: 'onSelfSave', + method: 'extractTextToLocale', + }, + ]; + return ( + + { + dn.insertAdjacent(insertPosition, { + type: 'void', + title, + 'x-component': 'Menu.SubMenu', + 'x-decorator': 'ACLMenuItemProvider', + 'x-component-props': { + icon, + }, + 'x-server-hooks': serverHooks, + }); + }} + /> + { + dn.insertAdjacent(insertPosition, { + type: 'void', + title, + 'x-component': 'Menu.Item', + 'x-decorator': 'ACLMenuItemProvider', + 'x-component-props': { + icon, + }, + 'x-server-hooks': serverHooks, + properties: { + page: { + type: 'void', + 'x-component': 'Page', + 'x-async': true, + properties: { + grid: { + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'BlockInitializers', + properties: {}, + }, + }, + }, + }, + }); + }} + /> + { + dn.insertAdjacent(insertPosition, { + type: 'void', + title, + 'x-component': 'Menu.URL', + 'x-decorator': 'ACLMenuItemProvider', + 'x-component-props': { + icon, + href, + }, + 'x-server-hooks': serverHooks, + }); + }} + /> + + { + const { data } = await api.request({ + url: file.url, + baseURL: '/', + }); + const s = data ?? {}; + s.title = title; + dn.insertAdjacent(insertPosition, s); + }} + /> + + ); +}; + +export const MenuDesigner = () => { + const field = useField(); + const fieldSchema = useFieldSchema(); + const api = useAPIClient(); + const { dn, refresh } = useDesignable(); + const { t } = useTranslation(); + const menuSchema = findMenuSchema(fieldSchema); + const items = toItems(menuSchema?.properties); + const effects = (form) => { + onFieldChange('target', (field: Field) => { + const [, component] = field?.value?.split?.('||') || []; + field.query('position').take((f: Field) => { + f.dataSource = + component === 'Menu.SubMenu' + ? [ + { label: t('Before'), value: 'beforeBegin' }, + { label: t('After'), value: 'afterEnd' }, + { label: t('Inner'), value: 'beforeEnd' }, + ] + : [ + { label: t('Before'), value: 'beforeBegin' }, + { label: t('After'), value: 'afterEnd' }, + ]; + }); + }); + }; + const schema = { + type: 'object', + title: t('Edit menu item'), + properties: { + title: { + title: t('Menu item title'), + required: true, + 'x-decorator': 'FormItem', + 'x-component': 'Input', + 'x-component-props': {}, + }, + icon: { + title: t('Menu item icon'), + 'x-component': 'IconPicker', + 'x-decorator': 'FormItem', + }, + }, + }; + const initialValues = { + title: field.title, + icon: field.componentProps.icon, + }; + if (fieldSchema['x-component'] === 'Menu.URL') { + schema.properties['href'] = { + title: t('Link'), + 'x-component': 'Input', + 'x-decorator': 'FormItem', + }; + initialValues['href'] = field.componentProps.href; + } + return ( + + { + const schema = { + ['x-uid']: fieldSchema['x-uid'], + 'x-server-hooks': [ + { + type: 'onSelfSave', + method: 'extractTextToLocale', + }, + ], + }; + if (title) { + fieldSchema.title = title; + field.title = title; + schema['title'] = title; + refresh(); + } + field.componentProps.icon = icon; + field.componentProps.href = href; + schema['x-component-props'] = { icon, href }; + fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {}; + fieldSchema['x-component-props']['icon'] = icon; + fieldSchema['x-component-props']['href'] = href; + dn.emit('patch', { + schema, + }); + }} + /> + { + const [uid] = target?.split?.('||') || []; + if (!uid) { + return; + } + const current = findByUid(menuSchema, uid); + const dn = createDesignable({ + t, + api, + refresh, + current, + }); + dn.loadAPIClientEvents(); + dn.insertAdjacent(position, fieldSchema); + }} + /> + + + + + + { + const deleteUid = (s: ISchema) => { + delete s['name']; + delete s['x-uid']; + Object.keys(s.properties || {}).forEach((key) => { + deleteUid(s.properties[key]); + }); + }; + const { data } = await api.request({ + url: `/uiSchemas:getJsonSchema/${fieldSchema['x-uid']}?includeAsyncNode=true`, + }); + const s = data?.data || {}; + deleteUid(s); + const blob = new Blob([JSON.stringify(s, null, 2)], { type: 'application/json' }); + saveAs(blob, 'export.json'); + message.success('保存成功'); + }} + /> + + + ); +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/components/FormFilter/FormFilterScope.tsx b/packages/plugins/@hera/plugin-core/src/client/components/FormFilter/FormFilterScope.tsx new file mode 100644 index 000000000..2ffaf6384 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/FormFilter/FormFilterScope.tsx @@ -0,0 +1,105 @@ +import { css } from '@emotion/css'; +import { ArrayCollapse } from '@formily/antd-v5'; +import { Form } from '@formily/core'; +import { observer, useField, useFieldSchema } from '@formily/react'; +import { + FormBlockContext, + RecordProvider, + SchemaComponent, + getShouldChange, + useCollectionManager_deprecated, + __UNSAFE__VariablesContextType, + __UNSAFE__DynamicComponentProps, + __UNSAFE__VariableOption, +} from '@nocobase/client'; +import React, { useMemo } from 'react'; +import { FilterContext } from './context'; +import { VariableInput } from './VariableInput'; + +interface usePropsReturn { + options: any; + defaultValues: any[]; + collectionName: string; + form: Form; + variables: __UNSAFE__VariablesContextType; + localVariables: __UNSAFE__VariableOption | __UNSAFE__VariableOption[]; + record: Record; + /** + * create 表示创建表单,update 表示更新表单 + */ + formBlockType: 'create' | 'update'; + fields: any; +} + +interface Props { + useProps: () => usePropsReturn; + dynamicComponent: any; +} + +export const FormFilterScope = observer( + (props: Props) => { + const fieldSchema = useFieldSchema(); + const { useProps, dynamicComponent } = props; + const { options, defaultValues, collectionName, form, formBlockType, variables, localVariables, record, fields } = + useProps(); + const { getAllCollectionsInheritChain } = useCollectionManager_deprecated(); + const components = useMemo(() => ({ ArrayCollapse }), []); + const schema = useMemo( + () => ({ + type: 'object', + properties: { + condition: { + 'x-component': 'Filter', + default: defaultValues, + 'x-component-props': { + collectionName, + useProps() { + return { + options, + className: css` + position: relative; + width: 100%; + margin-left: 10px; + `, + }; + }, + dynamicComponent: (props: __UNSAFE__DynamicComponentProps) => { + const { collectionField } = props; + return ( + + ); + }, + }, + }, + }, + }), + [collectionName, defaultValues, form, localVariables, options, props, record, variables, fields], + ); + const value = useMemo( + () => ({ field: options, fieldSchema, dynamicComponent, options: options || [] }), + [dynamicComponent, fieldSchema, options], + ); + + return ( + + + + + + + + ); + }, + { displayName: 'FormLinkageRules' }, +); diff --git a/packages/plugins/@hera/plugin-core/src/client/components/FormFilter/SchemaSettingsRemove.tsx b/packages/plugins/@hera/plugin-core/src/client/components/FormFilter/SchemaSettingsRemove.tsx new file mode 100644 index 000000000..a96bb3342 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/FormFilter/SchemaSettingsRemove.tsx @@ -0,0 +1,63 @@ +import { ISchema, useField, useFieldSchema, useForm } from '@formily/react'; +import { SchemaSettingsItem, useFormActiveFields, useSchemaSettings } from '@nocobase/client'; +import { App, ModalFuncProps } from 'antd'; +import { FC } from 'react'; +import { useTranslation } from '../../locale'; +import { Field } from '@formily/core'; +import React from 'react'; + +export interface SchemaSettingsRemoveProps { + confirm?: ModalFuncProps; + removeParentsIfNoChildren?: boolean; + breakRemoveOn?: ISchema | ((s: ISchema) => boolean); +} +export const SchemaSettingsRemove: FC = (props) => { + const { confirm, removeParentsIfNoChildren, breakRemoveOn } = props; + const { dn, template } = useSchemaSettings(); + const { t } = useTranslation(); + const field = useField(); + const fieldSchema = useFieldSchema(); + const form = useForm(); + const { modal } = App.useApp(); + const { removeActiveFieldName } = useFormActiveFields() || {}; + return ( + { + modal.confirm({ + title: t('Delete block'), + content: t('Are you sure you want to delete it?'), + ...confirm, + async onOk() { + const options = { + removeParentsIfNoChildren, + breakRemoveOn, + }; + if (field?.required) { + field.required = false; + fieldSchema['required'] = false; + } + await dn.remove(null, options); + await confirm?.onOk?.(); + delete form.values[fieldSchema.name]; + const name = fieldSchema.name as string; + const title = fieldSchema.title; + for (const key in form.fields) { + if (key.includes(name) && form.fields[key].title == title) { + delete form.fields[key]; + } + } + removeActiveFieldName?.(fieldSchema.name as string); + if (field?.setInitialValue && field?.reset) { + field.setInitialValue(null); + field.reset(); + } + }, + }); + }} + > + {t('Delete')} + + ); +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/components/FormFilter/VariableInput.tsx b/packages/plugins/@hera/plugin-core/src/client/components/FormFilter/VariableInput.tsx new file mode 100644 index 000000000..5a6cb2670 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/FormFilter/VariableInput.tsx @@ -0,0 +1,432 @@ +import { Form } from '@formily/core'; +// @ts-ignore +import { Schema } from '@formily/json-schema'; +import { SchemaOptionsContext, useField, useFieldSchema } from '@formily/react'; +import { + CollectionFieldOptions, + useVariableScope, + __UNSAFE__VariablesContextType, + __UNSAFE__VariableOption, + Variable, + isVariable, + useUserVariable, + __UNSAFE__VariableInputOption, + __UNSAFE__, + useDateVariable, + useCollectionManager_deprecated, + useCompile, +} from '@nocobase/client'; + +import _ from 'lodash'; +import React, { useCallback, useContext, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FilterContext } from './context'; +const { useBlockCollection, useValues, useVariableOptions, useContextAssociationFields, useRecordVariable } = + __UNSAFE__; + +interface GetShouldChangeProps { + collectionField: CollectionFieldOptions; + variables: __UNSAFE__VariablesContextType; + localVariables: __UNSAFE__VariableOption | __UNSAFE__VariableOption[]; + /** `useCollectionManager` 返回的 */ + getAllCollectionsInheritChain: (collectionName: string) => string[]; +} + +interface RenderSchemaComponentProps { + value: any; + onChange: (value: any) => void; +} + +type Props = { + value: any; + onChange: (value: any, optionPath?: any[]) => void; + renderSchemaComponent: (props: RenderSchemaComponentProps) => any; + schema?: any; + /** 消费变量值的字段 */ + targetFieldSchema?: Schema; + children?: any; + className?: string; + style?: React.CSSProperties; + collectionField: CollectionFieldOptions; + contextCollectionName?: string; + /**指定当前表单数据表 */ + currentFormCollectionName?: string; + /**指定当前对象数据表 */ + currentIterationCollectionName?: string; + /** + * 根据 `onChange` 的第一个参数,判断是否需要触发 `onChange` + * @param value `onChange` 的第一个参数 + * @returns 返回为 `true` 时,才会触发 `onChange` + */ + shouldChange?: (value: any, optionPath?: any[]) => Promise; + form?: Form; + /** + * 当前表单的记录,数据来自数据库 + */ + record?: Record; + /** + * 可以用该方法对内部的 scope 进行筛选和修改 + * @param scope + * @returns + */ + returnScope?: (scope: __UNSAFE__VariableInputOption[]) => any[]; + fields: any; +}; + +/** + * 注意:该组件存在以下问题: + * - 在选中选项的时候该组件不能触发重渲染 + * - 如果触发重渲染可能会导致无法展开子选项列表 + * @param props + * @returns + */ +export const VariableInput = (props: Props) => { + const { + value, + onChange, + renderSchemaComponent: RenderSchemaComponent, + style, + schema, + className, + contextCollectionName, + collectionField, + shouldChange, + form, + record, + returnScope = _.identity, + targetFieldSchema, + currentFormCollectionName, + currentIterationCollectionName, + fields, + } = props; + const { name: blockCollectionName } = useBlockCollection(); + const scope = useVariableScope(); + const { operator, schema: uiSchema = collectionField?.uiSchema } = useValues(); + + const variableOptions = useVariableOptions({ + collectionField, + form, + record, + operator, + uiSchema, + targetFieldSchema, + currentFormCollectionName, + currentIterationCollectionName, + }); + const contextVariable = useContextAssociationFields({ schema, maxDepth: 2, contextCollectionName, collectionField }); + const { compatOldVariables } = useCompatOldVariables({ + collectionField, + uiSchema, + targetFieldSchema, + blockCollectionName, + }); + + if (contextCollectionName && variableOptions.every((item) => item.value !== contextVariable.value)) { + variableOptions.push(contextVariable); + } + + const handleChange = useCallback( + (value: any, optionPath: any[]) => { + if (!shouldChange) { + return onChange(value); + } + + // `shouldChange` 这个函数的运算量比较大,会导致展开变量列表时有明显的卡顿感,在这里加个延迟能有效解决这个问题 + setTimeout(async () => { + if (await shouldChange(value, optionPath)) { + onChange(value); + } + }); + }, + [onChange, shouldChange], + ); + const options = useVariableCustomOptions(fields); + return ( + + + + ); +}; + +/** + * 通过限制用户的选择,来防止用户选择错误的变量 + */ +export const getShouldChange = ({ + collectionField, + variables, + localVariables, + getAllCollectionsInheritChain, +}: GetShouldChangeProps) => { + const collectionsInheritChain = collectionField ? getAllCollectionsInheritChain(collectionField.target) : []; + + return async (value: any, optionPath: any[]) => { + if (_.isString(value) && value.includes('$nRole')) { + return true; + } + + if (!isVariable(value) || !variables || !collectionField) { + return true; + } + + // `json` 可以选择任意类型的变量,详见:https://nocobase.feishu.cn/docx/EmNEdEBOnoQohUx2UmBcqIQ5nyh#FPLfdSRDEoXR65xW0mBcdfL5n0c + if (collectionField.interface === 'json') { + return true; + } + + const lastOption = optionPath[optionPath.length - 1]; + + // 点击叶子节点时,必须更新 value + if (lastOption && _.isEmpty(lastOption.children) && !lastOption.loadChildren) { + return true; + } + + const collectionFieldOfVariable = await variables.getCollectionField(value, localVariables); + + if (!collectionField) { + return false; + } + + // `一对一` 和 `一对多` 的不能用于设置默认值,因为其具有唯一性 + if (['o2o', 'o2m', 'oho'].includes(collectionFieldOfVariable?.interface)) { + return false; + } + if (!collectionField.target && collectionFieldOfVariable?.target) { + return false; + } + if (collectionField.target && !collectionFieldOfVariable?.target) { + return false; + } + if ( + collectionField.target && + collectionFieldOfVariable?.target && + !collectionsInheritChain.includes(collectionFieldOfVariable?.target) + ) { + return false; + } + + return true; + }; +}; + +export interface FormatVariableScopeParam { + children: any[]; + disabled: boolean; + name: string; + title: string; +} + +export interface FormatVariableScopeReturn { + value: string; + key: string; + label: string; + disabled: boolean; + children?: any[]; +} + +/** + * 兼容老版本的变量 + * @param variables + */ +export function useCompatOldVariables(props: { + uiSchema: any; + collectionField: CollectionFieldOptions; + blockCollectionName: string; + noDisabled?: boolean; + targetFieldSchema?: Schema; +}) { + const { uiSchema, collectionField, noDisabled, targetFieldSchema, blockCollectionName } = props; + const { t } = useTranslation(); + const lowLevelUserVariable = useUserVariable({ + maxDepth: 1, + uiSchema: uiSchema, + collectionField, + noDisabled, + targetFieldSchema, + }); + const currentRecordVariable = useRecordVariable({ + schema: uiSchema, + collectionName: blockCollectionName, + collectionField, + noDisabled, + targetFieldSchema, + }); + + const compatOldVariables = useCallback( + (variables: __UNSAFE__VariableInputOption[], { value }) => { + if (!isVariable(value)) { + return variables; + } + + variables = _.cloneDeep(variables); + + const systemVariable: __UNSAFE__VariableInputOption = { + value: '$system', + key: '$system', + label: t('System variables'), + isLeaf: false, + children: [ + { + value: 'now', + key: 'now', + label: t('Current time'), + isLeaf: true, + depth: 1, + }, + ], + depth: 0, + }; + const currentTime = { + value: 'currentTime', + label: t('Current time'), + children: null, + }; + + if (value.includes('$system')) { + variables.push(systemVariable); + } + + if (value.includes(`${blockCollectionName}.`)) { + const variable = variables.find((item) => item.value === '$nForm' || item.value === '$nRecord'); + if (variable) { + variable.value = blockCollectionName; + } + } + + if (value.includes('$form')) { + const variable = variables.find((item) => item.value === '$nForm'); + if (variable) { + variable.value = '$form'; + } + } + + if (value.includes('currentUser')) { + const userVariable = variables.find((item) => item.value === '$user'); + if (userVariable) { + userVariable.value = 'currentUser'; + } else { + variables.unshift({ ...lowLevelUserVariable, value: 'currentUser' }); + } + } + + if (value.includes('currentRecord')) { + const formVariable = variables.find((item) => item.value === '$nRecord'); + if (formVariable) { + formVariable.value = 'currentRecord'; + } else { + variables.unshift({ ...currentRecordVariable, value: 'currentRecord' }); + } + } + + if (value.includes('currentTime')) { + variables.push(currentTime); + } + + if (value.includes('$date')) { + const formVariable = variables.find((item) => item.value === '$nDate'); + if (formVariable) { + formVariable.value = '$date'; + } + } + + return variables; + }, + [blockCollectionName], + ); + + return { compatOldVariables }; +} + +export const useVariableCustomOptions = (fields) => { + const field = useField(); + const { operator, schema } = field.data || {}; + const userVariable = useUserVariable({ + collectionField: { uiSchema: schema }, + uiSchema: schema, + }); + const dateVariable = useDateVariable({ operator, schema }); + const filterVariable = useFilterVariable(fields); + + const result = useMemo( + () => [userVariable, dateVariable, filterVariable].filter(Boolean), + [dateVariable, userVariable, filterVariable], + ); + + if (!operator || !schema) return []; + + return result; +}; + +export const useFilterVariable = (fields) => { + const { t } = useTranslation(); + const compile = useCompile(); + const { collections } = useCollectionManager_deprecated(); + const customFields = []; + for (const field in fields) { + customFields.push({ + key: field, + ...fields[field], + }); + } + + const options = customFields + .filter((value) => value?.props?.name.includes('custom.')) + .map((custom) => { + const value = custom?.props?.name.replace(/^custom\./, ''); + const collection = collections.filter((collection) => collection.name === value)[0]; + let children = collection.fields.map((collectionField) => { + if ( + collectionField.interface !== 'o2o' && + collectionField.interface !== 'oho' && + collectionField.interface !== 'm2o' && + collectionField.interface !== 'createdBy' && + collectionField.interface !== 'updatedBy' && + collectionField.interface !== 'o2m' && + collectionField.interface !== 'm2m' && + collectionField.interface !== 'linkTo' && + collectionField.interface !== 'chinaRegion' && + collectionField.interface !== 'obo' && + collectionField.interface !== 'createdAt' && + collectionField.interface !== 'updatedAt' + ) { + return { + key: collectionField.key, + value: collectionField.name, + label: compile(collectionField.uiSchema.title), + }; + } + }); + children = children.filter(Boolean); + return children.length + ? { + key: custom?.props?.name, + value, + label: custom.title, + children, + } + : { + key: custom?.props?.name, + value, + label: custom.title, + }; + }); + const result = useMemo( + () => ({ + label: t('Current filter'), + value: '$nFilter', + key: '$nFilter', + children: options, + }), + [options, t], + ); + + if (!options.length) return null; + + return result; +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/components/FormFilter/context.ts b/packages/plugins/@hera/plugin-core/src/client/components/FormFilter/context.ts new file mode 100644 index 000000000..5e80dc964 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/FormFilter/context.ts @@ -0,0 +1,16 @@ +import { ObjectField } from '@formily/core'; +import { Schema } from '@formily/react'; +import { createContext } from 'react'; + +export interface FilterContextProps { + field?: ObjectField & { collectionName?: string }; + fieldSchema?: Schema; + dynamicComponent?: any; + options?: any[]; + disabled?: boolean; + collectionName?: string; +} + +export const RemoveConditionContext = createContext(null); +export const FilterContext = createContext(null); +export const FilterLogicContext = createContext(null); diff --git a/packages/plugins/@hera/plugin-core/src/client/components/GroupBlockConfigure/GroupBlockConfigure.tsx b/packages/plugins/@hera/plugin-core/src/client/components/GroupBlockConfigure/GroupBlockConfigure.tsx new file mode 100644 index 000000000..7e70e5a60 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/GroupBlockConfigure/GroupBlockConfigure.tsx @@ -0,0 +1,21 @@ +import { SchemaSettingsItem } from '@nocobase/client'; +import React, { useContext } from 'react'; +import { useTranslation } from '../../locale'; +import { Modal } from 'antd'; +import { GroupBlockContext } from '../../schema-initializer/GroupBlockInitializer'; + +export const GroupBlockConfigure = (props) => { + const { t } = useTranslation(); + const { setVisible, visible } = useContext(GroupBlockContext); + return ( + { + setVisible(true); + }} + > + {t('Configure')} + + ); +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/components/GroupBlockConfigure/GroupConfigure.tsx b/packages/plugins/@hera/plugin-core/src/client/components/GroupBlockConfigure/GroupConfigure.tsx new file mode 100644 index 000000000..860536b0f --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/GroupBlockConfigure/GroupConfigure.tsx @@ -0,0 +1,149 @@ +import { useField, useFieldSchema } from '@formily/react'; +import { useDesignable, useFieldNames } from '@nocobase/client'; +import { App, Button, Flex, Modal, Select } from 'antd'; +import React, { useContext, useEffect, useState } from 'react'; +import { useTranslation } from '../../locale'; +import { GroupBlockContext } from '../../schema-initializer/GroupBlockInitializer'; +import { transformers } from './transformers'; +import { DeleteOutlined } from '@ant-design/icons'; + +export type SelectedField = { + field: string | string[]; + alias?: string; +}; + +export const GroupConfigure = (props) => { + const { visible, setVisible } = useContext(GroupBlockContext); + const { t } = useTranslation(); + const { dn } = useDesignable(); + const { modal } = App.useApp(); + const fieldSchema = useFieldSchema(); + const measure = fieldSchema['x-decorator-props']?.params?.measures; + const [fieldOption, setFieldOption] = useState( + JSON.parse(JSON.stringify(measure.filter((item) => item.fieldFormat))), + ); + const valueOption = measure.map((item) => { + return { + label: item.label, + value: item.field[0], + }; + }); + const decimal = transformers.option.filter((item) => item.value === 'decimal')[0].childrens; + useEffect(() => { + if (!visible) { + return; + } + }, [visible]); + return ( + { + measure.forEach((measureItem) => { + delete measureItem['fieldFormat']; + fieldOption.forEach((fieldOptionItem) => { + if (measureItem.field[0] === fieldOptionItem?.fieldFormat?.fieldValue) { + measureItem['fieldFormat'] = { ...fieldOptionItem.fieldFormat }; + } + }); + }); + + fieldSchema['x-decorator-props']['params'].measures = measure; + dn.emit('patch', { + schema: fieldSchema, + }); + setVisible(false); + dn.refresh(); + }} + onCancel={() => { + modal.confirm({ + title: t('Are you sure to cancel?'), + content: t('You changes are not saved. If you click OK, your changes will be lost.'), + okButtonProps: { + danger: true, + }, + onOk: () => { + setVisible(false); + }, + }); + }} + width={'30%'} + > +
+ {fieldOption.map((item, index) => { + return ( +
+ { + fieldConfig('format', fieldOption, setFieldOption, index, v); + }} + /> + {item?.fieldFormat?.option === 'decimal' ? ( + + + + setFormData({ ...formData, link: e.target.value })} + placeholder="Please enter Link" + defaultValue={formData?.link} + /> + + + + + ); +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/components/MobileLink.tsx b/packages/plugins/@hera/plugin-core/src/client/components/MobileLink.tsx new file mode 100644 index 000000000..f20fe8fc1 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/MobileLink.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { css } from '@nocobase/client'; +import { Button, Tooltip } from 'antd'; +import { MobileOutlined } from '@ant-design/icons'; +import { useTranslation } from '../locale'; + +export const MobileLink = () => { + const { t } = useTranslation(); + return ( +
+ + + +
+ ); +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/components/Notifications.tsx b/packages/plugins/@hera/plugin-core/src/client/components/Notifications.tsx new file mode 100644 index 000000000..071321c87 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/Notifications.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { css, useRequest } from '@nocobase/client'; +import { Badge, Button, Popover, List, message } from 'antd'; +import { BellOutlined } from '@ant-design/icons'; +import { useLinkKey, useInitializationLinkKey } from '../hooks/useNotifications'; +import { useNavigate } from 'react-router-dom'; +import { dayjs } from '@nocobase/utils/client'; + +export const Notifications = () => { + // 注册链接关联,链接管理使用nocobase代码方式注册数据表 + useInitializationLinkKey(); + const linkDetail = useLinkKey(); + + const navigate = useNavigate(); + const { data, run } = useRequest<{ data: any }>({ + url: `/system_notifications:get`, + }); + const getNotificationList = data?.data || []; + const { run: updateRead } = useRequest<{ data: any }>({ + url: `/system_notifications:update`, + params: { + ids: getNotificationList.map((item) => item.id), + }, + }); + const content = ( + + + + + } + renderItem={(item: any) => ( + + + {item.content} + + )} + /> + ); + return ( +
+ + + + + +
+ ); +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/components/OnlineUserProvider.tsx b/packages/plugins/@hera/plugin-core/src/client/components/OnlineUserProvider.tsx new file mode 100644 index 000000000..e01e2e841 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/OnlineUserProvider.tsx @@ -0,0 +1,68 @@ +import { uid } from '@formily/shared'; +import { css, useAPIClient, useApp } from '@nocobase/client'; +import { Button, Dropdown } from 'antd'; +import React, { useEffect, useState } from 'react'; +import _ from 'lodash'; + +const OnlineUserManger = () => { + const app = useApp(); + const [onlineUserItems, setOnlineUserItems] = useState([]); + const api = useAPIClient(); + useEffect(() => { + app.ws.on('message', (event: MessageEvent) => { + const data = JSON.parse(event.data); + if (data?.type === 'plugin-online-user') { + const onlineUserItems = data.payload.users?.map((user) => { + if (user) { + return { + key: uid(), + label: user.nickname, + }; + } + }); + setOnlineUserItems(onlineUserItems); + } + }); + }, [app]); + + useEffect(() => { + const data = { + type: 'plugin-online-user:client', + payload: { + token: api.auth.getToken(), + }, + }; + app.ws.send(JSON.stringify(data)); + }, []); + + return ( + + + + ); +}; + +export const OnlineUserDropdown = () => { + return ( +
+ +
+ ); +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/components/OutboundButton.tsx b/packages/plugins/@hera/plugin-core/src/client/components/OutboundButton.tsx new file mode 100644 index 000000000..71cbc44af --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/OutboundButton.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Popover, Space, Button, Input } from 'antd'; +import { useFieldSchema } from '@formily/react'; +import { useProps } from '@nocobase/client'; +import { ShareAltOutlined } from '@ant-design/icons'; + +export const OutboundButton = (props) => { + const { onClick } = useProps(props); + const schema = useFieldSchema(); + const url = window.location.href.split('/', 3).join('/'); + return ( + + + + + } + > + + + ); +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/components/PDFViewer.tsx b/packages/plugins/@hera/plugin-core/src/client/components/PDFViewer.tsx new file mode 100644 index 000000000..a6ed92ca2 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/PDFViewer.tsx @@ -0,0 +1,100 @@ +import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; +import { Spin } from 'antd'; +import { LoadingOutlined } from '@ant-design/icons'; +import { Document, Page, pdfjs } from 'react-pdf'; +import { saveAs } from 'file-saver'; +import 'react-pdf/dist/Page/AnnotationLayer.css'; +import 'react-pdf/dist/Page/TextLayer.css'; +import { uid } from '@formily/shared'; +import { useRequest } from '@nocobase/client'; +import { useTranslation } from '../locale'; +// TODO CMap settings. + +pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cat.net/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.js`; + +interface PDFViewerProps { + file: string; + width: number; +} + +interface PDFViewerRef { + download: () => void; + print: () => void; +} + +const LoadingSpin = ({ children, spinning }) => { + const { t } = useTranslation(); + return ( + } spinning={spinning}> + {children} + + ); +}; + +export const PDFViewer = forwardRef((props, ref) => { + const { t } = useTranslation(); + const [numPages, setNumPages] = useState(0); + const [contentWindow, setContentWindow] = useState(null); + const { file, width = 960 } = props; + const { loading, data, run } = useRequest({ + url: file, + responseType: 'arraybuffer', + }); + useImperativeHandle(ref, () => ({ + download() { + const blob = new Blob([data as ArrayBuffer], { type: 'application/pdf' }); + saveAs(blob, uid() + '.pdf'); + }, + print() { + contentWindow.print(); + }, + })); + useEffect(() => { + run(); + }, [file]); + useEffect(() => { + if (loading || !data) { + return; + } + const blob = new Blob([data as ArrayBuffer], { type: 'application/pdf' }); + const url = URL.createObjectURL(blob); + const iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + iframe.src = url; + iframe.onload = () => { + setContentWindow(iframe.contentWindow); + }; + document.body.appendChild(iframe); + return () => { + // 需要释放资源 + URL.revokeObjectURL(url); + document.body.removeChild(iframe); + }; + }, [data, loading]); + + return ( + + ( + +
+
+ )} + onLoadSuccess={async (doc) => { + setNumPages(doc.numPages); + }} + noData={
} + error={
{t('error')}
} + > + {Array.from(new Array(numPages), (el, index) => ( + +

+ {index + 1}/{numPages}页 +

+
+ ))} +
+
+ ); +}); diff --git a/packages/plugins/@hera/plugin-core/src/client/components/SchemaSettingOptions.tsx b/packages/plugins/@hera/plugin-core/src/client/components/SchemaSettingOptions.tsx new file mode 100644 index 000000000..ddd4154f6 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/SchemaSettingOptions.tsx @@ -0,0 +1,378 @@ +import { Field } from '@formily/core'; +import { ISchema, useField, useFieldSchema, useForm } from '@formily/react'; +import { + SchemaSettingsLinkageRules, + SchemaSettingsModalItem, + SchemaSettingsSelectItem, + SchemaSettingsSwitchItem, + css, + mergeFilter, + removeNullCondition, + useBlockRequestContext, + useCollection_deprecated, + useCollectionManager_deprecated, + useCompile, + useDesignable, + useFieldModeOptions, + useFilterFieldProps, + useFilterOptions, + useFormBlockContext, + useFormBlockType, + useLinkageCollectionFilterOptions, + useLocalVariables, + useProps, + useRecord, + useSchemaTemplateManager, + useSortFields, + useVariables, +} from '@nocobase/client'; +import _, { get } from 'lodash'; +import React, { useCallback, useContext, useMemo } from 'react'; +import { useTranslation } from '../locale'; +import { FormFilterScope } from './FormFilter/FormFilterScope'; + +export const useFormulaTitleOptions = () => { + const compile = useCompile(); + const { getCollectionJoinField, getCollectionFields } = useCollectionManager_deprecated(); + const { getField } = useCollection_deprecated(); + const fieldSchema = useFieldSchema(); + const { t } = useTranslation(); + const { dn } = useDesignable(); + const collectionManage = useCollectionManager_deprecated(); + const collectionManageField = fieldSchema['x-compoent-custom'] + ? collectionManage.collections.filter((value) => value.name === fieldSchema['x-decorator-props'])[0] + : {}; + const collectionField = getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']); + let fields = []; + if (collectionField) { + fields = getCollectionFields( + collectionField + ? collectionField.target + ? collectionField.target + : collectionField.collectionName + : fieldSchema['name'], + ); + } else if (collectionManageField) { + fields = collectionManageField['fields']; + } + const options = []; + fields?.forEach((field) => { + if (field.interface !== 'm2m') { + if (field.uiSchema) { + options.push({ + label: compile(field.uiSchema.title), + value: field.name, + children: + getCollectionFields(field.target) + ?.filter((subField) => subField.uiSchema) + .map((subField) => ({ + label: subField.uiSchema ? compile(subField.uiSchema.title) : '', + value: subField.name, + })) ?? [], + }); + } + } + }); + return options; +}; + +export const useFormulaTitleVisible = () => { + const fieldSchema = useFieldSchema(); + const options = useFormulaTitleOptions(); + // FIXME 这里现在只有当设置为 select,默认为 select 的时候看不到 + return ( + options.length > 0 && + ((fieldSchema['x-component-props']?.mode === 'Select' && + fieldSchema['x-component-props']?.fieldNames?.value !== undefined && + fieldSchema['x-component'] === 'CollectionField') || + fieldSchema['x-compoent-custom']) + ); +}; + +export const EditFormulaTitleField = () => { + const { getCollectionJoinField, collections } = useCollectionManager_deprecated(); + const { getField } = useCollection_deprecated(); + const field = useField(); + const fieldSchema = useFieldSchema(); + const { t } = useTranslation(); + const { dn } = useDesignable(); + + let collectionField = getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']); + collectionField = collectionField + ? collectionField + : collections.find((value) => value.name === fieldSchema['x-decorator-props'])?.fields; + const options = useFormulaTitleOptions(); + const editTitle = async (formula) => { + const schema = { + ['x-uid']: fieldSchema['x-uid'], + }; + const fieldNames = { + ...collectionField?.uiSchema?.['x-component-props']?.['fieldNames'], + ...field.componentProps.fieldNames, + formula, + }; + fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {}; + fieldSchema['x-component-props']['fieldNames'] = fieldNames; + schema['x-component-props'] = fieldSchema['x-component-props']; + field.componentProps.fieldNames = fieldSchema['x-component-props'].fieldNames; + dn.emit('patch', { + schema, + }); + dn.refresh(); + }; + + return ( + { + if (formula) { + editTitle(formula); + } + }} + /> + ); +}; + +export const usePaginationVisible = () => { + const fieldSchema = useFieldSchema(); + return fieldSchema['x-component-props']?.mode === 'SubTable'; +}; + +export const useSetFilterScopeVisible = () => { + const fieldSchema = useFieldSchema(); + return fieldSchema['x-component-props']?.useProps === '{{ useFilterBlockActionProps }}'; +}; + +export const EditTitle = () => { + const { getCollectionJoinField } = useCollectionManager_deprecated(); + const { getField } = useCollection_deprecated(); + const field = useField(); + const fieldSchema = useFieldSchema(); + const { t } = useTranslation(); + const { dn } = useDesignable(); + const collectionField = getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']); + return ( + { + if (title) { + field.title = title; + fieldSchema.title = title; + dn.emit('patch', { + schema: { + 'x-uid': fieldSchema['x-uid'], + title: fieldSchema.title, + }, + }); + } + dn.refresh(); + }} + /> + ); +}; + +export const EditTitleField = () => { + const { getCollectionFields, getCollectionJoinField } = useCollectionManager_deprecated(); + const { getField } = useCollection_deprecated(); + const field = useField(); + const fieldSchema = useFieldSchema(); + const { t } = useTranslation(); + const { dn } = useDesignable(); + const compile = useCompile(); + const collectionManage = useCollectionManager_deprecated(); + const collectionManageField = fieldSchema['x-compoent-custom'] + ? collectionManage.collections.filter((value) => value.name === fieldSchema['x-decorator-props'])[0] + : {}; + const collectionField = getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']); + let targetFields = []; + if (collectionField) { + targetFields = collectionField?.target + ? getCollectionFields(collectionField?.target) + : getCollectionFields(collectionField?.targetCollection) ?? []; + } else if (collectionManageField) { + targetFields = collectionManageField['fields']; + } + const options = targetFields + .filter((field) => !field?.target && field.type !== 'boolean') + .map((field) => ({ + value: field?.name, + label: compile(field?.uiSchema?.title) || field?.name, + })); + + return options.length > 0 && + (fieldSchema['x-component'] === 'CollectionField' || fieldSchema['x-compoent-custom']) ? ( + { + const schema = { + ['x-uid']: fieldSchema['x-uid'], + }; + const fieldNames = { + ...collectionField?.uiSchema?.['x-component-props']?.['fieldNames'], + ...field.componentProps.fieldNames, + label, + }; + fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {}; + fieldSchema['x-component-props']['fieldNames'] = fieldNames; + schema['x-component-props'] = fieldSchema['x-component-props']; + field.componentProps.fieldNames = fieldSchema['x-component-props'].fieldNames; + dn.emit('patch', { + schema, + }); + dn.refresh(); + }} + /> + ) : null; +}; + +export const IsTablePageSize = () => { + const { dn } = useDesignable(); + const fieldSchema = useFieldSchema(); + const { t } = useTranslation(); + return ( + { + if (!fieldSchema['x-component-props'].pagination) { + fieldSchema['x-component-props'] = { + ...fieldSchema['x-component-props'], + pagination: false, + }; + } + fieldSchema['x-component-props'].pagination = v; + dn.emit('patch', { + schema: { + 'x-uid': fieldSchema['x-uid'], + 'x-component-props': { + ...fieldSchema?.['x-component-props'], + }, + }, + }); + dn.refresh(); + }} + /> + ); +}; + +const findGridSchema = (fieldSchema) => { + return fieldSchema.reduceProperties((buf, s) => { + if (s['x-component'] === 'FormV2') { + const f = s.reduceProperties((buf, s) => { + if (s['x-component'] === 'Grid' || s['x-component'] === 'BlockTemplate') { + return s; + } + return buf; + }, null); + if (f) { + return f; + } + } + return buf; + }, null); +}; + +export const SetFilterScope = (props) => { + const { t } = useTranslation(); + const fieldSchema = useFieldSchema(); + const field = useField(); + const fields = field.form.fields; + const { collectionName } = props; + const gridSchema = findGridSchema(fieldSchema) || fieldSchema; + const { form } = useFormBlockContext(); + const type = props?.type || ['Action', 'Action.Link'].includes(fieldSchema['x-component']) ? 'button' : 'field'; + const variables = useVariables(); + const localVariables = useLocalVariables(); + const record = useRecord(); + const { type: formBlockType } = useFormBlockType(); + const schema = useMemo( + () => ({ + type: 'object', + title: t('Custom filter'), + properties: { + fieldReaction: { + 'x-component': FormFilterScope, + 'x-component-props': { + useProps: () => { + const options = useLinkageCollectionFilterOptions(collectionName); + return { + options, + defaultValues: gridSchema?.['x-filter-rules'] || fieldSchema?.['x-filter-rules'], + type, + collectionName, + form, + variables, + localVariables, + record, + formBlockType, + fields, + }; + }, + }, + }, + }, + }), + [], + ); + const { getTemplateById } = useSchemaTemplateManager(); + const { dn } = useDesignable(); + const onSubmit = useCallback( + (v) => { + const rules = v.fieldReaction.condition; + const templateId = gridSchema['x-component'] === 'BlockTemplate' && gridSchema['x-component-props'].templateId; + const uid = (templateId && getTemplateById(templateId).uid) || gridSchema['x-uid']; + const schema = { + ['x-uid']: uid, + }; + + gridSchema['x-filter-rules'] = rules; + schema['x-filter-rules'] = rules; + dn.emit('patch', { + schema, + }); + dn.refresh(); + }, + [dn, getTemplateById, gridSchema], + ); + return ; +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/components/SignatureInput.tsx b/packages/plugins/@hera/plugin-core/src/client/components/SignatureInput.tsx new file mode 100644 index 000000000..e7b8fa833 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/SignatureInput.tsx @@ -0,0 +1,48 @@ +import { connect, mapProps } from '@formily/react'; +import { Button } from 'antd'; +import React, { useRef, useEffect } from 'react'; +import SignaturePad from './SignaturePad'; + +export const SignatureInput = connect( + (props) => { + const { onChange, value } = props; + const signatureRef = useRef(null); + useEffect(() => { + if (value) { + signatureRef.current.fromDataURL(value); + } + }, []); + const handleClear = () => { + if (signatureRef.current) { + signatureRef.current.clear(); + onChange && onChange(''); + } + }; + const handleSave = () => { + if (signatureRef.current) { + const signatureDataURL = signatureRef.current.toDataURL(); + onChange && onChange(signatureDataURL); + } + }; + const divStyle: any = { + pointerEvents: props.disabled ? 'none' : 'auto', + opacity: props.disabled ? 0.8 : 1, + }; + return ( +
+ + {!props.disabled && ( +
+ + +
+ )} +
+ ); + }, + mapProps((props, field) => { + return { + ...props, + }; + }), +); diff --git a/packages/plugins/@hera/plugin-core/src/client/components/SignaturePad.tsx b/packages/plugins/@hera/plugin-core/src/client/components/SignaturePad.tsx new file mode 100644 index 000000000..aee67b791 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/SignaturePad.tsx @@ -0,0 +1,394 @@ +import * as React from 'react'; +import SigPad, { Options, PointGroup, ToSVGOptions } from 'signature_pad'; +import { debounce } from 'throttle-debounce'; + +type Props = { + width?: number; + height?: number; + options?: Options; + canvasProps?: { [key: string]: string | { [key: string]: string } }; +} & DefaultProps; + +type DefaultProps = { + redrawOnResize: boolean; + debounceInterval: number; +}; + +type State = { + canvasWidth: number; + canvasHeight: number; +}; + +/** + * @class + * @classdesc Signature pad component. + * @extends {PureComponent} + */ +class SignaturePad extends React.PureComponent { + static displayName = 'react-signature-pad-wrapper'; + + static defaultProps: DefaultProps = { + redrawOnResize: false, + debounceInterval: 150, + }; + + private canvasRef = React.createRef(); + + private signaturePad!: SigPad; + + private callResizeHandler!: debounce<() => void>; + + /** + * Create a new signature pad. + * + * @param {Props} props + */ + constructor(props: Props) { + super(props); + + this.state = { canvasWidth: 0, canvasHeight: 0 }; + + this.callResizeHandler = debounce<() => void>(this.props.debounceInterval, this.handleResize.bind(this)); + } + + /** + * Initialise the signature pad once the canvas element is rendered. + * + * @return {void} + */ + componentDidMount(): void { + const canvas = this.canvasRef.current; + + if (canvas) { + if (!this.props.width || !this.props.height) { + canvas.style.width = '100%'; + window.addEventListener('resize', this.callResizeHandler); + } + + this.signaturePad = new SigPad(canvas, this.props.options); + + this.scaleCanvas(canvas); + } + } + + /** + * Remove the resize event listener and switch the signature pad off on + * unmount. + * + * @return {void} + */ + componentWillUnmount(): void { + if (!this.props.width || !this.props.height) { + window.removeEventListener('resize', this.callResizeHandler); + } + + this.signaturePad.off(); + } + + /** + * Get the original signature_pad instance. + * + * @return {SigPad} + */ + get instance(): SigPad { + return this.signaturePad; + } + + /** + * Get the canvas ref. + * + * @return {Object} + */ + get canvas(): React.RefObject { + return this.canvasRef; + } + + /** + * Set the radius of a single dot. + * + * @param {number} dotSize + * @return {void} + */ + set dotSize(dotSize: number) { + this.signaturePad.dotSize = dotSize; + } + + /** + * Get the radius of a single dot. + * + * @return {number} + */ + get dotSize(): number { + return this.signaturePad.dotSize; + } + + /** + * Set the minimum width of a line. + * + * @param {number} minWidth + * @return {void} + */ + set minWidth(minWidth: number) { + this.signaturePad.minWidth = minWidth; + } + + /** + * Get the minimum width of a line. + * + * @return {number} + */ + get minWidth(): number { + return this.signaturePad.minWidth; + } + + /** + * Get the maximum width of a line. + * + * @param {number} maxWidth + * @return {void} + */ + set maxWidth(maxWidth: number) { + this.signaturePad.maxWidth = maxWidth; + } + + /** + * Get the maximum width of a line. + * + * @return {number} + */ + get maxWidth(): number { + return this.signaturePad.maxWidth; + } + + /** + * Set the throttle for drawing the next point at most once every x ms. + * + * @param {number} throttle + * @return {void} + */ + set throttle(throttle: number) { + this.signaturePad.throttle = throttle; + } + + /** + * Get the throttle for drawing the next point at most once every x ms. + * + * @return {number} + */ + get throttle(): number { + return this.signaturePad.throttle; + } + + /** + * Set the color used to clear the background. + * + * @param {string} color + * @return {void} + */ + set backgroundColor(color: string) { + this.signaturePad.backgroundColor = color; + } + + /** + * Get the color used to clear the background. + * + * @return {string} + */ + get backgroundColor(): string { + return this.signaturePad.backgroundColor; + } + + /** + * Set the color used to draw the lines. + * + * @param {string} color + * @return {void} + */ + set penColor(color: string) { + this.signaturePad.penColor = color; + } + + /** + * Get the color used to draw the lines. + * + * @return {string} + */ + get penColor(): string { + return this.signaturePad.penColor; + } + + /** + * Set weight used to modify new velocity based on the previous velocity. + * + * @param {number} weight + * @return {void} + */ + set velocityFilterWeight(weight: number) { + this.signaturePad.velocityFilterWeight = weight; + } + + /** + * Get weight used to modify new velocity based on the previous velocity. + * + * @return {number} + */ + get velocityFilterWeight(): number { + return this.signaturePad.velocityFilterWeight; + } + + /** + * Determine if the canvas is empty. + * + * @return {boolean} + */ + isEmpty(): boolean { + return this.signaturePad.isEmpty(); + } + + /** + * Clear the canvas. + * + * @return {void} + */ + clear(): void { + this.signaturePad.clear(); + } + + /** + * Draw a signature from a data URL. + * + * @param {string} dataUrl + * @param {object} options + * @return {void} + */ + fromDataURL( + dataUrl: string, + options: Partial<{ ratio: number; width: number; height: number; xOffset: number; yOffset: number }> = {}, + ): void { + this.signaturePad.fromDataURL(dataUrl, options); + } + + /** + * Get the signature data as a data URL. + * + * @param {?string} mime + * @param {?number} encoderOptions + * @return {string} + */ + toDataURL(type?: string, encoderOptions?: number): string { + return this.signaturePad.toDataURL(type, encoderOptions); + } + + /** + * Get the signature data as an SVG string without converting to base64. + * + * @param {?ToSVGOptions} svgOptions + * @return {string} + */ + toSVG(svgOptions?: ToSVGOptions): string { + return this.signaturePad.toSVG(svgOptions); + } + + /** + * Draw a signature from an array of point groups. + * + * @param {PointGroup[]} data + * @return {void} + */ + fromData(data: PointGroup[]): void { + this.signaturePad.fromData(data); + } + + /** + * Get the signature pad data an array of point groups. + * + * @return {PointGroup[]} + */ + toData(): PointGroup[] { + return this.signaturePad.toData(); + } + + /** + * Turn the signature pad off. + * + * @return {void} + */ + off(): void { + this.signaturePad.off(); + } + + /** + * Turn the signature pad on. + * + * @return {void} + */ + on(): void { + this.signaturePad.on(); + } + + /** + * Handle a resize event. + * + * @return {void} + */ + handleResize(): void { + const canvas = this.canvasRef.current; + + if (canvas) { + this.scaleCanvas(canvas); + } + } + + /** + * Scale the canvas. + * + * @param {HTMLCanvasElement} canvas + * @return {void} + */ + scaleCanvas(canvas: HTMLCanvasElement): void { + const ratio = Math.max(window.devicePixelRatio || 1, 1); + const width = (this.props.width || canvas.offsetWidth) * ratio; + const height = (this.props.height || canvas.offsetHeight) * ratio; + + // Avoid needlessly setting height / width if dimensions haven't changed + const { canvasWidth, canvasHeight } = this.state; + + if (width === canvasWidth && height === canvasHeight) return; + + let data; + + if (this.props.redrawOnResize && this.signaturePad && !this.signaturePad.isEmpty()) { + data = this.signaturePad.toDataURL(); + } + + canvas.width = width; + canvas.height = height; + + this.setState({ canvasWidth: width, canvasHeight: height }); + + const ctx = canvas.getContext('2d'); + + if (ctx) { + ctx.scale(ratio, ratio); + } + + if (data) { + this.signaturePad.fromDataURL(data); + } else if (this.signaturePad) { + this.signaturePad.clear(); + } + } + + /** + * Render the signature pad component. + * + * @return {ReactNode} + */ + render(): React.ReactNode { + const { canvasProps } = this.props; + + return ; + } +} + +export default SignaturePad; diff --git a/packages/plugins/@hera/plugin-core/src/client/components/TokenConfiguration.tsx b/packages/plugins/@hera/plugin-core/src/client/components/TokenConfiguration.tsx new file mode 100644 index 000000000..91efbcdca --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/components/TokenConfiguration.tsx @@ -0,0 +1,105 @@ +import { useAPIClient } from '@nocobase/client'; +import { useBoolean } from 'ahooks'; +import { Button, Card, Form, Input, Tabs, message } from 'antd'; +import React, { useEffect, useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; +import { TokenConfigurationResourceKey, getSSKey, useMapConfiguration } from '../hooks/useTokenConfiguration'; +interface BaseConfigurationProps { + type: 'feishu'; +} + +export const TokenTypes = [{ label: '飞书', value: 'feishu' }]; + +const BaseConfiguration: React.FC = ({ type, children }) => { + const [isDisabled, disableAction] = useBoolean(false); + const apiClient = useAPIClient(); + const [form] = Form.useForm(); + const data = useMapConfiguration(type); + useEffect(() => { + if (data) { + form.setFieldsValue(data); + disableAction.toggle(); + } + }, [data]); + + const resource = useMemo(() => { + return apiClient.resource(TokenConfigurationResourceKey); + }, [apiClient]); + + const onSubmit = (values) => { + resource + .set({ + ...values, + type, + }) + .then((res) => { + sessionStorage.removeItem(getSSKey(type)); + disableAction.toggle(); + message.success('保存成功'); + }) + .catch((err) => { + message.success('保存失败'); + }); + }; + return ( +
+ {children} + {isDisabled ? ( + + ) : ( + + + + )} +
+ ); +}; + +const AMapConfiguration = () => { + return ( + + + + + + + + + + + + ); +}; + +const components = { + feishu: AMapConfiguration, +}; + +const tabList = TokenTypes.map((item) => { + return { + ...item, + component: components[item.value], + }; +}); + +export const Configuration = () => { + const location = useLocation(); + const search = new URLSearchParams(location.search); + return ( + + + {tabList.map((tab) => { + return ( + + + + ); + })} + + + ); +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/hooks/useCreateActionProps.tsx b/packages/plugins/@hera/plugin-core/src/client/hooks/useCreateActionProps.tsx new file mode 100644 index 000000000..17849ea98 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/hooks/useCreateActionProps.tsx @@ -0,0 +1,221 @@ +import { ISchema, useField, useFieldSchema, useForm } from '@formily/react'; +import { + __UNSAFE__, + getFormValues, + isVariable, + transformVariableValue, + useActionContext, + useBlockRequestContext, + useCollection_deprecated, + useCompile, + useDesignable, + useFilterByTk, + useFormActiveFields, + useLocalVariables, + useRecord, + useVariables, +} from '@nocobase/client'; +import { useTranslation } from 'react-i18next'; +import { App, message } from 'antd'; +import { useNavigate, useParams } from 'react-router-dom'; +import { isURL } from '@nocobase/utils/client'; +import React, { useCallback } from 'react'; +import _ from 'lodash'; + +const pageDetailsViewer = 'PageLayout'; + +const viewerSchema: ISchema = { + type: 'void', + title: '{{ t("View record") }}', + 'x-component': pageDetailsViewer, + 'x-component-props': { + className: 'nb-action-popup', + }, + properties: { + page: { + type: 'void', + title: '详情页面', + 'x-designer': 'Page.Designer', + 'x-component': 'Page', + 'x-component-props': { disablePageHeader: true }, + properties: { + grid: { + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'RecordBlockInitializers', + properties: {}, + }, + }, + }, + }, +}; + +export const useInsertSchema = () => { + const fieldSchema = useFieldSchema(); + const { insertAfterBegin } = useDesignable(); + const insert = useCallback( + (ss) => { + const schema = fieldSchema.reduceProperties((buf, s) => { + if (s['x-component'] === pageDetailsViewer) { + return s; + } + return buf; + }, null); + if (!schema) { + insertAfterBegin(_.cloneDeep(ss)); + } + }, + [pageDetailsViewer], + ); + return insert; +}; + +export const useCreateActionProps = () => { + const form = useForm(); + const { field, resource, __parent } = useBlockRequestContext(); + const { setVisible, fieldSchema } = useActionContext(); + const navigate = useNavigate(); + const actionSchema = useFieldSchema(); + const actionField = useField(); + const { fields, getField, getTreeParentField, name } = useCollection_deprecated(); + const compile = useCompile(); + const filterByTk = useFilterByTk(); + const currentRecord = useRecord(); + const { modal } = App.useApp(); + const variables = useVariables(); + const localVariables = useLocalVariables({ currentForm: form }); + const { getActiveFieldsName } = useFormActiveFields() || {}; + const { t } = useTranslation(); + const action = actionField.componentProps.saveMode || 'create'; + const filterKeys = actionField.componentProps.filterKeys?.checked || []; + const dn = useDesignable(); + const params = useParams(); + const insert = useInsertSchema(); + let fieldSchema2 = useFieldSchema(); + return { + async onClick() { + const fieldNames = fields.map((field) => field.name); + const { + assignedValues: originalAssignedValues = {}, + onSuccess, + overwriteValues, + skipValidator, + triggerWorkflows, + sessionSubmit, + } = actionSchema?.['x-action-settings'] ?? {}; + const addChild = fieldSchema?.['x-component-props']?.addChild; + const assignedValues = {}; + + const waitList = Object.keys(originalAssignedValues).map(async (key) => { + const value = originalAssignedValues[key]; + const collectionField = getField(key); + + if (process.env.NODE_ENV !== 'production') { + if (!collectionField) { + throw new Error(`useCreateActionProps: field "${key}" not found in collection "${name}"`); + } + } + + if (isVariable(value)) { + const result = await variables?.parseVariable(value, localVariables); + if (result) { + assignedValues[key] = transformVariableValue(result, { targetCollectionField: collectionField }); + } + } else if (value !== null && value !== '') { + assignedValues[key] = value; + } + }); + await Promise.all(waitList); + + if (!skipValidator) { + await form.submit(); + } + const values = getFormValues({ + filterByTk, + field, + form, + fieldNames, + getField, + resource, + actionFields: getActiveFieldsName?.('form') || [], + }); + // const values = omitBy(formValues, (value) => isEqual(JSON.stringify(value), '[{}]')); + if (addChild) { + const treeParentField = getTreeParentField(); + values[treeParentField?.name ?? 'parent'] = currentRecord?.__parent; + values[treeParentField?.foreignKey ?? 'parentId'] = currentRecord?.__parent?.id; + } + actionField.data = field.data || {}; + actionField.data.loading = true; + try { + const data = await resource[action]({ + values: { + ...values, + ...overwriteValues, + ...assignedValues, + }, + filterKeys: filterKeys, + // TODO(refactor): should change to inject by plugin + triggerWorkflows: triggerWorkflows?.length + ? triggerWorkflows.map((row) => [row.workflowKey, row.context].filter(Boolean).join('!')).join(',') + : undefined, + }); + actionField.data.loading = false; + actionField.data.data = data; + __parent?.service?.refresh?.(); + if (!onSuccess?.successMessage) { + if (sessionSubmit) { + message.success(t('Saved successfully')); + if (dn.designable) { + insert(viewerSchema); + } + fieldSchema2.reduceProperties((buf, s) => { + if (s['x-component'] === pageDetailsViewer) { + fieldSchema2 = s; + return s; + } + return buf; + }); + if (fieldSchema2['x-component'] === pageDetailsViewer) { + navigate(`page/${fieldSchema2['x-uid']}/records/${name}/${data?.data?.data?.id ?? ''}`); + } + await form.reset(); + return; + } + } + if (!onSuccess?.dataClear) { + await form.reset(); + } + if (onSuccess?.manualClose) { + modal.success({ + title: compile(onSuccess?.successMessage), + onOk: async () => { + if (onSuccess?.redirecting && onSuccess?.redirectTo) { + if (isURL(onSuccess.redirectTo)) { + window.location.href = onSuccess.redirectTo; + } else { + navigate(onSuccess.redirectTo); + } + } + }, + }); + } else { + message.success(compile(onSuccess?.successMessage)); + if (onSuccess?.redirecting && onSuccess?.redirectTo) { + if (isURL(onSuccess.redirectTo)) { + window.location.href = onSuccess.redirectTo; + } else { + navigate(onSuccess.redirectTo); + } + } + } + + if (!onSuccess) { + setVisible?.(false); + } + } catch (error) { + actionField.data.loading = false; + } + }, + }; +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/hooks/useCustomComponent.tsx b/packages/plugins/@hera/plugin-core/src/client/hooks/useCustomComponent.tsx new file mode 100644 index 000000000..cd45d6e19 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/hooks/useCustomComponent.tsx @@ -0,0 +1,11 @@ +import { useApp } from '@nocobase/client'; +import _ from 'lodash'; +import { KEY_CUSTOM_COMPONENT_LABEL, KEY_CUSTOM_COMPONENT_TYPE } from '..'; + +export const useCustomComponent = (type: string) => { + const app = useApp(); + return _.filter(app.components, (component) => component[KEY_CUSTOM_COMPONENT_TYPE] === type).map((component) => ({ + label: component[KEY_CUSTOM_COMPONENT_LABEL], + value: component.displayName, + })); +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/hooks/useCustomizeUpdateActionProps.tsx b/packages/plugins/@hera/plugin-core/src/client/hooks/useCustomizeUpdateActionProps.tsx new file mode 100644 index 000000000..2ba11b097 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/hooks/useCustomizeUpdateActionProps.tsx @@ -0,0 +1,120 @@ +import { useFieldSchema, useForm } from '@formily/react'; +import { + TableFieldResource, + __UNSAFE__, + isVariable, + transformVariableValue, + useBlockRequestContext, + useCollection_deprecated, + useCompile, + useFilterByTk, + useLocalVariables, + useVariables, +} from '@nocobase/client'; +import { useNavigate } from 'react-router-dom'; +import { App, Modal, message } from 'antd'; +import { isURL } from '@nocobase/utils/client'; + +import React from 'react'; +import { ExclamationCircleFilled } from '@ant-design/icons'; + +export const useCustomizeUpdateActionProps = () => { + const { resource, __parent, service } = useBlockRequestContext(); + const filterByTk = useFilterByTk(); + const actionSchema = useFieldSchema(); + const navigate = useNavigate(); + const compile = useCompile(); + const form = useForm(); + const { modal } = App.useApp(); + const variables = useVariables(); + const localVariables = useLocalVariables({ currentForm: form }); + const { name, getField } = useCollection_deprecated(); + const { confirm } = Modal; + return { + async onClick() { + const { + assignedValues: originalAssignedValues = {}, + onSuccess, + skipValidator, + sessionUpdate, + } = actionSchema?.['x-action-settings'] ?? {}; + if (sessionUpdate) { + confirm({ + title: '问询', + content: '确认要继续此操作?', + icon: , + onOk() { + sessionUpdatePopconfirm(); + }, + onCancel() { + message.warning('取消更新'); + }, + }); + } else { + sessionUpdatePopconfirm(); + } + + async function sessionUpdatePopconfirm() { + const assignedValues = {}; + const waitList = Object.keys(originalAssignedValues).map(async (key) => { + const value = originalAssignedValues[key]; + const collectionField = getField(key); + + if (process.env.NODE_ENV !== 'production') { + if (!collectionField) { + throw new Error(`useCustomizeUpdateActionProps: field "${key}" not found in collection "${name}"`); + } + } + + if (isVariable(value)) { + const result = await variables?.parseVariable(value, localVariables); + if (result) { + assignedValues[key] = transformVariableValue(result, { targetCollectionField: collectionField }); + } + } else if (value != null && value !== '') { + assignedValues[key] = value; + } + }); + await Promise.all(waitList); + + if (skipValidator === false) { + await form.submit(); + } + await resource.update({ + filterByTk, + values: { ...assignedValues }, + }); + service?.refresh?.(); + if (!(resource instanceof TableFieldResource)) { + __parent?.service?.refresh?.(); + } + if (!onSuccess?.successMessage) { + return; + } + if (onSuccess?.manualClose) { + modal.success({ + title: compile(onSuccess?.successMessage), + onOk: async () => { + if (onSuccess?.redirecting && onSuccess?.redirectTo) { + if (isURL(onSuccess.redirectTo)) { + window.location.href = onSuccess.redirectTo; + } else { + navigate(onSuccess.redirectTo); + } + } + }, + }); + } else { + message.success(compile(onSuccess?.successMessage)); + if (onSuccess?.redirecting && onSuccess?.redirectTo) { + if (isURL(onSuccess.redirectTo)) { + window.location.href = onSuccess.redirectTo; + } else { + navigate(onSuccess.redirectTo); + } + } + } + } + }, + }; +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/hooks/useFilterBlockActionProps.tsx b/packages/plugins/@hera/plugin-core/src/client/hooks/useFilterBlockActionProps.tsx new file mode 100644 index 000000000..82a0f9786 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/hooks/useFilterBlockActionProps.tsx @@ -0,0 +1,159 @@ +import { useField, useFieldSchema, useForm } from '@formily/react'; +import { isEmpty } from '@formily/shared'; +import { + findFilterTargets, + mergeFilter, + transformToFilter, + useCollection_deprecated, + useCollectionManager_deprecated, + useFilterBlock, +} from '@nocobase/client'; +import flat from 'flat'; + +export const removeNullCondition = (filter, fieldSchema?) => { + const filterSchema = fieldSchema ? fieldSchema['x-filter-rules'] : ''; + const filterSchemaItem = flat(filterSchema || ''); + const filterItem = {}; + const items = flat(filter || {}); + const values = {}; + let isFilterCustom = false; + if (filterSchema && (filterSchema.$and?.length || filterSchema.$or?.length)) { + for (const key in items) { + for (const filterItems in filterSchemaItem) { + const match = filterSchemaItem[filterItems].slice(11, -2); + if (key.includes(filterItems)) { + isFilterCustom = true; + break; + } + } + } + } + if (!isFilterCustom) { + if (Object.keys(items).filter((item) => item.includes('custom')).length) { + if (filterSchema && (filterSchema.$and?.length || filterSchema.$or?.length)) { + for (const filterKey in filterSchemaItem) { + const match = filterSchemaItem[filterKey]?.slice(11, -2); + const collection = match?.split('.')[0]; + if (Object.keys(items).filter((item) => item.includes(collection)).length) { + for (const key in items) { + if (key.includes(collection)) { + if (key.includes(match)) { + filterSchemaItem[filterKey] = items[key]; + filterItem[match] = items[key]; + } + if (key.includes(collection)) delete items[key]; + else { + const value = items[key]; + if (value != null && !isEmpty(value)) { + values[key] = value; + } + } + } + } + } else if (Object.keys(items).filter((item) => !item.includes('custom')).length) { + const filterItem = Object.keys(items).filter((item) => !item.includes('custom')); + filterItem.forEach((key) => { + values[key] = items[key]; + }); + } + } + for (const item in filterSchemaItem) { + for (const key in filterItem) { + if (filterSchemaItem[item].includes(key)) { + filterSchemaItem[item] = filterItem[key]; + } + } + if (filterSchemaItem[item].includes('$nFilter')) { + delete filterSchemaItem[item]; + } + } + const flatValue = flat.unflatten(values); + const flatFieldSchema = flat.unflatten(filterSchemaItem); + return { + $and: [flatValue, flatFieldSchema], + }; + } else { + for (const key in items) { + if (!key.includes('custom')) { + const value = items[key]; + if (value != null && !isEmpty(value)) { + values[key] = value; + } + } + } + return flat.unflatten(values); + } + } else { + for (const key in items) { + const value = items[key]; + if (value != null && !isEmpty(value)) { + values[key] = value; + } + } + return flat.unflatten(values); + } + } else { + return flat.unflatten(items); + } +}; + +export const useFilterBlockActionProps = () => { + const form = useForm(); + const actionField = useField(); + const fieldSchema = useFieldSchema(); + const { getDataBlocks } = useFilterBlock(); + const { name } = useCollection_deprecated(); + const { getCollectionJoinField } = useCollectionManager_deprecated(); + + actionField.data = actionField.data || {}; + + return { + async onClick() { + const { targets = [], uid } = findFilterTargets(fieldSchema); + + actionField.data.loading = true; + try { + // 收集 filter 的值 + await Promise.all( + getDataBlocks().map(async (block) => { + const target = targets.find((target) => target.uid === block.uid); + if (!target) return; + + const param = block.service.params?.[0] || {}; + // 保留原有的 filter + const storedFilter = block.service.params?.[1]?.filters || {}; + + storedFilter[uid] = removeNullCondition( + transformToFilter(form.values, fieldSchema, getCollectionJoinField, name), + fieldSchema, + ); + if (block.defaultFilter) { + getDataBlocks().forEach((getblock) => { + if (getblock.uid !== block.uid && getblock.collection.name === block.collection.name) { + getblock['defaultFilter'] = block.defaultFilter; + } + }); + } + const mergedFilter = mergeFilter([ + ...Object.values(storedFilter).map((filter) => removeNullCondition(filter, fieldSchema)), + block.defaultFilter, + ]); + + return block.doFilter( + { + ...param, + page: 1, + filter: mergedFilter, + }, + { filters: storedFilter }, + ); + }), + ); + actionField.data.loading = false; + } catch (error) { + console.error(error); + actionField.data.loading = false; + } + }, + }; +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/hooks/useFilterFormCustomProps.tsx b/packages/plugins/@hera/plugin-core/src/client/hooks/useFilterFormCustomProps.tsx new file mode 100644 index 000000000..271926366 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/hooks/useFilterFormCustomProps.tsx @@ -0,0 +1,16 @@ +import { useSchemaInitializer } from '@nocobase/client'; + +export const useFilterFormCustomProps = () => { + const { insert } = useSchemaInitializer(); + return { + title: 'Custom', + onClick: () => { + insert({ + type: 'void', + 'x-decorator': 'CardItem', + 'x-component': 'h1', + 'x-content': 'Custom block', + }); + }, + }; +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/hooks/useGetCustomAssociatedComponents.tsx b/packages/plugins/@hera/plugin-core/src/client/hooks/useGetCustomAssociatedComponents.tsx new file mode 100644 index 000000000..d83870a5d --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/hooks/useGetCustomAssociatedComponents.tsx @@ -0,0 +1,6 @@ +import { CUSTOM_COMPONENT_TYPE_ASSOCIATED_FIELD } from '..'; +import { useCustomComponent } from './useCustomComponent'; + +export const useGetCustomAssociatedComponents = () => { + return useCustomComponent(CUSTOM_COMPONENT_TYPE_ASSOCIATED_FIELD); +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/hooks/useGetCustomComponents.tsx b/packages/plugins/@hera/plugin-core/src/client/hooks/useGetCustomComponents.tsx new file mode 100644 index 000000000..cc62a2492 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/hooks/useGetCustomComponents.tsx @@ -0,0 +1,6 @@ +import { CUSTOM_COMPONENT_TYPE_FIELD } from '..'; +import { useCustomComponent } from './useCustomComponent'; + +export const useGetCustomComponents = () => { + return useCustomComponent(CUSTOM_COMPONENT_TYPE_FIELD); +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/hooks/useNotifications.ts b/packages/plugins/@hera/plugin-core/src/client/hooks/useNotifications.ts new file mode 100644 index 000000000..79fd81555 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/hooks/useNotifications.ts @@ -0,0 +1,24 @@ +import { useRequest } from '@nocobase/client'; +export const useLinkKey = () => { + const { data } = useRequest<{ + data: any; + }>({ + resource: 'link-manage', + action: 'get', + params: { + name: 'Notifications', + }, + }); + return data?.data[0]; +}; +export const useInitializationLinkKey = () => { + useRequest<{ + data: any; + }>({ + resource: 'link-manage', + action: 'init', + params: { + name: 'Notifications', + }, + }); +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/hooks/useOutboundActionProps.ts b/packages/plugins/@hera/plugin-core/src/client/hooks/useOutboundActionProps.ts new file mode 100644 index 000000000..6b06d4f62 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/hooks/useOutboundActionProps.ts @@ -0,0 +1,22 @@ +import { message } from 'antd'; +import { useFieldSchema } from '@formily/react'; +import copy from 'copy-to-clipboard'; + +export const useOutboundActionProps = () => { + let schema = useFieldSchema(); + + while (!('x-decorator-props' in schema)) { + schema = schema.parent; + } + const url = window.location.href.split('/', 3).join('/'); + return { + async onClick() { + const c = copy(`${url}/r/${schema['x-uid']}`); + if (c) { + message.success('链接保存成功'); + } else { + message.success('链接保存失败'); + } + }, + }; +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/hooks/usePluginVersion.ts b/packages/plugins/@hera/plugin-core/src/client/hooks/usePluginVersion.ts new file mode 100644 index 000000000..b33a4c5d2 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/hooks/usePluginVersion.ts @@ -0,0 +1,22 @@ +import { useRequest } from '@nocobase/client'; +interface IPluginDetailData { + packageJson: PackageJSON; +} + +interface PackageJSON { + name: string; + version: string; + description?: string; + repository?: string | { type: string; url: string }; + homepage?: string; + license?: string; + devDependencies?: Record; + dependencies?: Record; +} + +export const usePluginVersion = () => { + const { data } = useRequest<{ data: IPluginDetailData }>({ + url: 'hera:version', + }); + return data?.data?.packageJson?.version; +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/hooks/useTokenConfiguration.ts b/packages/plugins/@hera/plugin-core/src/client/hooks/useTokenConfiguration.ts new file mode 100644 index 000000000..f56922e4c --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/hooks/useTokenConfiguration.ts @@ -0,0 +1,42 @@ +import { useRequest } from '@nocobase/client'; +import { useMemo } from 'react'; + +export const TokenConfigurationResourceKey = 'token-configuration'; +export const getSSKey = (type) => { + return `NOCOBASE_PLUGIN_TOKEN_CONFIGURATION_${type}`; +}; + +export const useMapConfiguration = (type: string) => { + // cache + const config = useMemo(() => { + const d = sessionStorage.getItem(getSSKey(type)); + if (d) { + return JSON.parse(d); + } + return d; + }, [type]); + + const { data } = useRequest<{ + data: any; + }>( + { + resource: TokenConfigurationResourceKey, + action: 'get', + params: { + type, + }, + }, + { + onSuccess(data) { + sessionStorage.setItem(getSSKey(type), JSON.stringify(data?.data)); + }, + refreshOnWindowFocus: false, + refreshDeps: [], + manual: config ? true : false, + }, + ); + + if (config) return config; + + return data?.data; +}; diff --git a/packages/plugins/@hera/plugin-core/src/client/index.tsx b/packages/plugins/@hera/plugin-core/src/client/index.tsx new file mode 100644 index 000000000..c0ad6142a --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/index.tsx @@ -0,0 +1,404 @@ +import React, { ComponentType } from 'react'; +import { autorun } from '@formily/reactive'; +import { + Menu, + Plugin, + RemoteSchemaTemplateManagerProvider, + TableV2, + EditTitleField, + useCollection_deprecated, + SchemaSettingOptions, +} from '@nocobase/client'; +import { HeraAdminLayout } from './layouts'; +import { remove } from 'lodash'; +import { CalendarBlockInitializer } from './schema-initializer/CalendarBlockInitializer'; +import { MenuDesigner } from './components/ExtendedMenuDesigner'; +import { SessionSubmit, SessionUpdate } from './components/ExtendedActionDesigner'; +import { DetailsPage } from './pages/DetailsPage'; +import { OutboundPage } from './pages/OutboundPage'; +import { HomePageConfiguration } from './pages/HomePageConfiguration'; +import { Configuration } from './components/TokenConfiguration'; +import { HomePage } from './pages/Home'; +import { InternalPDFViewer } from './schema-components/PDFViewer'; +import { + PDFViewerBlockInitializer, + PDFViewerPrintActionInitializer, + PDFViewerProvider, + pdfViewActionInitializer, + usePDFViewerPrintActionProps, +} from './schema-initializer/PDFVIewerBlockInitializer'; +import { OutboundButton } from './components/OutboundButton'; +import { useCustomizeUpdateActionProps } from './hooks/useCustomizeUpdateActionProps'; +import { OutboundLinkActionInitializer } from './schema-initializer/OutboundLinkActionInitializer'; +import { CreateSubmitActionInitializer } from './schema-initializer/CreateSubmitActionInitializer'; +import { FilterAssociatedFields } from './schema-initializer/FilterAssociatedFields'; +import { useFieldSchema } from '@formily/react'; +import { isValid } from '@formily/shared'; +import { useCreateActionProps } from './hooks/useCreateActionProps'; +import { PageLayout } from './pages/PageLayout'; +import { useOutboundActionProps } from './hooks/useOutboundActionProps'; +import { ExtendedAssociationField } from './schema-components/association-field/Editable'; +import { AssociatedField } from './components/AssociatedField'; +import { DatePicker } from './schema-components/date-picker'; +import { Page } from './schema-components/page'; +import { SettingBlockInitializer } from './schema-initializer/SettingBlockInitializer'; +import { + EditFormulaTitleField, + IsTablePageSize, + useFormulaTitleVisible, + usePaginationVisible, + EditTitle, + SetFilterScope, + useSetFilterScopeVisible, +} from './components/SchemaSettingOptions'; +import { SignatureInput } from './components/SignatureInput'; +import { RemoteSelect } from './schema-components/remote-select'; +import { Select } from './schema-components/select/Select'; +import { Locale, tval } from './locale'; +import { LinkManager } from './components/LinkManager'; +import { + GroupBlockInitializer, + GroupBlockProvider, + GroupBlockToolbar, + groupBlockSettings, +} from './schema-initializer/GroupBlockInitializer'; +import { useFilterFormCustomProps } from './hooks/useFilterFormCustomProps'; +import { + FilterFormItem, + FilterItemCustomDesigner, + FilterFormItemCustom, +} from './schema-initializer/FilterFormItemCustomInitializer/FilterFormItemCustom'; +import { GroupBlock } from './schema-components/GroupBlock'; +import { + CustomComponentDispatcher, + CustomComponentStub, + customComponentDispatcherSettings, +} from './components/CustomComponentDispatcher'; +import { useFilterBlockActionProps } from './hooks/useFilterBlockActionProps'; +import { AfterSuccess } from './components/Action.Designer'; +import { GroupBlockConfigure } from './components/GroupBlockConfigure/GroupBlockConfigure'; +import { AssociatedFieldInterface } from './interfaces/associated'; +import { CalcFieldInterface } from './interfaces/calc'; +import { CustomFieldInterface } from './interfaces/custom'; +import { CustomAssociatedFieldInterface } from './interfaces/customAssociated'; +import { SignaturePadFieldInterface } from './interfaces/signatureSchema'; +import { CalcResult } from './components/CalcResult'; +import { CustomAssociatedField } from './components/CustomAssociatedField'; +import Expression from './components/Expression'; +import { CustomField } from './components/CustomField'; +import { useGetCustomAssociatedComponents } from './hooks/useGetCustomAssociatedComponents'; +import { useGetCustomComponents } from './hooks/useGetCustomComponents'; + +export enum CustomComponentType { + CUSTOM_FORM_ITEM, + CUSTOM_FIELD, + CUSTOM_ASSOCIATED_FIELD, +} + +export interface CustomComponentOption { + label: string; + name: string; + type?: CustomComponentType; + component: ComponentType; +} +export { usePDFViewerRef } from './schema-initializer/PDFVIewerBlockInitializer'; + +export class PluginCoreClient extends Plugin { + locale: Locale; + + async registerSettings() { + this.app.pluginSettingsManager.add('home_page', { + title: this.locale.lang('HomePage Config'), + icon: 'HomeOutlined', + Component: HomePageConfiguration, + }); + this.app.pluginSettingsManager.add('token', { + title: '第三方接入配置', + icon: 'ShareAltOutlined', + Component: Configuration, + }); + this.app.pluginSettingsManager.add('linkmanage', { + title: '配置链接', + icon: 'ShareAltOutlined', + Component: LinkManager, + }); + this.schemaSettingsManager.add(groupBlockSettings); + this.schemaSettingsManager.add(customComponentDispatcherSettings); + } + + async registerActions() { + const actionSettings = this.app.schemaSettingsManager.get('ActionSettings'); + actionSettings.add('sessionSubmit', { + Component: SessionSubmit, + useVisible() { + const fieldSchema = useFieldSchema(); + return isValid(fieldSchema?.['x-action-settings']?.sessionSubmit); + }, + }); + actionSettings.add('sessionUpdate', { + Component: SessionUpdate, + useVisible() { + const fieldSchema = useFieldSchema(); + return isValid(fieldSchema?.['x-action-settings']?.sessionUpdate); + }, + }); + } + + async registerScopesAndComponents() { + this.app.addScopes({ + useOutboundActionProps, + useCustomizeUpdateActionProps, + useCreateActionProps, + useFilterFormCustomProps, + useFilterBlockActionProps, + usePDFViewerPrintActionProps, + useGetCustomAssociatedComponents, + useGetCustomComponents, + }); + + this.app.addComponents({ + AssociatedField, + Expression, + CustomField, + CustomAssociatedField, + CalcResult, + PDFViewerPrintActionInitializer, + PDFViewerProvider, + GroupBlock, + CustomComponentStub, + CustomComponentDispatcher, + GroupBlockInitializer, + GroupBlockToolbar, + GroupBlockProvider, + Page, + DatePicker, + RemoteSelect, + SignatureInput, + AssociationField: ExtendedAssociationField, + OutboundButton, + OutboundLinkActionInitializer, + PDFViewerBlockInitializer, + PDFViwer: InternalPDFViewer, + AdminLayout: HeraAdminLayout, + ExtendedCalendarBlockInitializer: CalendarBlockInitializer, + SettingBlock: SettingBlockInitializer, + CreateSubmitActionInitializer, + PageLayout, + FilterAssociatedFields, + FilterFormItemCustom, + FilterFormItem, + FilterItemCustomDesigner, + Select, + EditTitle, + EditTitleField, + AfterSuccess, + GroupBlockConfigure, + Menu: { + ...Menu, + // @ts-ignore + Designer: MenuDesigner, + }, + }); + } + + async registerRouters() { + this.app.router.add('outbound', { + path: '/r/:id', + element: , + }); + this.app.router.remove('root'); + this.app.router.add('home', { + path: '/', + element: , + }); + this.app.router.add('admin.details_page', { + path: '/admin/:name/page/:pageId/records/*', + Component: DetailsPage, + }); + } + + async registerTricks() { + // Loading this provider in an unauthenticated state will result in an error; remove it here. + remove(this.app.providers, ([provider]) => provider === RemoteSchemaTemplateManagerProvider); + } + + async registerSchemaInitializer() { + const associationFields = { + type: 'item', + name: 'associationFields', + title: '筛选区块添加一对一的引用', + Component: 'FilterAssociatedFields', + }; + const outboundItem = { + type: 'item', + name: 'enableActions.outbound', + title: '外链', + Component: 'OutboundLinkActionInitializer', + schema: { + 'x-align': 'right', + }, + }; + const calendarBlockItem = { + name: 'calendarV2', + title: '{{t("Calendar")}}', + Component: 'ExtendedCalendarBlockInitializer', + }; + const settingBlockItem = { + name: 'setting', + title: tval('System setting'), + Component: 'SettingBlock', + }; + const refreshActionItem = { + type: 'item', + name: 'refreshAction', + title: "{{t('Refresh')}}", + Component: 'RefreshActionInitializer', + schema: { + 'x-align': 'right', + }, + }; + const customItem = { + title: '自定义', + name: 'custom', + type: 'item', + Component: 'FilterFormItemCustom', + }; + this.schemaInitializerManager.add(pdfViewActionInitializer); + this.app.schemaInitializerManager.addItem('TableActionInitializers', outboundItem.name, outboundItem); + this.app.schemaInitializerManager.get('ReadPrettyFormActionInitializers').add(outboundItem.name, outboundItem); + this.app.schemaInitializerManager.get('BlockInitializers').add(calendarBlockItem.name, calendarBlockItem); + this.app.schemaInitializerManager.get('BlockInitializers').add(settingBlockItem.name, settingBlockItem); + this.app.schemaInitializerManager.get('BlockInitializers').add('dataBlocks.groupBlock', { + title: tval('Group block'), + Component: 'GroupBlockInitializer', + }); + this.app.schemaInitializerManager.get('KanbanActionInitializers').add(refreshActionItem.name, refreshActionItem); + this.app.schemaInitializerManager.get('KanbanActionInitializers').add(outboundItem.name, outboundItem); + this.app.schemaInitializerManager.get('FilterFormItemInitializers').add(associationFields.name, associationFields); + this.app.schemaInitializerManager.addItem('FilterFormItemInitializers', 'custom-item-divider', { + type: 'divider', + }); + this.app.schemaInitializerManager.addItem('FilterFormItemInitializers', customItem.name, customItem); + + const addCustomComponent = { + name: 'addCustomComponent', + title: this.locale.lang('Add custom component'), + Component: 'BlockItemInitializer', + schema: { + type: 'void', + 'x-editable': false, + 'x-decorator': 'FormItem', + 'x-settings': 'customComponentDispatcherSettings', + 'x-component': 'CustomComponentDispatcher', + 'x-component-props': { + component: 'CustomComponentStub', + }, + }, + }; + this.app.schemaInitializerManager.addItem('FormItemInitializers', addCustomComponent.name, addCustomComponent); + this.app.schemaInitializerManager.addItem( + 'ReadPrettyFormItemInitializers', + addCustomComponent.name, + addCustomComponent, + ); + } + + async afterAdd() {} + async beforeLoad() {} + async afterLoad() { + // log for debug + if (process.env.NODE_ENV !== 'production') { + console.info('current components', this.app.components); + console.info('current schemaSettings', this.app.schemaSettingsManager.getAll()); + console.info('current schemaInitializer', this.app.schemaInitializerManager.getAll()); + console.info('current providers', this.app.providers); + } + await this.registerSchemaInitializer(); + } + + async registerInterfaces() { + this.app.dataSourceManager.addFieldInterfaces([ + AssociatedFieldInterface, + CalcFieldInterface, + CustomFieldInterface, + CustomAssociatedFieldInterface, + SignaturePadFieldInterface, + ]); + } + + async load() { + this.locale = new Locale(this.app); + await this.registerTricks(); + await this.registerScopesAndComponents(); + await this.registerSettings(); + await this.registerActions(); + await this.registerRouters(); + await this.registerInterfaces(); + this.schemaSettingsManager.addItem('FilterFormItemSettings', 'formulatitleField', { + Component: EditFormulaTitleField, + useVisible: useFormulaTitleVisible, + }); + this.schemaSettingsManager.addItem('FormItemSettings', 'hera-divider', { + type: 'divider', + useVisible() { + const v1 = useFormulaTitleVisible(); + const v2 = usePaginationVisible(); + return v1 || v2; + }, + }); + this.schemaSettingsManager.addItem('FormItemSettings', 'formulatitleField', { + Component: EditFormulaTitleField, + useVisible: useFormulaTitleVisible, + }); + this.schemaSettingsManager.addItem('FormItemSettings', 'isTablePageSize', { + Component: IsTablePageSize, + useVisible: usePaginationVisible, + }); + this.schemaSettingsManager.addItem('ActionSettings', 'Customize.setFilterScope', { + Component: SetFilterScope, + useVisible: useSetFilterScopeVisible, + useComponentProps() { + const { name } = useCollection_deprecated(); + return { + collectionName: name, + }; + }, + }); + const SchemaSettingOptionItems = this.schemaSettingsManager + .get('ActionSettings') + .items.filter((item) => item.name === 'Customize')[0].children; + SchemaSettingOptionItems.forEach((item) => { + if (item.name === 'afterSuccess') { + (item as SchemaSettingOptions).Component = AfterSuccess; + } + }); + const previewBlockItem = { + title: tval('preview block'), + name: 'previewBlock', + type: 'itemGroup', + children: [], + }; + this.app.schemaInitializerManager.get('RecordBlockInitializers').add(previewBlockItem.name, previewBlockItem); + + // listen to connected events. + autorun(() => { + if (this.app.ws.connected) { + const data = { + type: 'plugin-online-user:client', + payload: { + token: this.app.apiClient.auth.getToken(), + }, + }; + this.app.ws.send(JSON.stringify(data)); + } + }); + } +} + +export default PluginCoreClient; + +export const KEY_CUSTOM_COMPONENT_TYPE = '__componentType'; +export const KEY_CUSTOM_COMPONENT_LABEL = '__componentLabel'; +export const CUSTOM_COMPONENT_TYPE_FIELD = 'FIELD'; +export const CUSTOM_COMPONENT_TYPE_FORM_ITEM = 'FORM_ITEM'; +export const CUSTOM_COMPONENT_TYPE_ASSOCIATED_FIELD = 'ASSOCIATED_FIELD'; diff --git a/packages/plugins/@hera/plugin-core/src/client/interfaces/associated.ts b/packages/plugins/@hera/plugin-core/src/client/interfaces/associated.ts new file mode 100644 index 000000000..bd8640723 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/interfaces/associated.ts @@ -0,0 +1,107 @@ +import { CollectionFieldInterface, defaultProps } from '@nocobase/client'; + +export class AssociatedFieldInterface extends CollectionFieldInterface { + name = 'associated'; + type = 'object'; + group = 'relation'; + order = 10; + title = '关联字段'; + description = '关联字段'; + isAssociation = true; + default = { + type: 'belongsTo', + // name, + uiSchema: { + // title, + 'x-component': 'AssociatedField', + }, + }; + availableTypes = ['belongsTo']; + properties = { + ...defaultProps, + 'uiSchema.x-component-props.collection': { + type: 'string', + title: '关联数据表', + 'x-decorator': 'FormItem', + 'x-component': 'Select', + 'x-component-props': { + multiple: false, + }, + enum: '{{collections}}', + required: true, + }, + 'uiSchema.x-component-props.sourceCollection': { + type: 'string', + title: '查询数据表', + 'x-decorator': 'FormItem', + 'x-component': 'Select', + 'x-component-props': { + multiple: false, + }, + enum: '{{collections}}', + required: true, + }, + 'uiSchema.x-component-props.sourceField': { + type: 'string', + title: '查询数据表到关联数据表的路径', + 'x-component': 'Input', + 'x-decorator': 'FormItem', + required: true, + }, + 'uiSchema.x-component-props.fieldExp': { + type: 'string', + title: '关联字段', + required: true, + 'x-component': 'Expression', + 'x-decorator': 'FormItem', + 'x-component-props': { + useCurrentFields: '{{ useCurrentFields }}', + }, + }, + 'uiSchema.x-component-props.dateFieldExp': { + type: 'string', + title: '关联日期', + required: true, + 'x-component': 'Expression', + 'x-decorator': 'FormItem', + 'x-component-props': { + useCurrentFields: '{{ useCurrentFields }}', + }, + }, + target: { + type: 'string', + title: '关联数据表', + 'x-decorator': 'FormItem', + 'x-component': 'Select', + 'x-component-props': { + multiple: false, + }, + enum: '{{collections}}', + required: true, + 'x-display': 'hidden', + 'x-reactions': [ + { + dependencies: ['uiSchema.x-component-props.collection'], + fulfill: { + state: { + value: '{{$deps[0]}}', + }, + }, + }, + ], + }, + targetKey: { + type: 'string', + title: '关联数据表键', + required: true, + default: 'id', + 'x-display': 'hidden', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + }, + }; + filterable = { + nested: true, + children: [], + }; +} diff --git a/packages/plugins/@hera/plugin-core/src/client/interfaces/calc.ts b/packages/plugins/@hera/plugin-core/src/client/interfaces/calc.ts new file mode 100644 index 000000000..85728c6c3 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/interfaces/calc.ts @@ -0,0 +1,111 @@ +import { interfacesProperties, CollectionFieldInterface } from '@nocobase/client'; +const { defaultProps } = interfacesProperties; + +const formulaType = [ + { + dependencies: ['dataType'], + fulfill: { + state: { + display: '{{["formula"].includes($deps[0]) ? "visible" : "none"}}', + }, + }, + }, +]; + +const panelType = [ + { + dependencies: ['dataType'], + fulfill: { + state: { + display: '{{["jsCode"].includes($deps[0]) ? "visible" : "none"}}', + }, + }, + }, +]; + +export class CalcFieldInterface extends CollectionFieldInterface { + name = 'calc2'; + type = 'object'; + group = 'advanced'; + title = '数据计算'; + description = '数据字段计算'; + sortable = true; + default = { + type: 'virtual', + uiSchema: { + type: 'string', + 'x-component': 'CalcResult', + 'x-component-props': { + formula: '', + prefix: '', + suffix: '', + decimal: '', + panel: '', + }, + 'x-read-pretty': true, + }, + }; + properties = { + ...defaultProps, + dataType: { + type: 'string', + title: '计算类型', + 'x-decorator': 'FormItem', + 'x-component': 'Select', + enum: [ + { value: 'formula', label: '公式' }, + { value: 'jsCode', label: '代码' }, + ], + required: true, + default: 'formula', + description: '公式:使用公式计算,代码:使用js代码计算', + }, + 'uiSchema.x-component-props.formula': { + type: 'string', + title: '公式', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + default: '', + required: true, + 'x-reactions': formulaType, + }, + 'uiSchema.x-component-props.panel': { + type: 'string', + title: 'jsCode', + 'x-decorator': 'FormItem', + 'x-component': 'Input.TextArea', + description: '注意,此内容有值计算操作将以此内容为准,而不是公式,适用于面板中的计算', + required: true, + 'x-reactions': panelType, + }, + 'uiSchema.x-component-props.prefix': { + type: 'string', + title: '前缀', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + }, + 'uiSchema.x-component-props.suffix': { + type: 'string', + title: '后缀', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + }, + 'uiSchema.x-component-props.decimal': { + type: 'string', + title: '{{t("Precision")}}', + 'x-component': 'Select', + 'x-decorator': 'FormItem', + required: true, + default: '0', + enum: [ + { value: '0', label: '1' }, + { value: '1', label: '1.0' }, + { value: '2', label: '1.00' }, + { value: '3', label: '1.000' }, + { value: '4', label: '1.0000' }, + { value: '5', label: '1.00000' }, + ], + 'x-reactions': formulaType, + }, + }; +} diff --git a/packages/plugins/@hera/plugin-core/src/client/interfaces/custom.ts b/packages/plugins/@hera/plugin-core/src/client/interfaces/custom.ts new file mode 100644 index 000000000..48de1d7be --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/interfaces/custom.ts @@ -0,0 +1,31 @@ +import { interfacesProperties, CollectionFieldInterface } from '@nocobase/client'; +const { defaultProps } = interfacesProperties; + +export class CustomFieldInterface extends CollectionFieldInterface { + name = 'custom'; + type = 'object'; + group = 'advanced'; + title = '自定义字段'; + description = '自定义字段'; + sortable = true; + default = { + type: 'virtual', + uiSchema: { + type: 'string', + 'x-component': 'CustomField', + 'x-read-pretty': true, + }, + }; + properties = { + ...defaultProps, + 'uiSchema.x-component-props.component': { + type: 'string', + title: '组件', + 'x-decorator': 'FormItem', + 'x-component': 'Select', + enum: ' {{ useGetCustomComponents() }} ', + required: true, + description: '需要在插件中注册相应的组件后可以使用', + }, + }; +} diff --git a/packages/plugins/@hera/plugin-core/src/client/interfaces/customAssociated.ts b/packages/plugins/@hera/plugin-core/src/client/interfaces/customAssociated.ts new file mode 100644 index 000000000..b254e657a --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/interfaces/customAssociated.ts @@ -0,0 +1,57 @@ +import { defaultProps, CollectionFieldInterface } from '@nocobase/client'; + +export class CustomAssociatedFieldInterface extends CollectionFieldInterface { + name = 'customAssociated'; + type = 'object'; + group = 'relation'; + order = 10; + title = '自定义关联字段'; + description = '自定义关联字段'; + isAssociation = true; + default = { + type: 'virtual', + // type: 'belongsTo', + // name, + uiSchema: { + // title, + 'x-component': 'CustomAssociatedField', + }, + }; + availableTypes = ['belongsTo']; + properties = { + ...defaultProps, + 'uiSchema.x-component-props.component': { + type: 'string', + title: '组件', + 'x-decorator': 'FormItem', + 'x-component': 'Select', + enum: ' {{ useGetCustomAssociatedComponents() }}', + required: true, + description: '需要在插件中注册相应的组件后可以使用', + }, + target: { + type: 'string', + title: '关联数据表', + 'x-decorator': 'FormItem', + 'x-component': 'Select', + 'x-component-props': { + multiple: false, + }, + enum: '{{collections}}', + required: true, + }, + targetKey: { + type: 'string', + title: '关联数据表键', + required: true, + default: 'id', + 'x-display': 'hidden', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + }, + }; + filterable = { + nested: true, + children: [], + }; +} diff --git a/packages/plugins/@hera/plugin-core/src/client/interfaces/signatureSchema.ts b/packages/plugins/@hera/plugin-core/src/client/interfaces/signatureSchema.ts new file mode 100644 index 000000000..1f28fa69a --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/interfaces/signatureSchema.ts @@ -0,0 +1,25 @@ +import { defaultProps, CollectionFieldInterface } from '@nocobase/client'; +import { tval } from '../locale'; + +export class SignaturePadFieldInterface extends CollectionFieldInterface { + name = 'signatureSchema'; + type = 'object'; + group = 'advanced'; + order = 2; // 可以调整字段的顺序 + title = tval('Signature input'); + sortable = true; + default = { + interface: 'signature', // 添加手写签名的接口标识 + type: 'json', + uiSchema: { + type: 'signature', // 添加手写签名的 UI 类型 + 'x-component': 'SignatureInput', + }, + }; + availableTypes = ['json']; + hasDefaultValue = false; // 手写签名通常不需要默认值 + + properties = { + ...defaultProps, + }; +} diff --git a/packages/plugins/@hera/plugin-core/src/client/layouts/index.tsx b/packages/plugins/@hera/plugin-core/src/client/layouts/index.tsx new file mode 100644 index 000000000..c0954df59 --- /dev/null +++ b/packages/plugins/@hera/plugin-core/src/client/layouts/index.tsx @@ -0,0 +1,510 @@ +import { css } from '@emotion/css'; +import { useSessionStorageState } from 'ahooks'; +import { App, Layout, Spin, FloatButton } from 'antd'; +import { ToolOutlined, CommentOutlined, CalculatorOutlined, HighlightOutlined } from '@ant-design/icons'; +import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { Link, Outlet, useMatch, useNavigate, useParams } from 'react-router-dom'; +import { + CurrentUser, + PinnedPluginList, + SchemaComponent, + findByUid, + findMenuItem, + useACLRoleContext, + useAdminSchemaUid, + useDocumentTitle, + useRequest, + useSystemSettings, + useToken, + useApp, + AdminProvider, + RemoteSchemaComponent, + useCurrentUserSettingsMenu, + SelectWithTitle, + useDesignable, +} from '@nocobase/client'; +import { Tabs } from 'antd'; +import type { TabsProps } from 'antd'; +import { usePluginVersion } from '../hooks/usePluginVersion'; +import { OnlineUserDropdown } from '../components/OnlineUserProvider'; +import { MobileLink } from '../components/MobileLink'; +import { Notifications } from '../components/Notifications'; +import { useTranslation } from '../locale'; + +export const useAppSpin = () => { + const app = useApp(); + return { + render: () => (app ? app?.renderComponent?.('AppSpin') : React.createElement(Spin)), + }; +}; + +const filterByACL = (schema, options) => { + const { allowAll, allowMenuItemIds = [] } = options; + if (allowAll) { + return schema; + } + const filterSchema = (s) => { + if (!s) { + return; + } + for (const key in s.properties) { + if (Object.prototype.hasOwnProperty.call(s.properties, key)) { + const element = s.properties[key]; + if (element['x-uid'] && !allowMenuItemIds.includes(element['x-uid'])) { + delete s.properties[key]; + } + if (element['x-uid']) { + filterSchema(element); + } + } + } + }; + filterSchema(schema); + return schema; +}; + +const SchemaIdContext = createContext(null); +const useMenuProps = () => { + const defaultSelectedUid = useContext(SchemaIdContext); + return { + selectedUid: defaultSelectedUid, + defaultSelectedUid, + }; +}; + +const MenuEditor = (props) => { + const { notification } = App.useApp(); + const [hasNotice, setHasNotice] = useSessionStorageState('plugin-notice', { defaultValue: false }); + const { setTitle } = useDocumentTitle(); + const navigate = useNavigate(); + const params = useParams(); + const isMatchAdmin = useMatch('/admin'); + const isMatchAdminName = useMatch('/admin/:name'); + const defaultSelectedUid = params.name; + const { sideMenuRef } = props; + const ctx = useACLRoleContext(); + const [current, setCurrent] = useState(null); + const onSelect = ({ item }) => { + const schema = item.props.schema; + setTitle(schema.title); + setCurrent(schema); + navigate(`/admin/${schema['x-uid']}`); + }; + const { render } = useAppSpin(); + const adminSchemaUid = useAdminSchemaUid(); + const { data, loading } = useRequest<{ + data: any; + }>( + { + url: `/uiSchemas:getJsonSchema/${adminSchemaUid}`, + }, + { + refreshDeps: [adminSchemaUid], + onSuccess(data) { + const schema = filterByACL(data?.data, ctx); + // url 为 `/admin` 的情况 + if (isMatchAdmin) { + const s = findMenuItem(schema); + if (s) { + navigate(`/admin/${s['x-uid']}`); + setTitle(s.title); + } else { + navigate(`/admin/`); + } + return; + } + + // url 不为 `/admin/xxx` 的情况,不做处理 + if (!isMatchAdminName) return; + + // url 为 `admin/xxx` 的情况 + const s = findByUid(schema, defaultSelectedUid); + if (s) { + setTitle(s.title); + } else { + const s = findMenuItem(schema); + if (s) { + navigate(`/admin/${s['x-uid']}`); + setTitle(s.title); + } else { + navigate(`/admin/`); + } + } + }, + }, + ); + + useEffect(() => { + const properties = Object.values(current?.root?.properties || {}).shift()?.['properties'] || data?.data?.properties; + if (properties && sideMenuRef.current) { + const pageType = Object.values(properties).find( + (item) => item['x-uid'] === params.name && item['x-component'] === 'Menu.Item', + ); + if (pageType) { + sideMenuRef.current.style.display = 'none'; + } else { + sideMenuRef.current.style.display = 'block'; + } + } + }, [data?.data, params.name, sideMenuRef]); + + const schema = useMemo(() => { + const s = filterByACL(data?.data, ctx); + if (s?.['x-component-props']) { + s['x-component-props']['useProps'] = useMenuProps; + } + return s; + }, [data?.data]); + + useRequest( + { + url: 'applicationPlugins:list', + params: { + sort: 'id', + paginate: false, + }, + }, + { + onSuccess: ({ data }) => { + setHasNotice(true); + const errorPlugins = data.filter((item) => !item.isCompatible); + if (errorPlugins.length) { + notification.error({ + message: 'Plugin dependencies check failed', + description: ( +
+
+ These plugins failed dependency checks. Please go to the{' '} + plugin management page for more details.{' '} +
+
    + {errorPlugins.map((item) => ( +
  • + {item.displayName} - {item.packageName} +
  • + ))} +
+
+ ), + }); + } + }, + manual: true, + // ready: !hasNotice, + }, + ); + + if (loading) { + return void 0; + } + return ( + + + + ); +}; + +export function MyRouteSchemaComponent({ name }: { name: string }) { + return ; +} + +export const InternalAdminLayout = (props: any) => { + const app = useApp(); + const sideMenuRef = useRef(); + const result = useSystemSettings(); + const params = useParams<{ name?: string }>(); + const { token } = useToken(); + const { render } = useAppSpin(); + const { title } = useDocumentTitle(); + const navigate = useNavigate(); + const [items, setItems] = useState([]); + const pageStyle = usePageStyle(); + + useEffect(() => { + if (params.name && title && pageStyle === 'tab') { + setItems((items) => { + if (!items.find((value) => value.key === params.name)) { + return [ + ...items, + { + key: params.name, + label: title, + children: , + }, + ]; + } else { + return items; + } + }); + } + }, [params.name, title]); + + const onEdit = (targetKey: React.MouseEvent | React.KeyboardEvent | string, action: 'add' | 'remove') => { + if (action === 'remove') { + setItems((items) => { + return items.filter((item) => item.key !== targetKey); + }); + } + }; + + return ( + + .ant-menu-item:hover { + background-color: ${token.colorBgHeaderMenuHover}; + color: ${token.colorTextHeaderMenuHover}; + } + + position: fixed; + left: 0; + right: 0; + height: var(--nb-header-height); + line-height: var(--nb-header-height); + padding: 0; + z-index: 100; + background-color: ${token.colorBgHeader}; + + .ant-menu { + background-color: transparent; + } + + .ant-menu-item { + color: ${token.colorTextHeaderMenu}; + } + `} + > +
+
+
+ +

+ {result?.data?.data?.title} +

+
+
+ +
+
+
+ + + + + +
+
+
+ {params.name && ( + + )} + div { + position: relative; + } + .ant-layout-footer { + position: absolute; + bottom: 0; + text-align: center; + width: 100%; + z-index: 0; + padding: 0px 50px; + } + `} + > +
+ <> + {params.name && pageStyle === 'tab' ? ( + { + navigate(`/admin/${key}`); + }} + activeKey={params.name} + /> + ) : ( + + )} + +
+
+ ); +}; + +export const useTabSettings = (props) => { + return { + key: 'tab', + eventKey: 'tab', + label: