feat: harden ai agent admin workflow experience
This commit is contained in:
+23
@@ -187,8 +187,15 @@ docker/web/index/*
|
|||||||
uniapp-zq/
|
uniapp-zq/
|
||||||
web/apps/web-ele/src/components/screen-design/
|
web/apps/web-ele/src/components/screen-design/
|
||||||
web/apps/web-ele/src/components/form-design/
|
web/apps/web-ele/src/components/form-design/
|
||||||
|
web/apps/web-ele/src/components/contract-design/
|
||||||
|
web/apps/web-ele/src/components/import-export-manager/
|
||||||
|
web/apps/web-ele/src/components/wiki/
|
||||||
|
web/apps/web-ele/src/components/zq-draw/
|
||||||
web/apps/web-ele/src/components/zq-editor/
|
web/apps/web-ele/src/components/zq-editor/
|
||||||
|
web/apps/web-ele/src/components/zq-smart-table/
|
||||||
|
web/apps/web-ele/src/components/zq-whiteboard/
|
||||||
web/apps/web-ele/src/components/whiteboard/
|
web/apps/web-ele/src/components/whiteboard/
|
||||||
|
web/apps/web-ele/src/components/workflow/
|
||||||
web/apps/web-ele/src/views/online-dev/
|
web/apps/web-ele/src/views/online-dev/
|
||||||
!web/apps/web-ele/src/views/online-dev/
|
!web/apps/web-ele/src/views/online-dev/
|
||||||
web/apps/web-ele/src/views/online-dev/*
|
web/apps/web-ele/src/views/online-dev/*
|
||||||
@@ -252,11 +259,26 @@ web/docs/src/public/guide/
|
|||||||
web/packages/zq-univer/
|
web/packages/zq-univer/
|
||||||
web/apps/web-ele/src/locales/langs/*/dashboard-design.json*
|
web/apps/web-ele/src/locales/langs/*/dashboard-design.json*
|
||||||
!web/apps/web-ele/src/locales/langs/*/dashboard-design.json
|
!web/apps/web-ele/src/locales/langs/*/dashboard-design.json
|
||||||
|
web/apps/web-ele/src/locales/langs/*/*.json_new
|
||||||
|
web/apps/web-ele/src/locales/langs/*/*.json_sync
|
||||||
|
web/apps/web-ele/src/locales/langs/*/contract-design.json
|
||||||
|
web/apps/web-ele/src/locales/langs/*/draw.json
|
||||||
web/apps/web-ele/src/locales/langs/*/form-design.json
|
web/apps/web-ele/src/locales/langs/*/form-design.json
|
||||||
web/apps/web-ele/src/locales/langs/*/screen-design.json
|
web/apps/web-ele/src/locales/langs/*/screen-design.json
|
||||||
|
web/apps/web-ele/src/locales/langs/*/whiteboard.json
|
||||||
|
web/apps/web-ele/src/locales/langs/*/wiki.json
|
||||||
|
web/apps/web-ele/src/locales/langs/*/zq-editor.json
|
||||||
|
web/apps/web-ele/src/locales/langs/*/zq-smart-table.json
|
||||||
web/apps/web-ele/src/router/routes/modules/form-layout.ts
|
web/apps/web-ele/src/router/routes/modules/form-layout.ts
|
||||||
web/apps/web-ele/src/router/routes/modules/online-dev.ts
|
web/apps/web-ele/src/router/routes/modules/online-dev.ts
|
||||||
|
web/apps/web-ele/src/router/routes/modules/chat.ts
|
||||||
|
web/apps/web-ele/src/router/routes/modules/demos.ts
|
||||||
web/apps/web-ele/src/router/routes/modules/zq-smart-table.ts
|
web/apps/web-ele/src/router/routes/modules/zq-smart-table.ts
|
||||||
|
web/apps/web-ele/src/api/smart-table.ts
|
||||||
|
web/apps/web-ele/src/composables/use-offline-cache.ts
|
||||||
|
web/apps/web-ele/src/store/wiki.ts
|
||||||
|
web/apps/web-ele/src/store/zq-smart-table.ts
|
||||||
|
web/apps/web-ele/src/styles/zq-smart-table/
|
||||||
web/apps/web-ele/src/components/report-design/
|
web/apps/web-ele/src/components/report-design/
|
||||||
backend-fastapi/online_dev/
|
backend-fastapi/online_dev/
|
||||||
!backend-fastapi/online_dev/
|
!backend-fastapi/online_dev/
|
||||||
@@ -329,6 +351,7 @@ web/apps/web-ele/src/views/_core/database-monitor/
|
|||||||
!web/apps/web-ele/src/views/_core/database-monitor/
|
!web/apps/web-ele/src/views/_core/database-monitor/
|
||||||
!web/apps/web-ele/src/views/_core/database-monitor/**
|
!web/apps/web-ele/src/views/_core/database-monitor/**
|
||||||
web/apps/web-ele/src/views/_core/demo/
|
web/apps/web-ele/src/views/_core/demo/
|
||||||
|
web/apps/web-ele/src/views/_core/login-log/README.md
|
||||||
web/apps/web-ele/src/views/_core/mobile-signature/
|
web/apps/web-ele/src/views/_core/mobile-signature/
|
||||||
web/apps/web-ele/src/views/_core/redis-manager/
|
web/apps/web-ele/src/views/_core/redis-manager/
|
||||||
web/apps/web-ele/src/views/_core/redis-monitor/
|
web/apps/web-ele/src/views/_core/redis-monitor/
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
"grid-layout-plus": "^1.1.1",
|
"grid-layout-plus": "^1.1.1",
|
||||||
"pinia": "catalog:",
|
"pinia": "catalog:",
|
||||||
"vue": "catalog:",
|
"vue": "catalog:",
|
||||||
|
"vuedraggable": "^4.1.0",
|
||||||
"vue-router": "catalog:"
|
"vue-router": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -5,84 +5,72 @@
|
|||||||
|
|
||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue';
|
||||||
|
|
||||||
import type { BaseFormComponentType } from '@vben-core/form-ui';
|
import type { BaseFormComponentType } from '@vben/common-ui';
|
||||||
import type { Recordable } from '@vben/types';
|
import type { Recordable } from '@vben/types';
|
||||||
|
|
||||||
import { defineAsyncComponent, defineComponent, h, ref } from 'vue';
|
import { defineAsyncComponent, defineComponent, h, ref } from 'vue';
|
||||||
|
|
||||||
import { globalShareState } from '@vben-core/shared/global-state';
|
import { ApiComponent, globalShareState } from '@vben/common-ui';
|
||||||
import { $t } from '@vben/locales';
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
import { ApiComponent } from '@vben/common-ui/es/api-component';
|
import { ElNotification } from 'element-plus';
|
||||||
|
|
||||||
const ElButton = defineAsyncComponent(() =>
|
const ElButton = defineAsyncComponent(() =>
|
||||||
import('element-plus/es/components/button/index').then((res) => res.ElButton),
|
Promise.all([
|
||||||
|
import('element-plus/es/components/button/index'),
|
||||||
|
import('element-plus/es/components/button/style/css'),
|
||||||
|
]).then(([res]) => res.ElButton),
|
||||||
);
|
);
|
||||||
const ElCheckbox = defineAsyncComponent(() =>
|
const ElCheckbox = defineAsyncComponent(() =>
|
||||||
import('element-plus/es/components/checkbox/index').then(
|
Promise.all([
|
||||||
(res) => res.ElCheckbox,
|
import('element-plus/es/components/checkbox/index'),
|
||||||
),
|
import('element-plus/es/components/checkbox/style/css'),
|
||||||
|
]).then(([res]) => res.ElCheckbox),
|
||||||
);
|
);
|
||||||
const ElCheckboxButton = defineAsyncComponent(() =>
|
const ElCheckboxButton = defineAsyncComponent(() =>
|
||||||
import('element-plus/es/components/checkbox/index').then(
|
Promise.all([
|
||||||
(res) => res.ElCheckboxButton,
|
import('element-plus/es/components/checkbox/index'),
|
||||||
),
|
import('element-plus/es/components/checkbox-button/style/css'),
|
||||||
|
]).then(([res]) => res.ElCheckboxButton),
|
||||||
);
|
);
|
||||||
const ElCheckboxGroup = defineAsyncComponent(() =>
|
const ElCheckboxGroup = defineAsyncComponent(() =>
|
||||||
import('element-plus/es/components/checkbox/index').then(
|
Promise.all([
|
||||||
(res) => res.ElCheckboxGroup,
|
import('element-plus/es/components/checkbox/index'),
|
||||||
),
|
import('element-plus/es/components/checkbox-group/style/css'),
|
||||||
|
]).then(([res]) => res.ElCheckboxGroup),
|
||||||
);
|
);
|
||||||
const ElDivider = defineAsyncComponent(() =>
|
const ElCascader = defineAsyncComponent(() =>
|
||||||
import('element-plus/es/components/divider/index').then(
|
Promise.all([
|
||||||
(res) => res.ElDivider,
|
import('element-plus/es/components/cascader/index'),
|
||||||
),
|
import('element-plus/es/components/cascader/style/css'),
|
||||||
);
|
]).then(([res]) => res.ElCascader),
|
||||||
const ElInput = defineAsyncComponent(() =>
|
|
||||||
import('element-plus/es/components/input/index').then((res) => res.ElInput),
|
|
||||||
);
|
|
||||||
const ElRadio = defineAsyncComponent(() =>
|
|
||||||
import('element-plus/es/components/radio/index').then((res) => res.ElRadio),
|
|
||||||
);
|
|
||||||
const ElRadioButton = defineAsyncComponent(() =>
|
|
||||||
import('element-plus/es/components/radio/index').then(
|
|
||||||
(res) => res.ElRadioButton,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const ElRadioGroup = defineAsyncComponent(() =>
|
|
||||||
import('element-plus/es/components/radio/index').then(
|
|
||||||
(res) => res.ElRadioGroup,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const ElSpace = defineAsyncComponent(() =>
|
|
||||||
import('element-plus/es/components/space/index').then((res) => res.ElSpace),
|
|
||||||
);
|
);
|
||||||
const ElDatePicker = defineAsyncComponent(() =>
|
const ElDatePicker = defineAsyncComponent(() =>
|
||||||
import('element-plus/es/components/date-picker/index').then(
|
Promise.all([
|
||||||
(res) => res.ElDatePicker,
|
import('element-plus/es/components/date-picker/index'),
|
||||||
|
import('element-plus/es/components/date-picker/style/css'),
|
||||||
|
]).then(([res]) => res.ElDatePicker),
|
||||||
|
);
|
||||||
|
const DeptSelector = defineAsyncComponent(() =>
|
||||||
|
import('#/components/zq-form/dept-selector/dept-selector.vue').then(
|
||||||
|
(res) => res.default,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const ElInputNumber = defineAsyncComponent(() =>
|
const PostSelector = defineAsyncComponent(() =>
|
||||||
import('element-plus/es/components/input-number/index').then(
|
import('#/components/zq-form/post-selector/post-selector.vue').then(
|
||||||
(res) => res.ElInputNumber,
|
(res) => res.default,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const ElSelectV2 = defineAsyncComponent(() =>
|
|
||||||
import('element-plus/es/components/select-v2/index').then(
|
|
||||||
(res) => res.ElSelectV2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const ElTreeSelect = defineAsyncComponent(() =>
|
|
||||||
import('element-plus/es/components/tree-select/index').then(
|
|
||||||
(res) => res.ElTreeSelect,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const RoleSelector = defineAsyncComponent(() =>
|
const RoleSelector = defineAsyncComponent(() =>
|
||||||
import('#/components/zq-form/role-selector/role-selector.vue').then(
|
import('#/components/zq-form/role-selector/role-selector.vue').then(
|
||||||
(res) => res.default,
|
(res) => res.default,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const FileSelector = defineAsyncComponent(() =>
|
||||||
|
import('#/components/zq-form/file-selector/file-selector.vue').then(
|
||||||
|
(res) => res.default,
|
||||||
|
),
|
||||||
|
);
|
||||||
const ImageSelector = defineAsyncComponent(() =>
|
const ImageSelector = defineAsyncComponent(() =>
|
||||||
import('#/components/zq-form/image-selector/image-selector.vue').then(
|
import('#/components/zq-form/image-selector/image-selector.vue').then(
|
||||||
(res) => res.default,
|
(res) => res.default,
|
||||||
@@ -93,11 +81,106 @@ const UserSelector = defineAsyncComponent(() =>
|
|||||||
(res) => res.default,
|
(res) => res.default,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const JsonEditor = defineAsyncComponent(() =>
|
||||||
|
import('#/components/json-editor/json-editor.vue').then((res) => res.default),
|
||||||
|
);
|
||||||
|
const FormSelector = defineAsyncComponent(() =>
|
||||||
|
import('#/components/zq-form/form-selector/form-selector.vue').then(
|
||||||
|
(res) => res.default,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const TableSelector = defineAsyncComponent(() =>
|
||||||
|
import('#/components/zq-form/table-selector/table-selector.vue').then(
|
||||||
|
(res) => res.default,
|
||||||
|
),
|
||||||
|
);
|
||||||
const ZqIconPicker = defineAsyncComponent(() =>
|
const ZqIconPicker = defineAsyncComponent(() =>
|
||||||
import('#/components/zq-form/zq-icon-picker/zq-icon-picker.vue').then(
|
import('#/components/zq-form/zq-icon-picker/zq-icon-picker.vue').then(
|
||||||
(res) => res.default,
|
(res) => res.default,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const CronSelector = defineAsyncComponent(() =>
|
||||||
|
import('#/components/zq-form/cron-selector/cron-selector.vue').then(
|
||||||
|
(res) => res.default,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const CodeEditor = defineAsyncComponent(() =>
|
||||||
|
import('#/components/zq-form/code-editor/code-editor.vue').then(
|
||||||
|
(res) => res.default,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const ElDivider = defineAsyncComponent(() =>
|
||||||
|
Promise.all([
|
||||||
|
import('element-plus/es/components/divider/index'),
|
||||||
|
import('element-plus/es/components/divider/style/css'),
|
||||||
|
]).then(([res]) => res.ElDivider),
|
||||||
|
);
|
||||||
|
const ElInput = defineAsyncComponent(() =>
|
||||||
|
Promise.all([
|
||||||
|
import('element-plus/es/components/input/index'),
|
||||||
|
import('element-plus/es/components/input/style/css'),
|
||||||
|
]).then(([res]) => res.ElInput),
|
||||||
|
);
|
||||||
|
const ElInputNumber = defineAsyncComponent(() =>
|
||||||
|
Promise.all([
|
||||||
|
import('element-plus/es/components/input-number/index'),
|
||||||
|
import('element-plus/es/components/input-number/style/css'),
|
||||||
|
]).then(([res]) => res.ElInputNumber),
|
||||||
|
);
|
||||||
|
const ElRadio = defineAsyncComponent(() =>
|
||||||
|
Promise.all([
|
||||||
|
import('element-plus/es/components/radio/index'),
|
||||||
|
import('element-plus/es/components/radio/style/css'),
|
||||||
|
]).then(([res]) => res.ElRadio),
|
||||||
|
);
|
||||||
|
const ElRadioButton = defineAsyncComponent(() =>
|
||||||
|
Promise.all([
|
||||||
|
import('element-plus/es/components/radio/index'),
|
||||||
|
import('element-plus/es/components/radio-button/style/css'),
|
||||||
|
]).then(([res]) => res.ElRadioButton),
|
||||||
|
);
|
||||||
|
const ElRadioGroup = defineAsyncComponent(() =>
|
||||||
|
Promise.all([
|
||||||
|
import('element-plus/es/components/radio/index'),
|
||||||
|
import('element-plus/es/components/radio-group/style/css'),
|
||||||
|
]).then(([res]) => res.ElRadioGroup),
|
||||||
|
);
|
||||||
|
const ElSelectV2 = defineAsyncComponent(() =>
|
||||||
|
Promise.all([
|
||||||
|
import('element-plus/es/components/select-v2/index'),
|
||||||
|
import('element-plus/es/components/select-v2/style/css'),
|
||||||
|
]).then(([res]) => res.ElSelectV2),
|
||||||
|
);
|
||||||
|
const ElSpace = defineAsyncComponent(() =>
|
||||||
|
Promise.all([
|
||||||
|
import('element-plus/es/components/space/index'),
|
||||||
|
import('element-plus/es/components/space/style/css'),
|
||||||
|
]).then(([res]) => res.ElSpace),
|
||||||
|
);
|
||||||
|
const ElSwitch = defineAsyncComponent(() =>
|
||||||
|
Promise.all([
|
||||||
|
import('element-plus/es/components/switch/index'),
|
||||||
|
import('element-plus/es/components/switch/style/css'),
|
||||||
|
]).then(([res]) => res.ElSwitch),
|
||||||
|
);
|
||||||
|
const ElTimePicker = defineAsyncComponent(() =>
|
||||||
|
Promise.all([
|
||||||
|
import('element-plus/es/components/time-picker/index'),
|
||||||
|
import('element-plus/es/components/time-picker/style/css'),
|
||||||
|
]).then(([res]) => res.ElTimePicker),
|
||||||
|
);
|
||||||
|
const ElTreeSelect = defineAsyncComponent(() =>
|
||||||
|
Promise.all([
|
||||||
|
import('element-plus/es/components/tree-select/index'),
|
||||||
|
import('element-plus/es/components/tree-select/style/css'),
|
||||||
|
]).then(([res]) => res.ElTreeSelect),
|
||||||
|
);
|
||||||
|
const ElUpload = defineAsyncComponent(() =>
|
||||||
|
Promise.all([
|
||||||
|
import('element-plus/es/components/upload/index'),
|
||||||
|
import('element-plus/es/components/upload/style/css'),
|
||||||
|
]).then(([res]) => res.ElUpload),
|
||||||
|
);
|
||||||
|
|
||||||
const withDefaultPlaceholder = <T extends Component>(
|
const withDefaultPlaceholder = <T extends Component>(
|
||||||
component: T,
|
component: T,
|
||||||
@@ -105,7 +188,7 @@ const withDefaultPlaceholder = <T extends Component>(
|
|||||||
componentProps: Recordable<any> = {},
|
componentProps: Recordable<any> = {},
|
||||||
) => {
|
) => {
|
||||||
return defineComponent({
|
return defineComponent({
|
||||||
name: (component as any).name,
|
name: component.name,
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
setup: (props: any, { attrs, expose, slots }) => {
|
setup: (props: any, { attrs, expose, slots }) => {
|
||||||
const placeholder =
|
const placeholder =
|
||||||
@@ -134,33 +217,41 @@ const withDefaultPlaceholder = <T extends Component>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
|
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
|
||||||
let initialized = false;
|
|
||||||
|
|
||||||
export type ComponentType =
|
export type ComponentType =
|
||||||
| 'ApiSelect'
|
| 'ApiSelect'
|
||||||
| 'ApiTreeSelect'
|
| 'ApiTreeSelect'
|
||||||
|
| 'Cascader'
|
||||||
| 'Checkbox'
|
| 'Checkbox'
|
||||||
| 'CheckboxGroup'
|
| 'CheckboxGroup'
|
||||||
| 'CodeEditor'
|
| 'CodeEditor'
|
||||||
|
| 'CronSelector'
|
||||||
| 'DatePicker'
|
| 'DatePicker'
|
||||||
|
| 'DeptSelector'
|
||||||
| 'Divider'
|
| 'Divider'
|
||||||
|
| 'FileSelector'
|
||||||
|
| 'FormSelector'
|
||||||
| 'IconPicker'
|
| 'IconPicker'
|
||||||
| 'ImageSelector'
|
| 'ImageSelector'
|
||||||
| 'Input'
|
| 'Input'
|
||||||
| 'InputNumber'
|
| 'InputNumber'
|
||||||
|
| 'JsonEditor'
|
||||||
|
| 'PostSelector'
|
||||||
| 'RadioGroup'
|
| 'RadioGroup'
|
||||||
| 'RoleSelector'
|
| 'RoleSelector'
|
||||||
| 'Select'
|
| 'Select'
|
||||||
| 'Space'
|
| 'Space'
|
||||||
|
| 'Switch'
|
||||||
|
| 'TableSelector'
|
||||||
|
| 'TaskArgsEditor'
|
||||||
|
| 'TaskKwargsEditor'
|
||||||
| 'Textarea'
|
| 'Textarea'
|
||||||
|
| 'TimePicker'
|
||||||
|
| 'TreeSelect'
|
||||||
|
| 'Upload'
|
||||||
| 'UserSelector'
|
| 'UserSelector'
|
||||||
| BaseFormComponentType;
|
| BaseFormComponentType;
|
||||||
|
|
||||||
function initComponentAdapter() {
|
async function initComponentAdapter() {
|
||||||
if (initialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const components: Partial<Record<ComponentType, Component>> = {
|
const components: Partial<Record<ComponentType, Component>> = {
|
||||||
// 如果你的组件体积比较大,可以使用异步加载
|
// 如果你的组件体积比较大,可以使用异步加载
|
||||||
// Button: () =>
|
// Button: () =>
|
||||||
@@ -192,26 +283,24 @@ function initComponentAdapter() {
|
|||||||
visibleEvent: 'onVisibleChange',
|
visibleEvent: 'onVisibleChange',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
Cascader: withDefaultPlaceholder(ElCascader, 'select'),
|
||||||
Checkbox: ElCheckbox,
|
Checkbox: ElCheckbox,
|
||||||
CheckboxGroup: (props, { attrs, slots }) => {
|
CheckboxGroup: (props, { attrs, slots }) => {
|
||||||
const mergedProps = { ...props, ...attrs } as Recordable<any>;
|
|
||||||
let defaultSlot;
|
let defaultSlot;
|
||||||
if (Reflect.has(slots, 'default')) {
|
if (Reflect.has(slots, 'default')) {
|
||||||
defaultSlot = slots.default;
|
defaultSlot = slots.default;
|
||||||
} else {
|
} else {
|
||||||
const { options, isButton } = mergedProps;
|
const { options, isButton } = attrs;
|
||||||
if (Array.isArray(options)) {
|
if (Array.isArray(options)) {
|
||||||
defaultSlot = () =>
|
defaultSlot = () =>
|
||||||
options.map((option) =>
|
options.map((option) =>
|
||||||
h(isButton ? ElCheckboxButton : ElCheckbox, option, () =>
|
h(isButton ? ElCheckboxButton : ElCheckbox, option),
|
||||||
option.label ?? option.value,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return h(
|
return h(
|
||||||
ElCheckboxGroup,
|
ElCheckboxGroup,
|
||||||
mergedProps,
|
{ ...props, ...attrs },
|
||||||
{ ...slots, default: defaultSlot },
|
{ ...slots, default: defaultSlot },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -223,36 +312,47 @@ function initComponentAdapter() {
|
|||||||
PrimaryButton: (props, { attrs, slots }) => {
|
PrimaryButton: (props, { attrs, slots }) => {
|
||||||
return h(ElButton, { ...props, attrs, type: 'primary' }, slots);
|
return h(ElButton, { ...props, attrs, type: 'primary' }, slots);
|
||||||
},
|
},
|
||||||
|
DeptSelector: withDefaultPlaceholder(DeptSelector, 'select'),
|
||||||
|
PostSelector: withDefaultPlaceholder(PostSelector, 'select'),
|
||||||
RoleSelector: withDefaultPlaceholder(RoleSelector, 'select'),
|
RoleSelector: withDefaultPlaceholder(RoleSelector, 'select'),
|
||||||
Divider: ElDivider,
|
Divider: ElDivider,
|
||||||
|
FileSelector: withDefaultPlaceholder(FileSelector, 'select'),
|
||||||
|
FormSelector: withDefaultPlaceholder(FormSelector, 'select'),
|
||||||
|
TableSelector: withDefaultPlaceholder(TableSelector, 'select'),
|
||||||
IconPicker: withDefaultPlaceholder(ZqIconPicker, 'select'),
|
IconPicker: withDefaultPlaceholder(ZqIconPicker, 'select'),
|
||||||
ImageSelector: withDefaultPlaceholder(ImageSelector, 'select'),
|
ImageSelector: withDefaultPlaceholder(ImageSelector, 'select'),
|
||||||
Input: withDefaultPlaceholder(ElInput, 'input'),
|
Input: withDefaultPlaceholder(ElInput, 'input'),
|
||||||
Textarea: withDefaultPlaceholder(ElInput, 'input', { type: 'textarea' }),
|
Textarea: withDefaultPlaceholder(ElInput, 'input', { type: 'textarea' }),
|
||||||
CodeEditor: withDefaultPlaceholder(ElInput, 'input', {
|
JsonEditor,
|
||||||
autosize: { minRows: 8 },
|
CodeEditor,
|
||||||
type: 'textarea',
|
CronSelector,
|
||||||
}),
|
TaskArgsEditor: defineAsyncComponent(() =>
|
||||||
|
import('#/views/_core/scheduler/modules/task-args-editor.vue').then(
|
||||||
|
(res) => res.default,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TaskKwargsEditor: defineAsyncComponent(() =>
|
||||||
|
import('#/views/_core/scheduler/modules/task-kwargs-editor.vue').then(
|
||||||
|
(res) => res.default,
|
||||||
|
),
|
||||||
|
),
|
||||||
InputNumber: withDefaultPlaceholder(ElInputNumber, 'input'),
|
InputNumber: withDefaultPlaceholder(ElInputNumber, 'input'),
|
||||||
RadioGroup: (props, { attrs, slots }) => {
|
RadioGroup: (props, { attrs, slots }) => {
|
||||||
const mergedProps = { ...props, ...attrs } as Recordable<any>;
|
|
||||||
let defaultSlot;
|
let defaultSlot;
|
||||||
if (Reflect.has(slots, 'default')) {
|
if (Reflect.has(slots, 'default')) {
|
||||||
defaultSlot = slots.default;
|
defaultSlot = slots.default;
|
||||||
} else {
|
} else {
|
||||||
const { options, isButton } = mergedProps;
|
const { options } = attrs;
|
||||||
if (Array.isArray(options)) {
|
if (Array.isArray(options)) {
|
||||||
defaultSlot = () =>
|
defaultSlot = () =>
|
||||||
options.map((option) =>
|
options.map((option) =>
|
||||||
h(isButton ? ElRadioButton : ElRadio, option, () =>
|
h(attrs.isButton ? ElRadioButton : ElRadio, option),
|
||||||
option.label ?? option.value,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return h(
|
return h(
|
||||||
ElRadioGroup,
|
ElRadioGroup,
|
||||||
mergedProps,
|
{ ...props, ...attrs },
|
||||||
{ ...slots, default: defaultSlot },
|
{ ...slots, default: defaultSlot },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -260,6 +360,28 @@ function initComponentAdapter() {
|
|||||||
return h(ElSelectV2, { ...props, attrs }, slots);
|
return h(ElSelectV2, { ...props, attrs }, slots);
|
||||||
},
|
},
|
||||||
Space: ElSpace,
|
Space: ElSpace,
|
||||||
|
Switch: ElSwitch,
|
||||||
|
TimePicker: (props, { attrs, slots }) => {
|
||||||
|
const { name, id, isRange } = props;
|
||||||
|
const extraProps: Recordable<any> = {};
|
||||||
|
if (isRange) {
|
||||||
|
if (name && !Array.isArray(name)) {
|
||||||
|
extraProps.name = [name, `${name}_end`];
|
||||||
|
}
|
||||||
|
if (id && !Array.isArray(id)) {
|
||||||
|
extraProps.id = [id, `${id}_end`];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h(
|
||||||
|
ElTimePicker,
|
||||||
|
{
|
||||||
|
...props,
|
||||||
|
...attrs,
|
||||||
|
...extraProps,
|
||||||
|
},
|
||||||
|
slots,
|
||||||
|
);
|
||||||
|
},
|
||||||
DatePicker: (props, { attrs, slots }) => {
|
DatePicker: (props, { attrs, slots }) => {
|
||||||
const { name, id, type } = props;
|
const { name, id, type } = props;
|
||||||
const extraProps: Recordable<any> = {};
|
const extraProps: Recordable<any> = {};
|
||||||
@@ -281,19 +403,18 @@ function initComponentAdapter() {
|
|||||||
slots,
|
slots,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
TreeSelect: withDefaultPlaceholder(ElTreeSelect, 'select'),
|
||||||
|
Upload: ElUpload,
|
||||||
UserSelector: withDefaultPlaceholder(UserSelector, 'select'),
|
UserSelector: withDefaultPlaceholder(UserSelector, 'select'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 将组件注册到全局共享状态中
|
// 将组件注册到全局共享状态中
|
||||||
globalShareState.setComponents(components);
|
globalShareState.setComponents(components);
|
||||||
initialized = true;
|
|
||||||
|
|
||||||
// 定义全局共享状态中的消息提示
|
// 定义全局共享状态中的消息提示
|
||||||
globalShareState.defineMessage({
|
globalShareState.defineMessage({
|
||||||
// 复制成功消息提示
|
// 复制成功消息提示
|
||||||
copyPreferencesSuccess: (title: string, content: string) => {
|
copyPreferencesSuccess: (title, content) => {
|
||||||
void import('element-plus/es/components/notification/index').then(
|
|
||||||
({ ElNotification }) => {
|
|
||||||
ElNotification({
|
ElNotification({
|
||||||
title,
|
title,
|
||||||
message: content,
|
message: content,
|
||||||
@@ -302,8 +423,6 @@ function initComponentAdapter() {
|
|||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
import type {
|
import type {
|
||||||
VbenFormSchema as FormSchema,
|
VbenFormSchema as FormSchema,
|
||||||
VbenFormProps,
|
VbenFormProps,
|
||||||
} from '@vben-core/form-ui';
|
} from '@vben/common-ui';
|
||||||
|
|
||||||
import type { ComponentType } from './component';
|
import type { ComponentType } from './component';
|
||||||
|
|
||||||
import { setupVbenForm, useVbenForm as useForm, z } from '@vben-core/form-ui';
|
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
|
||||||
import { $t } from '@vben/locales';
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
import { initComponentAdapter } from './component';
|
async function initSetupVbenForm() {
|
||||||
|
|
||||||
let initialized = false;
|
|
||||||
|
|
||||||
function initSetupVbenForm() {
|
|
||||||
if (initialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
initComponentAdapter();
|
|
||||||
setupVbenForm<ComponentType>({
|
setupVbenForm<ComponentType>({
|
||||||
config: {
|
config: {
|
||||||
modelPropNameMap: {
|
modelPropNameMap: {
|
||||||
@@ -40,13 +31,9 @@ function initSetupVbenForm() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
initialized = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const useVbenForm = ((...args: Parameters<typeof useForm<ComponentType>>) => {
|
const useVbenForm = useForm<ComponentType>;
|
||||||
initSetupVbenForm();
|
|
||||||
return useForm<ComponentType>(...args);
|
|
||||||
}) as typeof useForm<ComponentType>;
|
|
||||||
|
|
||||||
export { initSetupVbenForm, useVbenForm, z };
|
export { initSetupVbenForm, useVbenForm, z };
|
||||||
|
|
||||||
|
|||||||
@@ -27,16 +27,17 @@ setupVbenVxeTable({
|
|||||||
columnConfig: {
|
columnConfig: {
|
||||||
resizable: true,
|
resizable: true,
|
||||||
},
|
},
|
||||||
|
minHeight: 180,
|
||||||
formConfig: {
|
formConfig: {
|
||||||
|
// 全局禁用vxe-table的表单配置,使用formOptions
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
minHeight: 180,
|
|
||||||
proxyConfig: {
|
proxyConfig: {
|
||||||
autoLoad: true,
|
autoLoad: true,
|
||||||
response: {
|
response: {
|
||||||
list: '',
|
|
||||||
result: 'items',
|
result: 'items',
|
||||||
total: 'total',
|
total: 'total',
|
||||||
|
list: '',
|
||||||
},
|
},
|
||||||
showActionMsg: true,
|
showActionMsg: true,
|
||||||
showResponseMsg: false,
|
showResponseMsg: false,
|
||||||
@@ -47,31 +48,34 @@ setupVbenVxeTable({
|
|||||||
} as VxeTableGridOptions,
|
} as VxeTableGridOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 表格配置项可以用 cellRender: { name: 'CellImage' },
|
||||||
vxeUI.renderer.add('CellImage', {
|
vxeUI.renderer.add('CellImage', {
|
||||||
renderTableDefault(_renderOpts, params) {
|
renderTableDefault(_renderOpts, params) {
|
||||||
const { column, row } = params;
|
const { column, row } = params;
|
||||||
const src = row[column.field];
|
const src = row[column.field];
|
||||||
return h(ElImage, { previewSrcList: [src], src });
|
return h(ElImage, { src, previewSrcList: [src] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 表格配置项可以用 cellRender: { name: 'CellLink' },
|
||||||
vxeUI.renderer.add('CellLink', {
|
vxeUI.renderer.add('CellLink', {
|
||||||
renderTableDefault(renderOpts) {
|
renderTableDefault(renderOpts) {
|
||||||
const { props } = renderOpts;
|
const { props } = renderOpts;
|
||||||
return h(
|
return h(
|
||||||
ElButton,
|
ElButton,
|
||||||
{ link: true, size: 'small' },
|
{ size: 'small', link: true },
|
||||||
{ default: () => props?.text },
|
{ default: () => props?.text },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 单元格渲染: Tag
|
||||||
vxeUI.renderer.add('CellTag', {
|
vxeUI.renderer.add('CellTag', {
|
||||||
renderTableDefault({ options, props }, { column, row }) {
|
renderTableDefault({ options, props }, { column, row }) {
|
||||||
const value = get(row, column.field);
|
const value = get(row, column.field);
|
||||||
const tagOptions = options ?? [
|
const tagOptions = options ?? [
|
||||||
{ label: $t('common.enabled'), type: 'success', value: true },
|
{ type: 'success', label: $t('common.enabled'), value: true },
|
||||||
{ label: $t('common.disabled'), type: 'danger', value: false },
|
{ type: 'danger', label: $t('common.disabled'), value: false },
|
||||||
];
|
];
|
||||||
const tagItem = tagOptions.find((item) => item.value === value);
|
const tagItem = tagOptions.find((item) => item.value === value);
|
||||||
return h(
|
return h(
|
||||||
@@ -85,24 +89,36 @@ setupVbenVxeTable({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册表格的操作按钮渲染器
|
||||||
|
*/
|
||||||
vxeUI.renderer.add('CellOperation', {
|
vxeUI.renderer.add('CellOperation', {
|
||||||
renderTableDefault({ attrs, options, props }, { column, row }) {
|
renderTableDefault({ attrs, options, props }, { column, row }) {
|
||||||
const defaultProps = { link: true, size: 'small', ...props };
|
const defaultProps = { size: 'small', link: true, ...props };
|
||||||
const align =
|
let align = 'end';
|
||||||
column.align === 'center'
|
switch (column.align) {
|
||||||
? 'center'
|
case 'center': {
|
||||||
: column.align === 'left'
|
align = 'center';
|
||||||
? 'start'
|
break;
|
||||||
: 'end';
|
}
|
||||||
|
case 'left': {
|
||||||
|
align = 'start';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
align = 'end';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
const presets: Recordable<Recordable<any>> = {
|
const presets: Recordable<Recordable<any>> = {
|
||||||
delete: {
|
delete: {
|
||||||
icon: 'ep:delete',
|
|
||||||
text: $t('common.delete'),
|
|
||||||
type: 'danger',
|
type: 'danger',
|
||||||
|
text: $t('common.delete'),
|
||||||
|
icon: 'ep:delete',
|
||||||
},
|
},
|
||||||
edit: {
|
edit: {
|
||||||
icon: 'ep:edit',
|
|
||||||
text: $t('common.edit'),
|
text: $t('common.edit'),
|
||||||
|
icon: 'ep:edit',
|
||||||
type: 'primary',
|
type: 'primary',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -116,11 +132,11 @@ setupVbenVxeTable({
|
|||||||
: {
|
: {
|
||||||
code: opt,
|
code: opt,
|
||||||
text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
|
text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
|
||||||
type: 'primary',
|
type: 'primary', // 自定义按钮默认使用主题色
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
};
|
};
|
||||||
}
|
} else {
|
||||||
|
// 对象配置的按钮,如果没有 type,也默认为 primary
|
||||||
const buttonConfig = {
|
const buttonConfig = {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
...presets[opt.code],
|
...presets[opt.code],
|
||||||
@@ -130,6 +146,7 @@ setupVbenVxeTable({
|
|||||||
buttonConfig.type = 'primary';
|
buttonConfig.type = 'primary';
|
||||||
}
|
}
|
||||||
return buttonConfig;
|
return buttonConfig;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.map((opt) => {
|
.map((opt) => {
|
||||||
const optBtn: Recordable<any> = {};
|
const optBtn: Recordable<any> = {};
|
||||||
@@ -141,20 +158,22 @@ setupVbenVxeTable({
|
|||||||
.filter((opt) => opt.show !== false);
|
.filter((opt) => opt.show !== false);
|
||||||
|
|
||||||
function renderBtn(opt: Recordable<any>, listen = true) {
|
function renderBtn(opt: Recordable<any>, listen = true) {
|
||||||
const { code, icon, text, ...btnProps } = opt;
|
const { icon, text, code, ...btnProps } = opt;
|
||||||
const buttonType =
|
const buttonType =
|
||||||
btnProps.type === 'danger'
|
btnProps.type === 'danger'
|
||||||
? 'danger'
|
? 'danger'
|
||||||
: btnProps.type === 'primary'
|
: (btnProps.type === 'primary'
|
||||||
? 'primary'
|
? 'primary'
|
||||||
: 'default';
|
: 'default');
|
||||||
|
|
||||||
const button = h(
|
const button = h(
|
||||||
ElButton,
|
ElButton,
|
||||||
{
|
{
|
||||||
...btnProps,
|
...btnProps,
|
||||||
circle: !!icon,
|
type: buttonType,
|
||||||
|
size: 'small',
|
||||||
link: true,
|
link: true,
|
||||||
|
circle: !!icon,
|
||||||
onClick: listen
|
onClick: listen
|
||||||
? () =>
|
? () =>
|
||||||
attrs?.onClick?.({
|
attrs?.onClick?.({
|
||||||
@@ -162,17 +181,19 @@ setupVbenVxeTable({
|
|||||||
row,
|
row,
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
size: 'small',
|
|
||||||
type: buttonType,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
default: () =>
|
default: () => {
|
||||||
icon ? h(IconifyIcon, { class: 'size-4', icon }) : text,
|
if (icon) {
|
||||||
|
return h(IconifyIcon, { class: 'size-4', icon });
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!icon) return button;
|
// 如果有图标,用 Tooltip 包装;否则直接返回按钮
|
||||||
|
if (icon) {
|
||||||
return h(
|
return h(
|
||||||
ElTooltip,
|
ElTooltip,
|
||||||
{
|
{
|
||||||
@@ -185,29 +206,37 @@ setupVbenVxeTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
function renderConfirm(opt: Recordable<any>) {
|
function renderConfirm(opt: Recordable<any>) {
|
||||||
const { icon, text } = opt;
|
const { icon, text } = opt;
|
||||||
|
|
||||||
const button = h(
|
const button = h(
|
||||||
ElButton,
|
ElButton,
|
||||||
{
|
{
|
||||||
circle: !!icon,
|
|
||||||
link: true,
|
|
||||||
size: 'small',
|
|
||||||
title: icon ? text : undefined,
|
|
||||||
type: 'danger',
|
type: 'danger',
|
||||||
|
size: 'small',
|
||||||
|
link: true,
|
||||||
|
circle: !!icon,
|
||||||
|
title: icon ? text : undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
default: () =>
|
default: () => {
|
||||||
icon ? h(IconifyIcon, { class: 'size-4', icon }) : text,
|
if (icon) {
|
||||||
|
return h(IconifyIcon, { class: 'size-4', icon });
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return h(
|
return h(
|
||||||
ElPopconfirm,
|
ElPopconfirm,
|
||||||
{
|
{
|
||||||
cancelButtonText: $t('common.cancel'),
|
title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),
|
||||||
confirmButtonText: $t('common.confirm'),
|
confirmButtonText: $t('common.confirm'),
|
||||||
|
cancelButtonText: $t('common.cancel'),
|
||||||
confirmButtonType: 'danger',
|
confirmButtonType: 'danger',
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
attrs?.onClick?.({
|
attrs?.onClick?.({
|
||||||
@@ -215,14 +244,13 @@ setupVbenVxeTable({
|
|||||||
row,
|
row,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
reference: () => button,
|
||||||
default: () =>
|
default: () =>
|
||||||
$t('ui.actionMessage.deleteConfirm', [
|
$t('ui.actionMessage.deleteConfirm', [
|
||||||
row[attrs?.nameField || 'name'],
|
row[attrs?.nameField || 'name'],
|
||||||
]),
|
]),
|
||||||
reference: () => button,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -240,10 +268,14 @@ setupVbenVxeTable({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
|
||||||
|
// vxeUI.formats.add
|
||||||
},
|
},
|
||||||
useVbenForm,
|
useVbenForm,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 自定义类型定义
|
||||||
export type OnActionClickParams<T = Recordable<any>> = {
|
export type OnActionClickParams<T = Recordable<any>> = {
|
||||||
code: string;
|
code: string;
|
||||||
row: T;
|
row: T;
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ export interface ParamDefinition {
|
|||||||
default?: any;
|
default?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表后处理配置(排序 + 条数限制)
|
||||||
|
*/
|
||||||
|
export interface ListConfig {
|
||||||
|
sort_field?: string;
|
||||||
|
sort_order?: 'asc' | 'desc';
|
||||||
|
/** 0 表示不额外限制(仍受系统最大 1000 条约束) */
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 树形配置
|
* 树形配置
|
||||||
*/
|
*/
|
||||||
@@ -131,12 +141,17 @@ export interface DataSource {
|
|||||||
// SQL 配置
|
// SQL 配置
|
||||||
sql_content?: string;
|
sql_content?: string;
|
||||||
db_connection?: string;
|
db_connection?: string;
|
||||||
|
/** SQL 执行目标库/catalog */
|
||||||
|
db_database?: string;
|
||||||
|
/** SQL 执行目标模式/schema */
|
||||||
|
db_schema?: string;
|
||||||
// 静态数据
|
// 静态数据
|
||||||
static_data?: any[];
|
static_data?: any[];
|
||||||
// 参数定义
|
// 参数定义
|
||||||
params?: ParamDefinition[];
|
params?: ParamDefinition[];
|
||||||
// 结果处理
|
// 结果处理
|
||||||
result_type?: ResultType;
|
result_type?: ResultType;
|
||||||
|
list_config?: ListConfig;
|
||||||
tree_config?: TreeConfig;
|
tree_config?: TreeConfig;
|
||||||
field_mapping?: Record<string, string>;
|
field_mapping?: Record<string, string>;
|
||||||
chart_config?: ChartConfig;
|
chart_config?: ChartConfig;
|
||||||
@@ -195,12 +210,17 @@ export interface DataSourceInput {
|
|||||||
// SQL 配置
|
// SQL 配置
|
||||||
sql_content?: string;
|
sql_content?: string;
|
||||||
db_connection?: string;
|
db_connection?: string;
|
||||||
|
/** SQL 执行目标库/catalog */
|
||||||
|
db_database?: string;
|
||||||
|
/** SQL 执行目标模式/schema */
|
||||||
|
db_schema?: string;
|
||||||
// 静态数据
|
// 静态数据
|
||||||
static_data?: any[];
|
static_data?: any[];
|
||||||
// 参数定义
|
// 参数定义
|
||||||
params?: ParamDefinition[];
|
params?: ParamDefinition[];
|
||||||
// 结果处理
|
// 结果处理
|
||||||
result_type?: ResultType;
|
result_type?: ResultType;
|
||||||
|
list_config?: ListConfig;
|
||||||
tree_config?: TreeConfig;
|
tree_config?: TreeConfig;
|
||||||
field_mapping?: Record<string, string>;
|
field_mapping?: Record<string, string>;
|
||||||
chart_config?: ChartConfig;
|
chart_config?: ChartConfig;
|
||||||
@@ -268,6 +288,10 @@ export interface TestRequest {
|
|||||||
// SQL 配置
|
// SQL 配置
|
||||||
sql_content?: string;
|
sql_content?: string;
|
||||||
db_connection?: string;
|
db_connection?: string;
|
||||||
|
/** SQL 执行目标库/catalog */
|
||||||
|
db_database?: string;
|
||||||
|
/** SQL 执行目标模式/schema */
|
||||||
|
db_schema?: string;
|
||||||
// 静态数据
|
// 静态数据
|
||||||
static_data?: any[];
|
static_data?: any[];
|
||||||
// 参数
|
// 参数
|
||||||
@@ -275,6 +299,7 @@ export interface TestRequest {
|
|||||||
params?: Record<string, any>;
|
params?: Record<string, any>;
|
||||||
// 结果处理
|
// 结果处理
|
||||||
result_type?: ResultType;
|
result_type?: ResultType;
|
||||||
|
list_config?: ListConfig;
|
||||||
tree_config?: TreeConfig;
|
tree_config?: TreeConfig;
|
||||||
field_mapping?: Record<string, string>;
|
field_mapping?: Record<string, string>;
|
||||||
chart_config?: ChartConfig;
|
chart_config?: ChartConfig;
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
export * from './announcement';
|
export * from '../online-dev/form-data-api';
|
||||||
|
export * from '../online-dev/form-manager';
|
||||||
|
// 注意: login-log 模块请直接导入以避免 PaginatedResponse 类型冲突
|
||||||
|
// import { getLoginLogListApi, ... } from '#/api/core/login-log';
|
||||||
|
// 注意: ai-platform 模块请直接导入以避免类型冲突
|
||||||
|
// import { ... } from '#/api/core/ai-platform';
|
||||||
|
export * from '../online-dev/screen-design';
|
||||||
export * from './application';
|
export * from './application';
|
||||||
export * from './auth';
|
export * from './auth';
|
||||||
|
export * from './data-source';
|
||||||
|
export * from './database-connection';
|
||||||
|
export * from './database-manager';
|
||||||
export * from './dept';
|
export * from './dept';
|
||||||
export * from './dict';
|
export * from './dict';
|
||||||
export * from './file';
|
|
||||||
export * from './field-permission';
|
|
||||||
export * from './menu';
|
export * from './menu';
|
||||||
export * from './message';
|
export * from './oauth';
|
||||||
export * from './permission';
|
export * from './permission';
|
||||||
|
export * from './post';
|
||||||
export * from './role';
|
export * from './role';
|
||||||
export * from './resource-scope';
|
export * from './scheduler';
|
||||||
export * from './server-health';
|
export * from './server-monitor';
|
||||||
export * from './ui-config';
|
|
||||||
export * from './user';
|
export * from './user';
|
||||||
export * from './websocket';
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export interface LoginLog {
|
|||||||
user_id?: string;
|
user_id?: string;
|
||||||
username: string;
|
username: string;
|
||||||
status: number;
|
status: number;
|
||||||
login_type?: string;
|
|
||||||
failure_reason?: number;
|
failure_reason?: number;
|
||||||
failure_message?: string;
|
failure_message?: string;
|
||||||
login_ip: string;
|
login_ip: string;
|
||||||
@@ -34,7 +33,6 @@ export interface LoginLogListParams {
|
|||||||
username?: string;
|
username?: string;
|
||||||
user_id?: string;
|
user_id?: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
login_type?: string;
|
|
||||||
failure_reason?: number;
|
failure_reason?: number;
|
||||||
login_ip?: string;
|
login_ip?: string;
|
||||||
device_type?: string;
|
device_type?: string;
|
||||||
|
|||||||
@@ -110,14 +110,19 @@ export interface MenuStats {
|
|||||||
/**
|
/**
|
||||||
* 获取用户所有菜单(路由树)
|
* 获取用户所有菜单(路由树)
|
||||||
* @param applicationCode 应用编码,用于过滤应用专属菜单
|
* @param applicationCode 应用编码,用于过滤应用专属菜单
|
||||||
|
* @param devMode 开发模式:true只返回系统菜单,false只返回应用菜单
|
||||||
*/
|
*/
|
||||||
export async function getAllMenusApi(
|
export async function getAllMenusApi(
|
||||||
applicationCode?: string,
|
applicationCode?: string,
|
||||||
|
devMode?: boolean,
|
||||||
) {
|
) {
|
||||||
const params: Record<string, any> = {};
|
const params: Record<string, any> = {};
|
||||||
if (applicationCode) {
|
if (applicationCode) {
|
||||||
params.application_code = applicationCode;
|
params.application_code = applicationCode;
|
||||||
}
|
}
|
||||||
|
if (devMode !== undefined) {
|
||||||
|
params.devMode = devMode;
|
||||||
|
}
|
||||||
return requestClient.get<RouteRecordStringComponent[]>(
|
return requestClient.get<RouteRecordStringComponent[]>(
|
||||||
'/api/core/menu/route/tree',
|
'/api/core/menu/route/tree',
|
||||||
{ params },
|
{ params },
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export interface PostCreateInput {
|
|||||||
status?: boolean;
|
status?: boolean;
|
||||||
description?: string;
|
description?: string;
|
||||||
dept_id?: string;
|
dept_id?: string;
|
||||||
sort?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PostUpdateInput extends Partial<PostCreateInput> {}
|
export interface PostUpdateInput extends Partial<PostCreateInput> {}
|
||||||
@@ -121,7 +120,7 @@ export async function deletePostApi(postId: string) {
|
|||||||
*/
|
*/
|
||||||
export async function batchDeletePostApi(data: PostBatchDeleteInput) {
|
export async function batchDeletePostApi(data: PostBatchDeleteInput) {
|
||||||
return requestClient.post<{ count: number }>(
|
return requestClient.post<{ count: number }>(
|
||||||
'/api/core/post/batch/delete',
|
'/api/core/post/batch_delete',
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -133,7 +132,7 @@ export async function batchUpdatePostStatusApi(
|
|||||||
data: PostBatchUpdateStatusInput,
|
data: PostBatchUpdateStatusInput,
|
||||||
) {
|
) {
|
||||||
return requestClient.post<{ count: number }>(
|
return requestClient.post<{ count: number }>(
|
||||||
'/api/core/post/batch/status',
|
'/api/core/post/batch_update_status',
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -142,7 +141,7 @@ export async function batchUpdatePostStatusApi(
|
|||||||
* 根据部门ID获取岗位列表
|
* 根据部门ID获取岗位列表
|
||||||
*/
|
*/
|
||||||
export async function getPostsByDeptApi(deptId: string) {
|
export async function getPostsByDeptApi(deptId: string) {
|
||||||
return requestClient.get<Post[]>(`/api/core/post/by/dept/${deptId}`);
|
return requestClient.get<Post[]>(`/api/core/post/by_dept/${deptId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -195,7 +194,7 @@ export async function getPostStatsApi() {
|
|||||||
* 导出岗位数据
|
* 导出岗位数据
|
||||||
*/
|
*/
|
||||||
export async function exportPostApi(params?: PostListParams) {
|
export async function exportPostApi(params?: PostListParams) {
|
||||||
return requestClient.get<Blob>('/api/core/post/export/excel', {
|
return requestClient.get<Blob>('/api/core/post/export', {
|
||||||
params,
|
params,
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
});
|
});
|
||||||
@@ -208,7 +207,7 @@ export async function importPostApi(file: File) {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
return requestClient.post<{ error_count: number; success_count: number }>(
|
return requestClient.post<{ error_count: number; success_count: number }>(
|
||||||
'/api/core/post/import/excel',
|
'/api/core/post/import',
|
||||||
formData,
|
formData,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@@ -222,7 +221,7 @@ export async function importPostApi(file: File) {
|
|||||||
* 获取简单岗位列表(用于选择器)
|
* 获取简单岗位列表(用于选择器)
|
||||||
*/
|
*/
|
||||||
export async function getSimplePostListApi() {
|
export async function getSimplePostListApi() {
|
||||||
return requestClient.get<Post[]>('/api/core/post/all');
|
return requestClient.get<Post[]>('/api/core/post/simple');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { requestClient } from '#/api/request';
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务器监控相关类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 基础信息
|
||||||
export interface BasicInfo {
|
export interface BasicInfo {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
ip_address: string;
|
ip_address: string;
|
||||||
@@ -14,6 +19,7 @@ export interface BasicInfo {
|
|||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CPU信息
|
||||||
export interface CpuInfo {
|
export interface CpuInfo {
|
||||||
physical_cores: number;
|
physical_cores: number;
|
||||||
total_cores: number;
|
total_cores: number;
|
||||||
@@ -26,6 +32,7 @@ export interface CpuInfo {
|
|||||||
cpu_stats: Record<string, any>;
|
cpu_stats: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 内存信息
|
||||||
export interface MemoryVirtual {
|
export interface MemoryVirtual {
|
||||||
total: number;
|
total: number;
|
||||||
available: number;
|
available: number;
|
||||||
@@ -53,6 +60,7 @@ export interface MemoryInfo {
|
|||||||
swap: MemorySwap;
|
swap: MemorySwap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 磁盘信息
|
||||||
export interface DiskPartition {
|
export interface DiskPartition {
|
||||||
device: string;
|
device: string;
|
||||||
mountpoint: string;
|
mountpoint: string;
|
||||||
@@ -73,6 +81,7 @@ export interface DiskInfo {
|
|||||||
total_write_time?: number;
|
total_write_time?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 网络信息
|
||||||
export interface NetworkTotal {
|
export interface NetworkTotal {
|
||||||
bytes_sent: number;
|
bytes_sent: number;
|
||||||
bytes_recv: number;
|
bytes_recv: number;
|
||||||
@@ -127,6 +136,7 @@ export interface NetworkInfo {
|
|||||||
connections: NetworkConnection[];
|
connections: NetworkConnection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 进程信息
|
||||||
export interface Process {
|
export interface Process {
|
||||||
pid: number;
|
pid: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -143,6 +153,7 @@ export interface ProcessInfo {
|
|||||||
sleeping_processes: number;
|
sleeping_processes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 系统负载
|
||||||
export interface SystemLoad {
|
export interface SystemLoad {
|
||||||
load_1min: number;
|
load_1min: number;
|
||||||
load_5min: number;
|
load_5min: number;
|
||||||
@@ -150,6 +161,7 @@ export interface SystemLoad {
|
|||||||
cpu_count: number;
|
cpu_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 启动时间
|
||||||
export interface BootTime {
|
export interface BootTime {
|
||||||
boot_time: string;
|
boot_time: string;
|
||||||
uptime_seconds: number;
|
uptime_seconds: number;
|
||||||
@@ -159,6 +171,7 @@ export interface BootTime {
|
|||||||
uptime_minutes: number;
|
uptime_minutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 用户信息
|
||||||
export interface UserInfo {
|
export interface UserInfo {
|
||||||
name: string;
|
name: string;
|
||||||
terminal?: string;
|
terminal?: string;
|
||||||
@@ -167,12 +180,14 @@ export interface UserInfo {
|
|||||||
pid?: number;
|
pid?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 电池信息
|
||||||
export interface BatteryInfo {
|
export interface BatteryInfo {
|
||||||
percent: number;
|
percent: number;
|
||||||
power_plugged: boolean;
|
power_plugged: boolean;
|
||||||
seconds_left?: number;
|
seconds_left?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 实时统计
|
||||||
export interface RealtimeStats {
|
export interface RealtimeStats {
|
||||||
cpu_percent: number;
|
cpu_percent: number;
|
||||||
memory_percent: number;
|
memory_percent: number;
|
||||||
@@ -202,6 +217,7 @@ export interface RealtimeStats {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 服务器监控完整响应
|
||||||
export interface ServerMonitorResponse {
|
export interface ServerMonitorResponse {
|
||||||
basic_info: BasicInfo;
|
basic_info: BasicInfo;
|
||||||
cpu_info: CpuInfo;
|
cpu_info: CpuInfo;
|
||||||
@@ -215,62 +231,105 @@ export interface ServerMonitorResponse {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API调用函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取服务器完整监控信息
|
||||||
|
*/
|
||||||
export async function getServerOverviewApi() {
|
export async function getServerOverviewApi() {
|
||||||
return requestClient.get<ServerMonitorResponse>(
|
return requestClient.get<ServerMonitorResponse>(
|
||||||
'/api/core/server_monitor/overview',
|
'/api/core/server_monitor/overview',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取实时统计信息
|
||||||
|
*/
|
||||||
export async function getRealtimeStatsApi() {
|
export async function getRealtimeStatsApi() {
|
||||||
return requestClient.get<RealtimeStats>('/api/core/server_monitor/realtime');
|
return requestClient.get<RealtimeStats>('/api/core/server_monitor/realtime');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取基础系统信息
|
||||||
|
*/
|
||||||
export async function getBasicInfoApi() {
|
export async function getBasicInfoApi() {
|
||||||
return requestClient.get<BasicInfo>('/api/core/server_monitor/basic_info');
|
return requestClient.get<BasicInfo>('/api/core/server_monitor/basic_info');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取CPU信息
|
||||||
|
*/
|
||||||
export async function getCpuInfoApi() {
|
export async function getCpuInfoApi() {
|
||||||
return requestClient.get<CpuInfo>('/api/core/server_monitor/cpu_info');
|
return requestClient.get<CpuInfo>('/api/core/server_monitor/cpu_info');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取内存信息
|
||||||
|
*/
|
||||||
export async function getMemoryInfoApi() {
|
export async function getMemoryInfoApi() {
|
||||||
return requestClient.get<MemoryInfo>('/api/core/server_monitor/memory_info');
|
return requestClient.get<MemoryInfo>('/api/core/server_monitor/memory_info');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取磁盘信息
|
||||||
|
*/
|
||||||
export async function getDiskInfoApi() {
|
export async function getDiskInfoApi() {
|
||||||
return requestClient.get<DiskInfo>('/api/core/server_monitor/disk_info');
|
return requestClient.get<DiskInfo>('/api/core/server_monitor/disk_info');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取网络信息
|
||||||
|
*/
|
||||||
export async function getNetworkInfoApi() {
|
export async function getNetworkInfoApi() {
|
||||||
return requestClient.get<NetworkInfo>(
|
return requestClient.get<NetworkInfo>(
|
||||||
'/api/core/server_monitor/network_info',
|
'/api/core/server_monitor/network_info',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取进程信息
|
||||||
|
*/
|
||||||
export async function getProcessInfoApi() {
|
export async function getProcessInfoApi() {
|
||||||
return requestClient.get<ProcessInfo>(
|
return requestClient.get<ProcessInfo>(
|
||||||
'/api/core/server_monitor/process_info',
|
'/api/core/server_monitor/process_info',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取系统负载信息
|
||||||
|
*/
|
||||||
export async function getSystemLoadApi() {
|
export async function getSystemLoadApi() {
|
||||||
return requestClient.get<SystemLoad>('/api/core/server_monitor/system_load');
|
return requestClient.get<SystemLoad>('/api/core/server_monitor/system_load');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取系统启动时间信息
|
||||||
|
*/
|
||||||
export async function getBootTimeApi() {
|
export async function getBootTimeApi() {
|
||||||
return requestClient.get<BootTime>('/api/core/server_monitor/boot_time');
|
return requestClient.get<BootTime>('/api/core/server_monitor/boot_time');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户信息
|
||||||
|
*/
|
||||||
export async function getUsersInfoApi() {
|
export async function getUsersInfoApi() {
|
||||||
return requestClient.get<UserInfo[]>('/api/core/server_monitor/users_info');
|
return requestClient.get<UserInfo[]>('/api/core/server_monitor/users_info');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取温度信息
|
||||||
|
*/
|
||||||
export async function getTemperatureInfoApi() {
|
export async function getTemperatureInfoApi() {
|
||||||
return requestClient.get<Record<string, any>>(
|
return requestClient.get<Record<string, any>>(
|
||||||
'/api/core/server_monitor/temperature_info',
|
'/api/core/server_monitor/temperature_info',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取电池信息
|
||||||
|
*/
|
||||||
export async function getBatteryInfoApi() {
|
export async function getBatteryInfoApi() {
|
||||||
return requestClient.get<BatteryInfo>(
|
return requestClient.get<BatteryInfo>(
|
||||||
'/api/core/server_monitor/battery_info',
|
'/api/core/server_monitor/battery_info',
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function mergePreferencesConfig(
|
|||||||
current: PreferencesConfig,
|
current: PreferencesConfig,
|
||||||
updates: PreferencesConfig,
|
updates: PreferencesConfig,
|
||||||
): PreferencesConfig {
|
): PreferencesConfig {
|
||||||
return merge({}, current, updates) as PreferencesConfig;
|
return merge({}, updates, current) as PreferencesConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 前端偏好配置API ============
|
// ============ 前端偏好配置API ============
|
||||||
|
|||||||
@@ -39,6 +39,46 @@ export namespace WebSocketApi {
|
|||||||
heartbeatInterval?: number;
|
heartbeatInterval?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 监控数据类型 */
|
||||||
|
export interface MonitorMessage {
|
||||||
|
type:
|
||||||
|
| 'get_overview'
|
||||||
|
| 'get_realtime'
|
||||||
|
| 'set_interval'
|
||||||
|
| 'start_monitor'
|
||||||
|
| 'stop_monitor'
|
||||||
|
| 'test_connection';
|
||||||
|
interval?: number;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务器监控数据 */
|
||||||
|
export interface ServerMonitorData {
|
||||||
|
basic_info?: any;
|
||||||
|
cpu_info?: any;
|
||||||
|
memory_info?: any;
|
||||||
|
disk_info?: any;
|
||||||
|
network_info?: any;
|
||||||
|
process_info?: any;
|
||||||
|
system_load?: any;
|
||||||
|
boot_time?: any;
|
||||||
|
users_info?: any;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redis监控数据 */
|
||||||
|
export interface RedisMonitorData {
|
||||||
|
connection_id?: string;
|
||||||
|
connection_name?: string;
|
||||||
|
status?: string;
|
||||||
|
info?: any;
|
||||||
|
memory?: any;
|
||||||
|
stats?: any;
|
||||||
|
keyspace?: any[];
|
||||||
|
clients?: any[];
|
||||||
|
slow_log?: any[];
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -359,26 +399,34 @@ export class WebSocketManager {
|
|||||||
/**
|
/**
|
||||||
* 创建WebSocket连接
|
* 创建WebSocket连接
|
||||||
*/
|
*/
|
||||||
|
function buildWebSocketUrl(endpoint: string): string {
|
||||||
|
const apiUrl = import.meta.env.VITE_GLOB_API_URL || '';
|
||||||
|
const currentProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
|
||||||
|
let wsOrigin = `${currentProtocol}//${location.host}`;
|
||||||
|
let apiPath = apiUrl;
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(apiUrl)) {
|
||||||
|
const parsed = new URL(apiUrl);
|
||||||
|
wsOrigin = `${parsed.protocol === 'https:' ? 'wss:' : 'ws:'}//${parsed.host}`;
|
||||||
|
apiPath = parsed.pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePath = apiPath
|
||||||
|
.replace(/\/$/, '')
|
||||||
|
.replace(/\/(?:basic-api|api)$/i, '');
|
||||||
|
const normalizedBase = basePath && basePath !== '/' ? basePath : '';
|
||||||
|
const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||||
|
|
||||||
|
return `${wsOrigin}${normalizedBase}${normalizedEndpoint}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function createWebSocket(
|
export function createWebSocket(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
callbacks?: WebSocketApi.WebSocketCallbacks,
|
callbacks?: WebSocketApi.WebSocketCallbacks,
|
||||||
): WebSocketManager {
|
): WebSocketManager {
|
||||||
// 构建WebSocket URL
|
|
||||||
let wsUrl: string;
|
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
// 开发环境:直接连接到后端服务器
|
|
||||||
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
wsUrl = `${wsProtocol}//localhost:8000${endpoint}`;
|
|
||||||
} else {
|
|
||||||
// 生产环境:使用当前域名
|
|
||||||
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsHost = location.host;
|
|
||||||
wsUrl = `${wsProtocol}//${wsHost}${endpoint}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: WebSocketApi.WebSocketConfig = {
|
const config: WebSocketApi.WebSocketConfig = {
|
||||||
url: wsUrl,
|
url: buildWebSocketUrl(endpoint),
|
||||||
reconnect: true,
|
reconnect: true,
|
||||||
maxReconnectAttempts: 5,
|
maxReconnectAttempts: 5,
|
||||||
reconnectInterval: 3000,
|
reconnectInterval: 3000,
|
||||||
@@ -390,7 +438,7 @@ export function createWebSocket(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create test WebSocket connection
|
* 创建测试WebSocket连接
|
||||||
*/
|
*/
|
||||||
export function createTestWebSocket(
|
export function createTestWebSocket(
|
||||||
callbacks?: WebSocketApi.WebSocketCallbacks,
|
callbacks?: WebSocketApi.WebSocketCallbacks,
|
||||||
@@ -399,7 +447,7 @@ export function createTestWebSocket(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create notification WebSocket connection
|
* 创建通知WebSocket连接
|
||||||
*/
|
*/
|
||||||
export function createNotificationWebSocket(
|
export function createNotificationWebSocket(
|
||||||
callbacks?: WebSocketApi.WebSocketCallbacks,
|
callbacks?: WebSocketApi.WebSocketCallbacks,
|
||||||
@@ -408,10 +456,197 @@ export function createNotificationWebSocket(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create chat WebSocket connection
|
* 创建服务器监控WebSocket连接
|
||||||
|
*/
|
||||||
|
export function createServerMonitorWebSocket(
|
||||||
|
callbacks?: WebSocketApi.WebSocketCallbacks,
|
||||||
|
): WebSocketManager {
|
||||||
|
return createWebSocket('/ws/server-monitor/', callbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建Redis监控WebSocket连接
|
||||||
|
*/
|
||||||
|
export function createRedisMonitorWebSocket(
|
||||||
|
callbacks?: WebSocketApi.WebSocketCallbacks,
|
||||||
|
): WebSocketManager {
|
||||||
|
return createWebSocket('/ws/redis-monitor/', callbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建数据库监控WebSocket连接
|
||||||
|
*/
|
||||||
|
export function createDatabaseMonitorWebSocket(
|
||||||
|
callbacks?: WebSocketApi.WebSocketCallbacks,
|
||||||
|
): WebSocketManager {
|
||||||
|
return createWebSocket('/ws/database-monitor/', callbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监控WebSocket管理器类
|
||||||
|
*/
|
||||||
|
export class MonitorWebSocketManager extends WebSocketManager {
|
||||||
|
/**
|
||||||
|
* 获取概览信息
|
||||||
|
*/
|
||||||
|
getOverview(): boolean {
|
||||||
|
return this.send({
|
||||||
|
type: 'get_overview',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取实时统计
|
||||||
|
*/
|
||||||
|
getRealtime(): boolean {
|
||||||
|
return this.send({
|
||||||
|
type: 'get_realtime',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始监控
|
||||||
|
*/
|
||||||
|
startMonitoring(): boolean {
|
||||||
|
return this.send({
|
||||||
|
type: 'start_monitor',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止监控
|
||||||
|
*/
|
||||||
|
stopMonitoring(): boolean {
|
||||||
|
return this.send({
|
||||||
|
type: 'stop_monitor',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试连接(适用于Redis监控)
|
||||||
|
*/
|
||||||
|
testConnection(): boolean {
|
||||||
|
return this.send({
|
||||||
|
type: 'test_connection',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建服务器监控WebSocket管理器
|
||||||
|
*/
|
||||||
|
export function createServerMonitorManager(
|
||||||
|
callbacks?: WebSocketApi.WebSocketCallbacks,
|
||||||
|
): MonitorWebSocketManager {
|
||||||
|
const wsManager = createServerMonitorWebSocket(callbacks);
|
||||||
|
// 创建增强版管理器
|
||||||
|
return Object.setPrototypeOf(wsManager, MonitorWebSocketManager.prototype);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建Redis监控WebSocket管理器
|
||||||
|
*/
|
||||||
|
export function createRedisMonitorManager(
|
||||||
|
callbacks?: WebSocketApi.WebSocketCallbacks,
|
||||||
|
): MonitorWebSocketManager {
|
||||||
|
const wsManager = createRedisMonitorWebSocket(callbacks);
|
||||||
|
// 创建增强版管理器
|
||||||
|
return Object.setPrototypeOf(wsManager, MonitorWebSocketManager.prototype);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据库监控WebSocket管理器类
|
||||||
|
*/
|
||||||
|
export class DatabaseMonitorWebSocketManager extends WebSocketManager {
|
||||||
|
/**
|
||||||
|
* 获取数据库配置列表
|
||||||
|
*/
|
||||||
|
getConfigs(): boolean {
|
||||||
|
return this.send({
|
||||||
|
type: 'get_configs',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取概览信息
|
||||||
|
*/
|
||||||
|
getOverview(dbName: string): boolean {
|
||||||
|
return this.send({
|
||||||
|
type: 'get_overview',
|
||||||
|
db_name: dbName,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取实时统计
|
||||||
|
*/
|
||||||
|
getRealtime(dbName: string): boolean {
|
||||||
|
return this.send({
|
||||||
|
type: 'get_realtime',
|
||||||
|
db_name: dbName,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始监控
|
||||||
|
*/
|
||||||
|
startMonitoring(dbName: string): boolean {
|
||||||
|
return this.send({
|
||||||
|
type: 'start_monitor',
|
||||||
|
db_name: dbName,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止监控
|
||||||
|
*/
|
||||||
|
stopMonitoring(): boolean {
|
||||||
|
return this.send({
|
||||||
|
type: 'stop_monitor',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试连接
|
||||||
|
*/
|
||||||
|
testConnection(dbName: string): boolean {
|
||||||
|
return this.send({
|
||||||
|
type: 'test_connection',
|
||||||
|
db_name: dbName,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建聊天WebSocket连接
|
||||||
*/
|
*/
|
||||||
export function createChatWebSocket(
|
export function createChatWebSocket(
|
||||||
callbacks?: WebSocketApi.WebSocketCallbacks,
|
callbacks?: WebSocketApi.WebSocketCallbacks,
|
||||||
): WebSocketManager {
|
): WebSocketManager {
|
||||||
return createWebSocket('/ws/chat/', callbacks);
|
return createWebSocket('/ws/chat/', callbacks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建数据库监控WebSocket管理器
|
||||||
|
*/
|
||||||
|
export function createDatabaseMonitorManager(
|
||||||
|
callbacks?: WebSocketApi.WebSocketCallbacks,
|
||||||
|
): DatabaseMonitorWebSocketManager {
|
||||||
|
const wsManager = createDatabaseMonitorWebSocket(callbacks);
|
||||||
|
// 创建增强版管理器
|
||||||
|
return Object.setPrototypeOf(
|
||||||
|
wsManager,
|
||||||
|
DatabaseMonitorWebSocketManager.prototype,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useElementPlusDesignTokens } from '@vben/hooks';
|
import { useElementPlusDesignTokens } from '@vben/hooks';
|
||||||
|
|
||||||
import { ElConfigProvider } from 'element-plus/es/components/config-provider/index';
|
import { ElConfigProvider } from 'element-plus';
|
||||||
|
|
||||||
import { elementLocale } from '#/locales';
|
import { elementLocale } from '#/locales';
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +1,35 @@
|
|||||||
import { createApp, watchEffect } from 'vue';
|
import { createApp, watchEffect } from 'vue';
|
||||||
|
|
||||||
import { registerAccessDirective } from '@vben/access';
|
import { registerAccessDirective } from '@vben/access';
|
||||||
|
import { registerLoadingDirective } from '@vben/common-ui';
|
||||||
import { preferences } from '@vben/preferences';
|
import { preferences } from '@vben/preferences';
|
||||||
import { initStores } from '@vben/stores';
|
import { initStores } from '@vben/stores';
|
||||||
import '@vben/styles';
|
import '@vben/styles';
|
||||||
import '@vben/styles/ele';
|
import '@vben/styles/ele';
|
||||||
|
|
||||||
import { useTitle } from '@vueuse/core';
|
import { useTitle } from '@vueuse/core';
|
||||||
|
import ElementPlus from 'element-plus';
|
||||||
|
|
||||||
import { $t, setupI18n } from '#/locales';
|
import { $t, setupI18n } from '#/locales';
|
||||||
import { useAppContextStore } from '#/store/app-context';
|
|
||||||
|
|
||||||
|
import { initComponentAdapter } from './adapter/component';
|
||||||
|
import { initSetupVbenForm, useVbenForm } from './adapter/form';
|
||||||
import App from './app.vue';
|
import App from './app.vue';
|
||||||
import { initMessageAdapter } from './adapter/message';
|
import { setupZqTable } from './components/zq-table';
|
||||||
import { setupElementPlus } from './plugins/element-plus';
|
|
||||||
import { router } from './router';
|
import { router } from './router';
|
||||||
|
|
||||||
import 'element-plus/theme-chalk/base.css';
|
|
||||||
import 'element-plus/theme-chalk/dark/css-vars.css';
|
|
||||||
import 'element-plus/theme-chalk/el-overlay.css';
|
|
||||||
import 'element-plus/theme-chalk/el-popper.css';
|
|
||||||
import 'element-plus/theme-chalk/el-icon.css';
|
|
||||||
import 'element-plus/theme-chalk/el-button.css';
|
|
||||||
import 'element-plus/theme-chalk/el-button-group.css';
|
|
||||||
import 'element-plus/theme-chalk/el-checkbox.css';
|
|
||||||
import 'element-plus/theme-chalk/el-checkbox-button.css';
|
|
||||||
import 'element-plus/theme-chalk/el-checkbox-group.css';
|
|
||||||
import 'element-plus/theme-chalk/el-radio.css';
|
|
||||||
import 'element-plus/theme-chalk/el-radio-button.css';
|
|
||||||
import 'element-plus/theme-chalk/el-radio-group.css';
|
|
||||||
import 'element-plus/theme-chalk/el-input.css';
|
|
||||||
import 'element-plus/theme-chalk/el-input-number.css';
|
|
||||||
import 'element-plus/theme-chalk/el-select.css';
|
|
||||||
import 'element-plus/theme-chalk/el-select-v2.css';
|
|
||||||
import 'element-plus/theme-chalk/el-select-dropdown.css';
|
|
||||||
import 'element-plus/theme-chalk/el-select-dropdown-v2.css';
|
|
||||||
import 'element-plus/theme-chalk/el-option.css';
|
|
||||||
import 'element-plus/theme-chalk/el-option-group.css';
|
|
||||||
import 'element-plus/theme-chalk/el-tree.css';
|
|
||||||
import 'element-plus/theme-chalk/el-tree-select.css';
|
|
||||||
import 'element-plus/theme-chalk/el-form.css';
|
|
||||||
import 'element-plus/theme-chalk/el-form-item.css';
|
|
||||||
import 'element-plus/theme-chalk/el-table.css';
|
|
||||||
import 'element-plus/theme-chalk/el-table-column.css';
|
|
||||||
import 'element-plus/theme-chalk/el-card.css';
|
|
||||||
import 'element-plus/theme-chalk/el-dialog.css';
|
|
||||||
import 'element-plus/theme-chalk/el-message.css';
|
|
||||||
import 'element-plus/theme-chalk/el-message-box.css';
|
|
||||||
import 'element-plus/theme-chalk/el-notification.css';
|
|
||||||
import 'element-plus/theme-chalk/el-popover.css';
|
|
||||||
import 'element-plus/theme-chalk/el-tooltip.css';
|
|
||||||
import 'element-plus/theme-chalk/el-scrollbar.css';
|
|
||||||
import 'element-plus/theme-chalk/el-empty.css';
|
|
||||||
import 'element-plus/theme-chalk/el-tag.css';
|
|
||||||
import 'element-plus/theme-chalk/el-pagination.css';
|
|
||||||
import 'element-plus/theme-chalk/el-loading.css';
|
|
||||||
import 'element-plus/theme-chalk/el-divider.css';
|
|
||||||
import 'element-plus/theme-chalk/el-skeleton.css';
|
|
||||||
import 'element-plus/theme-chalk/el-skeleton-item.css';
|
|
||||||
import 'element-plus/theme-chalk/el-cascader.css';
|
|
||||||
import 'element-plus/theme-chalk/el-cascader-panel.css';
|
|
||||||
import 'element-plus/theme-chalk/el-date-picker.css';
|
|
||||||
|
|
||||||
async function bootstrap(namespace: string) {
|
async function bootstrap(namespace: string) {
|
||||||
|
// 初始化组件适配器
|
||||||
|
await initComponentAdapter();
|
||||||
|
|
||||||
|
// 初始化表单组件
|
||||||
|
await initSetupVbenForm();
|
||||||
|
|
||||||
|
// 初始化 ZqTable (注入 form)
|
||||||
|
setupZqTable({
|
||||||
|
useVbenForm,
|
||||||
|
});
|
||||||
|
|
||||||
// // 设置弹窗的默认配置
|
// // 设置弹窗的默认配置
|
||||||
// setDefaultModalProps({
|
// setDefaultModalProps({
|
||||||
// fullscreenButton: false,
|
// fullscreenButton: false,
|
||||||
@@ -73,8 +40,15 @@ async function bootstrap(namespace: string) {
|
|||||||
// });
|
// });
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
setupElementPlus(app);
|
app.use(ElementPlus);
|
||||||
initMessageAdapter();
|
|
||||||
|
// 注册Element Plus提供的v-loading指令
|
||||||
|
|
||||||
|
// 注册Vben提供的v-loading和v-spinning指令
|
||||||
|
registerLoadingDirective(app, {
|
||||||
|
loading: false, // Vben提供的v-loading指令和Element Plus提供的v-loading指令二选一即可,此处false表示不注册Vben提供的v-loading指令
|
||||||
|
spinning: 'spinning',
|
||||||
|
});
|
||||||
|
|
||||||
// 国际化 i18n 配置
|
// 国际化 i18n 配置
|
||||||
await setupI18n(app);
|
await setupI18n(app);
|
||||||
@@ -153,6 +127,7 @@ async function bootstrap(namespace: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 初始化应用上下文(从 URL 参数中获取 appCode)
|
// 初始化应用上下文(从 URL 参数中获取 appCode)
|
||||||
|
const { useAppContextStore } = await import('#/store/app-context');
|
||||||
const appContextStore = useAppContextStore();
|
const appContextStore = useAppContextStore();
|
||||||
await appContextStore.initFromUrl();
|
await appContextStore.initFromUrl();
|
||||||
|
|
||||||
@@ -166,6 +141,10 @@ async function bootstrap(namespace: string) {
|
|||||||
// 配置路由及路由守卫
|
// 配置路由及路由守卫
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
|
||||||
|
// 配置Motion插件
|
||||||
|
const { MotionPlugin } = await import('@vben/plugins/motion');
|
||||||
|
app.use(MotionPlugin);
|
||||||
|
|
||||||
// 动态更新标题
|
// 动态更新标题
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (preferences.app.dynamicTitle) {
|
if (preferences.app.dynamicTitle) {
|
||||||
|
|||||||
@@ -139,9 +139,6 @@ const getStepLabel = (step: ReasoningStep) => {
|
|||||||
if (step.status === 'running') {
|
if (step.status === 'running') {
|
||||||
return '执行中';
|
return '执行中';
|
||||||
}
|
}
|
||||||
if (step.status === 'failed') {
|
|
||||||
return '执行失败';
|
|
||||||
}
|
|
||||||
return '已完成';
|
return '已完成';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,14 +150,6 @@ const getStepLabel = (step: ReasoningStep) => {
|
|||||||
tool_result: '工具结果',
|
tool_result: '工具结果',
|
||||||
knowledge_retrieval: '知识库检索',
|
knowledge_retrieval: '知识库检索',
|
||||||
annotation_reply: '标注回复',
|
annotation_reply: '标注回复',
|
||||||
parallel_start: '并行中',
|
|
||||||
parallel_complete: '并行完成',
|
|
||||||
waiting_input: '等待输入',
|
|
||||||
error: '失败',
|
|
||||||
loop_iteration_start: '循环中',
|
|
||||||
loop_iteration_complete: '循环完成',
|
|
||||||
loop_iteration_error: '循环失败',
|
|
||||||
loop_complete: '循环完成',
|
|
||||||
};
|
};
|
||||||
return labels[step.type] || step.type;
|
return labels[step.type] || step.type;
|
||||||
};
|
};
|
||||||
@@ -172,9 +161,6 @@ const getStepColor = (step: ReasoningStep) => {
|
|||||||
if (step.status === 'running') {
|
if (step.status === 'running') {
|
||||||
return 'text-blue-500';
|
return 'text-blue-500';
|
||||||
}
|
}
|
||||||
if (step.status === 'failed') {
|
|
||||||
return 'text-red-500';
|
|
||||||
}
|
|
||||||
return 'text-emerald-500';
|
return 'text-emerald-500';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,69 +172,10 @@ const getStepColor = (step: ReasoningStep) => {
|
|||||||
tool_result: 'text-teal-500',
|
tool_result: 'text-teal-500',
|
||||||
knowledge_retrieval: 'text-cyan-500',
|
knowledge_retrieval: 'text-cyan-500',
|
||||||
annotation_reply: 'text-emerald-500',
|
annotation_reply: 'text-emerald-500',
|
||||||
parallel_start: 'text-indigo-500',
|
|
||||||
parallel_complete: 'text-emerald-500',
|
|
||||||
waiting_input: 'text-amber-500',
|
|
||||||
error: 'text-red-500',
|
|
||||||
loop_iteration_start: 'text-blue-500',
|
|
||||||
loop_iteration_complete: 'text-emerald-500',
|
|
||||||
loop_iteration_error: 'text-red-500',
|
|
||||||
loop_complete: 'text-emerald-500',
|
|
||||||
};
|
};
|
||||||
return colors[step.type] || 'text-gray-500';
|
return colors[step.type] || 'text-gray-500';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStepMetaItems = (step: ReasoningStep) => {
|
|
||||||
const items: string[] = [];
|
|
||||||
const communication = step.communication;
|
|
||||||
const actor = communication?.actor || {};
|
|
||||||
const target = communication?.target || {};
|
|
||||||
const actorName =
|
|
||||||
actor.agent_name ||
|
|
||||||
actor.agent_code ||
|
|
||||||
actor.node_label ||
|
|
||||||
step.agent_name ||
|
|
||||||
step.agent_code;
|
|
||||||
const targetName = target.agent_name || target.agent_code || target.node_label;
|
|
||||||
if (actorName && targetName) {
|
|
||||||
items.push(`交接: ${actorName} -> ${targetName}`);
|
|
||||||
} else if (communication?.channel) {
|
|
||||||
items.push(`通道: ${communication.channel}`);
|
|
||||||
}
|
|
||||||
if (step.agent_name || step.agent_code) {
|
|
||||||
items.push(`智能体: ${step.agent_name || step.agent_code}`);
|
|
||||||
}
|
|
||||||
if (step.branch_label || step.branch_id) {
|
|
||||||
items.push(`分支: ${step.branch_label || step.branch_id}`);
|
|
||||||
}
|
|
||||||
if (step.subflow_name) {
|
|
||||||
items.push(`子流程: ${step.subflow_name}`);
|
|
||||||
}
|
|
||||||
if (step.model || step.model_id) {
|
|
||||||
items.push(`模型: ${step.model || step.model_id}`);
|
|
||||||
}
|
|
||||||
if (communication?.message && communication.message !== step.content) {
|
|
||||||
items.push(`消息: ${communication.message}`);
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
};
|
|
||||||
|
|
||||||
const messageMetaItems = computed(() => {
|
|
||||||
if (isUser.value) return [];
|
|
||||||
|
|
||||||
const items: string[] = [];
|
|
||||||
if (props.message.agent_name || props.message.agent_code) {
|
|
||||||
items.push(`智能体: ${props.message.agent_name || props.message.agent_code}`);
|
|
||||||
}
|
|
||||||
if (props.message.model_name || props.message.model_id) {
|
|
||||||
items.push(`模型: ${props.message.model_name || props.message.model_id}`);
|
|
||||||
}
|
|
||||||
if (props.message.collaboration_mode) {
|
|
||||||
items.push(`模式: ${props.message.collaboration_mode}`);
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 格式化文件大小
|
// 格式化文件大小
|
||||||
const formatFileSize = (bytes?: number) => {
|
const formatFileSize = (bytes?: number) => {
|
||||||
if (!bytes) return '';
|
if (!bytes) return '';
|
||||||
@@ -350,16 +277,6 @@ const formatVoiceDuration = (seconds: number) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 消息气泡 -->
|
<!-- 消息气泡 -->
|
||||||
<div v-if="messageMetaItems.length" class="chat-message-meta">
|
|
||||||
<span
|
|
||||||
v-for="item in messageMetaItems"
|
|
||||||
:key="item"
|
|
||||||
class="chat-message-meta-item"
|
|
||||||
>
|
|
||||||
{{ item }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chat-bubble" :class="{ 'is-error': isFailed }">
|
<div class="chat-bubble" :class="{ 'is-error': isFailed }">
|
||||||
<!-- 执行步骤(执行中展开显示,完成后折叠) -->
|
<!-- 执行步骤(执行中展开显示,完成后折叠) -->
|
||||||
<div v-if="message.reasoning_steps?.length" class="bubble-steps">
|
<div v-if="message.reasoning_steps?.length" class="bubble-steps">
|
||||||
@@ -384,18 +301,7 @@ const formatVoiceDuration = (seconds: number) => {
|
|||||||
<span class="step-indicator" :class="getStepColor(step)">
|
<span class="step-indicator" :class="getStepColor(step)">
|
||||||
{{ getStepLabel(step) }}
|
{{ getStepLabel(step) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="step-text" :title="step.content">
|
<span class="step-text">{{ step.content }}</span>
|
||||||
{{ step.content }}
|
|
||||||
</span>
|
|
||||||
<div v-if="getStepMetaItems(step).length" class="step-meta">
|
|
||||||
<span
|
|
||||||
v-for="item in getStepMetaItems(step)"
|
|
||||||
:key="item"
|
|
||||||
class="step-meta-item"
|
|
||||||
>
|
|
||||||
{{ item }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -684,27 +590,6 @@ const formatVoiceDuration = (seconds: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 推理步骤 */
|
/* 推理步骤 */
|
||||||
.chat-message-meta {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-meta-item {
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1px 5px;
|
|
||||||
background: var(--el-fill-color-blank);
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 18px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-reasoning {
|
.chat-reasoning {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@@ -1354,11 +1239,9 @@ const formatVoiceDuration = (seconds: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bubble-step {
|
.bubble-step {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
column-gap: 8px;
|
gap: 8px;
|
||||||
row-gap: 4px;
|
|
||||||
padding: 3px 0;
|
padding: 3px 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
@@ -1384,24 +1267,4 @@ const formatVoiceDuration = (seconds: number) => {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-meta {
|
|
||||||
grid-column: 2;
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-meta-item {
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1px 5px;
|
|
||||||
background: var(--el-fill-color-blank);
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ import type { Attachment, ChatMessage, VoiceMessage } from './types';
|
|||||||
*/
|
*/
|
||||||
import { computed, nextTick, ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { Bot, ChevronDown, ChevronUp, Trash2 } from '@vben/icons';
|
import { ChevronDown, ChevronUp, Trash2 } from '@vben/icons';
|
||||||
import { useUserStore } from '@vben/stores';
|
import { useUserStore } from '@vben/stores';
|
||||||
|
|
||||||
import { ElScrollbar } from 'element-plus';
|
import { ElScrollbar } from 'element-plus';
|
||||||
|
|
||||||
|
import AiWorkingAnimation from '#/components/ai-loading/AiWorkingAnimation.vue';
|
||||||
import { getFileUrl } from '#/composables/useFileUrl';
|
import { getFileUrl } from '#/composables/useFileUrl';
|
||||||
|
|
||||||
import InputArea from './InputArea.vue';
|
import InputArea from './InputArea.vue';
|
||||||
@@ -245,17 +246,16 @@ defineExpose({
|
|||||||
<!-- 内容区域(欢迎页或消息列表) -->
|
<!-- 内容区域(欢迎页或消息列表) -->
|
||||||
<div v-if="messages.length === 0" class="chatbox-content">
|
<div v-if="messages.length === 0" class="chatbox-content">
|
||||||
<div class="chatbox-center">
|
<div class="chatbox-center">
|
||||||
<div class="chatbox-welcome">
|
<!-- AI 工作动画 -->
|
||||||
<div class="welcome-icon">
|
<AiWorkingAnimation
|
||||||
<Bot class="size-8" />
|
:title="welcomeConfig?.title || ''"
|
||||||
</div>
|
:subtitle="welcomeConfig?.description || emptyText || '开始对话吧'"
|
||||||
<h3 v-if="welcomeConfig?.title" class="welcome-title">
|
:show-progress="false"
|
||||||
{{ welcomeConfig.title }}
|
:show-steps="false"
|
||||||
</h3>
|
:show-background="false"
|
||||||
<p class="welcome-desc">
|
class="h-full min-h-[400px] flex-1"
|
||||||
{{ welcomeConfig?.description || emptyText || '开始对话吧' }}
|
>
|
||||||
</p>
|
<!-- 建议问题 -->
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="welcomeConfig?.suggestions?.length"
|
v-if="welcomeConfig?.suggestions?.length"
|
||||||
class="welcome-suggestions"
|
class="welcome-suggestions"
|
||||||
@@ -287,7 +287,7 @@ defineExpose({
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AiWorkingAnimation>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -41,50 +41,22 @@ export interface ReasoningStep {
|
|||||||
type:
|
type:
|
||||||
| 'action'
|
| 'action'
|
||||||
| 'annotation_reply'
|
| 'annotation_reply'
|
||||||
| 'error'
|
|
||||||
| 'knowledge_retrieval'
|
| 'knowledge_retrieval'
|
||||||
| 'loop_complete'
|
|
||||||
| 'loop_iteration_complete'
|
|
||||||
| 'loop_iteration_error'
|
|
||||||
| 'loop_iteration_start'
|
|
||||||
| 'node_complete'
|
| 'node_complete'
|
||||||
| 'node_start'
|
| 'node_start'
|
||||||
| 'observation'
|
| 'observation'
|
||||||
| 'parallel_complete'
|
|
||||||
| 'parallel_start'
|
|
||||||
| 'thought'
|
| 'thought'
|
||||||
| 'tool_call'
|
| 'tool_call'
|
||||||
| 'tool_result'
|
| 'tool_result';
|
||||||
| 'waiting_input';
|
|
||||||
content: string;
|
content: string;
|
||||||
tool?: string;
|
tool?: string;
|
||||||
params?: Record<string, any>;
|
params?: Record<string, any>;
|
||||||
result?: any;
|
result?: any;
|
||||||
node_id?: string;
|
node_id?: string;
|
||||||
node_type?: string;
|
node_type?: string;
|
||||||
branch_id?: string;
|
|
||||||
branch_label?: string;
|
|
||||||
agent_code?: string;
|
|
||||||
agent_name?: string;
|
|
||||||
model?: string;
|
|
||||||
model_id?: string;
|
|
||||||
subflow_name?: string;
|
|
||||||
from_subflow?: boolean;
|
|
||||||
collaboration_role?: string;
|
|
||||||
collaboration_mode?: string;
|
|
||||||
communication?: {
|
|
||||||
actor?: Record<string, any>;
|
|
||||||
branch_id?: string;
|
|
||||||
branch_label?: string;
|
|
||||||
channel?: string;
|
|
||||||
event?: string;
|
|
||||||
message?: string;
|
|
||||||
status?: string;
|
|
||||||
target?: Record<string, any>;
|
|
||||||
};
|
|
||||||
output?: any;
|
output?: any;
|
||||||
/** 步骤状态:running 执行中,completed 已完成,failed 执行失败 */
|
/** 步骤状态:running 执行中,completed 已完成 */
|
||||||
status?: 'completed' | 'failed' | 'running';
|
status?: 'completed' | 'running';
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,12 +94,10 @@ export interface InteractionConfig {
|
|||||||
|
|
||||||
// 设计预览类型
|
// 设计预览类型
|
||||||
export type DesignPreviewType =
|
export type DesignPreviewType =
|
||||||
| 'agent_capability'
|
|
||||||
| 'database_design'
|
| 'database_design'
|
||||||
| 'knowledge_retrieval'
|
| 'form_basic_info'
|
||||||
| 'list_config'
|
| 'form_ui_design'
|
||||||
| 'llm_config'
|
| 'list_config';
|
||||||
| 'workflow_result';
|
|
||||||
|
|
||||||
// 设计预览配置
|
// 设计预览配置
|
||||||
export interface DesignPreview {
|
export interface DesignPreview {
|
||||||
@@ -157,10 +127,6 @@ export interface ChatMessage {
|
|||||||
error_message?: string;
|
error_message?: string;
|
||||||
feedback?: 'dislike' | 'like' | null;
|
feedback?: 'dislike' | 'like' | null;
|
||||||
model_name?: string;
|
model_name?: string;
|
||||||
agent_code?: string;
|
|
||||||
agent_name?: string;
|
|
||||||
collaboration_mode?: string;
|
|
||||||
model_id?: string;
|
|
||||||
/** 语音消息 */
|
/** 语音消息 */
|
||||||
voice?: VoiceMessage;
|
voice?: VoiceMessage;
|
||||||
/** 对话流交互配置(等待用户输入时) */
|
/** 对话流交互配置(等待用户输入时) */
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type {
|
|||||||
*/
|
*/
|
||||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { Close, Setting } from '@element-plus/icons-vue';
|
import { Settings2, X } from '@vben/icons';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ElButton,
|
ElButton,
|
||||||
@@ -31,10 +31,19 @@ import {
|
|||||||
|
|
||||||
import { AiWorkingAnimation } from '#/components/ai-loading';
|
import { AiWorkingAnimation } from '#/components/ai-loading';
|
||||||
import { ChatBox } from '#/components/ChatBox';
|
import { ChatBox } from '#/components/ChatBox';
|
||||||
|
import {
|
||||||
|
AppDesignPanel,
|
||||||
|
AppSettingsPanel,
|
||||||
|
DashboardBasicInfoConfirmPanel,
|
||||||
|
DashboardDesignConfirmPanel,
|
||||||
|
DashboardPublishConfirmPanel,
|
||||||
|
DesignEditorPanel,
|
||||||
|
SystemSummaryConfirmPanel,
|
||||||
|
} from '#/components/form-editor';
|
||||||
|
|
||||||
import { useChatApi } from './composables/useChatApi';
|
import { useChatApi } from './composables/useChatApi';
|
||||||
import { useEventHandler } from './composables/useEventHandler';
|
import { useEventHandler } from './composables/useEventHandler';
|
||||||
|
|
||||||
|
|
||||||
// ==================== Props ====================
|
// ==================== Props ====================
|
||||||
const props = withDefaults(defineProps<AiChatPanelProps>(), {
|
const props = withDefaults(defineProps<AiChatPanelProps>(), {
|
||||||
mode: 'workflow',
|
mode: 'workflow',
|
||||||
@@ -184,13 +193,44 @@ const variablesDialogVisible = ref(false);
|
|||||||
const variablesForm = ref<Record<string, any>>({});
|
const variablesForm = ref<Record<string, any>>({});
|
||||||
const pendingMessage = ref('');
|
const pendingMessage = ref('');
|
||||||
|
|
||||||
|
// ==================== 设计预览面板状态 ====================
|
||||||
|
const showDesignPanel = ref(false);
|
||||||
|
const currentDesign = ref<DesignPreviewData | undefined>(undefined);
|
||||||
|
|
||||||
|
const showAppDesignPanel = ref(false);
|
||||||
|
const currentAppDesign = ref<DesignPreviewData | undefined>(undefined);
|
||||||
|
|
||||||
|
const showAppSettingsPanel = ref(false);
|
||||||
|
const currentAppSettings = ref<DesignPreviewData | undefined>(undefined);
|
||||||
|
|
||||||
|
const showDashboardBasicInfoPanel = ref(false);
|
||||||
|
const currentDashboardBasicInfo = ref<DesignPreviewData | undefined>(undefined);
|
||||||
|
|
||||||
|
const showDashboardDesignPanel = ref(false);
|
||||||
|
const currentDashboardDesign = ref<DesignPreviewData | undefined>(undefined);
|
||||||
|
|
||||||
|
const showDashboardPublishPanel = ref(false);
|
||||||
|
const currentDashboardPublish = ref<DesignPreviewData | undefined>(undefined);
|
||||||
|
|
||||||
|
const showSystemSummaryPanel = ref(false);
|
||||||
|
const currentSystemSummary = ref<DesignPreviewData | undefined>(undefined);
|
||||||
|
|
||||||
// AI 工作动画状态(用于 application 类型工作流)
|
// AI 工作动画状态(用于 application 类型工作流)
|
||||||
const showLoadingAnimation = ref(false);
|
const showLoadingAnimation = ref(false);
|
||||||
const loadingAnimationTitle = ref('AI 正在开发...');
|
const loadingAnimationTitle = ref('AI 正在开发...');
|
||||||
|
|
||||||
// 是否有任何设计面板打开
|
// 是否有任何设计面板打开
|
||||||
const hasAnyDesignPanelOpen = computed(() => showLoadingAnimation.value);
|
const hasAnyDesignPanelOpen = computed(
|
||||||
|
() =>
|
||||||
|
showDesignPanel.value ||
|
||||||
|
showAppDesignPanel.value ||
|
||||||
|
showAppSettingsPanel.value ||
|
||||||
|
showDashboardBasicInfoPanel.value ||
|
||||||
|
showDashboardDesignPanel.value ||
|
||||||
|
showDashboardPublishPanel.value ||
|
||||||
|
showSystemSummaryPanel.value ||
|
||||||
|
showLoadingAnimation.value,
|
||||||
|
);
|
||||||
|
|
||||||
// ==================== 工作流变量 ====================
|
// ==================== 工作流变量 ====================
|
||||||
const startNode = computed(() => props.nodes?.find((n) => n.type === 'start'));
|
const startNode = computed(() => props.nodes?.find((n) => n.type === 'start'));
|
||||||
@@ -234,16 +274,45 @@ const generateId = () =>
|
|||||||
`msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
`msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
const handleDesignPreview = (data: DesignPreviewData) => {
|
const handleDesignPreview = (data: DesignPreviewData) => {
|
||||||
|
// 关闭加载动画
|
||||||
showLoadingAnimation.value = false;
|
showLoadingAnimation.value = false;
|
||||||
const content = [data.title, data.message].filter(Boolean).join('\n\n');
|
|
||||||
if (content) {
|
switch (data.type) {
|
||||||
messages.value.push({
|
case 'app_design': {
|
||||||
id: generateId(),
|
currentAppDesign.value = data;
|
||||||
role: 'assistant',
|
showAppDesignPanel.value = true;
|
||||||
content,
|
break;
|
||||||
timestamp: new Date(),
|
}
|
||||||
status: 'completed',
|
case 'app_settings': {
|
||||||
});
|
currentAppSettings.value = data;
|
||||||
|
showAppSettingsPanel.value = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'dashboard_basic_info': {
|
||||||
|
currentDashboardBasicInfo.value = data;
|
||||||
|
showDashboardBasicInfoPanel.value = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'dashboard_design': {
|
||||||
|
currentDashboardDesign.value = data;
|
||||||
|
showDashboardDesignPanel.value = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'dashboard_publish': {
|
||||||
|
currentDashboardPublish.value = data;
|
||||||
|
showDashboardPublishPanel.value = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'system_summary': {
|
||||||
|
currentSystemSummary.value = data;
|
||||||
|
showSystemSummaryPanel.value = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
currentDesign.value = data;
|
||||||
|
showDesignPanel.value = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -685,6 +754,132 @@ const handleUpdateMessage = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ==================== 设计预览处理 ====================
|
// ==================== 设计预览处理 ====================
|
||||||
|
const createDesignConfirmHandler = (
|
||||||
|
showRef: ReturnType<typeof ref<boolean>>,
|
||||||
|
dataRef: ReturnType<typeof ref<DesignPreviewData | undefined>>,
|
||||||
|
confirmMessage: string,
|
||||||
|
) => {
|
||||||
|
return (data: Record<string, any>) => {
|
||||||
|
showRef.value = false;
|
||||||
|
dataRef.value = undefined;
|
||||||
|
|
||||||
|
// 全屏布局且是 application 类型工作流时,显示加载动画
|
||||||
|
if (
|
||||||
|
props.layout === 'fullscreen' &&
|
||||||
|
props.agent?.workflow_type === 'application'
|
||||||
|
) {
|
||||||
|
showLoadingAnimation.value = true;
|
||||||
|
loadingAnimationTitle.value = 'AI 正在继续开发...';
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMsg: ChatMessage = {
|
||||||
|
id: generateId(),
|
||||||
|
role: 'user',
|
||||||
|
content: confirmMessage,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
messages.value.push(userMsg);
|
||||||
|
|
||||||
|
waitingForInput.value = false;
|
||||||
|
waitingConfig.value = null;
|
||||||
|
|
||||||
|
resumeExecution(JSON.stringify(data));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDesignCloseHandler = (
|
||||||
|
showRef: ReturnType<typeof ref<boolean>>,
|
||||||
|
dataRef: ReturnType<typeof ref<DesignPreviewData | undefined>>,
|
||||||
|
) => {
|
||||||
|
return () => {
|
||||||
|
showRef.value = false;
|
||||||
|
dataRef.value = undefined;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDesignConfirm = createDesignConfirmHandler(
|
||||||
|
showDesignPanel,
|
||||||
|
currentDesign,
|
||||||
|
'确认设计',
|
||||||
|
);
|
||||||
|
const handleDesignClose = createDesignCloseHandler(
|
||||||
|
showDesignPanel,
|
||||||
|
currentDesign,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAppDesignConfirm = createDesignConfirmHandler(
|
||||||
|
showAppDesignPanel,
|
||||||
|
currentAppDesign,
|
||||||
|
'确认应用设计',
|
||||||
|
);
|
||||||
|
const handleAppDesignClose = createDesignCloseHandler(
|
||||||
|
showAppDesignPanel,
|
||||||
|
currentAppDesign,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAppSettingsConfirm = createDesignConfirmHandler(
|
||||||
|
showAppSettingsPanel,
|
||||||
|
currentAppSettings,
|
||||||
|
'确认应用设置',
|
||||||
|
);
|
||||||
|
const handleAppSettingsClose = createDesignCloseHandler(
|
||||||
|
showAppSettingsPanel,
|
||||||
|
currentAppSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDashboardBasicInfoConfirm = createDesignConfirmHandler(
|
||||||
|
showDashboardBasicInfoPanel,
|
||||||
|
currentDashboardBasicInfo,
|
||||||
|
'确认仪表盘基础信息',
|
||||||
|
);
|
||||||
|
const handleDashboardBasicInfoClose = createDesignCloseHandler(
|
||||||
|
showDashboardBasicInfoPanel,
|
||||||
|
currentDashboardBasicInfo,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDashboardDesignConfirm = createDesignConfirmHandler(
|
||||||
|
showDashboardDesignPanel,
|
||||||
|
currentDashboardDesign,
|
||||||
|
'确认仪表盘设计',
|
||||||
|
);
|
||||||
|
const handleDashboardDesignClose = createDesignCloseHandler(
|
||||||
|
showDashboardDesignPanel,
|
||||||
|
currentDashboardDesign,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDashboardPublishConfirm = (data: Record<string, any>) => {
|
||||||
|
showDashboardPublishPanel.value = false;
|
||||||
|
currentDashboardPublish.value = undefined;
|
||||||
|
|
||||||
|
const userMsg: ChatMessage = {
|
||||||
|
id: generateId(),
|
||||||
|
role: 'user',
|
||||||
|
content: `发布到菜单:${data.menu_name}`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
messages.value.push(userMsg);
|
||||||
|
|
||||||
|
waitingForInput.value = false;
|
||||||
|
waitingConfig.value = null;
|
||||||
|
|
||||||
|
resumeExecution(JSON.stringify(data));
|
||||||
|
};
|
||||||
|
const handleDashboardPublishClose = createDesignCloseHandler(
|
||||||
|
showDashboardPublishPanel,
|
||||||
|
currentDashboardPublish,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSystemSummaryConfirm = createDesignConfirmHandler(
|
||||||
|
showSystemSummaryPanel,
|
||||||
|
currentSystemSummary,
|
||||||
|
'完成',
|
||||||
|
);
|
||||||
|
const handleSystemSummaryClose = createDesignCloseHandler(
|
||||||
|
showSystemSummaryPanel,
|
||||||
|
currentSystemSummary,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== 暴露方法 ====================
|
||||||
defineExpose({
|
defineExpose({
|
||||||
clearChat: handleClear,
|
clearChat: handleClear,
|
||||||
sendMessage: handleSend,
|
sendMessage: handleSend,
|
||||||
@@ -724,14 +919,14 @@ onUnmounted(() => {
|
|||||||
mode === 'workflow' && enableVariablesForm && hasMultipleVariables
|
mode === 'workflow' && enableVariablesForm && hasMultipleVariables
|
||||||
"
|
"
|
||||||
link
|
link
|
||||||
:icon="Setting"
|
:icon="Settings2"
|
||||||
title="配置输入变量"
|
title="配置输入变量"
|
||||||
@click="variablesDialogVisible = true"
|
@click="variablesDialogVisible = true"
|
||||||
/>
|
/>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="showCloseButton"
|
v-if="showCloseButton"
|
||||||
link
|
link
|
||||||
:icon="Close"
|
:icon="X"
|
||||||
@click="$emit('close')"
|
@click="$emit('close')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -750,7 +945,6 @@ onUnmounted(() => {
|
|||||||
:enable-image="enableImage"
|
:enable-image="enableImage"
|
||||||
:enable-voice="enableVoice"
|
:enable-voice="enableVoice"
|
||||||
:enable-feedback="enableFeedback"
|
:enable-feedback="enableFeedback"
|
||||||
:show-reasoning-steps="true"
|
|
||||||
:show-clear-button="showClearButton"
|
:show-clear-button="showClearButton"
|
||||||
:empty-text="emptyText"
|
:empty-text="emptyText"
|
||||||
:welcome-config="welcomeConfig"
|
:welcome-config="welcomeConfig"
|
||||||
@@ -811,6 +1005,133 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 设计编辑面板 -->
|
<!-- 设计编辑面板 -->
|
||||||
|
<DesignEditorPanel
|
||||||
|
v-if="showDesignPanel"
|
||||||
|
:visible="showDesignPanel"
|
||||||
|
:design="currentDesign as any"
|
||||||
|
@update:visible="showDesignPanel = $event"
|
||||||
|
@confirm="handleDesignConfirm"
|
||||||
|
@close="handleDesignClose"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 应用设计面板 -->
|
||||||
|
<AppDesignPanel
|
||||||
|
v-if="showAppDesignPanel"
|
||||||
|
:visible="showAppDesignPanel"
|
||||||
|
:design="currentAppDesign as any"
|
||||||
|
@update:visible="showAppDesignPanel = $event"
|
||||||
|
@confirm="handleAppDesignConfirm"
|
||||||
|
@close="handleAppDesignClose"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 应用设置面板 -->
|
||||||
|
<AppSettingsPanel
|
||||||
|
v-if="showAppSettingsPanel"
|
||||||
|
:visible="showAppSettingsPanel"
|
||||||
|
:settings="currentAppSettings as any"
|
||||||
|
@update:visible="showAppSettingsPanel = $event"
|
||||||
|
@confirm="handleAppSettingsConfirm"
|
||||||
|
@close="handleAppSettingsClose"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 仪表盘基础信息面板 -->
|
||||||
|
<DashboardBasicInfoConfirmPanel
|
||||||
|
v-if="showDashboardBasicInfoPanel"
|
||||||
|
:visible="showDashboardBasicInfoPanel"
|
||||||
|
:basic-info="currentDashboardBasicInfo as any"
|
||||||
|
@update:visible="showDashboardBasicInfoPanel = $event"
|
||||||
|
@confirm="handleDashboardBasicInfoConfirm"
|
||||||
|
@close="handleDashboardBasicInfoClose"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 仪表盘设计面板 -->
|
||||||
|
<DashboardDesignConfirmPanel
|
||||||
|
v-if="showDashboardDesignPanel"
|
||||||
|
:visible="showDashboardDesignPanel"
|
||||||
|
:design="currentDashboardDesign as any"
|
||||||
|
@update:visible="showDashboardDesignPanel = $event"
|
||||||
|
@confirm="handleDashboardDesignConfirm"
|
||||||
|
@close="handleDashboardDesignClose"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 仪表盘发布面板 -->
|
||||||
|
<DashboardPublishConfirmPanel
|
||||||
|
v-if="showDashboardPublishPanel"
|
||||||
|
:visible="showDashboardPublishPanel"
|
||||||
|
:publish-data="currentDashboardPublish as any"
|
||||||
|
@update:visible="showDashboardPublishPanel = $event"
|
||||||
|
@confirm="handleDashboardPublishConfirm"
|
||||||
|
@close="handleDashboardPublishClose"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 系统总结面板 -->
|
||||||
|
<SystemSummaryConfirmPanel
|
||||||
|
v-if="showSystemSummaryPanel"
|
||||||
|
:visible="showSystemSummaryPanel"
|
||||||
|
:data="currentSystemSummary as any"
|
||||||
|
@update:visible="showSystemSummaryPanel = $event"
|
||||||
|
@confirm="handleSystemSummaryConfirm"
|
||||||
|
@close="handleSystemSummaryClose"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 侧边栏布局时的设计面板(保持原有行为) -->
|
||||||
|
<template v-if="layout === 'sidebar'">
|
||||||
|
<DesignEditorPanel
|
||||||
|
:visible="showDesignPanel"
|
||||||
|
:design="currentDesign as any"
|
||||||
|
@update:visible="showDesignPanel = $event"
|
||||||
|
@confirm="handleDesignConfirm"
|
||||||
|
@close="handleDesignClose"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AppDesignPanel
|
||||||
|
:visible="showAppDesignPanel"
|
||||||
|
:design="currentAppDesign as any"
|
||||||
|
@update:visible="showAppDesignPanel = $event"
|
||||||
|
@confirm="handleAppDesignConfirm"
|
||||||
|
@close="handleAppDesignClose"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AppSettingsPanel
|
||||||
|
:visible="showAppSettingsPanel"
|
||||||
|
:settings="currentAppSettings as any"
|
||||||
|
@update:visible="showAppSettingsPanel = $event"
|
||||||
|
@confirm="handleAppSettingsConfirm"
|
||||||
|
@close="handleAppSettingsClose"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DashboardBasicInfoConfirmPanel
|
||||||
|
:visible="showDashboardBasicInfoPanel"
|
||||||
|
:basic-info="currentDashboardBasicInfo as any"
|
||||||
|
@update:visible="showDashboardBasicInfoPanel = $event"
|
||||||
|
@confirm="handleDashboardBasicInfoConfirm"
|
||||||
|
@close="handleDashboardBasicInfoClose"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DashboardDesignConfirmPanel
|
||||||
|
:visible="showDashboardDesignPanel"
|
||||||
|
:design="currentDashboardDesign as any"
|
||||||
|
@update:visible="showDashboardDesignPanel = $event"
|
||||||
|
@confirm="handleDashboardDesignConfirm"
|
||||||
|
@close="handleDashboardDesignClose"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DashboardPublishConfirmPanel
|
||||||
|
:visible="showDashboardPublishPanel"
|
||||||
|
:publish-data="currentDashboardPublish as any"
|
||||||
|
@update:visible="showDashboardPublishPanel = $event"
|
||||||
|
@confirm="handleDashboardPublishConfirm"
|
||||||
|
@close="handleDashboardPublishClose"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SystemSummaryConfirmPanel
|
||||||
|
:visible="showSystemSummaryPanel"
|
||||||
|
:data="currentSystemSummary as any"
|
||||||
|
@update:visible="showSystemSummaryPanel = $event"
|
||||||
|
@confirm="handleSystemSummaryConfirm"
|
||||||
|
@close="handleSystemSummaryClose"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function useChatApi(props: AiChatPanelProps): {
|
|||||||
const adapter: ChatApiAdapter = {
|
const adapter: ChatApiAdapter = {
|
||||||
sendMessage: (
|
sendMessage: (
|
||||||
message: string,
|
message: string,
|
||||||
_inputs: Record<string, any> | undefined,
|
inputs: Record<string, any> | undefined,
|
||||||
onEvent: (event: StreamEvent) => void,
|
onEvent: (event: StreamEvent) => void,
|
||||||
onError: (error: Error) => void,
|
onError: (error: Error) => void,
|
||||||
onComplete: () => void,
|
onComplete: () => void,
|
||||||
@@ -110,12 +110,16 @@ export function useChatApi(props: AiChatPanelProps): {
|
|||||||
type: att.type,
|
type: att.type,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 从 inputs 中提取 form_code(如果存在)
|
||||||
|
const formCode = inputs?.form_code;
|
||||||
|
|
||||||
return sendAgentMessageStream(
|
return sendAgentMessageStream(
|
||||||
props.agentId,
|
props.agentId,
|
||||||
{
|
{
|
||||||
message,
|
message,
|
||||||
conversation_id: conversationId.value || undefined,
|
conversation_id: conversationId.value || undefined,
|
||||||
attachments: attachmentInputs,
|
attachments: attachmentInputs,
|
||||||
|
form_code: formCode,
|
||||||
},
|
},
|
||||||
(event) => {
|
(event) => {
|
||||||
// 捕获 conversation_id
|
// 捕获 conversation_id
|
||||||
@@ -231,17 +235,6 @@ export function useChatApi(props: AiChatPanelProps): {
|
|||||||
params: step.params,
|
params: step.params,
|
||||||
node_id: step.node_id,
|
node_id: step.node_id,
|
||||||
node_type: step.node_type,
|
node_type: step.node_type,
|
||||||
branch_id: step.branch_id,
|
|
||||||
branch_label: step.branch_label,
|
|
||||||
agent_code: step.agent_code,
|
|
||||||
agent_name: step.agent_name,
|
|
||||||
model: step.model,
|
|
||||||
model_id: step.model_id,
|
|
||||||
subflow_name: step.subflow_name,
|
|
||||||
from_subflow: step.from_subflow,
|
|
||||||
collaboration_role: step.collaboration_role,
|
|
||||||
collaboration_mode: step.collaboration_mode,
|
|
||||||
communication: step.communication,
|
|
||||||
output: step.output,
|
output: step.output,
|
||||||
status: step.status,
|
status: step.status,
|
||||||
timestamp: step.timestamp,
|
timestamp: step.timestamp,
|
||||||
|
|||||||
@@ -56,12 +56,16 @@ export interface AgentInfo {
|
|||||||
|
|
||||||
/** 设计预览类型 */
|
/** 设计预览类型 */
|
||||||
export type DesignPreviewType =
|
export type DesignPreviewType =
|
||||||
| 'agent_capability'
|
| 'app_design'
|
||||||
|
| 'app_settings'
|
||||||
|
| 'dashboard_basic_info'
|
||||||
|
| 'dashboard_design'
|
||||||
|
| 'dashboard_publish'
|
||||||
| 'database_design'
|
| 'database_design'
|
||||||
| 'knowledge_retrieval'
|
| 'form_basic_info'
|
||||||
|
| 'form_ui_design'
|
||||||
| 'list_config'
|
| 'list_config'
|
||||||
| 'llm_config'
|
| 'system_summary';
|
||||||
| 'workflow_result';
|
|
||||||
|
|
||||||
/** 设计预览数据 */
|
/** 设计预览数据 */
|
||||||
export interface DesignPreviewData {
|
export interface DesignPreviewData {
|
||||||
@@ -69,9 +73,7 @@ export interface DesignPreviewData {
|
|||||||
title: string;
|
title: string;
|
||||||
data: Record<string, any>;
|
data: Record<string, any>;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
data_sources?: any[];
|
|
||||||
form_fields?: any[];
|
form_fields?: any[];
|
||||||
schema_fields?: any[];
|
|
||||||
table_configs?: any[];
|
table_configs?: any[];
|
||||||
layoutOptions?: any[];
|
layoutOptions?: any[];
|
||||||
themeOptions?: any[];
|
themeOptions?: any[];
|
||||||
@@ -90,7 +92,6 @@ export interface NodeEvent {
|
|||||||
node_id: string;
|
node_id: string;
|
||||||
node_type?: string;
|
node_type?: string;
|
||||||
node_label?: string;
|
node_label?: string;
|
||||||
event_type?: string;
|
|
||||||
status?: 'failed' | 'running' | 'success';
|
status?: 'failed' | 'running' | 'success';
|
||||||
inputs?: Record<string, any>;
|
inputs?: Record<string, any>;
|
||||||
outputs?: Record<string, any>;
|
outputs?: Record<string, any>;
|
||||||
@@ -98,7 +99,6 @@ export interface NodeEvent {
|
|||||||
tokens_used?: number;
|
tokens_used?: number;
|
||||||
error_message?: string;
|
error_message?: string;
|
||||||
loop_iteration?: number;
|
loop_iteration?: number;
|
||||||
warnings?: any[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 循环迭代事件 */
|
/** 循环迭代事件 */
|
||||||
@@ -135,7 +135,7 @@ export interface AiChatPanelProps {
|
|||||||
agent?: AgentInfo | null;
|
agent?: AgentInfo | null;
|
||||||
/** 当前对话 ID(agent-chat 模式用于加载历史) */
|
/** 当前对话 ID(agent-chat 模式用于加载历史) */
|
||||||
conversationId?: null | string;
|
conversationId?: null | string;
|
||||||
/** 初始输入参数 */
|
/** 初始输入参数(如 form_code 等系统变量) */
|
||||||
initialInputs?: Record<string, any>;
|
initialInputs?: Record<string, any>;
|
||||||
|
|
||||||
// ========== 功能开关 ==========
|
// ========== 功能开关 ==========
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 飘过的代码行 - 左侧 -->
|
<!-- 飘过的代码行 - 左侧 -->
|
||||||
<div v-if="showBackground" class="flying-code left-side">
|
<div class="flying-code left-side">
|
||||||
<div class="code-row" style="--i: 0">
|
<div class="code-row" style="--i: 0">
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +138,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 飘过的代码行 - 右侧 -->
|
<!-- 飘过的代码行 - 右侧 -->
|
||||||
<div v-if="showBackground" class="flying-code right-side">
|
<div class="flying-code right-side">
|
||||||
<div class="code-row" style="--i: 0">function createApp(config) {</div>
|
<div class="code-row" style="--i: 0">function createApp(config) {</div>
|
||||||
<div class="code-row" style="--i: 1">const state = reactive({});</div>
|
<div class="code-row" style="--i: 1">const state = reactive({});</div>
|
||||||
<div class="code-row" style="--i: 2">watch(() => props.value, cb);</div>
|
<div class="code-row" style="--i: 2">watch(() => props.value, cb);</div>
|
||||||
@@ -152,7 +152,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 数据粒子流 -->
|
<!-- 数据粒子流 -->
|
||||||
<div v-if="showBackground" class="particles">
|
<div class="particles">
|
||||||
<span
|
<span
|
||||||
v-for="p in particles"
|
v-for="p in particles"
|
||||||
:key="p.id"
|
:key="p.id"
|
||||||
@@ -178,7 +178,7 @@ onUnmounted(() => {
|
|||||||
<!-- 机器人工作台 -->
|
<!-- 机器人工作台 -->
|
||||||
<div class="workstation">
|
<div class="workstation">
|
||||||
<!-- 思考气泡 -->
|
<!-- 思考气泡 -->
|
||||||
<div v-if="showBackground" class="thought-bubble">
|
<div class="thought-bubble">
|
||||||
<span class="thought-text">{{ thoughts[currentThought] }}</span>
|
<span class="thought-text">{{ thoughts[currentThought] }}</span>
|
||||||
<div class="bubble-dots"><span></span><span></span><span></span></div>
|
<div class="bubble-dots"><span></span><span></span><span></span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,7 +284,7 @@ onUnmounted(() => {
|
|||||||
<!-- 桌子 -->
|
<!-- 桌子 -->
|
||||||
<div class="desk">
|
<div class="desk">
|
||||||
<!-- 小显示器在桌上 -->
|
<!-- 小显示器在桌上 -->
|
||||||
<div v-if="showBackground" class="mini-monitor">
|
<div class="mini-monitor">
|
||||||
<div class="monitor-frame">
|
<div class="monitor-frame">
|
||||||
<div class="mini-screen">
|
<div class="mini-screen">
|
||||||
<div class="py-code">
|
<div class="py-code">
|
||||||
@@ -340,7 +340,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 飘浮代码片段 -->
|
<!-- 飘浮代码片段 -->
|
||||||
<div v-if="showBackground" class="floating">
|
<div class="floating">
|
||||||
<span class="code-snippet" style="--x: 8%; --y: 15%; --d: 0s">const app =</span>
|
<span class="code-snippet" style="--x: 8%; --y: 15%; --d: 0s">const app =</span>
|
||||||
<span class="code-snippet" style="--x: 78%; --y: 20%; --d: 0.8s">async () =></span>
|
<span class="code-snippet" style="--x: 78%; --y: 20%; --d: 0.8s">async () =></span>
|
||||||
<span class="code-snippet" style="--x: 5%; --y: 75%; --d: 1.6s">return data</span>
|
<span class="code-snippet" style="--x: 5%; --y: 75%; --d: 1.6s">return data</span>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const getWidget = (i: string) => {
|
|||||||
const getAnimationDelay = (i: string) => {
|
const getAnimationDelay = (i: string) => {
|
||||||
if (!dashboardConfig.value) return 0;
|
if (!dashboardConfig.value) return 0;
|
||||||
const index = dashboardConfig.value.widgets.findIndex((w) => w.i === i);
|
const index = dashboardConfig.value.widgets.findIndex((w) => w.i === i);
|
||||||
return index * 20;
|
return index * 80; // 每个组件延迟 80ms
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,35 @@ const widgetComponents: Record<
|
|||||||
string,
|
string,
|
||||||
ReturnType<typeof defineAsyncComponent>
|
ReturnType<typeof defineAsyncComponent>
|
||||||
> = {
|
> = {
|
||||||
|
'stat-card': defineAsyncComponent(() => import('./widgets/StatCard.vue')),
|
||||||
|
'progress-card': defineAsyncComponent(
|
||||||
|
() => import('./widgets/ProgressCard.vue'),
|
||||||
|
),
|
||||||
|
'chart-line': defineAsyncComponent(() => import('./widgets/ChartLine.vue')),
|
||||||
|
'chart-bar': defineAsyncComponent(() => import('./widgets/ChartBar.vue')),
|
||||||
|
'chart-pie': defineAsyncComponent(() => import('./widgets/ChartPie.vue')),
|
||||||
|
'chart-gauge': defineAsyncComponent(() => import('./widgets/ChartGauge.vue')),
|
||||||
|
'chart-area': defineAsyncComponent(() => import('./widgets/ChartArea.vue')),
|
||||||
|
'chart-radar': defineAsyncComponent(() => import('./widgets/ChartRadar.vue')),
|
||||||
|
'chart-funnel': defineAsyncComponent(
|
||||||
|
() => import('./widgets/ChartFunnel.vue'),
|
||||||
|
),
|
||||||
|
'chart-scatter': defineAsyncComponent(
|
||||||
|
() => import('./widgets/ChartScatter.vue'),
|
||||||
|
),
|
||||||
|
'chart-ring': defineAsyncComponent(() => import('./widgets/ChartRing.vue')),
|
||||||
|
'chart-heatmap': defineAsyncComponent(
|
||||||
|
() => import('./widgets/ChartHeatmap.vue'),
|
||||||
|
),
|
||||||
|
'chart-kline': defineAsyncComponent(() => import('./widgets/ChartKline.vue')),
|
||||||
|
'chart-sankey': defineAsyncComponent(
|
||||||
|
() => import('./widgets/ChartSankey.vue'),
|
||||||
|
),
|
||||||
|
'todo-list': defineAsyncComponent(() => import('./widgets/TodoList.vue')),
|
||||||
'notice-list': defineAsyncComponent(() => import('./widgets/NoticeList.vue')),
|
'notice-list': defineAsyncComponent(() => import('./widgets/NoticeList.vue')),
|
||||||
|
'ranking-list': defineAsyncComponent(
|
||||||
|
() => import('./widgets/RankingList.vue'),
|
||||||
|
),
|
||||||
'announcement-list': defineAsyncComponent(
|
'announcement-list': defineAsyncComponent(
|
||||||
() => import('./widgets/AnnouncementList.vue'),
|
() => import('./widgets/AnnouncementList.vue'),
|
||||||
),
|
),
|
||||||
@@ -53,6 +81,18 @@ const widgetComponents: Record<
|
|||||||
),
|
),
|
||||||
clock: defineAsyncComponent(() => import('./widgets/ClockWidget.vue')),
|
clock: defineAsyncComponent(() => import('./widgets/ClockWidget.vue')),
|
||||||
weather: defineAsyncComponent(() => import('./widgets/WeatherWidget.vue')),
|
weather: defineAsyncComponent(() => import('./widgets/WeatherWidget.vue')),
|
||||||
|
'image-carousel': defineAsyncComponent(
|
||||||
|
() => import('./widgets/ImageCarousel.vue'),
|
||||||
|
),
|
||||||
|
'data-table': defineAsyncComponent(() => import('./widgets/DataTable.vue')),
|
||||||
|
'form-render': defineAsyncComponent(
|
||||||
|
() => import('./widgets/FormRenderWidget.vue'),
|
||||||
|
),
|
||||||
|
iframe: defineAsyncComponent(() => import('./widgets/IframeWidget.vue')),
|
||||||
|
'video-player': defineAsyncComponent(
|
||||||
|
() => import('./widgets/VideoPlayer.vue'),
|
||||||
|
),
|
||||||
|
image: defineAsyncComponent(() => import('./widgets/ImageWidget.vue')),
|
||||||
'approval-center': defineAsyncComponent(
|
'approval-center': defineAsyncComponent(
|
||||||
() => import('./widgets/ApprovalCenter.vue'),
|
() => import('./widgets/ApprovalCenter.vue'),
|
||||||
),
|
),
|
||||||
@@ -60,6 +100,18 @@ const widgetComponents: Record<
|
|||||||
'server-monitor': defineAsyncComponent(
|
'server-monitor': defineAsyncComponent(
|
||||||
() => import('./widgets/ServerMonitor.vue'),
|
() => import('./widgets/ServerMonitor.vue'),
|
||||||
),
|
),
|
||||||
|
'filter-input': defineAsyncComponent(
|
||||||
|
() => import('./widgets/FilterInput.vue'),
|
||||||
|
),
|
||||||
|
'filter-select': defineAsyncComponent(
|
||||||
|
() => import('./widgets/FilterSelect.vue'),
|
||||||
|
),
|
||||||
|
'filter-date': defineAsyncComponent(
|
||||||
|
() => import('./widgets/FilterDate.vue'),
|
||||||
|
),
|
||||||
|
'filter-date-range': defineAsyncComponent(
|
||||||
|
() => import('./widgets/FilterDateRange.vue'),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const store = useDashboardDesignStore();
|
const store = useDashboardDesignStore();
|
||||||
@@ -249,7 +301,7 @@ const animationClass = computed(() => {
|
|||||||
<!-- 加载状态 -->
|
<!-- 加载状态 -->
|
||||||
<div
|
<div
|
||||||
v-if="isLoading"
|
v-if="isLoading"
|
||||||
class="widget-loading-mask absolute inset-0 z-10 flex items-center justify-center"
|
class="absolute inset-0 z-10 flex items-center justify-center bg-white/50"
|
||||||
>
|
>
|
||||||
<Loader2 class="text-primary h-6 w-6 animate-spin" />
|
<Loader2 class="text-primary h-6 w-6 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
@@ -296,9 +348,7 @@ const animationClass = computed(() => {
|
|||||||
.widget-enter {
|
.widget-enter {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px) scale(0.95);
|
transform: translateY(20px) scale(0.95);
|
||||||
transition:
|
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
opacity 0.22s cubic-bezier(0.22, 1, 0.36, 1),
|
|
||||||
transform 0.22s cubic-bezier(0.22, 1, 0.36, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-entered {
|
.widget-entered {
|
||||||
@@ -312,8 +362,4 @@ const animationClass = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 入场动画 */
|
/* 入场动画 */
|
||||||
|
|
||||||
.widget-loading-mask {
|
|
||||||
background: color-mix(in srgb, var(--el-bg-color) 78%, transparent);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+22
-75
@@ -1,136 +1,88 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Component } from 'vue';
|
|
||||||
import type { DashboardWidget } from '#/components/dashboard-design';
|
import type { DashboardWidget } from '#/components/dashboard-design';
|
||||||
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FilePen,
|
FilePen,
|
||||||
IconifyIcon,
|
|
||||||
Play,
|
Play,
|
||||||
Send,
|
Send,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
} from '@vben/icons';
|
} from '@vben/icons';
|
||||||
import { $t } from '@vben/locales';
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
import { useAppContextStore } from '#/store/app-context';
|
|
||||||
|
|
||||||
interface ApprovalMenuItem {
|
|
||||||
color: string;
|
|
||||||
icon: Component | string;
|
|
||||||
key: number | string;
|
|
||||||
path: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
widget: DashboardWidget;
|
widget: DashboardWidget;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const router = useRouter();
|
// 审批中心菜单项
|
||||||
const appContextStore = useAppContextStore();
|
const menuItems = computed(() => [
|
||||||
|
|
||||||
const defaultColors = [
|
|
||||||
'rgba(59, 130, 246, 0.85)',
|
|
||||||
'rgba(245, 158, 11, 0.85)',
|
|
||||||
'rgba(139, 92, 246, 0.85)',
|
|
||||||
'rgba(14, 165, 233, 0.85)',
|
|
||||||
'rgba(249, 115, 22, 0.85)',
|
|
||||||
'rgba(20, 184, 166, 0.85)',
|
|
||||||
];
|
|
||||||
|
|
||||||
const defaultMenuItems = computed<ApprovalMenuItem[]>(() => [
|
|
||||||
{
|
{
|
||||||
key: 'initiated',
|
key: 'initiated',
|
||||||
title: $t('dashboard-design.widgets.approvalCenter.initiated'),
|
title: $t('dashboard-design.widgets.approvalCenter.initiated'),
|
||||||
icon: Send,
|
icon: Send,
|
||||||
color: defaultColors[0]!,
|
color: 'rgba(59, 130, 246, 0.85)',
|
||||||
path: '/workflow/initiated',
|
path: '/workflow/initiated',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'pending',
|
key: 'pending',
|
||||||
title: $t('dashboard-design.widgets.approvalCenter.pending'),
|
title: $t('dashboard-design.widgets.approvalCenter.pending'),
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
color: defaultColors[1]!,
|
color: 'rgba(245, 158, 11, 0.85)',
|
||||||
path: '/workflow/pending',
|
path: '/workflow/pending',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'handling',
|
key: 'handling',
|
||||||
title: $t('dashboard-design.widgets.approvalCenter.handling'),
|
title: $t('dashboard-design.widgets.approvalCenter.handling'),
|
||||||
icon: FilePen,
|
icon: FilePen,
|
||||||
color: defaultColors[2]!,
|
color: 'rgba(139, 92, 246, 0.85)',
|
||||||
path: '/workflow/pending',
|
path: '/workflow/pending',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'handled',
|
key: 'handled',
|
||||||
title: $t('dashboard-design.widgets.approvalCenter.handled'),
|
title: $t('dashboard-design.widgets.approvalCenter.handled'),
|
||||||
icon: ClipboardCheck,
|
icon: ClipboardCheck,
|
||||||
color: defaultColors[3]!,
|
color: 'rgba(14, 165, 233, 0.85)',
|
||||||
path: '/workflow/handled',
|
path: '/workflow/handled',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'copy',
|
key: 'copy',
|
||||||
title: $t('dashboard-design.widgets.approvalCenter.copy'),
|
title: $t('dashboard-design.widgets.approvalCenter.copy'),
|
||||||
icon: UserCheck,
|
icon: UserCheck,
|
||||||
color: defaultColors[4]!,
|
color: 'rgba(249, 115, 22, 0.85)',
|
||||||
path: '/workflow/copy',
|
path: '/workflow/copy',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'start',
|
key: 'start',
|
||||||
title: $t('dashboard-design.widgets.approvalCenter.start'),
|
title: $t('dashboard-design.widgets.approvalCenter.start'),
|
||||||
icon: Play,
|
icon: Play,
|
||||||
color: defaultColors[5]!,
|
color: 'rgba(20, 184, 166, 0.85)',
|
||||||
path: '/workflow/start',
|
path: '/workflow/start',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const displayMenuItems = computed<ApprovalMenuItem[]>(() => {
|
// 路由前缀
|
||||||
const customMenus = props.widget.props.menus || [];
|
|
||||||
if (!customMenus.length) return defaultMenuItems.value;
|
|
||||||
|
|
||||||
return customMenus.map((item: any, index: number) => ({
|
|
||||||
key: item.id || item.key || item.title || index,
|
|
||||||
title: item.title,
|
|
||||||
icon: item.icon || 'lucide:circle',
|
|
||||||
color:
|
|
||||||
item.color || item.bgColor || defaultColors[index % defaultColors.length],
|
|
||||||
path: item.path || '',
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
const routePrefix = computed(
|
const routePrefix = computed(
|
||||||
() => props.widget.props.routePrefix || '/app/workflow_center',
|
() => props.widget.props.routePrefix || '/app/workflow_center',
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleClick(item: ApprovalMenuItem) {
|
// 点击菜单项
|
||||||
if (!item.path) return;
|
const handleClick = (item: { path: string }) => {
|
||||||
|
|
||||||
if (item.path.startsWith('http://') || item.path.startsWith('https://')) {
|
|
||||||
window.open(item.path, '_blank');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.widget.props.menus?.length) {
|
|
||||||
const code = appContextStore.appCode;
|
|
||||||
const targetPath = code ? `/app/${code}${item.path}` : item.path;
|
|
||||||
router.push(targetPath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.open(`${routePrefix.value}${item.path}`, '_blank');
|
window.open(`${routePrefix.value}${item.path}`, '_blank');
|
||||||
}
|
};
|
||||||
|
|
||||||
function handleMore() {
|
// 点击更多
|
||||||
|
const handleMore = () => {
|
||||||
window.open(`${routePrefix.value}/workflow/pending`, '_blank');
|
window.open(`${routePrefix.value}/workflow/pending`, '_blank');
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="approval-center flex h-full flex-col p-4">
|
<div class="approval-center flex h-full flex-col p-4">
|
||||||
|
<!-- 头部 -->
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<span class="text-sm font-medium">{{ widget.props.title }}</span>
|
<span class="text-sm font-medium">{{ widget.props.title }}</span>
|
||||||
<button
|
<button
|
||||||
@@ -143,10 +95,10 @@ function handleMore() {
|
|||||||
<ChevronRight class="h-3.5 w-3.5" />
|
<ChevronRight class="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 菜单网格 -->
|
||||||
<div class="flex flex-1 items-center justify-around gap-2">
|
<div class="flex flex-1 items-center justify-around gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="item in displayMenuItems"
|
v-for="item in menuItems"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
class="flex cursor-pointer flex-col items-center gap-2 transition-transform hover:scale-105"
|
class="flex cursor-pointer flex-col items-center gap-2 transition-transform hover:scale-105"
|
||||||
@click="handleClick(item)"
|
@click="handleClick(item)"
|
||||||
@@ -155,16 +107,11 @@ function handleMore() {
|
|||||||
class="flex h-11 w-11 items-center justify-center rounded-full"
|
class="flex h-11 w-11 items-center justify-center rounded-full"
|
||||||
:style="{ backgroundColor: item.color }"
|
:style="{ backgroundColor: item.color }"
|
||||||
>
|
>
|
||||||
<IconifyIcon
|
<component :is="item.icon" class="h-5 w-5 text-white" />
|
||||||
v-if="typeof item.icon === 'string'"
|
|
||||||
:icon="item.icon"
|
|
||||||
class="h-5 w-5 text-white"
|
|
||||||
/>
|
|
||||||
<component :is="item.icon" v-else class="h-5 w-5 text-white" />
|
|
||||||
</div>
|
</div>
|
||||||
<span class="text-muted-foreground whitespace-nowrap text-xs">
|
<span class="text-muted-foreground whitespace-nowrap text-xs">{{
|
||||||
{{ item.title }}
|
item.title
|
||||||
</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DashboardWidget } from '../../store/dashboardDesignStore';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElEmpty, ElTable, ElTableColumn } from 'element-plus';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
widget: DashboardWidget;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const columns = computed(() => props.widget.props.columns || []);
|
||||||
|
const tableData = computed(() => props.widget.props.data || []);
|
||||||
|
|
||||||
|
// 表格高度配置
|
||||||
|
const tableHeight = computed(() => {
|
||||||
|
if (props.widget.props.maxHeight) return undefined;
|
||||||
|
return props.widget.props.height || '100%';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 状态颜色映射
|
||||||
|
const getStatusClass = (status: string) => {
|
||||||
|
const statusMap: Record<string, string> = {
|
||||||
|
[$t('dashboard-design.widgets.dataTable.status.normal')]: 'status-success',
|
||||||
|
[$t('dashboard-design.widgets.dataTable.status.warning')]: 'status-warning',
|
||||||
|
[$t('dashboard-design.widgets.dataTable.status.error')]: 'status-danger',
|
||||||
|
[$t('dashboard-design.widgets.dataTable.status.success')]: 'status-success',
|
||||||
|
[$t('dashboard-design.widgets.dataTable.status.failed')]: 'status-danger',
|
||||||
|
[$t('dashboard-design.widgets.dataTable.status.processing')]: 'status-info',
|
||||||
|
};
|
||||||
|
return statusMap[status] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 统计类型国际化标签
|
||||||
|
const summaryTypeLabels = computed(() => ({
|
||||||
|
sum: $t('dashboard-design.widgets.dataTable.summary.sum'),
|
||||||
|
avg: $t('dashboard-design.widgets.dataTable.summary.avg'),
|
||||||
|
count: $t('dashboard-design.widgets.dataTable.summary.count'),
|
||||||
|
max: $t('dashboard-design.widgets.dataTable.summary.max'),
|
||||||
|
min: $t('dashboard-design.widgets.dataTable.summary.min'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 表尾统计方法
|
||||||
|
const getSummaryMethod = (param: { columns: any[]; data: any[] }) => {
|
||||||
|
const { columns: tableCols, data } = param;
|
||||||
|
const sums: string[] = [];
|
||||||
|
const summaryColumns = props.widget.props.summaryColumns || [];
|
||||||
|
const summaryType = props.widget.props.summaryType || 'sum';
|
||||||
|
const precision = props.widget.props.summaryPrecision ?? 2;
|
||||||
|
|
||||||
|
tableCols.forEach((column, index) => {
|
||||||
|
// 第一列显示统计类型名称
|
||||||
|
if (index === 0) {
|
||||||
|
sums[index] =
|
||||||
|
summaryTypeLabels.value[
|
||||||
|
summaryType as keyof typeof summaryTypeLabels.value
|
||||||
|
] || summaryType;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找该列的统计配置
|
||||||
|
const summaryConfig = summaryColumns.find(
|
||||||
|
(sc: any) => sc.field === column.property,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!summaryConfig || !summaryConfig.enabled) {
|
||||||
|
sums[index] = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取该列的所有数值
|
||||||
|
const values = data.map((item) => Number(item[column.property]));
|
||||||
|
const validValues = values.filter((value) => !Number.isNaN(value));
|
||||||
|
|
||||||
|
if (validValues.length === 0) {
|
||||||
|
sums[index] = '-';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: number;
|
||||||
|
switch (summaryType) {
|
||||||
|
case 'avg': {
|
||||||
|
result =
|
||||||
|
validValues.reduce((acc, val) => acc + val, 0) / validValues.length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'count': {
|
||||||
|
result = validValues.length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'max': {
|
||||||
|
result = Math.max(...validValues);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'min': {
|
||||||
|
result = Math.min(...validValues);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'sum': {
|
||||||
|
result = validValues.reduce((acc, val) => acc + val, 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
result = validValues.reduce((acc, val) => acc + val, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sums[index] = result.toFixed(precision);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sums;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 合并单元格方法
|
||||||
|
const getSpanMethod = ({ row, column, rowIndex, columnIndex }: any) => {
|
||||||
|
const mergeConfig = props.widget.props.mergeConfig;
|
||||||
|
if (!mergeConfig || !mergeConfig.enabled) return;
|
||||||
|
|
||||||
|
const mergeRules = mergeConfig.rules || [];
|
||||||
|
|
||||||
|
for (const rule of mergeRules) {
|
||||||
|
if (rule.type === 'row' && rule.field === column.property) {
|
||||||
|
// 行合并:相同值的相邻行合并
|
||||||
|
const data = tableData.value;
|
||||||
|
const currentValue = row[rule.field];
|
||||||
|
|
||||||
|
// 检查是否是合并组的第一行
|
||||||
|
if (rowIndex === 0 || data[rowIndex - 1][rule.field] !== currentValue) {
|
||||||
|
let rowspan = 1;
|
||||||
|
for (let i = rowIndex + 1; i < data.length; i++) {
|
||||||
|
if (data[i][rule.field] === currentValue) {
|
||||||
|
rowspan++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rowspan, colspan: 1 };
|
||||||
|
} else {
|
||||||
|
// 被合并的行
|
||||||
|
return { rowspan: 0, colspan: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.type === 'column' && rowIndex === rule.rowIndex) {
|
||||||
|
// 列合并
|
||||||
|
if (columnIndex === rule.startCol) {
|
||||||
|
return { rowspan: 1, colspan: rule.colspan || 1 };
|
||||||
|
} else if (
|
||||||
|
columnIndex > rule.startCol &&
|
||||||
|
columnIndex < rule.startCol + (rule.colspan || 1)
|
||||||
|
) {
|
||||||
|
return { rowspan: 0, colspan: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="data-table-widget flex h-full w-full flex-col p-3">
|
||||||
|
<div
|
||||||
|
v-if="widget.props.title"
|
||||||
|
class="text-muted-foreground mb-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
{{ widget.props.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<ElTable
|
||||||
|
:data="tableData"
|
||||||
|
:stripe="widget.props.stripe !== false"
|
||||||
|
:border="widget.props.border"
|
||||||
|
:size="widget.props.size || 'default'"
|
||||||
|
:height="tableHeight"
|
||||||
|
:max-height="widget.props.maxHeight"
|
||||||
|
:highlight-current-row="widget.props.highlightCurrentRow"
|
||||||
|
:show-header="widget.props.showHeader !== false"
|
||||||
|
:empty-text="widget.props.emptyText || $t('common.noData')"
|
||||||
|
:show-summary="widget.props.showSummary"
|
||||||
|
:summary-method="
|
||||||
|
widget.props.showSummary ? getSummaryMethod : undefined
|
||||||
|
"
|
||||||
|
:span-method="
|
||||||
|
widget.props.mergeConfig?.enabled ? getSpanMethod : undefined
|
||||||
|
"
|
||||||
|
:header-cell-style="{
|
||||||
|
textAlign: widget.props.headerAlign || 'left',
|
||||||
|
...(widget.props.headerBgColor?.includes('gradient')
|
||||||
|
? { background: widget.props.headerBgColor }
|
||||||
|
: {
|
||||||
|
backgroundColor:
|
||||||
|
widget.props.headerBgColor || 'var(--el-fill-color-light)',
|
||||||
|
}),
|
||||||
|
}"
|
||||||
|
:cell-style="{
|
||||||
|
textAlign: widget.props.cellAlign || 'left',
|
||||||
|
}"
|
||||||
|
v-loading="widget.props.loading"
|
||||||
|
>
|
||||||
|
<ElTableColumn
|
||||||
|
v-if="widget.props.showIndex"
|
||||||
|
type="index"
|
||||||
|
label="#"
|
||||||
|
width="50"
|
||||||
|
/>
|
||||||
|
<ElTableColumn
|
||||||
|
v-for="col in columns"
|
||||||
|
:key="col.prop"
|
||||||
|
:prop="col.prop"
|
||||||
|
:label="col.label"
|
||||||
|
:width="col.width"
|
||||||
|
:min-width="col.minWidth"
|
||||||
|
:align="col.align || widget.props.cellAlign || 'left'"
|
||||||
|
:header-align="col.headerAlign || widget.props.headerAlign || 'left'"
|
||||||
|
:fixed="col.fixed"
|
||||||
|
:sortable="col.sortable"
|
||||||
|
:show-overflow-tooltip="col.showOverflowTooltip !== false"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span
|
||||||
|
v-if="col.prop === 'status'"
|
||||||
|
:class="getStatusClass(row[col.prop])"
|
||||||
|
>
|
||||||
|
{{ row[col.prop] }}
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ row[col.prop] }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<template #empty>
|
||||||
|
<ElEmpty
|
||||||
|
:description="widget.props.emptyText || $t('common.noData')"
|
||||||
|
:image-size="80"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ElTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.data-table-widget {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table-widget :deep(.el-table) {
|
||||||
|
width: 100% !important;
|
||||||
|
--el-table-header-bg-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table-widget :deep(.el-table__inner-wrapper) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table-widget :deep(.el-table__header-wrapper),
|
||||||
|
.data-table-widget :deep(.el-table__body-wrapper) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-success {
|
||||||
|
color: var(--el-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warning {
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-danger {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
color: var(--el-color-info);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DashboardWidget } from '../../store/dashboardDesignStore';
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElDatePicker } from 'element-plus';
|
||||||
|
|
||||||
|
import { useDashboardDesignStore } from '../../store/dashboardDesignStore';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
widget: DashboardWidget;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const store = useDashboardDesignStore();
|
||||||
|
const dateValue = ref<string>(props.widget.props.defaultValue || '');
|
||||||
|
|
||||||
|
const label = computed(() => props.widget.props.label || '');
|
||||||
|
const placeholder = computed(
|
||||||
|
() =>
|
||||||
|
props.widget.props.placeholder ||
|
||||||
|
$t('dashboard-design.material.defaultProps.filterDatePlaceholder'),
|
||||||
|
);
|
||||||
|
const paramKey = computed(() => props.widget.props.paramKey || '');
|
||||||
|
const dateFormat = computed(
|
||||||
|
() => props.widget.props.dateFormat || 'YYYY-MM-DD',
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelPosition = computed(
|
||||||
|
() => props.widget.props.labelPosition || 'left',
|
||||||
|
);
|
||||||
|
const labelWidth = computed(() => props.widget.props.labelWidth ?? 80);
|
||||||
|
const labelAlign = computed(() => props.widget.props.labelAlign || 'right');
|
||||||
|
const componentSize = computed(
|
||||||
|
() => props.widget.props.componentSize || 'default',
|
||||||
|
);
|
||||||
|
const showBorder = computed(() => props.widget.props.showBorder ?? false);
|
||||||
|
const borderRadius = computed(() => props.widget.props.borderRadius ?? 4);
|
||||||
|
|
||||||
|
const isTopLabel = computed(() => labelPosition.value === 'top');
|
||||||
|
const isHiddenLabel = computed(() => labelPosition.value === 'hidden');
|
||||||
|
|
||||||
|
const containerClass = computed(() => [
|
||||||
|
'filter-widget h-full w-full',
|
||||||
|
isTopLabel.value ? 'flex flex-col' : 'flex items-center',
|
||||||
|
showBorder.value ? 'filter-widget--bordered' : '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const containerStyle = computed(() => ({
|
||||||
|
padding: isTopLabel.value ? '8px 12px' : '0 12px',
|
||||||
|
borderRadius: showBorder.value ? `${borderRadius.value}px` : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const labelStyle = computed(() => ({
|
||||||
|
width: isTopLabel.value ? 'auto' : `${labelWidth.value}px`,
|
||||||
|
textAlign: labelAlign.value as 'center' | 'left' | 'right',
|
||||||
|
flexShrink: 0,
|
||||||
|
marginBottom: isTopLabel.value ? '4px' : undefined,
|
||||||
|
marginRight: isTopLabel.value ? undefined : '8px',
|
||||||
|
}));
|
||||||
|
|
||||||
|
function handleChange(val: any) {
|
||||||
|
if (props.isDesignMode) return;
|
||||||
|
if (paramKey.value) {
|
||||||
|
store.updateGlobalParam(paramKey.value, val || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.widget.props.defaultValue,
|
||||||
|
(val) => {
|
||||||
|
if (!props.isDesignMode && val !== undefined) {
|
||||||
|
dateValue.value = val;
|
||||||
|
if (paramKey.value) {
|
||||||
|
store.updateGlobalParam(paramKey.value, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="containerClass" :style="containerStyle">
|
||||||
|
<span
|
||||||
|
v-if="label && !isHiddenLabel"
|
||||||
|
class="text-foreground text-sm font-medium"
|
||||||
|
:style="labelStyle"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="dateValue"
|
||||||
|
type="date"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="isDesignMode"
|
||||||
|
:format="dateFormat"
|
||||||
|
:value-format="dateFormat"
|
||||||
|
:size="componentSize"
|
||||||
|
clearable
|
||||||
|
class="!flex-1"
|
||||||
|
@change="handleChange"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="isDesignMode && !paramKey"
|
||||||
|
class="ml-2 flex-shrink-0 text-xs text-orange-500"
|
||||||
|
>
|
||||||
|
{{ $t('dashboard-design.filter.noParamKey') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-widget--bordered {
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
background-color: var(--el-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-widget.flex-col :deep(.el-input),
|
||||||
|
.filter-widget.flex-col :deep(.el-select),
|
||||||
|
.filter-widget.flex-col :deep(.el-date-editor) {
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DashboardWidget } from '../../store/dashboardDesignStore';
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElDatePicker } from 'element-plus';
|
||||||
|
|
||||||
|
import { useDashboardDesignStore } from '../../store/dashboardDesignStore';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
widget: DashboardWidget;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const store = useDashboardDesignStore();
|
||||||
|
const dateRange = ref<[string, string] | []>(
|
||||||
|
props.widget.props.defaultValue || [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = computed(() => props.widget.props.label || '');
|
||||||
|
const startPlaceholder = computed(
|
||||||
|
() =>
|
||||||
|
props.widget.props.startPlaceholder ||
|
||||||
|
$t('dashboard-design.material.defaultProps.filterStartDate'),
|
||||||
|
);
|
||||||
|
const endPlaceholder = computed(
|
||||||
|
() =>
|
||||||
|
props.widget.props.endPlaceholder ||
|
||||||
|
$t('dashboard-design.material.defaultProps.filterEndDate'),
|
||||||
|
);
|
||||||
|
const startParamKey = computed(
|
||||||
|
() => props.widget.props.startParamKey || '',
|
||||||
|
);
|
||||||
|
const endParamKey = computed(
|
||||||
|
() => props.widget.props.endParamKey || '',
|
||||||
|
);
|
||||||
|
const dateFormat = computed(
|
||||||
|
() => props.widget.props.dateFormat || 'YYYY-MM-DD',
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelPosition = computed(
|
||||||
|
() => props.widget.props.labelPosition || 'left',
|
||||||
|
);
|
||||||
|
const labelWidth = computed(() => props.widget.props.labelWidth ?? 80);
|
||||||
|
const labelAlign = computed(() => props.widget.props.labelAlign || 'right');
|
||||||
|
const componentSize = computed(
|
||||||
|
() => props.widget.props.componentSize || 'default',
|
||||||
|
);
|
||||||
|
const showBorder = computed(() => props.widget.props.showBorder ?? false);
|
||||||
|
const borderRadius = computed(() => props.widget.props.borderRadius ?? 4);
|
||||||
|
|
||||||
|
const isTopLabel = computed(() => labelPosition.value === 'top');
|
||||||
|
const isHiddenLabel = computed(() => labelPosition.value === 'hidden');
|
||||||
|
|
||||||
|
const containerClass = computed(() => [
|
||||||
|
'filter-widget h-full w-full',
|
||||||
|
isTopLabel.value ? 'flex flex-col' : 'flex items-center',
|
||||||
|
showBorder.value ? 'filter-widget--bordered' : '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const containerStyle = computed(() => ({
|
||||||
|
padding: isTopLabel.value ? '8px 12px' : '0 12px',
|
||||||
|
borderRadius: showBorder.value ? `${borderRadius.value}px` : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const labelStyle = computed(() => ({
|
||||||
|
width: isTopLabel.value ? 'auto' : `${labelWidth.value}px`,
|
||||||
|
textAlign: labelAlign.value as 'center' | 'left' | 'right',
|
||||||
|
flexShrink: 0,
|
||||||
|
marginBottom: isTopLabel.value ? '4px' : undefined,
|
||||||
|
marginRight: isTopLabel.value ? undefined : '8px',
|
||||||
|
}));
|
||||||
|
|
||||||
|
function handleChange(val: [string, string] | null) {
|
||||||
|
if (props.isDesignMode) return;
|
||||||
|
if (val && val.length === 2) {
|
||||||
|
if (startParamKey.value) {
|
||||||
|
store.updateGlobalParam(startParamKey.value, val[0]);
|
||||||
|
}
|
||||||
|
if (endParamKey.value) {
|
||||||
|
store.updateGlobalParam(endParamKey.value, val[1]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (startParamKey.value) {
|
||||||
|
store.updateGlobalParam(startParamKey.value, '');
|
||||||
|
}
|
||||||
|
if (endParamKey.value) {
|
||||||
|
store.updateGlobalParam(endParamKey.value, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.widget.props.defaultValue,
|
||||||
|
(val) => {
|
||||||
|
if (!props.isDesignMode && Array.isArray(val) && val.length === 2) {
|
||||||
|
dateRange.value = val as [string, string];
|
||||||
|
if (startParamKey.value) {
|
||||||
|
store.updateGlobalParam(startParamKey.value, val[0]);
|
||||||
|
}
|
||||||
|
if (endParamKey.value) {
|
||||||
|
store.updateGlobalParam(endParamKey.value, val[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasParamKey = computed(
|
||||||
|
() => startParamKey.value || endParamKey.value,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="containerClass" :style="containerStyle">
|
||||||
|
<span
|
||||||
|
v-if="label && !isHiddenLabel"
|
||||||
|
class="text-foreground text-sm font-medium"
|
||||||
|
:style="labelStyle"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
:start-placeholder="startPlaceholder"
|
||||||
|
:end-placeholder="endPlaceholder"
|
||||||
|
:disabled="isDesignMode"
|
||||||
|
:format="dateFormat"
|
||||||
|
:value-format="dateFormat"
|
||||||
|
:size="componentSize"
|
||||||
|
clearable
|
||||||
|
class="!flex-1"
|
||||||
|
@change="handleChange"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="isDesignMode && !hasParamKey"
|
||||||
|
class="ml-2 flex-shrink-0 text-xs text-orange-500"
|
||||||
|
>
|
||||||
|
{{ $t('dashboard-design.filter.noParamKey') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-widget--bordered {
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
background-color: var(--el-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-widget.flex-col :deep(.el-input),
|
||||||
|
.filter-widget.flex-col :deep(.el-select),
|
||||||
|
.filter-widget.flex-col :deep(.el-date-editor) {
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DashboardWidget } from '../../store/dashboardDesignStore';
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Search } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElInput } from 'element-plus';
|
||||||
|
|
||||||
|
import { useDashboardDesignStore } from '../../store/dashboardDesignStore';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
widget: DashboardWidget;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const store = useDashboardDesignStore();
|
||||||
|
const inputValue = ref(props.widget.props.defaultValue || '');
|
||||||
|
|
||||||
|
const label = computed(() => props.widget.props.label || '');
|
||||||
|
const placeholder = computed(
|
||||||
|
() =>
|
||||||
|
props.widget.props.placeholder ||
|
||||||
|
$t('dashboard-design.material.defaultProps.filterInputPlaceholder'),
|
||||||
|
);
|
||||||
|
const paramKey = computed(() => props.widget.props.paramKey || '');
|
||||||
|
|
||||||
|
const labelPosition = computed(
|
||||||
|
() => props.widget.props.labelPosition || 'left',
|
||||||
|
);
|
||||||
|
const labelWidth = computed(() => props.widget.props.labelWidth ?? 80);
|
||||||
|
const labelAlign = computed(() => props.widget.props.labelAlign || 'right');
|
||||||
|
const componentSize = computed(
|
||||||
|
() => props.widget.props.componentSize || 'default',
|
||||||
|
);
|
||||||
|
const showBorder = computed(() => props.widget.props.showBorder ?? false);
|
||||||
|
const borderRadius = computed(() => props.widget.props.borderRadius ?? 4);
|
||||||
|
|
||||||
|
const isTopLabel = computed(() => labelPosition.value === 'top');
|
||||||
|
const isHiddenLabel = computed(() => labelPosition.value === 'hidden');
|
||||||
|
|
||||||
|
const containerClass = computed(() => [
|
||||||
|
'filter-widget h-full w-full',
|
||||||
|
isTopLabel.value ? 'flex flex-col' : 'flex items-center',
|
||||||
|
showBorder.value ? 'filter-widget--bordered' : '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const containerStyle = computed(() => ({
|
||||||
|
padding: isTopLabel.value ? '8px 12px' : '0 12px',
|
||||||
|
borderRadius: showBorder.value ? `${borderRadius.value}px` : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const labelStyle = computed(() => ({
|
||||||
|
width: isTopLabel.value ? 'auto' : `${labelWidth.value}px`,
|
||||||
|
textAlign: labelAlign.value as 'center' | 'left' | 'right',
|
||||||
|
flexShrink: 0,
|
||||||
|
marginBottom: isTopLabel.value ? '4px' : undefined,
|
||||||
|
marginRight: isTopLabel.value ? undefined : '8px',
|
||||||
|
}));
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function handleInput(val: string | number) {
|
||||||
|
if (props.isDesignMode) return;
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
if (paramKey.value) {
|
||||||
|
store.updateGlobalParam(paramKey.value, val);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClear() {
|
||||||
|
if (props.isDesignMode) return;
|
||||||
|
if (paramKey.value) {
|
||||||
|
store.updateGlobalParam(paramKey.value, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.widget.props.defaultValue,
|
||||||
|
(val) => {
|
||||||
|
if (!props.isDesignMode && val !== undefined) {
|
||||||
|
inputValue.value = val;
|
||||||
|
if (paramKey.value) {
|
||||||
|
store.updateGlobalParam(paramKey.value, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="containerClass" :style="containerStyle">
|
||||||
|
<span
|
||||||
|
v-if="label && !isHiddenLabel"
|
||||||
|
class="text-foreground text-sm font-medium"
|
||||||
|
:style="labelStyle"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
<ElInput
|
||||||
|
v-model="inputValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="isDesignMode"
|
||||||
|
clearable
|
||||||
|
:size="componentSize"
|
||||||
|
class="flex-1"
|
||||||
|
@input="handleInput"
|
||||||
|
@clear="handleClear"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Search class="h-4 w-4 opacity-50" />
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
|
<span
|
||||||
|
v-if="isDesignMode && !paramKey"
|
||||||
|
class="ml-2 flex-shrink-0 text-xs text-orange-500"
|
||||||
|
>
|
||||||
|
{{ $t('dashboard-design.filter.noParamKey') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-widget--bordered {
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
background-color: var(--el-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-widget.flex-col :deep(.el-input),
|
||||||
|
.filter-widget.flex-col :deep(.el-select),
|
||||||
|
.filter-widget.flex-col :deep(.el-date-editor) {
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DashboardWidget } from '../../store/dashboardDesignStore';
|
||||||
|
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElOption, ElSelect } from 'element-plus';
|
||||||
|
|
||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
import { useDashboardDesignStore } from '../../store/dashboardDesignStore';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
widget: DashboardWidget;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const store = useDashboardDesignStore();
|
||||||
|
const selectValue = ref<any>(props.widget.props.defaultValue || '');
|
||||||
|
const dynamicOptions = ref<{ label: string; value: any }[]>([]);
|
||||||
|
const loadingOptions = ref(false);
|
||||||
|
|
||||||
|
const label = computed(() => props.widget.props.label || '');
|
||||||
|
const placeholder = computed(
|
||||||
|
() =>
|
||||||
|
props.widget.props.placeholder ||
|
||||||
|
$t('dashboard-design.material.defaultProps.filterSelectPlaceholder'),
|
||||||
|
);
|
||||||
|
const paramKey = computed(() => props.widget.props.paramKey || '');
|
||||||
|
const multiple = computed(() => props.widget.props.multiple || false);
|
||||||
|
const clearable = computed(() => props.widget.props.clearable !== false);
|
||||||
|
|
||||||
|
const labelPosition = computed(
|
||||||
|
() => props.widget.props.labelPosition || 'left',
|
||||||
|
);
|
||||||
|
const labelWidth = computed(() => props.widget.props.labelWidth ?? 80);
|
||||||
|
const labelAlign = computed(() => props.widget.props.labelAlign || 'right');
|
||||||
|
const componentSize = computed(
|
||||||
|
() => props.widget.props.componentSize || 'default',
|
||||||
|
);
|
||||||
|
const showBorder = computed(() => props.widget.props.showBorder ?? false);
|
||||||
|
const borderRadius = computed(() => props.widget.props.borderRadius ?? 4);
|
||||||
|
|
||||||
|
const isTopLabel = computed(() => labelPosition.value === 'top');
|
||||||
|
const isHiddenLabel = computed(() => labelPosition.value === 'hidden');
|
||||||
|
|
||||||
|
const containerClass = computed(() => [
|
||||||
|
'filter-widget h-full w-full',
|
||||||
|
isTopLabel.value ? 'flex flex-col' : 'flex items-center',
|
||||||
|
showBorder.value ? 'filter-widget--bordered' : '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const containerStyle = computed(() => ({
|
||||||
|
padding: isTopLabel.value ? '8px 12px' : '0 12px',
|
||||||
|
borderRadius: showBorder.value ? `${borderRadius.value}px` : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const labelStyle = computed(() => ({
|
||||||
|
width: isTopLabel.value ? 'auto' : `${labelWidth.value}px`,
|
||||||
|
textAlign: labelAlign.value as 'center' | 'left' | 'right',
|
||||||
|
flexShrink: 0,
|
||||||
|
marginBottom: isTopLabel.value ? '4px' : undefined,
|
||||||
|
marginRight: isTopLabel.value ? undefined : '8px',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const options = computed(() => {
|
||||||
|
if (props.widget.props.optionSource === 'dataSource') {
|
||||||
|
return dynamicOptions.value;
|
||||||
|
}
|
||||||
|
return props.widget.props.options || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadDynamicOptions() {
|
||||||
|
const code = props.widget.props.optionDataSourceCode;
|
||||||
|
if (!code) return;
|
||||||
|
try {
|
||||||
|
loadingOptions.value = true;
|
||||||
|
const response = await requestClient.get(
|
||||||
|
`/api/core/data-source/execute/${code}`,
|
||||||
|
);
|
||||||
|
const rawData = response?.data ?? response;
|
||||||
|
const data = Array.isArray(rawData) ? rawData : [];
|
||||||
|
const labelField = props.widget.props.optionLabelField || 'label';
|
||||||
|
const valueField = props.widget.props.optionValueField || 'value';
|
||||||
|
dynamicOptions.value = data.map((item: any) => ({
|
||||||
|
label: String(item[labelField] ?? ''),
|
||||||
|
value: item[valueField],
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load filter options:', error);
|
||||||
|
} finally {
|
||||||
|
loadingOptions.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(val: any) {
|
||||||
|
if (props.isDesignMode) return;
|
||||||
|
if (paramKey.value) {
|
||||||
|
store.updateGlobalParam(paramKey.value, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClear() {
|
||||||
|
if (props.isDesignMode) return;
|
||||||
|
if (paramKey.value) {
|
||||||
|
store.updateGlobalParam(paramKey.value, multiple.value ? [] : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.widget.props.defaultValue,
|
||||||
|
(val) => {
|
||||||
|
if (!props.isDesignMode && val !== undefined) {
|
||||||
|
selectValue.value = val;
|
||||||
|
if (paramKey.value) {
|
||||||
|
store.updateGlobalParam(paramKey.value, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.widget.props.optionSource === 'dataSource') {
|
||||||
|
loadDynamicOptions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="containerClass" :style="containerStyle">
|
||||||
|
<span
|
||||||
|
v-if="label && !isHiddenLabel"
|
||||||
|
class="text-foreground text-sm font-medium"
|
||||||
|
:style="labelStyle"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
<ElSelect
|
||||||
|
v-model="selectValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="isDesignMode"
|
||||||
|
:multiple="multiple"
|
||||||
|
:clearable="clearable"
|
||||||
|
:loading="loadingOptions"
|
||||||
|
:size="componentSize"
|
||||||
|
class="flex-1"
|
||||||
|
@change="handleChange"
|
||||||
|
@clear="handleClear"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="opt in options"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
<span
|
||||||
|
v-if="isDesignMode && !paramKey"
|
||||||
|
class="ml-2 flex-shrink-0 text-xs text-orange-500"
|
||||||
|
>
|
||||||
|
{{ $t('dashboard-design.filter.noParamKey') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-widget--bordered {
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
background-color: var(--el-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-widget.flex-col :deep(.el-input),
|
||||||
|
.filter-widget.flex-col :deep(.el-select),
|
||||||
|
.filter-widget.flex-col :deep(.el-date-editor) {
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DashboardWidget } from '../../store/dashboardDesignStore';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { AlertCircle, ExternalLink, RefreshCw } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
widget: DashboardWidget;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const iframeRef = ref<HTMLIFrameElement | null>(null);
|
||||||
|
const isLoading = ref(true);
|
||||||
|
const hasError = ref(false);
|
||||||
|
|
||||||
|
const iframeUrl = computed(() => props.widget.props.url || '');
|
||||||
|
|
||||||
|
const handleLoad = () => {
|
||||||
|
isLoading.value = false;
|
||||||
|
hasError.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
isLoading.value = false;
|
||||||
|
hasError.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
if (iframeRef.value) {
|
||||||
|
isLoading.value = true;
|
||||||
|
hasError.value = false;
|
||||||
|
iframeRef.value.src = iframeUrl.value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openInNewTab = () => {
|
||||||
|
if (iframeUrl.value) {
|
||||||
|
window.open(iframeUrl.value, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="iframe-widget flex h-full flex-col">
|
||||||
|
<!-- 标题栏 -->
|
||||||
|
<div v-if="widget.props.title" class="iframe-header">
|
||||||
|
<span class="text-muted-foreground text-sm font-medium">{{
|
||||||
|
widget.props.title
|
||||||
|
}}</span>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
:title="$t('dashboard-design.widgets.iframe.refresh')"
|
||||||
|
@click="refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
:title="$t('dashboard-design.widgets.iframe.openNew')"
|
||||||
|
@click="openInNewTab"
|
||||||
|
>
|
||||||
|
<ExternalLink class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- iframe 容器 -->
|
||||||
|
<div class="iframe-container relative min-h-0 flex-1">
|
||||||
|
<!-- 设计模式下显示占位 -->
|
||||||
|
<div v-if="isDesignMode" class="design-placeholder">
|
||||||
|
<ExternalLink class="h-8 w-8 text-gray-400" />
|
||||||
|
<div class="mt-2 text-sm text-gray-500">
|
||||||
|
{{ $t('dashboard-design.widgets.iframe.placeholder') }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 max-w-full truncate px-4 text-xs text-gray-400">
|
||||||
|
{{ iframeUrl || $t('dashboard-design.widgets.iframe.noUrl') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 实际 iframe -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="isLoading" class="loading-overlay">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<div class="mt-2 text-sm text-gray-500">
|
||||||
|
{{ $t('dashboard-design.widgets.iframe.loading') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误状态 -->
|
||||||
|
<div v-if="hasError && !isLoading" class="error-overlay">
|
||||||
|
<AlertCircle class="h-8 w-8 text-red-400" />
|
||||||
|
<div class="mt-2 text-sm text-gray-500">
|
||||||
|
{{ $t('dashboard-design.widgets.iframe.loadFailed') }}
|
||||||
|
</div>
|
||||||
|
<button class="text-primary mt-2 text-xs" @click="refresh">
|
||||||
|
{{ $t('dashboard-design.widgets.iframe.retry') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- iframe -->
|
||||||
|
<iframe
|
||||||
|
v-show="!hasError"
|
||||||
|
ref="iframeRef"
|
||||||
|
:src="iframeUrl"
|
||||||
|
:class="{ 'with-border': widget.props.showBorder }"
|
||||||
|
:allowfullscreen="widget.props.allowFullscreen"
|
||||||
|
frameborder="0"
|
||||||
|
class="iframe-content"
|
||||||
|
@load="handleLoad"
|
||||||
|
@error="handleError"
|
||||||
|
></iframe>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.iframe-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iframe-container {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--el-fill-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay,
|
||||||
|
.error-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--el-border-color-lighter);
|
||||||
|
border-top-color: var(--el-color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.iframe-content {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iframe-content.with-border {
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+252
@@ -0,0 +1,252 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DashboardWidget } from '../../store/dashboardDesignStore';
|
||||||
|
|
||||||
|
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { ChevronLeft, ChevronRight } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { getFileUrl } from '#/composables/useFileUrl';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
widget: DashboardWidget;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const currentIndex = ref(0);
|
||||||
|
let autoplayTimer: null | ReturnType<typeof setInterval> = null;
|
||||||
|
|
||||||
|
// 处理后的图片列表(响应式)
|
||||||
|
const images = ref<any[]>([]);
|
||||||
|
|
||||||
|
// 异步解析图片URL
|
||||||
|
async function resolveImageUrl(url: string): Promise<string> {
|
||||||
|
if (!url) return '';
|
||||||
|
if (url.startsWith('file://')) {
|
||||||
|
const fileId = url.slice(7);
|
||||||
|
return await getFileUrl(fileId);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载图片列表
|
||||||
|
async function loadImages() {
|
||||||
|
const rawImages = props.widget.props.images || [];
|
||||||
|
const resolvedImages = await Promise.all(
|
||||||
|
rawImages.map(async (img: any) => ({
|
||||||
|
...img,
|
||||||
|
url: await resolveImageUrl(img.url || ''),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
images.value = resolvedImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听图片列表变化
|
||||||
|
watch(
|
||||||
|
() => props.widget.props.images,
|
||||||
|
() => {
|
||||||
|
loadImages();
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextSlide = () => {
|
||||||
|
if (images.value.length === 0) return;
|
||||||
|
currentIndex.value = (currentIndex.value + 1) % images.value.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevSlide = () => {
|
||||||
|
if (images.value.length === 0) return;
|
||||||
|
currentIndex.value =
|
||||||
|
(currentIndex.value - 1 + images.value.length) % images.value.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToSlide = (index: number) => {
|
||||||
|
currentIndex.value = index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAutoplay = () => {
|
||||||
|
if (autoplayTimer) {
|
||||||
|
clearInterval(autoplayTimer);
|
||||||
|
}
|
||||||
|
if (props.widget.props.autoplay && !props.isDesignMode) {
|
||||||
|
const interval = props.widget.props.interval || 3000;
|
||||||
|
autoplayTimer = setInterval(nextSlide, interval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAutoplay = () => {
|
||||||
|
if (autoplayTimer) {
|
||||||
|
clearInterval(autoplayTimer);
|
||||||
|
autoplayTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageClick = (image: any) => {
|
||||||
|
if (image.link && !props.isDesignMode) {
|
||||||
|
window.open(image.link, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
startAutoplay();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopAutoplay();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.widget.props.autoplay,
|
||||||
|
() => {
|
||||||
|
if (props.widget.props.autoplay) {
|
||||||
|
startAutoplay();
|
||||||
|
} else {
|
||||||
|
stopAutoplay();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="image-carousel relative h-full w-full overflow-hidden"
|
||||||
|
@mouseenter="stopAutoplay"
|
||||||
|
@mouseleave="startAutoplay"
|
||||||
|
>
|
||||||
|
<!-- 图片容器 -->
|
||||||
|
<div
|
||||||
|
class="carousel-track flex h-full transition-transform duration-500 ease-in-out"
|
||||||
|
:style="{ transform: `translateX(-${currentIndex * 100}%)` }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(image, index) in images"
|
||||||
|
:key="index"
|
||||||
|
class="carousel-slide h-full w-full flex-shrink-0"
|
||||||
|
:class="{ 'cursor-pointer': image.link && !isDesignMode }"
|
||||||
|
@click="handleImageClick(image)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="image.url"
|
||||||
|
:alt="
|
||||||
|
image.title ||
|
||||||
|
`${$t('dashboard-design.widgets.image.title')} ${Number(index) + 1}`
|
||||||
|
"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
<div v-if="image.title" class="slide-title">
|
||||||
|
{{ image.title }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 左右箭头 -->
|
||||||
|
<template v-if="widget.props.showArrow && images.length > 1">
|
||||||
|
<button class="arrow-btn arrow-left" @click.stop="prevSlide">
|
||||||
|
<ChevronLeft class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<button class="arrow-btn arrow-right" @click.stop="nextSlide">
|
||||||
|
<ChevronRight class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 指示器 -->
|
||||||
|
<div
|
||||||
|
v-if="widget.props.showIndicator && images.length > 1"
|
||||||
|
class="indicators"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="(_, index) in images"
|
||||||
|
:key="index"
|
||||||
|
class="indicator"
|
||||||
|
:class="{ active: currentIndex === Number(index) }"
|
||||||
|
@click.stop="goToSlide(Number(index))"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div
|
||||||
|
v-if="images.length === 0"
|
||||||
|
class="flex h-full w-full items-center justify-center text-gray-400"
|
||||||
|
>
|
||||||
|
{{ $t('dashboard-design.widgets.image.noData') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.carousel-track {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-title {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(transparent, rgb(0 0 0 / 60%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgb(255 255 255 / 80%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-carousel:hover .arrow-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-btn:hover {
|
||||||
|
background: rgb(255 255 255 / 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-left {
|
||||||
|
left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-right {
|
||||||
|
right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicators {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgb(255 255 255 / 50%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator.active {
|
||||||
|
width: 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DashboardWidget } from '../../store/dashboardDesignStore';
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { ElImage } from 'element-plus';
|
||||||
|
|
||||||
|
import { getFileUrl } from '#/composables/useFileUrl';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
widget: DashboardWidget;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const widgetProps = computed(() => props.widget.props);
|
||||||
|
|
||||||
|
// 主图URL(响应式)
|
||||||
|
const imageSrc = ref('');
|
||||||
|
|
||||||
|
// 预览图片列表(响应式)
|
||||||
|
const previewList = ref<string[]>([]);
|
||||||
|
|
||||||
|
// 异步解析图片URL
|
||||||
|
async function resolveImageUrl(url: string): Promise<string> {
|
||||||
|
if (!url) return '';
|
||||||
|
if (url.startsWith('file://')) {
|
||||||
|
const fileId = url.slice(7);
|
||||||
|
return await getFileUrl(fileId);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载图片URL
|
||||||
|
async function loadImageUrls() {
|
||||||
|
// 加载主图
|
||||||
|
imageSrc.value = await resolveImageUrl(widgetProps.value.src || '');
|
||||||
|
|
||||||
|
// 加载预览列表
|
||||||
|
const list = widgetProps.value.previewSrcList || [];
|
||||||
|
previewList.value =
|
||||||
|
list.length === 0 && imageSrc.value
|
||||||
|
? [imageSrc.value]
|
||||||
|
: await Promise.all(list.map((url: string) => resolveImageUrl(url)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听图片源变化
|
||||||
|
watch(
|
||||||
|
() => [widgetProps.value.src, widgetProps.value.previewSrcList],
|
||||||
|
() => {
|
||||||
|
loadImageUrls();
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="image-widget flex h-full w-full flex-col">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div
|
||||||
|
v-if="widgetProps.title"
|
||||||
|
class="mb-2 flex-shrink-0 text-sm font-medium"
|
||||||
|
style="color: var(--el-text-color-primary)"
|
||||||
|
>
|
||||||
|
{{ widgetProps.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图片容器 -->
|
||||||
|
<div class="relative min-h-0 flex-1 overflow-hidden rounded">
|
||||||
|
<ElImage
|
||||||
|
:src="imageSrc"
|
||||||
|
:alt="widgetProps.alt || '图片'"
|
||||||
|
:fit="widgetProps.fit || 'cover'"
|
||||||
|
:lazy="widgetProps.lazy !== false"
|
||||||
|
:preview-src-list="isDesignMode ? [] : previewList"
|
||||||
|
:z-index="widgetProps.zIndex || 2000"
|
||||||
|
:hide-on-click-modal="widgetProps.hideOnClickModal || false"
|
||||||
|
class="h-full w-full"
|
||||||
|
:preview-teleported="true"
|
||||||
|
>
|
||||||
|
<template #error>
|
||||||
|
<div
|
||||||
|
class="flex h-full w-full items-center justify-center"
|
||||||
|
style="background-color: var(--el-fill-color-light)"
|
||||||
|
>
|
||||||
|
<span style="color: var(--el-text-color-secondary)">
|
||||||
|
加载失败
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #placeholder>
|
||||||
|
<div
|
||||||
|
class="flex h-full w-full items-center justify-center"
|
||||||
|
style="background-color: var(--el-fill-color-lighter)"
|
||||||
|
>
|
||||||
|
<span style="color: var(--el-text-color-placeholder)">
|
||||||
|
加载中...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElImage>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.image-widget :deep(.el-image) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-widget :deep(.el-image__inner) {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-widget:hover :deep(.el-image__inner) {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ApplicationListItem } from '#/api/core/application';
|
|
||||||
import type { DashboardWidget } from '#/components/dashboard-design';
|
import type { DashboardWidget } from '#/components/dashboard-design';
|
||||||
|
import type { ApplicationListItem } from '#/api/core/application';
|
||||||
|
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
|
|
||||||
import { AppWindow, ChevronRight, IconifyIcon } from '@vben/icons';
|
import { AppWindow, ChevronRight, IconifyIcon } from '@vben/icons';
|
||||||
import { $t } from '@vben/locales';
|
import { $t } from '@vben/locales';
|
||||||
@@ -11,42 +10,23 @@ import { $t } from '@vben/locales';
|
|||||||
import { ElEmpty, ElScrollbar } from 'element-plus';
|
import { ElEmpty, ElScrollbar } from 'element-plus';
|
||||||
|
|
||||||
import { getApplicationListApi } from '#/api/core/application';
|
import { getApplicationListApi } from '#/api/core/application';
|
||||||
import { useAppContextStore } from '#/store/app-context';
|
|
||||||
|
|
||||||
interface StaticAppItem {
|
|
||||||
bgColor?: string;
|
|
||||||
color?: string;
|
|
||||||
icon?: string;
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
path?: string;
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
widget: DashboardWidget;
|
widget: DashboardWidget;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const router = useRouter();
|
// 应用列表
|
||||||
const appContextStore = useAppContextStore();
|
|
||||||
const appList = ref<ApplicationListItem[]>([]);
|
const appList = ref<ApplicationListItem[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
const staticMenus = computed<StaticAppItem[]>(
|
// 最大显示数量
|
||||||
() => props.widget.props.menus || [],
|
|
||||||
);
|
|
||||||
const maxCount = computed(() => props.widget.props.maxCount || 8);
|
const maxCount = computed(() => props.widget.props.maxCount || 8);
|
||||||
|
|
||||||
const displayApps = computed(() => {
|
// 显示的应用列表
|
||||||
if (staticMenus.value.length) {
|
const displayApps = computed(() => appList.value.slice(0, maxCount.value));
|
||||||
return staticMenus.value.slice(0, maxCount.value);
|
|
||||||
}
|
|
||||||
return appList.value.slice(0, maxCount.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadApps() {
|
|
||||||
if (staticMenus.value.length) return;
|
|
||||||
|
|
||||||
|
// 加载已发布的应用列表
|
||||||
|
const loadApps = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await getApplicationListApi({
|
const res = await getApplicationListApi({
|
||||||
@@ -60,50 +40,17 @@ async function loadApps() {
|
|||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function handleClick(item: ApplicationListItem | StaticAppItem) {
|
// 点击应用
|
||||||
const staticPath = (item as StaticAppItem).path;
|
const handleClick = (app: ApplicationListItem) => {
|
||||||
if (staticPath) {
|
|
||||||
if (staticPath.startsWith('http://') || staticPath.startsWith('https://')) {
|
|
||||||
window.open(staticPath, '_blank');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = appContextStore.appCode;
|
|
||||||
const targetPath = code ? `/app/${code}${staticPath}` : staticPath;
|
|
||||||
router.push(targetPath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = item as ApplicationListItem;
|
|
||||||
window.open(`${window.location.origin}/app/${app.code}`, '_blank');
|
window.open(`${window.location.origin}/app/${app.code}`, '_blank');
|
||||||
}
|
};
|
||||||
|
|
||||||
function handleMore() {
|
// 点击更多
|
||||||
const morePath = props.widget.props.morePath;
|
const handleMore = () => {
|
||||||
if (morePath) {
|
|
||||||
router.push(morePath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.open(`${window.location.origin}/application`, '_blank');
|
window.open(`${window.location.origin}/application`, '_blank');
|
||||||
}
|
};
|
||||||
|
|
||||||
function getItemTitle(item: ApplicationListItem | StaticAppItem) {
|
|
||||||
return (item as StaticAppItem).title || (item as StaticAppItem).name || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getItemIcon(item: ApplicationListItem | StaticAppItem) {
|
|
||||||
return (item as StaticAppItem).icon || (item as ApplicationListItem).icon || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getItemBg(item: ApplicationListItem | StaticAppItem) {
|
|
||||||
return (
|
|
||||||
(item as StaticAppItem).bgColor ||
|
|
||||||
(item as StaticAppItem).color ||
|
|
||||||
'linear-gradient(135deg, var(--el-color-primary-light-3), var(--el-color-primary))'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadApps();
|
loadApps();
|
||||||
@@ -112,6 +59,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="my-apps flex h-full flex-col p-4" v-loading="loading">
|
<div class="my-apps flex h-full flex-col p-4" v-loading="loading">
|
||||||
|
<!-- 头部 -->
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<span class="text-sm font-medium">{{ widget.props.title }}</span>
|
<span class="text-sm font-medium">{{ widget.props.title }}</span>
|
||||||
<button
|
<button
|
||||||
@@ -124,30 +72,31 @@ onMounted(() => {
|
|||||||
<ChevronRight class="h-3.5 w-3.5" />
|
<ChevronRight class="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 应用网格 -->
|
||||||
<ElScrollbar class="flex-1">
|
<ElScrollbar class="flex-1">
|
||||||
<div v-if="displayApps.length > 0" class="flex flex-wrap gap-4">
|
|
||||||
<div
|
<div
|
||||||
v-for="item in displayApps"
|
v-if="displayApps.length > 0"
|
||||||
:key="(item as any).id || getItemTitle(item)"
|
class="flex flex-wrap gap-4"
|
||||||
class="m-4 flex cursor-pointer flex-col items-center gap-2 transition-transform hover:scale-105"
|
>
|
||||||
|
<div
|
||||||
|
v-for="app in displayApps"
|
||||||
|
:key="app.id"
|
||||||
|
class="flex cursor-pointer flex-col items-center gap-2 transition-transform hover:scale-105 m-4"
|
||||||
style="width: 72px"
|
style="width: 72px"
|
||||||
@click="handleClick(item)"
|
@click="handleClick(app)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-12 w-12 items-center justify-center rounded-xl"
|
class="flex h-12 w-12 items-center justify-center rounded-xl"
|
||||||
:style="{ background: getItemBg(item) }"
|
style="background: linear-gradient(135deg, var(--el-color-primary-light-3), var(--el-color-primary))"
|
||||||
>
|
>
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
v-if="getItemIcon(item)"
|
v-if="app.icon"
|
||||||
:icon="getItemIcon(item)"
|
:icon="app.icon"
|
||||||
class="h-6 w-6 text-white"
|
class="h-6 w-6 text-white"
|
||||||
/>
|
/>
|
||||||
<AppWindow v-else class="h-6 w-6 text-white" />
|
<AppWindow v-else class="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span class="text-muted-foreground w-full truncate text-center text-xs">
|
<span class="text-muted-foreground w-full truncate text-center text-xs">{{ app.name }}</span>
|
||||||
{{ getItemTitle(item) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ElEmpty
|
<ElEmpty
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DashboardWidget } from '../../store/dashboardDesignStore';
|
||||||
|
|
||||||
|
import { ElProgress } from 'element-plus';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
widget: DashboardWidget;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="progress-card flex h-full flex-col justify-between p-4">
|
||||||
|
<div class="text-muted-foreground text-sm">{{ widget.props.title }}</div>
|
||||||
|
<div class="flex flex-1 items-center justify-center">
|
||||||
|
<ElProgress
|
||||||
|
type="dashboard"
|
||||||
|
:percentage="widget.props.percentage"
|
||||||
|
:status="widget.props.status || undefined"
|
||||||
|
:stroke-width="widget.props.strokeWidth"
|
||||||
|
:show-text="widget.props.showText"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 背景色由 WidgetRenderer 控制 */
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DashboardWidget } from '../../store/dashboardDesignStore';
|
||||||
|
|
||||||
|
import { Award } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElScrollbar } from 'element-plus';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
widget: DashboardWidget;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const getRankClass = (rank: number) => {
|
||||||
|
switch (rank) {
|
||||||
|
case 1: {
|
||||||
|
return 'bg-yellow-500 text-white';
|
||||||
|
}
|
||||||
|
case 2: {
|
||||||
|
return 'bg-gray-400 text-white';
|
||||||
|
}
|
||||||
|
case 3: {
|
||||||
|
return 'bg-amber-600 text-white';
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatValue = (value: number) => {
|
||||||
|
if (value >= 10_000) {
|
||||||
|
return `${(value / 10_000).toFixed(1)}${$t('dashboard-design.widgets.ranking.tenThousand')}`;
|
||||||
|
}
|
||||||
|
return value.toLocaleString();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ranking-list flex h-full flex-col p-3">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<Award class="text-muted-foreground h-4 w-4" />
|
||||||
|
<span class="text-muted-foreground text-sm font-medium">{{
|
||||||
|
widget.props.title
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<ElScrollbar class="flex-1">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="item in widget.props.items"
|
||||||
|
:key="item.rank"
|
||||||
|
class="flex items-center gap-3 rounded-md p-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-xs font-medium"
|
||||||
|
:class="getRankClass(item.rank)"
|
||||||
|
>
|
||||||
|
{{ item.rank }}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 truncate text-sm">{{ item.name }}</div>
|
||||||
|
<div class="text-muted-foreground text-sm font-medium">
|
||||||
|
{{ formatValue(item.value) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 背景色由 WidgetRenderer 控制 */
|
||||||
|
</style>
|
||||||
+1
-4
@@ -131,10 +131,7 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="server-monitor flex h-full flex-col p-4" v-loading="loading">
|
||||||
class="server-monitor flex h-full flex-col p-4"
|
|
||||||
v-loading="loading && widget.props.blockingLoading"
|
|
||||||
>
|
|
||||||
<!-- 头部 -->
|
<!-- 头部 -->
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<span class="text-sm font-medium">{{ widget.props.title }}</span>
|
<span class="text-sm font-medium">{{ widget.props.title }}</span>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DashboardWidget } from '../../store/dashboardDesignStore';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Award,
|
||||||
|
Bell,
|
||||||
|
CreditCard,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
} from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
showTitle?: boolean;
|
||||||
|
widget: DashboardWidget;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 图标映射
|
||||||
|
const iconMap: Record<string, any> = {
|
||||||
|
TrendingUp,
|
||||||
|
CreditCard,
|
||||||
|
Users,
|
||||||
|
Activity,
|
||||||
|
Bell,
|
||||||
|
Award,
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconComponent = computed(() => {
|
||||||
|
return iconMap[props.widget.props.iconName] || TrendingUp;
|
||||||
|
});
|
||||||
|
|
||||||
|
const trendClass = computed(() => {
|
||||||
|
const trend = props.widget.props.trend || 0;
|
||||||
|
if (trend > 0) return 'text-green-500';
|
||||||
|
if (trend < 0) return 'text-red-500';
|
||||||
|
return 'text-gray-500';
|
||||||
|
});
|
||||||
|
|
||||||
|
const trendIcon = computed(() => {
|
||||||
|
const trend = props.widget.props.trend || 0;
|
||||||
|
if (trend > 0) return '↑';
|
||||||
|
if (trend < 0) return '↓';
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatValue = (value: number) => {
|
||||||
|
if (value >= 10_000) {
|
||||||
|
return `${(value / 10_000).toFixed(1)}${$t('dashboard-design.widgets.ranking.tenThousand')}`;
|
||||||
|
}
|
||||||
|
return value.toLocaleString();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="stat-card flex h-full flex-col justify-between p-4">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-muted-foreground text-sm">
|
||||||
|
{{ widget.props.title }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-2xl font-bold">
|
||||||
|
{{ widget.props.prefix }}{{ formatValue(widget.props.value)
|
||||||
|
}}{{ widget.props.suffix }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-lg"
|
||||||
|
:style="{ backgroundColor: `${widget.props.iconColor}20` }"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="iconComponent"
|
||||||
|
class="h-5 w-5"
|
||||||
|
:style="{ color: widget.props.iconColor }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-center gap-1 text-sm">
|
||||||
|
<span :class="trendClass">
|
||||||
|
{{ trendIcon }} {{ Math.abs(widget.props.trend || 0) }}%
|
||||||
|
</span>
|
||||||
|
<span class="text-muted-foreground">{{ widget.props.trendLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 背景色由 WidgetRenderer 控制 */
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DashboardWidget } from '../../store/dashboardDesignStore';
|
||||||
|
|
||||||
|
import { CheckSquare } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElCheckbox, ElScrollbar, ElTag } from 'element-plus';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
widget: DashboardWidget;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const getPriorityType = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'high': {
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
case 'low': {
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
case 'medium': {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityLabel = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'high': {
|
||||||
|
return $t('dashboard-design.widgets.todo.priority.high');
|
||||||
|
}
|
||||||
|
case 'low': {
|
||||||
|
return $t('dashboard-design.widgets.todo.priority.low');
|
||||||
|
}
|
||||||
|
case 'medium': {
|
||||||
|
return $t('dashboard-design.widgets.todo.priority.medium');
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return priority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="todo-list flex h-full flex-col p-3">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<CheckSquare class="text-muted-foreground h-4 w-4" />
|
||||||
|
<span class="text-muted-foreground text-sm font-medium">{{
|
||||||
|
widget.props.title
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<ElScrollbar class="flex-1">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="item in widget.props.items"
|
||||||
|
:key="item.id"
|
||||||
|
class="flex items-center gap-2 rounded-md p-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<ElCheckbox :model-value="item.done" size="small" />
|
||||||
|
<span
|
||||||
|
class="flex-1 text-sm"
|
||||||
|
:class="{ 'text-muted-foreground line-through': item.done }"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</span>
|
||||||
|
<ElTag :type="getPriorityType(item.priority)" size="small">
|
||||||
|
{{ getPriorityLabel(item.priority) }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 背景色由 WidgetRenderer 控制 */
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DashboardWidget } from '../../store/dashboardDesignStore';
|
||||||
|
|
||||||
|
import { onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Maximize, Pause, Play, Volume2, VolumeX } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { getFileUrl } from '#/composables/useFileUrl';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
widget: DashboardWidget;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||||
|
const isPlaying = ref(false);
|
||||||
|
const isMuted = ref(false);
|
||||||
|
const currentTime = ref(0);
|
||||||
|
const duration = ref(0);
|
||||||
|
const progress = ref(0);
|
||||||
|
|
||||||
|
// 视频和封面URL(响应式)
|
||||||
|
const videoUrl = ref('');
|
||||||
|
const posterUrl = ref('');
|
||||||
|
|
||||||
|
// 异步解析URL
|
||||||
|
async function resolveUrl(url: string): Promise<string> {
|
||||||
|
if (!url) return '';
|
||||||
|
if (url.startsWith('file://')) {
|
||||||
|
const fileId = url.slice(7);
|
||||||
|
return await getFileUrl(fileId);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载URL
|
||||||
|
async function loadUrls() {
|
||||||
|
videoUrl.value = await resolveUrl(props.widget.props.url || '');
|
||||||
|
posterUrl.value = await resolveUrl(props.widget.props.poster || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听URL变化
|
||||||
|
watch(
|
||||||
|
() => [props.widget.props.url, props.widget.props.poster],
|
||||||
|
() => {
|
||||||
|
loadUrls();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (!videoRef.value) return;
|
||||||
|
|
||||||
|
if (isPlaying.value) {
|
||||||
|
videoRef.value.pause();
|
||||||
|
} else {
|
||||||
|
videoRef.value.play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
if (!videoRef.value) return;
|
||||||
|
videoRef.value.muted = !videoRef.value.muted;
|
||||||
|
isMuted.value = videoRef.value.muted;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
if (!videoRef.value) return;
|
||||||
|
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
videoRef.value.requestFullscreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
if (!videoRef.value) return;
|
||||||
|
currentTime.value = videoRef.value.currentTime;
|
||||||
|
progress.value = (currentTime.value / duration.value) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
if (!videoRef.value) return;
|
||||||
|
duration.value = videoRef.value.duration;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
isPlaying.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePause = () => {
|
||||||
|
isPlaying.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (e: MouseEvent) => {
|
||||||
|
if (!videoRef.value) return;
|
||||||
|
const target = e.currentTarget as HTMLElement;
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const percent = (e.clientX - rect.left) / rect.width;
|
||||||
|
videoRef.value.currentTime = percent * duration.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (videoRef.value) {
|
||||||
|
isMuted.value = props.widget.props.muted || false;
|
||||||
|
videoRef.value.muted = isMuted.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.widget.props.url,
|
||||||
|
() => {
|
||||||
|
if (videoRef.value) {
|
||||||
|
videoRef.value.load();
|
||||||
|
isPlaying.value = false;
|
||||||
|
currentTime.value = 0;
|
||||||
|
progress.value = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="video-player flex h-full flex-col">
|
||||||
|
<div
|
||||||
|
v-if="widget.props.title"
|
||||||
|
class="text-muted-foreground p-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
{{ widget.props.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="video-container relative min-h-0 flex-1 bg-black">
|
||||||
|
<video
|
||||||
|
ref="videoRef"
|
||||||
|
:src="videoUrl"
|
||||||
|
:poster="posterUrl"
|
||||||
|
:autoplay="widget.props.autoplay && !isDesignMode"
|
||||||
|
:loop="widget.props.loop"
|
||||||
|
:controls="false"
|
||||||
|
class="h-full w-full object-contain"
|
||||||
|
@timeupdate="handleTimeUpdate"
|
||||||
|
@loadedmetadata="handleLoadedMetadata"
|
||||||
|
@play="handlePlay"
|
||||||
|
@pause="handlePause"
|
||||||
|
></video>
|
||||||
|
|
||||||
|
<!-- 自定义控制栏 -->
|
||||||
|
<div v-if="!widget.props.controls" class="custom-controls">
|
||||||
|
<button class="control-btn" @click="togglePlay">
|
||||||
|
<Play v-if="!isPlaying" class="h-5 w-5" />
|
||||||
|
<Pause v-else class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="progress-bar" @click="handleSeek">
|
||||||
|
<div class="progress-fill" :style="{ width: `${progress}%` }"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="time-display">
|
||||||
|
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button class="control-btn" @click="toggleMute">
|
||||||
|
<VolumeX v-if="isMuted" class="h-4 w-4" />
|
||||||
|
<Volume2 v-else class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="control-btn" @click="toggleFullscreen">
|
||||||
|
<Maximize class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 原生控制栏 -->
|
||||||
|
<video
|
||||||
|
v-if="widget.props.controls"
|
||||||
|
ref="videoRef"
|
||||||
|
:src="videoUrl"
|
||||||
|
:poster="posterUrl"
|
||||||
|
:autoplay="widget.props.autoplay && !isDesignMode"
|
||||||
|
:loop="widget.props.loop"
|
||||||
|
:muted="widget.props.muted"
|
||||||
|
controls
|
||||||
|
class="h-full w-full object-contain"
|
||||||
|
></video>
|
||||||
|
|
||||||
|
<!-- 设计模式遮罩 -->
|
||||||
|
<div v-if="isDesignMode" class="design-overlay">
|
||||||
|
<Play class="h-12 w-12 text-white/80" />
|
||||||
|
<div class="mt-2 text-sm text-white/80">
|
||||||
|
{{ $t('dashboard-design.widgets.video.placeholder') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.video-container {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-controls {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: linear-gradient(transparent, rgb(0 0 0 / 70%));
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container:hover .custom-controls {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:hover {
|
||||||
|
background: rgb(255 255 255 / 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgb(255 255 255 / 30%);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
transition: width 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
min-width: 80px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgb(0 0 0 / 50%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
export { default as DashboardRenderer } from './DashboardRenderer.vue';
|
export { default as DashboardRenderer } from './DashboardRenderer.vue';
|
||||||
|
// 主页设计器组件导出
|
||||||
|
export { default as DashboardDesign } from './index.vue';
|
||||||
|
|
||||||
export type {
|
// Store 和类型导出
|
||||||
DashboardConfig,
|
export {
|
||||||
DashboardWidget,
|
type DashboardConfig,
|
||||||
DataSourceConfig,
|
type DashboardWidget,
|
||||||
DataSourceType,
|
type DataSourceConfig,
|
||||||
WidgetMaterial,
|
type DataSourceType,
|
||||||
WidgetType,
|
getWidgetMaterials,
|
||||||
|
useDashboardDesignStore,
|
||||||
|
type WidgetMaterial,
|
||||||
|
widgetMaterials,
|
||||||
|
type WidgetType,
|
||||||
} from './store/dashboardDesignStore';
|
} from './store/dashboardDesignStore';
|
||||||
|
|
||||||
|
// 工具函数导出
|
||||||
export { createRefreshTimer, fetchWidgetData } from './utils/dataFetcher';
|
export { createRefreshTimer, fetchWidgetData } from './utils/dataFetcher';
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 应用设计面板
|
||||||
|
* 用于展示LLM生成的应用设计方案,使用富文本编辑器显示和编辑
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
import { Maximize, Minimize } from '@vben/icons';
|
||||||
|
|
||||||
|
import { ElButton } from 'element-plus';
|
||||||
|
|
||||||
|
import { RichTextEditor } from '#/components/zq-form/rich-text-editor';
|
||||||
|
|
||||||
|
// 应用设计数据
|
||||||
|
export interface AppDesignData {
|
||||||
|
type: 'app_design';
|
||||||
|
title: string;
|
||||||
|
data: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
nodeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
design?: AppDesignData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:visible': [value: boolean];
|
||||||
|
'confirm': [data: Record<string, any>];
|
||||||
|
'close': [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 全屏状态
|
||||||
|
const isFullscreen = ref(false);
|
||||||
|
|
||||||
|
// 切换全屏
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
isFullscreen.value = !isFullscreen.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogVisible = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (val) => emit('update:visible', val),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 编辑内容(Markdown 格式)
|
||||||
|
const editContent = ref('');
|
||||||
|
|
||||||
|
// 富文本编辑器引用
|
||||||
|
const editorRef = ref<InstanceType<typeof RichTextEditor>>();
|
||||||
|
|
||||||
|
// 面板标题
|
||||||
|
const panelTitle = computed(() => {
|
||||||
|
return props.design?.data?.title || props.design?.title || '应用设计方案';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听设计数据变化,直接使用 Markdown 内容
|
||||||
|
watch(
|
||||||
|
() => props.design,
|
||||||
|
(newDesign) => {
|
||||||
|
if (newDesign && newDesign.data?.content) {
|
||||||
|
// 直接使用 Markdown 内容,编辑器会自动渲染
|
||||||
|
editContent.value = newDesign.data.content;
|
||||||
|
} else {
|
||||||
|
editContent.value = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 确认并继续
|
||||||
|
function handleConfirm() {
|
||||||
|
// 直接获取编辑器的 Markdown 内容
|
||||||
|
const markdownContent = editorRef.value?.getMarkdown() || editContent.value;
|
||||||
|
|
||||||
|
emit('confirm', {
|
||||||
|
title: panelTitle.value,
|
||||||
|
content: markdownContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭
|
||||||
|
function handleClose() {
|
||||||
|
dialogVisible.value = false;
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
:class="[
|
||||||
|
'border-border bg-card flex flex-col rounded-lg',
|
||||||
|
isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- 头部 -->
|
||||||
|
<div
|
||||||
|
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="text-foreground font-medium">
|
||||||
|
{{ panelTitle }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElButton size="small" @click="handleClose">{{ $t('common.cancel') }}</ElButton>
|
||||||
|
<ElButton size="small" type="primary" @click="handleConfirm">{{ $t('common.confirmAndContinue') }}</ElButton>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
:icon="isFullscreen ? Minimize : Maximize"
|
||||||
|
:title="isFullscreen ? '退出全屏' : '全屏'"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区域:Notion 风格编辑器 -->
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<RichTextEditor
|
||||||
|
ref="editorRef"
|
||||||
|
v-model="editContent"
|
||||||
|
:min-height="400"
|
||||||
|
:max-height="600"
|
||||||
|
placeholder="输入 / 打开命令菜单..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 应用设置面板
|
||||||
|
* 用于配置应用设置,包括应用配置、Logo配置、内置主题和布局
|
||||||
|
*/
|
||||||
|
import type { BuiltinThemeType, LayoutType, ThemeModeType } from '@vben/types';
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Maximize, Minimize } from '@vben/icons';
|
||||||
|
import {
|
||||||
|
Block,
|
||||||
|
BuiltinTheme,
|
||||||
|
Layout,
|
||||||
|
Theme,
|
||||||
|
} from '@vben/layouts/preferences-blocks';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElDivider,
|
||||||
|
ElInput,
|
||||||
|
ElMessage,
|
||||||
|
ElOption,
|
||||||
|
ElScrollbar,
|
||||||
|
ElSelect,
|
||||||
|
ElSwitch,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import { uploadFile } from '#/api/core/file';
|
||||||
|
import { getFileUrlPublic } from '#/composables/useFileUrl';
|
||||||
|
|
||||||
|
// 应用设置数据接口
|
||||||
|
export interface AppSettingsData {
|
||||||
|
type: 'app_settings';
|
||||||
|
title: string;
|
||||||
|
data: {
|
||||||
|
app?: {
|
||||||
|
defaultHomePath?: string;
|
||||||
|
dynamicTitle?: boolean;
|
||||||
|
enableCheckUpdates?: boolean;
|
||||||
|
enablePreferences?: boolean;
|
||||||
|
layout?: string;
|
||||||
|
locale?: string;
|
||||||
|
name?: string;
|
||||||
|
watermark?: boolean;
|
||||||
|
watermarkContent?: string;
|
||||||
|
};
|
||||||
|
breadcrumb?: Record<string, any>;
|
||||||
|
copyright?: Record<string, any>;
|
||||||
|
footer?: Record<string, any>;
|
||||||
|
header?: Record<string, any>;
|
||||||
|
logo?: {
|
||||||
|
enable?: boolean;
|
||||||
|
fit?: string;
|
||||||
|
source?: string;
|
||||||
|
};
|
||||||
|
navigation?: Record<string, any>;
|
||||||
|
shortcutKeys?: Record<string, any>;
|
||||||
|
sidebar?: Record<string, any>;
|
||||||
|
tabbar?: Record<string, any>;
|
||||||
|
theme?: {
|
||||||
|
builtinType?: string;
|
||||||
|
colorPrimary?: string;
|
||||||
|
mode?: string;
|
||||||
|
radius?: string;
|
||||||
|
semiDarkHeader?: boolean;
|
||||||
|
semiDarkSidebar?: boolean;
|
||||||
|
};
|
||||||
|
transition?: Record<string, any>;
|
||||||
|
widget?: Record<string, any>;
|
||||||
|
};
|
||||||
|
nodeId?: string;
|
||||||
|
layoutOptions?: Array<{ label: string; value: string }>;
|
||||||
|
themeOptions?: Array<{ label: string; value: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
settings?: AppSettingsData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
confirm: [data: Record<string, any>];
|
||||||
|
'update:visible': [value: boolean];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 全屏状态
|
||||||
|
const isFullscreen = ref(false);
|
||||||
|
|
||||||
|
// 切换全屏
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
isFullscreen.value = !isFullscreen.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 应用配置
|
||||||
|
const appName = ref('');
|
||||||
|
const appDefaultHomePath = ref('/analytics');
|
||||||
|
const appEnablePreferences = ref(true);
|
||||||
|
const appLayout = ref<LayoutType>('sidebar-nav');
|
||||||
|
|
||||||
|
// Logo配置
|
||||||
|
const logoEnable = ref(true);
|
||||||
|
const logoSource = ref('');
|
||||||
|
const logoFit = ref('contain');
|
||||||
|
const logoDisplayUrl = ref('');
|
||||||
|
|
||||||
|
// 主题配置
|
||||||
|
const themeMode = ref<ThemeModeType>('light');
|
||||||
|
const themeBuiltinType = ref<BuiltinThemeType>('default');
|
||||||
|
const themeColorPrimary = ref('hsl(212 100% 45%)');
|
||||||
|
const themeSemiDarkSidebar = ref(false);
|
||||||
|
const themeSemiDarkHeader = ref(false);
|
||||||
|
|
||||||
|
// 适应方式选项
|
||||||
|
const fitOptions = computed(() => [
|
||||||
|
{ label: $t('ai-platform.appSettings.fitOptions.contain'), value: 'contain' },
|
||||||
|
{ label: $t('ai-platform.appSettings.fitOptions.cover'), value: 'cover' },
|
||||||
|
{ label: $t('ai-platform.appSettings.fitOptions.fill'), value: 'fill' },
|
||||||
|
{ label: $t('ai-platform.appSettings.fitOptions.none'), value: 'none' },
|
||||||
|
{
|
||||||
|
label: $t('ai-platform.appSettings.fitOptions.scale-down'),
|
||||||
|
value: 'scale-down',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 面板标题
|
||||||
|
const panelTitle = computed(() => {
|
||||||
|
return props.settings?.title || $t('ai-platform.appSettings.title');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听设置数据变化
|
||||||
|
watch(
|
||||||
|
() => props.settings,
|
||||||
|
(newSettings) => {
|
||||||
|
if (newSettings?.data) {
|
||||||
|
const data = newSettings.data;
|
||||||
|
|
||||||
|
// 应用配置
|
||||||
|
appName.value = data.app?.name || '';
|
||||||
|
appDefaultHomePath.value = data.app?.defaultHomePath || '/analytics';
|
||||||
|
appEnablePreferences.value = data.app?.enablePreferences ?? true;
|
||||||
|
appLayout.value = (data.app?.layout || 'sidebar-nav') as LayoutType;
|
||||||
|
|
||||||
|
// Logo配置
|
||||||
|
logoEnable.value = data.logo?.enable ?? true;
|
||||||
|
logoSource.value = data.logo?.source || '';
|
||||||
|
logoFit.value = data.logo?.fit || 'contain';
|
||||||
|
loadLogoDisplayUrl(logoSource.value);
|
||||||
|
|
||||||
|
// 主题配置
|
||||||
|
themeMode.value = (data.theme?.mode || 'light') as ThemeModeType;
|
||||||
|
themeBuiltinType.value = (data.theme?.builtinType ||
|
||||||
|
'default') as BuiltinThemeType;
|
||||||
|
themeColorPrimary.value = data.theme?.colorPrimary || 'hsl(212 100% 45%)';
|
||||||
|
themeSemiDarkSidebar.value = data.theme?.semiDarkSidebar ?? false;
|
||||||
|
themeSemiDarkHeader.value = data.theme?.semiDarkHeader ?? false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听logoSource变化,加载显示URL
|
||||||
|
watch(logoSource, (newSource) => {
|
||||||
|
loadLogoDisplayUrl(newSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 判断是否为文件ID
|
||||||
|
function isFileId(value: string): boolean {
|
||||||
|
return (
|
||||||
|
/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test(value) ||
|
||||||
|
/^\d+$/.test(value) ||
|
||||||
|
/^[\w-]{10,30}$/.test(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载Logo显示URL
|
||||||
|
function loadLogoDisplayUrl(source: string) {
|
||||||
|
if (!source) {
|
||||||
|
logoDisplayUrl.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
source.startsWith('http://') ||
|
||||||
|
source.startsWith('https://') ||
|
||||||
|
source.startsWith('/') ||
|
||||||
|
source.startsWith('data:')
|
||||||
|
) {
|
||||||
|
logoDisplayUrl.value = source;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isFileId(source)) {
|
||||||
|
logoDisplayUrl.value = getFileUrlPublic(source);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logoDisplayUrl.value = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件上传
|
||||||
|
const uploadInputRef = ref<HTMLInputElement>();
|
||||||
|
|
||||||
|
function openFileSelector() {
|
||||||
|
uploadInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileInputChange(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const file = target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await uploadFile(file, {
|
||||||
|
isPublic: true,
|
||||||
|
source: 'avatar',
|
||||||
|
});
|
||||||
|
if (response?.id) {
|
||||||
|
logoSource.value = getFileUrlPublic(String(response.id));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.error($t('ui-config.loadError'));
|
||||||
|
}
|
||||||
|
target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLogo() {
|
||||||
|
logoSource.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建设置数据
|
||||||
|
function buildSettingsData(): Record<string, any> {
|
||||||
|
return {
|
||||||
|
app: {
|
||||||
|
name: appName.value,
|
||||||
|
defaultHomePath: appDefaultHomePath.value,
|
||||||
|
enablePreferences: appEnablePreferences.value,
|
||||||
|
layout: appLayout.value,
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
enable: logoEnable.value,
|
||||||
|
source: logoSource.value,
|
||||||
|
fit: logoFit.value,
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
mode: themeMode.value,
|
||||||
|
builtinType: themeBuiltinType.value,
|
||||||
|
colorPrimary: themeColorPrimary.value,
|
||||||
|
semiDarkSidebar: themeSemiDarkSidebar.value,
|
||||||
|
semiDarkHeader: themeSemiDarkHeader.value,
|
||||||
|
},
|
||||||
|
// 保留原有的其他配置
|
||||||
|
sidebar: props.settings?.data?.sidebar,
|
||||||
|
header: props.settings?.data?.header,
|
||||||
|
footer: props.settings?.data?.footer,
|
||||||
|
copyright: props.settings?.data?.copyright,
|
||||||
|
navigation: props.settings?.data?.navigation,
|
||||||
|
tabbar: props.settings?.data?.tabbar,
|
||||||
|
breadcrumb: props.settings?.data?.breadcrumb,
|
||||||
|
transition: props.settings?.data?.transition,
|
||||||
|
widget: props.settings?.data?.widget,
|
||||||
|
shortcutKeys: props.settings?.data?.shortcutKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认并继续
|
||||||
|
function handleConfirm() {
|
||||||
|
emit('confirm', buildSettingsData());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭
|
||||||
|
function handleClose() {
|
||||||
|
emit('update:visible', false);
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="app-settings-panel border-border bg-card flex flex-col rounded-lg"
|
||||||
|
:class="[isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full']"
|
||||||
|
>
|
||||||
|
<!-- 头部 -->
|
||||||
|
<div
|
||||||
|
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="text-foreground font-medium">
|
||||||
|
{{ panelTitle }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElButton size="small" @click="handleClose">
|
||||||
|
{{ $t('common.cancel') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton size="small" type="primary" @click="handleConfirm">
|
||||||
|
{{ $t('common.confirmAndContinue') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
:icon="isFullscreen ? Minimize : Maximize"
|
||||||
|
:title="isFullscreen ? '退出全屏' : '全屏'"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<ElScrollbar class="flex-1">
|
||||||
|
<div class="grid grid-cols-2 gap-6 p-6">
|
||||||
|
<!-- 左列:应用配置 + Logo配置 -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- 应用配置 -->
|
||||||
|
<div class="config-section">
|
||||||
|
<h4 class="mb-4 text-sm font-semibold">
|
||||||
|
{{ $t('ai-platform.appSettings.appConfig') }}
|
||||||
|
</h4>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Block :title="$t('ai-platform.appSettings.appName')">
|
||||||
|
<ElInput
|
||||||
|
v-model="appName"
|
||||||
|
:placeholder="
|
||||||
|
$t('ai-platform.appSettings.appNamePlaceholder')
|
||||||
|
"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</Block>
|
||||||
|
|
||||||
|
<Block :title="$t('ai-platform.appSettings.defaultHomePath')">
|
||||||
|
<ElInput
|
||||||
|
v-model="appDefaultHomePath"
|
||||||
|
:placeholder="
|
||||||
|
$t('ai-platform.appSettings.defaultHomePathPlaceholder')
|
||||||
|
"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</Block>
|
||||||
|
|
||||||
|
<Block :title="$t('ai-platform.appSettings.enablePreferences')">
|
||||||
|
<ElSwitch v-model="appEnablePreferences" />
|
||||||
|
</Block>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElDivider />
|
||||||
|
|
||||||
|
<!-- Logo配置 -->
|
||||||
|
<div class="config-section">
|
||||||
|
<h4 class="mb-4 text-sm font-semibold">
|
||||||
|
{{ $t('ai-platform.appSettings.logoConfig') }}
|
||||||
|
</h4>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Block :title="$t('ai-platform.appSettings.logoEnable')">
|
||||||
|
<ElSwitch v-model="logoEnable" />
|
||||||
|
</Block>
|
||||||
|
|
||||||
|
<template v-if="logoEnable">
|
||||||
|
<Block :title="$t('ai-platform.appSettings.logoSource')">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<!-- Logo 预览和上传 -->
|
||||||
|
<div class="logo-preview-container flex">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
v-if="logoDisplayUrl"
|
||||||
|
class="logo-preview"
|
||||||
|
@click="openFileSelector"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="logoDisplayUrl"
|
||||||
|
alt="Logo"
|
||||||
|
class="logo-image"
|
||||||
|
/>
|
||||||
|
<div class="logo-overlay">
|
||||||
|
<span class="text-sm text-white">{{
|
||||||
|
$t('common.replace')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="logo-placeholder"
|
||||||
|
@click="openFileSelector"
|
||||||
|
>
|
||||||
|
<span class="text-muted-foreground text-sm">{{
|
||||||
|
$t(
|
||||||
|
'ai-platform.appSettings.logoSourcePlaceholder',
|
||||||
|
)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElButton
|
||||||
|
v-if="logoDisplayUrl"
|
||||||
|
size="small"
|
||||||
|
@click="clearLogo"
|
||||||
|
>
|
||||||
|
{{ $t('common.clear') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="uploadInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleFileInputChange"
|
||||||
|
/>
|
||||||
|
<ElInput
|
||||||
|
v-model="logoSource"
|
||||||
|
:placeholder="
|
||||||
|
$t('ai-platform.appSettings.logoSourcePlaceholder')
|
||||||
|
"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Block>
|
||||||
|
|
||||||
|
<Block :title="$t('ai-platform.appSettings.logoFit')">
|
||||||
|
<ElSelect v-model="logoFit" class="w-full">
|
||||||
|
<ElOption
|
||||||
|
v-for="item in fitOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</Block>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右列:布局配置 + 主题配置 -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- 布局配置 -->
|
||||||
|
<div class="config-section">
|
||||||
|
<h4 class="mb-4 text-sm font-semibold">
|
||||||
|
{{ $t('ai-platform.appSettings.layoutConfig') }}
|
||||||
|
</h4>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Block :title="$t('ai-platform.appSettings.layout')">
|
||||||
|
<Layout v-model="appLayout" />
|
||||||
|
</Block>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElDivider />
|
||||||
|
|
||||||
|
<!-- 主题配置 -->
|
||||||
|
<div class="config-section">
|
||||||
|
<h4 class="mb-4 text-sm font-semibold">
|
||||||
|
{{ $t('ai-platform.appSettings.themeConfig') }}
|
||||||
|
</h4>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Block :title="$t('ai-platform.appSettings.themeMode')">
|
||||||
|
<Theme
|
||||||
|
v-model="themeMode"
|
||||||
|
v-model:theme-semi-dark-sidebar="themeSemiDarkSidebar"
|
||||||
|
v-model:theme-semi-dark-header="themeSemiDarkHeader"
|
||||||
|
/>
|
||||||
|
</Block>
|
||||||
|
|
||||||
|
<Block :title="$t('ai-platform.appSettings.builtinTheme')">
|
||||||
|
<BuiltinTheme
|
||||||
|
v-model="themeBuiltinType"
|
||||||
|
v-model:theme-color-primary="themeColorPrimary"
|
||||||
|
:is-dark="themeMode === 'dark'"
|
||||||
|
/>
|
||||||
|
</Block>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-settings-panel {
|
||||||
|
min-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-preview-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgb(0 0 0 / 50%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-preview:hover .logo-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border: 2px dashed hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-placeholder:hover {
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 表单基础信息编辑组件
|
||||||
|
* 可用于:表单管理器、工作流设计预览编辑
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElInput,
|
||||||
|
ElOption,
|
||||||
|
ElSelect,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
export interface BasicFormData {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
form_type: 'normal' | 'workflow';
|
||||||
|
sort: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: BasicFormData;
|
||||||
|
disabled?: boolean;
|
||||||
|
showTitle?: boolean;
|
||||||
|
labelWidth?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
disabled: false,
|
||||||
|
showTitle: true,
|
||||||
|
labelWidth: '100px',
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: BasicFormData];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const formData = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新单个字段
|
||||||
|
function updateField<K extends keyof BasicFormData>(key: K, value: BasicFormData[K]) {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, [key]: value });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="basic-info-editor">
|
||||||
|
<h3 v-if="showTitle" class="mb-6 text-center text-lg font-medium">
|
||||||
|
{{ $t('form-manager.editor.steps.basic') }}
|
||||||
|
</h3>
|
||||||
|
<ElForm
|
||||||
|
:model="formData"
|
||||||
|
:label-width="labelWidth"
|
||||||
|
label-position="right"
|
||||||
|
:disabled="disabled"
|
||||||
|
>
|
||||||
|
<ElFormItem :label="$t('form-manager.name')" required>
|
||||||
|
<ElInput
|
||||||
|
:model-value="formData.name"
|
||||||
|
:placeholder="$t('form-manager.placeholder.name')"
|
||||||
|
clearable
|
||||||
|
@update:model-value="updateField('name', $event)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="$t('form-manager.code')" required>
|
||||||
|
<ElInput
|
||||||
|
:model-value="formData.code"
|
||||||
|
:placeholder="$t('form-manager.placeholder.code')"
|
||||||
|
clearable
|
||||||
|
@update:model-value="updateField('code', $event)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="$t('form-manager.type')" required>
|
||||||
|
<ElSelect
|
||||||
|
:model-value="formData.form_type"
|
||||||
|
:placeholder="$t('form-manager.placeholder.type')"
|
||||||
|
class="w-full"
|
||||||
|
@update:model-value="updateField('form_type', $event)"
|
||||||
|
>
|
||||||
|
<ElOption :label="$t('form-manager.typeMap.normal')" value="normal" />
|
||||||
|
<ElOption :label="$t('form-manager.typeMap.workflow')" value="workflow" />
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="$t('common.sort')">
|
||||||
|
<ElInput
|
||||||
|
:model-value="formData.sort"
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
@update:model-value="updateField('sort', Number($event))"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="$t('form-manager.description')">
|
||||||
|
<ElInput
|
||||||
|
:model-value="formData.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
:placeholder="$t('form-manager.editor.placeholder.remark')"
|
||||||
|
@update:model-value="updateField('description', $event)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.basic-info-editor {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 仪表盘基础信息确认面板
|
||||||
|
* 复用 PageBasicInfoEditor 公共组件
|
||||||
|
*/
|
||||||
|
import type { PageBasicInfo } from './PageBasicInfoEditor.vue';
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Maximize, Minimize } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElButton } from 'element-plus';
|
||||||
|
|
||||||
|
import PageBasicInfoEditor from './PageBasicInfoEditor.vue';
|
||||||
|
|
||||||
|
export interface DashboardBasicInfoData {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
data: {
|
||||||
|
category: string;
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
name: string;
|
||||||
|
sort: number;
|
||||||
|
};
|
||||||
|
nodeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
basicInfo?: DashboardBasicInfoData;
|
||||||
|
visible: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
confirm: [data: Record<string, any>];
|
||||||
|
'update:visible': [value: boolean];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 全屏状态
|
||||||
|
const isFullscreen = ref(false);
|
||||||
|
|
||||||
|
// 切换全屏
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
isFullscreen.value = !isFullscreen.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogVisible = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (val) => emit('update:visible', val),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = ref<PageBasicInfo>({
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
category: 'dashboard',
|
||||||
|
description: '',
|
||||||
|
sort: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.basicInfo,
|
||||||
|
(info) => {
|
||||||
|
if (info?.data) {
|
||||||
|
form.value = {
|
||||||
|
name: info.data.name || '',
|
||||||
|
code: info.data.code || '',
|
||||||
|
category: info.data.category || 'dashboard',
|
||||||
|
description: info.data.description || '',
|
||||||
|
sort: info.data.sort || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
emit('confirm', { ...form.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
dialogVisible.value = false;
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="border-border bg-card flex flex-col rounded-lg"
|
||||||
|
:class="[isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full']"
|
||||||
|
>
|
||||||
|
<!-- 头部 -->
|
||||||
|
<div
|
||||||
|
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="text-foreground font-medium">
|
||||||
|
{{ basicInfo?.title || $t('ai-platform.dashboard.basicInfo.title') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElButton size="small" @click="handleClose">
|
||||||
|
{{ $t('common.cancel') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton size="small" type="primary" @click="handleConfirm">
|
||||||
|
{{ $t('common.confirmAndContinue') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
:icon="isFullscreen ? Minimize : Maximize"
|
||||||
|
:title="isFullscreen ? '退出全屏' : '全屏'"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容 -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
|
<PageBasicInfoEditor v-model="form" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 仪表盘设计确认面板
|
||||||
|
* 复用 DashboardDesign 组件
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Maximize, Minimize } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElButton } from 'element-plus';
|
||||||
|
|
||||||
|
import DashboardDesign from '#/components/dashboard-design/index.vue';
|
||||||
|
import { useDashboardDesignStore } from '#/components/dashboard-design/store/dashboardDesignStore';
|
||||||
|
|
||||||
|
export interface DashboardDesignData {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
data: {
|
||||||
|
dashboard_code?: string;
|
||||||
|
design_suggestion?: string;
|
||||||
|
design_title?: string;
|
||||||
|
page_config?: Record<string, any>;
|
||||||
|
};
|
||||||
|
nodeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
design?: DashboardDesignData;
|
||||||
|
visible: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
confirm: [data: Record<string, any>];
|
||||||
|
'update:visible': [value: boolean];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 全屏状态
|
||||||
|
const isFullscreen = ref(false);
|
||||||
|
|
||||||
|
// 切换全屏
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
isFullscreen.value = !isFullscreen.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dashboardDesignStore = useDashboardDesignStore();
|
||||||
|
|
||||||
|
const dialogVisible = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (val) => emit('update:visible', val),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听设计数据变化,导入初始配置
|
||||||
|
watch(
|
||||||
|
() => props.design,
|
||||||
|
(design) => {
|
||||||
|
if (
|
||||||
|
design?.data?.page_config &&
|
||||||
|
Object.keys(design.data.page_config).length > 0
|
||||||
|
) {
|
||||||
|
dashboardDesignStore.importConfig(
|
||||||
|
JSON.stringify(design.data.page_config),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dashboardDesignStore.clearCanvas();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
// 获取设计器配置
|
||||||
|
const pageConfig = JSON.parse(dashboardDesignStore.exportConfig());
|
||||||
|
|
||||||
|
emit('confirm', {
|
||||||
|
page_config: pageConfig,
|
||||||
|
design_title:
|
||||||
|
props.design?.data?.design_title ||
|
||||||
|
$t('ai-platform.dashboard.design.title'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
dialogVisible.value = false;
|
||||||
|
dashboardDesignStore.clearCanvas();
|
||||||
|
dashboardDesignStore.setActive(null);
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="border-border bg-card flex flex-col rounded-lg"
|
||||||
|
:class="[isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full']"
|
||||||
|
>
|
||||||
|
<!-- 头部 -->
|
||||||
|
<div
|
||||||
|
class="border-border bg-muted/50 flex shrink-0 items-center justify-between border-b px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="text-foreground flex items-center gap-3 font-medium">
|
||||||
|
<div
|
||||||
|
class="bg-primary flex h-8 w-8 items-center justify-center rounded"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-bold text-white">D</span>
|
||||||
|
</div>
|
||||||
|
<span>{{
|
||||||
|
design?.title || $t('ai-platform.dashboard.design.title')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElButton size="small" @click="handleClose">
|
||||||
|
{{ $t('common.cancel') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton size="small" type="primary" @click="handleConfirm">
|
||||||
|
{{ $t('common.confirmAndContinue') }}
|
||||||
|
</ElButton>
|
||||||
|
<div class="bg-border mx-1 h-5 w-px"></div>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
:icon="isFullscreen ? Minimize : Maximize"
|
||||||
|
:title="isFullscreen ? '退出全屏' : '全屏'"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 设计器内容 -->
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<DashboardDesign />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 仪表盘发布确认面板
|
||||||
|
* 复用 PagePublishInfoEditor 公共组件
|
||||||
|
*/
|
||||||
|
import type { PagePublishInfo } from './PagePublishInfoEditor.vue';
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Maximize, Minimize } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElButton } from 'element-plus';
|
||||||
|
|
||||||
|
import PagePublishInfoEditor from './PagePublishInfoEditor.vue';
|
||||||
|
|
||||||
|
export interface DashboardPublishData {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
data: {
|
||||||
|
application_id?: string;
|
||||||
|
dashboard_code: string;
|
||||||
|
dashboard_id: string;
|
||||||
|
menu_icon: string;
|
||||||
|
menu_name: string;
|
||||||
|
menu_order: number;
|
||||||
|
menu_parent_id?: string;
|
||||||
|
};
|
||||||
|
nodeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
publishData?: DashboardPublishData;
|
||||||
|
visible: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
confirm: [data: Record<string, any>];
|
||||||
|
'update:visible': [value: boolean];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 全屏状态
|
||||||
|
const isFullscreen = ref(false);
|
||||||
|
|
||||||
|
// 切换全屏
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
isFullscreen.value = !isFullscreen.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogVisible = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (val) => emit('update:visible', val),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = ref<PagePublishInfo>({
|
||||||
|
menu_name: '',
|
||||||
|
menu_parent_id: undefined,
|
||||||
|
menu_icon: 'lucide:layout-dashboard',
|
||||||
|
menu_order: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.publishData,
|
||||||
|
(data) => {
|
||||||
|
if (data?.data) {
|
||||||
|
form.value = {
|
||||||
|
menu_name: data.data.menu_name || '',
|
||||||
|
menu_parent_id: data.data.menu_parent_id || undefined,
|
||||||
|
menu_icon: data.data.menu_icon || 'lucide:layout-dashboard',
|
||||||
|
menu_order: data.data.menu_order || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
const confirmData = {
|
||||||
|
dashboard_id: props.publishData?.data.dashboard_id,
|
||||||
|
dashboard_code: props.publishData?.data.dashboard_code,
|
||||||
|
menu_name: form.value.menu_name,
|
||||||
|
menu_parent_id: form.value.menu_parent_id || '',
|
||||||
|
menu_icon: form.value.menu_icon,
|
||||||
|
menu_order: form.value.menu_order,
|
||||||
|
application_id: props.publishData?.data.application_id || '',
|
||||||
|
};
|
||||||
|
console.log('[DashboardPublishConfirmPanel] 确认数据:', confirmData);
|
||||||
|
emit('confirm', confirmData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
dialogVisible.value = false;
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="border-border bg-card flex flex-col rounded-lg"
|
||||||
|
:class="[isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full']"
|
||||||
|
>
|
||||||
|
<!-- 头部 -->
|
||||||
|
<div
|
||||||
|
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="text-foreground font-medium">
|
||||||
|
{{ publishData?.title || $t('ai-platform.dashboard.publish.title') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElButton size="small" @click="handleClose">
|
||||||
|
{{ $t('common.cancel') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton size="small" type="primary" @click="handleConfirm">
|
||||||
|
{{ $t('common.confirmAndContinue') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
:icon="isFullscreen ? Minimize : Maximize"
|
||||||
|
:title="isFullscreen ? '退出全屏' : '全屏'"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容 -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
|
<PagePublishInfoEditor
|
||||||
|
v-model="form"
|
||||||
|
:page-code="publishData?.data.dashboard_code || ''"
|
||||||
|
:show-route-info="true"
|
||||||
|
route-prefix="/page-render/"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 设计编辑面板
|
||||||
|
* 用于工作流中每步设计完成后的编辑
|
||||||
|
* 使用 FormEditorContent 公共组件,根据当前步骤显示对应内容
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
import { Maximize, Minimize } from '@vben/icons';
|
||||||
|
|
||||||
|
import { ElButton } from 'element-plus';
|
||||||
|
|
||||||
|
import FormEditorContent from './FormEditorContent.vue';
|
||||||
|
|
||||||
|
// 设计类型
|
||||||
|
export type DesignType = 'form_basic_info' | 'database_design' | 'form_ui_design' | 'list_config' | 'form_publish' | 'dashboard_basic_info' | 'dashboard_design' | 'dashboard_publish';
|
||||||
|
|
||||||
|
// 设计数据
|
||||||
|
export interface DesignData {
|
||||||
|
type: DesignType;
|
||||||
|
title: string;
|
||||||
|
data: Record<string, any>;
|
||||||
|
nodeId: string;
|
||||||
|
form_fields?: any[]; // 表单字段列表(用于列表设计)
|
||||||
|
table_configs?: any[]; // 数据表配置(用于表单设计)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
design?: DesignData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:visible': [value: boolean];
|
||||||
|
'save': [data: Record<string, any>];
|
||||||
|
'confirm': [data: Record<string, any>];
|
||||||
|
'close': [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 编辑器组件引用
|
||||||
|
const editorRef = ref<InstanceType<typeof FormEditorContent>>();
|
||||||
|
|
||||||
|
// 根据设计类型映射到步骤索引
|
||||||
|
const stepMap: Record<DesignType, number> = {
|
||||||
|
'form_basic_info': 0,
|
||||||
|
'database_design': 1,
|
||||||
|
'form_ui_design': 2,
|
||||||
|
'list_config': 3,
|
||||||
|
'form_publish': 4,
|
||||||
|
'dashboard_basic_info': 0,
|
||||||
|
'dashboard_design': 0,
|
||||||
|
'dashboard_publish': 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当前步骤
|
||||||
|
const currentStep = ref(0);
|
||||||
|
|
||||||
|
// 面板标题
|
||||||
|
const panelTitle = computed(() => {
|
||||||
|
if (!props.design) return '';
|
||||||
|
return props.design.title;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 全屏状态
|
||||||
|
const isFullscreen = ref(false);
|
||||||
|
|
||||||
|
// 切换全屏
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
isFullscreen.value = !isFullscreen.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听设计数据变化,设置当前步骤和初始化数据
|
||||||
|
watch(
|
||||||
|
() => props.design,
|
||||||
|
(newDesign) => {
|
||||||
|
if (!newDesign) return;
|
||||||
|
|
||||||
|
// 根据设计类型设置当前步骤
|
||||||
|
currentStep.value = stepMap[newDesign.type] ?? 0;
|
||||||
|
|
||||||
|
// 初始化编辑器数据
|
||||||
|
if (editorRef.value) {
|
||||||
|
initEditorData(newDesign);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听编辑器引用变化,初始化数据
|
||||||
|
watch(
|
||||||
|
() => editorRef.value,
|
||||||
|
(editor) => {
|
||||||
|
if (editor && props.design) {
|
||||||
|
initEditorData(props.design);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 初始化编辑器数据
|
||||||
|
function initEditorData(design: DesignData) {
|
||||||
|
if (!editorRef.value) return;
|
||||||
|
|
||||||
|
switch (design.type) {
|
||||||
|
case 'form_basic_info': {
|
||||||
|
const data = design.data || {};
|
||||||
|
editorRef.value.setData({
|
||||||
|
basicForm: {
|
||||||
|
name: data.name || '',
|
||||||
|
code: data.code || '',
|
||||||
|
form_type: data.form_type || 'normal',
|
||||||
|
sort: data.sort || 0,
|
||||||
|
description: data.description || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'database_design': {
|
||||||
|
const tableData = design.data?.table || design.data || {};
|
||||||
|
// 优先从 meta 中读取 schema,兼容旧数据从顶层读取
|
||||||
|
const metaData = tableData.meta || {};
|
||||||
|
editorRef.value.setData({
|
||||||
|
tableConfigs: [{
|
||||||
|
id: 'main-table',
|
||||||
|
type: 'main',
|
||||||
|
tableName: tableData.tableName || '',
|
||||||
|
alias: tableData.alias || tableData.tableName || '',
|
||||||
|
fields: tableData.fields || [],
|
||||||
|
meta: {
|
||||||
|
schema: metaData.schema || tableData.schema || '',
|
||||||
|
schemaRaw: metaData.schemaRaw || tableData.schemaRaw || '',
|
||||||
|
database: metaData.database || tableData.database || '',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'form_ui_design': {
|
||||||
|
const formConfig = design.data || {};
|
||||||
|
// 从 design 中获取 table_configs(后端在 waiting_config 中传递)
|
||||||
|
const tableConfigs = design.table_configs || design.data?.table_configs || [];
|
||||||
|
editorRef.value.setData({
|
||||||
|
tableConfigs: tableConfigs,
|
||||||
|
formConfig: {
|
||||||
|
items: formConfig.items || [],
|
||||||
|
labelWidth: formConfig.labelWidth || 120,
|
||||||
|
labelPosition: formConfig.labelPosition || 'right',
|
||||||
|
size: formConfig.size || 'default',
|
||||||
|
formPadding: formConfig.formPadding || 20,
|
||||||
|
formMargin: formConfig.formMargin || 0,
|
||||||
|
itemSpacing: formConfig.itemSpacing || 18,
|
||||||
|
formWidth: formConfig.formWidth || '100%',
|
||||||
|
formMaxWidth: formConfig.formMaxWidth || 0,
|
||||||
|
formBackground: formConfig.formBackground || false,
|
||||||
|
formBorder: formConfig.formBorder || false,
|
||||||
|
formBorderRadius: formConfig.formBorderRadius || 4,
|
||||||
|
formShadow: formConfig.formShadow || false,
|
||||||
|
disabled: formConfig.disabled || false,
|
||||||
|
tableConfigs: tableConfigs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'list_config': {
|
||||||
|
// 从 design 中获取 form_fields(后端在 waiting_config 中传递)
|
||||||
|
const formFields = design.form_fields || design.data?.form_fields || [];
|
||||||
|
editorRef.value.setData({
|
||||||
|
listConfig: design.data || {},
|
||||||
|
formFields: formFields,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'form_publish': {
|
||||||
|
const publishData = design.data || {};
|
||||||
|
editorRef.value.setData({
|
||||||
|
publishData: {
|
||||||
|
menu_name: publishData.menu_name || '',
|
||||||
|
menu_parent_id: publishData.menu_parent_id,
|
||||||
|
menu_icon: publishData.menu_icon || 'lucide:file-text',
|
||||||
|
menu_order: publishData.menu_order ?? 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前编辑的数据
|
||||||
|
function getCurrentData(): Record<string, any> {
|
||||||
|
if (!editorRef.value) return {};
|
||||||
|
|
||||||
|
const data = editorRef.value.getData();
|
||||||
|
const designType = props.design?.type;
|
||||||
|
|
||||||
|
switch (designType) {
|
||||||
|
case 'form_basic_info':
|
||||||
|
return { ...data.basicForm };
|
||||||
|
case 'database_design':
|
||||||
|
return {
|
||||||
|
type: data.tableConfigs[0]?.type || 'main', // 添加 type 字段,默认为 main
|
||||||
|
table: data.tableConfigs[0] ? {
|
||||||
|
tableName: data.tableConfigs[0].tableName,
|
||||||
|
alias: data.tableConfigs[0].alias,
|
||||||
|
fields: data.tableConfigs[0].fields,
|
||||||
|
meta: {
|
||||||
|
schema: data.tableConfigs[0].meta?.schema || '',
|
||||||
|
schemaRaw: data.tableConfigs[0].meta?.schemaRaw || '',
|
||||||
|
database: data.tableConfigs[0].meta?.database || '',
|
||||||
|
},
|
||||||
|
} : {},
|
||||||
|
dbConfig: props.design?.data?.dbConfig || 'default', // 保留原有的 dbConfig
|
||||||
|
};
|
||||||
|
case 'form_ui_design':
|
||||||
|
return {
|
||||||
|
items: data.formConfig.items,
|
||||||
|
labelWidth: data.formConfig.labelWidth,
|
||||||
|
labelPosition: data.formConfig.labelPosition,
|
||||||
|
};
|
||||||
|
case 'list_config':
|
||||||
|
return { ...data.listConfig };
|
||||||
|
case 'form_publish':
|
||||||
|
return { ...data.publishData };
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认并继续
|
||||||
|
function handleConfirm() {
|
||||||
|
const data = getCurrentData();
|
||||||
|
emit('confirm', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭
|
||||||
|
function handleClose() {
|
||||||
|
emit('update:visible', false);
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
:class="[
|
||||||
|
'border-border bg-card flex flex-col rounded-lg',
|
||||||
|
isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- 头部 -->
|
||||||
|
<div
|
||||||
|
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="text-foreground font-medium">
|
||||||
|
{{ panelTitle }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElButton size="small" @click="handleClose">{{ $t('common.cancel') }}</ElButton>
|
||||||
|
<ElButton size="small" type="primary" @click="handleConfirm">{{ $t('common.confirmAndContinue') }}</ElButton>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
:icon="isFullscreen ? Minimize : Maximize"
|
||||||
|
:title="isFullscreen ? '退出全屏' : '全屏'"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区域:使用 FormEditorContent 公共组件 -->
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<FormEditorContent
|
||||||
|
ref="editorRef"
|
||||||
|
:step="currentStep"
|
||||||
|
:show-steps="true"
|
||||||
|
:show-actions="false"
|
||||||
|
:workflow-mode="true"
|
||||||
|
@update:step="currentStep = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.design-editor-panel {
|
||||||
|
/* box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,816 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { TableConfig } from '#/views/online-dev/form-manager/modules/data-source-config.vue';
|
||||||
|
/**
|
||||||
|
* 表单编辑器内容组件
|
||||||
|
* 包含步骤条和所有步骤内容,可用于 Modal 或右侧面板
|
||||||
|
*/
|
||||||
|
import type { ValidationError } from '#/views/online-dev/form-manager/utils/formValidator';
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { CircleClose, Warning } from '@element-plus/icons-vue';
|
||||||
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElDialog,
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElIcon,
|
||||||
|
ElInput,
|
||||||
|
ElOption,
|
||||||
|
ElScrollbar,
|
||||||
|
ElSelect,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import FormDesign from '#/components/form-design/index.vue';
|
||||||
|
import { useFormDesignStore } from '#/components/form-design/store/formDesignStore';
|
||||||
|
import PublishInfoEditor from '#/components/form-editor/PublishInfoEditor.vue';
|
||||||
|
import DataSourceConfig from '#/views/online-dev/form-manager/modules/data-source-config.vue';
|
||||||
|
import ListDesign from '#/views/online-dev/form-manager/modules/list-design.vue';
|
||||||
|
import { validateFormConfig } from '#/views/online-dev/form-manager/utils/formValidator';
|
||||||
|
|
||||||
|
// 基础信息表单类型
|
||||||
|
export interface BasicFormData {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
form_type: 'normal' | 'workflow';
|
||||||
|
sort: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件属性
|
||||||
|
interface Props {
|
||||||
|
// 当前步骤(0-3),外部控制时使用
|
||||||
|
step?: number;
|
||||||
|
// 是否显示步骤条
|
||||||
|
showSteps?: boolean;
|
||||||
|
// 是否显示操作按钮
|
||||||
|
showActions?: boolean;
|
||||||
|
// 是否为只读模式
|
||||||
|
readonly?: boolean;
|
||||||
|
// 工作流模式:隐藏左侧数据库树,主表/从表可编辑
|
||||||
|
workflowMode?: boolean;
|
||||||
|
// 初始基础信息
|
||||||
|
initialBasicForm?: BasicFormData;
|
||||||
|
// 初始表配置
|
||||||
|
initialTableConfigs?: TableConfig[];
|
||||||
|
// 初始列表设计数据
|
||||||
|
initialListDesign?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
step: undefined,
|
||||||
|
showSteps: true,
|
||||||
|
showActions: true,
|
||||||
|
readonly: false,
|
||||||
|
workflowMode: false,
|
||||||
|
initialBasicForm: undefined,
|
||||||
|
initialTableConfigs: undefined,
|
||||||
|
initialListDesign: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
cancel: [];
|
||||||
|
save: [data: FormEditorData];
|
||||||
|
'step-change': [step: number];
|
||||||
|
'update:step': [step: number];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 发布配置数据类型
|
||||||
|
export interface PublishFormData {
|
||||||
|
menu_name: string;
|
||||||
|
menu_parent_id?: string;
|
||||||
|
menu_icon: string;
|
||||||
|
menu_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出的数据类型
|
||||||
|
export interface FormEditorData {
|
||||||
|
basicForm: BasicFormData;
|
||||||
|
tableConfigs: TableConfig[];
|
||||||
|
formConfig: {
|
||||||
|
disabled: boolean;
|
||||||
|
formBackground: boolean;
|
||||||
|
formBorder: boolean;
|
||||||
|
formBorderRadius: number;
|
||||||
|
formMargin: number;
|
||||||
|
formMaxWidth: number;
|
||||||
|
formPadding: number;
|
||||||
|
formShadow: boolean;
|
||||||
|
formWidth: string;
|
||||||
|
items: any[];
|
||||||
|
itemSpacing: number;
|
||||||
|
labelPosition: 'left' | 'right' | 'top';
|
||||||
|
labelWidth: number;
|
||||||
|
lifecycleHooks?: any[];
|
||||||
|
size: 'default' | 'large' | 'small';
|
||||||
|
tableConfigs: TableConfig[];
|
||||||
|
};
|
||||||
|
listConfig: Record<string, any>;
|
||||||
|
formFields: any[];
|
||||||
|
publishData?: PublishFormData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formDesignStore = useFormDesignStore();
|
||||||
|
|
||||||
|
// 当前步骤(内部状态)
|
||||||
|
const internalStep = ref(0);
|
||||||
|
|
||||||
|
// 计算当前步骤(支持外部控制)
|
||||||
|
const currentStep = computed({
|
||||||
|
get: () => (props.step === undefined ? internalStep.value : props.step),
|
||||||
|
set: (val) => {
|
||||||
|
if (props.step === undefined) {
|
||||||
|
internalStep.value = val;
|
||||||
|
} else {
|
||||||
|
emit('update:step', val);
|
||||||
|
}
|
||||||
|
emit('step-change', val);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 基础信息表单
|
||||||
|
const basicForm = ref<BasicFormData>({
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
form_type: 'normal',
|
||||||
|
sort: 0,
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 数据表配置
|
||||||
|
const tableConfigs = ref<TableConfig[]>([]);
|
||||||
|
|
||||||
|
// 表单设计字段列表(扁平化)
|
||||||
|
const formFields = ref<any[]>([]);
|
||||||
|
|
||||||
|
// 列表设计组件引用
|
||||||
|
const listDesignRef = ref();
|
||||||
|
|
||||||
|
// 列表设计数据
|
||||||
|
const listDesignData = ref<Record<string, any>>({});
|
||||||
|
|
||||||
|
// 发布配置数据(工作流模式)
|
||||||
|
const publishData = ref({
|
||||||
|
menu_name: '',
|
||||||
|
menu_parent_id: undefined as string | undefined,
|
||||||
|
menu_icon: 'lucide:file-text',
|
||||||
|
menu_order: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 步骤定义
|
||||||
|
const steps = computed(() => {
|
||||||
|
const baseSteps = [
|
||||||
|
{ title: $t('form-manager.editor.steps.basic'), index: 1 },
|
||||||
|
{ title: $t('form-manager.editor.steps.database'), index: 2 },
|
||||||
|
{ title: $t('form-manager.editor.steps.form'), index: 3 },
|
||||||
|
{ title: $t('form-manager.editor.steps.list'), index: 4 },
|
||||||
|
];
|
||||||
|
// 工作流模式下添加发布步骤
|
||||||
|
if (props.workflowMode) {
|
||||||
|
baseSteps.push({
|
||||||
|
title: $t('form-manager.editor.steps.publish'),
|
||||||
|
index: 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return baseSteps;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 是否可以进入下一步
|
||||||
|
const canGoNext = computed(() => {
|
||||||
|
if (currentStep.value === 0) {
|
||||||
|
return basicForm.value.name && basicForm.value.code;
|
||||||
|
}
|
||||||
|
if (currentStep.value === 1) {
|
||||||
|
const hasMainTable = tableConfigs.value.some((t) => t.type === 'main');
|
||||||
|
return hasMainTable;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canGoPrev = computed(() => currentStep.value > 0);
|
||||||
|
const isLastStep = computed(() => currentStep.value === steps.value.length - 1);
|
||||||
|
|
||||||
|
// 监听初始数据
|
||||||
|
watch(
|
||||||
|
() => props.initialBasicForm,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
basicForm.value = { ...val };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initialTableConfigs,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
tableConfigs.value = [...val];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initialListDesign,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
listDesignData.value = { ...val };
|
||||||
|
setTimeout(() => {
|
||||||
|
listDesignRef.value?.setData(val);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 根据数据库字段类型判断是否为数值类型
|
||||||
|
function isNumericDbType(dbType: string): boolean {
|
||||||
|
if (!dbType) return false;
|
||||||
|
const type = dbType.toLowerCase();
|
||||||
|
return (
|
||||||
|
type.includes('int') ||
|
||||||
|
type.includes('decimal') ||
|
||||||
|
type.includes('numeric') ||
|
||||||
|
type.includes('float') ||
|
||||||
|
type.includes('double') ||
|
||||||
|
type.includes('real') ||
|
||||||
|
type.includes('money')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 tableConfigs 中查找字段的数据库类型
|
||||||
|
function getFieldDbType(fieldName: string): string {
|
||||||
|
const mainTable = tableConfigs.value.find((t) => t.type === 'main');
|
||||||
|
if (mainTable) {
|
||||||
|
const field = mainTable.fields.find((f: any) => f.name === fieldName);
|
||||||
|
if (field) return field.type || '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归提取表单字段
|
||||||
|
function extractFields(items: any[]): any[] {
|
||||||
|
const layoutTypes = new Set([
|
||||||
|
'alert',
|
||||||
|
'collapse',
|
||||||
|
'divider',
|
||||||
|
'grid',
|
||||||
|
'html',
|
||||||
|
'spacer',
|
||||||
|
'steps',
|
||||||
|
'sub-table',
|
||||||
|
'tabs',
|
||||||
|
'text',
|
||||||
|
'timeline',
|
||||||
|
'title',
|
||||||
|
]);
|
||||||
|
|
||||||
|
let fields: any[] = [];
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (layoutTypes.has(item.type)) {
|
||||||
|
if (item.children) {
|
||||||
|
fields = fields.concat(extractFields(item.children));
|
||||||
|
}
|
||||||
|
if (item.columns) {
|
||||||
|
item.columns.forEach((col: any) => {
|
||||||
|
if (col.children) {
|
||||||
|
fields = fields.concat(extractFields(col.children));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (item.items) {
|
||||||
|
item.items.forEach((subItem: any) => {
|
||||||
|
if (subItem.children) {
|
||||||
|
fields = fields.concat(extractFields(subItem.children));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.field) {
|
||||||
|
const dbType = getFieldDbType(item.field);
|
||||||
|
fields.push({
|
||||||
|
label: item.label,
|
||||||
|
field: item.field,
|
||||||
|
component: item.type,
|
||||||
|
options: item.options,
|
||||||
|
props: item.props,
|
||||||
|
dbType,
|
||||||
|
isNumeric: isNumericDbType(dbType),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加系统字段
|
||||||
|
const mainTable = tableConfigs.value.find((t) => t.type === 'main');
|
||||||
|
if (mainTable) {
|
||||||
|
const systemFields = new Set([
|
||||||
|
'sys_create_datetime',
|
||||||
|
'sys_creator_id',
|
||||||
|
'sys_dept_id',
|
||||||
|
'sys_modifier_id',
|
||||||
|
'sys_update_datetime',
|
||||||
|
]);
|
||||||
|
const existingFieldNames = new Set(fields.map((f) => f.field));
|
||||||
|
|
||||||
|
mainTable.fields.forEach((dbField: any) => {
|
||||||
|
if (
|
||||||
|
systemFields.has(dbField.name) &&
|
||||||
|
!existingFieldNames.has(dbField.name)
|
||||||
|
) {
|
||||||
|
const dbType = dbField.type || '';
|
||||||
|
let component = 'input';
|
||||||
|
let fieldProps: any;
|
||||||
|
|
||||||
|
switch (dbField.name) {
|
||||||
|
case 'sys_create_datetime':
|
||||||
|
case 'sys_update_datetime': {
|
||||||
|
component = 'date-picker';
|
||||||
|
fieldProps = { type: 'datetimerange' };
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'sys_creator_id':
|
||||||
|
case 'sys_modifier_id': {
|
||||||
|
component = 'user-select';
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'sys_dept_id': {
|
||||||
|
component = 'dept-select';
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// No default
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
label: dbField.comment || dbField.name,
|
||||||
|
field: dbField.name,
|
||||||
|
component,
|
||||||
|
options: undefined,
|
||||||
|
props: fieldProps,
|
||||||
|
dbType,
|
||||||
|
isNumeric: isNumericDbType(dbType),
|
||||||
|
isSystemField: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationErrors = ref<ValidationError[]>([]);
|
||||||
|
const validationDialogVisible = ref(false);
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
if (currentStep.value < steps.value.length - 1) {
|
||||||
|
if (currentStep.value === 2) {
|
||||||
|
validationErrors.value = [];
|
||||||
|
const items = formDesignStore.formConf.items;
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
validationErrors.value = validateFormConfig(items, tableConfigs.value);
|
||||||
|
|
||||||
|
if (validationErrors.value.length > 0) {
|
||||||
|
validationDialogVisible.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
formFields.value = extractFields(items);
|
||||||
|
}
|
||||||
|
currentStep.value++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrev() {
|
||||||
|
if (currentStep.value > 0) {
|
||||||
|
currentStep.value--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前编辑器数据
|
||||||
|
function getData(): FormEditorData {
|
||||||
|
if (listDesignRef.value) {
|
||||||
|
listDesignData.value = listDesignRef.value.getData();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
basicForm: { ...basicForm.value },
|
||||||
|
tableConfigs: [...tableConfigs.value],
|
||||||
|
formConfig: {
|
||||||
|
items: formDesignStore.formConf.items,
|
||||||
|
labelWidth: formDesignStore.formConf.labelWidth,
|
||||||
|
labelPosition: formDesignStore.formConf.labelPosition as
|
||||||
|
| 'left'
|
||||||
|
| 'right'
|
||||||
|
| 'top',
|
||||||
|
size: formDesignStore.formConf.size as 'default' | 'large' | 'small',
|
||||||
|
formPadding: formDesignStore.formConf.formPadding,
|
||||||
|
formMargin: formDesignStore.formConf.formMargin,
|
||||||
|
itemSpacing: formDesignStore.formConf.itemSpacing,
|
||||||
|
formWidth: formDesignStore.formConf.formWidth,
|
||||||
|
formMaxWidth: Number(formDesignStore.formConf.formMaxWidth) || 0,
|
||||||
|
formBackground: Boolean(formDesignStore.formConf.formBackground),
|
||||||
|
formBorder: Boolean(formDesignStore.formConf.formBorder),
|
||||||
|
formBorderRadius: formDesignStore.formConf.formBorderRadius,
|
||||||
|
formShadow: Boolean(formDesignStore.formConf.formShadow),
|
||||||
|
disabled: Boolean(formDesignStore.formConf.disabled),
|
||||||
|
lifecycleHooks: formDesignStore.formConf.lifecycleHooks || [],
|
||||||
|
tableConfigs: [...tableConfigs.value],
|
||||||
|
},
|
||||||
|
listConfig: { ...listDesignData.value },
|
||||||
|
formFields: [...formFields.value],
|
||||||
|
publishData: { ...publishData.value },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置编辑器数据
|
||||||
|
function setData(data: Partial<FormEditorData>) {
|
||||||
|
if (data.basicForm) {
|
||||||
|
basicForm.value = { ...data.basicForm };
|
||||||
|
}
|
||||||
|
if (data.tableConfigs) {
|
||||||
|
tableConfigs.value = [...data.tableConfigs];
|
||||||
|
}
|
||||||
|
if (data.formConfig) {
|
||||||
|
if (data.formConfig.items) {
|
||||||
|
formDesignStore.formConf.items = data.formConfig.items;
|
||||||
|
}
|
||||||
|
if (data.formConfig.labelWidth !== undefined) {
|
||||||
|
formDesignStore.formConf.labelWidth = data.formConfig.labelWidth;
|
||||||
|
}
|
||||||
|
if (data.formConfig.labelPosition) {
|
||||||
|
formDesignStore.formConf.labelPosition = data.formConfig.labelPosition;
|
||||||
|
}
|
||||||
|
if (data.formConfig.size > 0) {
|
||||||
|
formDesignStore.formConf.size = data.formConfig.size;
|
||||||
|
}
|
||||||
|
if (data.formConfig.formPadding !== undefined) {
|
||||||
|
formDesignStore.formConf.formPadding = data.formConfig.formPadding;
|
||||||
|
}
|
||||||
|
if (data.formConfig.formMargin !== undefined) {
|
||||||
|
formDesignStore.formConf.formMargin = data.formConfig.formMargin;
|
||||||
|
}
|
||||||
|
if (data.formConfig.itemSpacing !== undefined) {
|
||||||
|
formDesignStore.formConf.itemSpacing = data.formConfig.itemSpacing;
|
||||||
|
}
|
||||||
|
if (data.formConfig.formWidth) {
|
||||||
|
formDesignStore.formConf.formWidth = data.formConfig.formWidth;
|
||||||
|
}
|
||||||
|
if (data.formConfig.formMaxWidth !== undefined) {
|
||||||
|
(formDesignStore.formConf as any).formMaxWidth =
|
||||||
|
data.formConfig.formMaxWidth;
|
||||||
|
}
|
||||||
|
if (data.formConfig.formBackground !== undefined) {
|
||||||
|
(formDesignStore.formConf as any).formBackground =
|
||||||
|
data.formConfig.formBackground;
|
||||||
|
}
|
||||||
|
if (data.formConfig.formBorder !== undefined) {
|
||||||
|
formDesignStore.formConf.formBorder = data.formConfig.formBorder;
|
||||||
|
}
|
||||||
|
if (data.formConfig.formBorderRadius !== undefined) {
|
||||||
|
formDesignStore.formConf.formBorderRadius =
|
||||||
|
data.formConfig.formBorderRadius;
|
||||||
|
}
|
||||||
|
if (data.formConfig.formShadow !== undefined) {
|
||||||
|
formDesignStore.formConf.formShadow = data.formConfig.formShadow;
|
||||||
|
}
|
||||||
|
if (data.formConfig.disabled !== undefined) {
|
||||||
|
formDesignStore.formConf.disabled = data.formConfig.disabled;
|
||||||
|
}
|
||||||
|
if (data.formConfig.lifecycleHooks) {
|
||||||
|
formDesignStore.formConf.lifecycleHooks = data.formConfig.lifecycleHooks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.listConfig) {
|
||||||
|
listDesignData.value = { ...data.listConfig };
|
||||||
|
setTimeout(() => {
|
||||||
|
listDesignRef.value?.setData(data.listConfig);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
if (data.formFields) {
|
||||||
|
formFields.value = [...data.formFields];
|
||||||
|
}
|
||||||
|
if (data.publishData) {
|
||||||
|
publishData.value = {
|
||||||
|
menu_name: data.publishData.menu_name || '',
|
||||||
|
menu_parent_id: data.publishData.menu_parent_id,
|
||||||
|
menu_icon: data.publishData.menu_icon || 'lucide:file-text',
|
||||||
|
menu_order: data.publishData.menu_order || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置编辑器
|
||||||
|
function reset() {
|
||||||
|
internalStep.value = 0;
|
||||||
|
basicForm.value = {
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
form_type: 'normal',
|
||||||
|
sort: 0,
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
tableConfigs.value = [];
|
||||||
|
formDesignStore.formConf.items = [];
|
||||||
|
formDesignStore.setActive(null);
|
||||||
|
listDesignData.value = {};
|
||||||
|
listDesignRef.value?.setData({});
|
||||||
|
formFields.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
emit('save', getData());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit('cancel');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转到指定步骤
|
||||||
|
function goToStep(step: number) {
|
||||||
|
if (step >= 0 && step < steps.value.length) {
|
||||||
|
currentStep.value = step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法
|
||||||
|
defineExpose({
|
||||||
|
getData,
|
||||||
|
setData,
|
||||||
|
reset,
|
||||||
|
goToStep,
|
||||||
|
currentStep,
|
||||||
|
basicForm,
|
||||||
|
tableConfigs,
|
||||||
|
formFields,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="form-editor-content flex h-full flex-col">
|
||||||
|
<!-- 步骤条(工作流模式下隐藏) -->
|
||||||
|
<div
|
||||||
|
v-if="showSteps && !workflowMode"
|
||||||
|
class="border-border bg-background flex-shrink-0 border-b px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<template v-for="(step, index) in steps" :key="index">
|
||||||
|
<div
|
||||||
|
class="flex cursor-pointer items-center px-3 py-1"
|
||||||
|
@click="index < currentStep ? (currentStep = index) : null"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center rounded-full border px-3 py-1 text-sm transition-all"
|
||||||
|
:class="[
|
||||||
|
index === currentStep
|
||||||
|
? 'bg-primary/10 border-primary text-primary font-medium'
|
||||||
|
: index < currentStep
|
||||||
|
? 'border-primary/50 text-primary/80 bg-transparent'
|
||||||
|
: 'border-border text-muted-foreground bg-transparent',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mr-2 flex h-5 w-5 items-center justify-center rounded-full text-xs"
|
||||||
|
:class="
|
||||||
|
index === currentStep
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: index < currentStep
|
||||||
|
? 'bg-primary/80 text-white'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ step.index }}
|
||||||
|
</span>
|
||||||
|
{{ step.title }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="index < steps.length - 1"
|
||||||
|
class="bg-border h-[1px] w-6"
|
||||||
|
:class="{ 'bg-primary/50': index < currentStep }"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤内容 -->
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<!-- 步骤1: 基础信息 -->
|
||||||
|
<div
|
||||||
|
v-show="currentStep === 0"
|
||||||
|
class="flex h-full items-center justify-center overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-[600px] px-4">
|
||||||
|
<div class="border-border bg-card rounded-lg border p-6 shadow-sm">
|
||||||
|
<h3
|
||||||
|
v-if="!workflowMode"
|
||||||
|
class="mb-6 text-center text-lg font-medium"
|
||||||
|
>
|
||||||
|
{{ $t('form-manager.editor.steps.basic') }}
|
||||||
|
</h3>
|
||||||
|
<ElForm
|
||||||
|
:model="basicForm"
|
||||||
|
label-width="100px"
|
||||||
|
label-position="right"
|
||||||
|
:disabled="readonly"
|
||||||
|
>
|
||||||
|
<ElFormItem :label="$t('form-manager.name')" required>
|
||||||
|
<ElInput
|
||||||
|
v-model="basicForm.name"
|
||||||
|
:placeholder="$t('form-manager.placeholder.name')"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="$t('form-manager.code')" required>
|
||||||
|
<ElInput
|
||||||
|
v-model="basicForm.code"
|
||||||
|
:placeholder="$t('form-manager.placeholder.code')"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="$t('form-manager.type')" required>
|
||||||
|
<ElSelect
|
||||||
|
v-model="basicForm.form_type"
|
||||||
|
:placeholder="$t('form-manager.placeholder.type')"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
:label="$t('form-manager.typeMap.normal')"
|
||||||
|
value="normal"
|
||||||
|
/>
|
||||||
|
<ElOption
|
||||||
|
:label="$t('form-manager.typeMap.workflow')"
|
||||||
|
value="workflow"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="$t('common.sort')">
|
||||||
|
<ElInput
|
||||||
|
v-model.number="basicForm.sort"
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="$t('form-manager.description')">
|
||||||
|
<ElInput
|
||||||
|
v-model="basicForm.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
:placeholder="$t('form-manager.editor.placeholder.remark')"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤2: 数据库设计 -->
|
||||||
|
<div v-show="currentStep === 1" class="h-full overflow-hidden p-4">
|
||||||
|
<div class="bg-card h-full w-full overflow-hidden rounded-lg shadow-sm">
|
||||||
|
<DataSourceConfig
|
||||||
|
v-model="tableConfigs"
|
||||||
|
:readonly="readonly"
|
||||||
|
:workflow-mode="workflowMode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤3: 表单设计 -->
|
||||||
|
<div v-show="currentStep === 2" class="h-full overflow-hidden">
|
||||||
|
<FormDesign
|
||||||
|
:data-source="tableConfigs"
|
||||||
|
:form-code="basicForm.code"
|
||||||
|
:form-db-config="tableConfigs.find((t) => t.type === 'main')?.meta?.dbName"
|
||||||
|
:readonly="readonly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤4: 列表设计 -->
|
||||||
|
<div
|
||||||
|
v-show="currentStep === 3"
|
||||||
|
class="flex h-full items-start justify-center overflow-hidden"
|
||||||
|
>
|
||||||
|
<ListDesign
|
||||||
|
ref="listDesignRef"
|
||||||
|
:form-fields="formFields"
|
||||||
|
:readonly="readonly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤5: 发布表单(仅工作流模式) -->
|
||||||
|
<div
|
||||||
|
v-if="workflowMode"
|
||||||
|
v-show="currentStep === 4"
|
||||||
|
class="flex h-full items-center justify-center overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-[600px] px-4">
|
||||||
|
<div class="border-border bg-card rounded-lg border p-6 shadow-sm">
|
||||||
|
<h3 class="mb-6 text-center text-lg font-medium">
|
||||||
|
{{ $t('form-manager.editor.steps.publish') }}
|
||||||
|
</h3>
|
||||||
|
<PublishInfoEditor
|
||||||
|
v-model="publishData"
|
||||||
|
:form-code="basicForm.code"
|
||||||
|
:show-route-info="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div
|
||||||
|
v-if="showActions"
|
||||||
|
class="border-border bg-background flex-shrink-0 border-t px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<ElButton v-if="canGoPrev" @click="handlePrev">
|
||||||
|
{{ $t('common.prev') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
v-if="!isLastStep"
|
||||||
|
type="primary"
|
||||||
|
:disabled="!canGoNext"
|
||||||
|
@click="handleNext"
|
||||||
|
>
|
||||||
|
{{ $t('common.next') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton v-if="isLastStep" type="primary" @click="handleSave">
|
||||||
|
{{ $t('common.save') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton @click="handleCancel">{{ $t('common.cancel') }}</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 校验错误报告弹窗 -->
|
||||||
|
<ElDialog
|
||||||
|
v-model="validationDialogVisible"
|
||||||
|
:title="$t('form-manager.editor.validate.title')"
|
||||||
|
width="500px"
|
||||||
|
:show-close="true"
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 rounded bg-red-50 p-3 text-red-600 dark:bg-red-900/20 dark:text-red-400"
|
||||||
|
>
|
||||||
|
<ElIcon class="text-xl"><Warning /></ElIcon>
|
||||||
|
<span class="text-sm font-medium">{{
|
||||||
|
$t('form-manager.editor.validate.repairTip')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-border rounded border p-1">
|
||||||
|
<ElScrollbar max-height="400px">
|
||||||
|
<div
|
||||||
|
v-for="(error, index) in validationErrors"
|
||||||
|
:key="index"
|
||||||
|
class="border-border/50 hover:bg-muted/50 flex items-start gap-3 border-b p-3 last:border-0"
|
||||||
|
>
|
||||||
|
<ElIcon
|
||||||
|
class="mt-0.5 text-lg"
|
||||||
|
:class="
|
||||||
|
error.type === 'error' ? 'text-red-500' : 'text-yellow-500'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<CircleClose v-if="error.type === 'error'" />
|
||||||
|
<Warning v-else />
|
||||||
|
</ElIcon>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-foreground text-sm font-medium">{{
|
||||||
|
error.component
|
||||||
|
}}</span>
|
||||||
|
<span class="text-muted-foreground text-xs">{{
|
||||||
|
error.message
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<ElButton @click="validationDialogVisible = false">
|
||||||
|
{{ $t('common.confirm') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-editor-content {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { ElButton, ElDialog, ElInput } from 'element-plus';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
basicInfo?: Record<string, any>;
|
||||||
|
data?: Record<string, any>;
|
||||||
|
design?: Record<string, any>;
|
||||||
|
publishData?: Record<string, any>;
|
||||||
|
settings?: Record<string, any>;
|
||||||
|
visible?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
(e: 'confirm', value: Record<string, any>): void;
|
||||||
|
(e: 'update:visible', value: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const localVisible = computed({
|
||||||
|
get: () => !!props.visible,
|
||||||
|
set: (value) => emit('update:visible', value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = computed(
|
||||||
|
() =>
|
||||||
|
props.design ||
|
||||||
|
props.settings ||
|
||||||
|
props.basicInfo ||
|
||||||
|
props.publishData ||
|
||||||
|
props.data ||
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = computed(
|
||||||
|
() =>
|
||||||
|
payload.value.title ||
|
||||||
|
payload.value.name ||
|
||||||
|
payload.value.type ||
|
||||||
|
'AI 设计结果',
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = ref('');
|
||||||
|
|
||||||
|
watch(
|
||||||
|
payload,
|
||||||
|
(value) => {
|
||||||
|
text.value = JSON.stringify(value, null, 2);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function parseValue() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text.value || '{}');
|
||||||
|
} catch {
|
||||||
|
return { content: text.value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit('update:visible', false);
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
emit('confirm', parseValue());
|
||||||
|
emit('update:visible', false);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElDialog
|
||||||
|
v-model="localVisible"
|
||||||
|
:title="String(title)"
|
||||||
|
width="720px"
|
||||||
|
append-to-body
|
||||||
|
destroy-on-close
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<ElInput
|
||||||
|
v-model="text"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 14, maxRows: 24 }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<ElButton @click="handleClose">取消</ElButton>
|
||||||
|
<ElButton type="primary" @click="handleConfirm">确认</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 页面基础信息编辑器
|
||||||
|
* 供 page-manager 和工作流节点共用
|
||||||
|
*/
|
||||||
|
import { watch } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElInput,
|
||||||
|
ElInputNumber,
|
||||||
|
ElOption,
|
||||||
|
ElSelect,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
export interface PageBasicInfo {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
sort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: PageBasicInfo;
|
||||||
|
isEditMode?: boolean;
|
||||||
|
showTitle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
isEditMode: false,
|
||||||
|
showTitle: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: PageBasicInfo];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 更新表单数据
|
||||||
|
function updateFormData(key: keyof PageBasicInfo, value: any) {
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
[key]: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分类选项
|
||||||
|
const categoryOptions = [
|
||||||
|
{ label: $t('page-manager.categoryMap.dashboard'), value: 'dashboard' },
|
||||||
|
{ label: $t('page-manager.categoryMap.portal'), value: 'portal' },
|
||||||
|
{ label: $t('page-manager.categoryMap.databoard'), value: 'databoard' },
|
||||||
|
{ label: $t('page-manager.categoryMap.other'), value: 'other' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 监听 modelValue 变化
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
() => {},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h3 v-if="showTitle" class="mb-6 text-center text-lg font-medium">
|
||||||
|
{{ $t('page-manager.editor.steps.basic') }}
|
||||||
|
</h3>
|
||||||
|
<ElForm :model="modelValue" label-width="100px" label-position="right">
|
||||||
|
<ElFormItem :label="$t('page-manager.name')" required>
|
||||||
|
<ElInput
|
||||||
|
:model-value="modelValue.name"
|
||||||
|
:placeholder="$t('page-manager.placeholder.name')"
|
||||||
|
clearable
|
||||||
|
@update:model-value="updateFormData('name', $event)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('page-manager.code')" required>
|
||||||
|
<ElInput
|
||||||
|
:model-value="modelValue.code"
|
||||||
|
:placeholder="$t('page-manager.placeholder.code')"
|
||||||
|
clearable
|
||||||
|
:disabled="isEditMode"
|
||||||
|
@update:model-value="updateFormData('code', $event)"
|
||||||
|
/>
|
||||||
|
<template #error>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
modelValue.code &&
|
||||||
|
!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(modelValue.code)
|
||||||
|
"
|
||||||
|
class="el-form-item__error"
|
||||||
|
>
|
||||||
|
{{ $t('page-manager.codeFormatError') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('page-manager.category')">
|
||||||
|
<ElSelect
|
||||||
|
:model-value="modelValue.category"
|
||||||
|
:placeholder="$t('page-manager.placeholder.category')"
|
||||||
|
class="w-full"
|
||||||
|
clearable
|
||||||
|
@update:model-value="updateFormData('category', $event)"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="opt in categoryOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('common.sort')">
|
||||||
|
<ElInputNumber
|
||||||
|
:model-value="modelValue.sort"
|
||||||
|
:min="0"
|
||||||
|
:max="9999"
|
||||||
|
class="w-full"
|
||||||
|
@update:model-value="updateFormData('sort', $event ?? 0)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('page-manager.description')">
|
||||||
|
<ElInput
|
||||||
|
:model-value="modelValue.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
:placeholder="$t('page-manager.placeholder.description')"
|
||||||
|
@update:model-value="updateFormData('description', $event)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 页面编辑器内容组件
|
||||||
|
* 包含步骤条和所有步骤内容,可用于 Modal 或工作流节点确认面板
|
||||||
|
* 步骤:1.基础信息 -> 2.页面设计 -> 3.发布(工作流模式)
|
||||||
|
*/
|
||||||
|
import type { PageBasicInfo } from './PageBasicInfoEditor.vue';
|
||||||
|
import type { PagePublishInfo } from './PagePublishInfoEditor.vue';
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElButton } from 'element-plus';
|
||||||
|
|
||||||
|
import DashboardDesign from '#/components/dashboard-design/index.vue';
|
||||||
|
import { useDashboardDesignStore } from '#/components/dashboard-design/store/dashboardDesignStore';
|
||||||
|
|
||||||
|
import PageBasicInfoEditor from './PageBasicInfoEditor.vue';
|
||||||
|
import PagePublishInfoEditor from './PagePublishInfoEditor.vue';
|
||||||
|
|
||||||
|
// 导出的数据类型
|
||||||
|
export interface PageEditorData {
|
||||||
|
basicForm: PageBasicInfo;
|
||||||
|
pageConfig: Record<string, any>;
|
||||||
|
publishData?: PagePublishInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件属性
|
||||||
|
interface Props {
|
||||||
|
// 当前步骤(0-2),外部控制时使用
|
||||||
|
step?: number;
|
||||||
|
// 是否显示步骤条
|
||||||
|
showSteps?: boolean;
|
||||||
|
// 是否显示操作按钮
|
||||||
|
showActions?: boolean;
|
||||||
|
// 是否为只读模式
|
||||||
|
readonly?: boolean;
|
||||||
|
// 工作流模式:显示发布步骤
|
||||||
|
workflowMode?: boolean;
|
||||||
|
// 是否为编辑模式(编码不可修改)
|
||||||
|
isEditMode?: boolean;
|
||||||
|
// 初始基础信息
|
||||||
|
initialBasicForm?: PageBasicInfo;
|
||||||
|
// 初始页面配置
|
||||||
|
initialPageConfig?: Record<string, any>;
|
||||||
|
// 初始发布数据
|
||||||
|
initialPublishData?: PagePublishInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
step: undefined,
|
||||||
|
showSteps: true,
|
||||||
|
showActions: true,
|
||||||
|
readonly: false,
|
||||||
|
workflowMode: false,
|
||||||
|
isEditMode: false,
|
||||||
|
initialBasicForm: undefined,
|
||||||
|
initialPageConfig: undefined,
|
||||||
|
initialPublishData: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
cancel: [];
|
||||||
|
save: [data: PageEditorData];
|
||||||
|
'step-change': [step: number];
|
||||||
|
'update:step': [step: number];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const dashboardDesignStore = useDashboardDesignStore();
|
||||||
|
|
||||||
|
// 当前步骤(内部状态)
|
||||||
|
const internalStep = ref(0);
|
||||||
|
|
||||||
|
// 计算当前步骤(支持外部控制)
|
||||||
|
const currentStep = computed({
|
||||||
|
get: () => (props.step === undefined ? internalStep.value : props.step),
|
||||||
|
set: (val) => {
|
||||||
|
if (props.step === undefined) {
|
||||||
|
internalStep.value = val;
|
||||||
|
} else {
|
||||||
|
emit('update:step', val);
|
||||||
|
}
|
||||||
|
emit('step-change', val);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 基础信息表单
|
||||||
|
const basicForm = ref<PageBasicInfo>({
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
category: 'dashboard',
|
||||||
|
description: '',
|
||||||
|
sort: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发布配置数据(工作流模式)
|
||||||
|
const publishData = ref<PagePublishInfo>({
|
||||||
|
menu_name: '',
|
||||||
|
menu_parent_id: undefined,
|
||||||
|
menu_icon: 'lucide:layout-dashboard',
|
||||||
|
menu_order: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 步骤定义
|
||||||
|
const steps = computed(() => {
|
||||||
|
const baseSteps = [
|
||||||
|
{ title: $t('page-manager.editor.steps.basic'), index: 1 },
|
||||||
|
{ title: $t('page-manager.editor.steps.design'), index: 2 },
|
||||||
|
];
|
||||||
|
// 工作流模式下添加发布步骤
|
||||||
|
if (props.workflowMode) {
|
||||||
|
baseSteps.push({
|
||||||
|
title: $t('page-manager.editor.steps.publish'),
|
||||||
|
index: 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return baseSteps;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 是否可以进入下一步
|
||||||
|
const canGoNext = computed(() => {
|
||||||
|
if (currentStep.value === 0) {
|
||||||
|
return basicForm.value.name && basicForm.value.code;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canGoPrev = computed(() => currentStep.value > 0);
|
||||||
|
const isLastStep = computed(() => currentStep.value === steps.value.length - 1);
|
||||||
|
|
||||||
|
// 监听初始数据
|
||||||
|
watch(
|
||||||
|
() => props.initialBasicForm,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
basicForm.value = { ...val };
|
||||||
|
// 同步菜单名称
|
||||||
|
if (props.workflowMode && !publishData.value.menu_name) {
|
||||||
|
publishData.value.menu_name = val.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initialPageConfig,
|
||||||
|
(val) => {
|
||||||
|
if (val && Object.keys(val).length > 0) {
|
||||||
|
dashboardDesignStore.importConfig(JSON.stringify(val));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initialPublishData,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
publishData.value = { ...val };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听基础信息变化,同步菜单名称
|
||||||
|
watch(
|
||||||
|
() => basicForm.value.name,
|
||||||
|
(name) => {
|
||||||
|
if (props.workflowMode && name && !publishData.value.menu_name) {
|
||||||
|
publishData.value.menu_name = name;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 上一步
|
||||||
|
function handlePrev() {
|
||||||
|
if (currentStep.value > 0) {
|
||||||
|
currentStep.value--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下一步
|
||||||
|
function handleNext() {
|
||||||
|
if (currentStep.value < steps.value.length - 1 && canGoNext.value) {
|
||||||
|
currentStep.value++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存
|
||||||
|
function handleSave() {
|
||||||
|
const pageConfig = JSON.parse(dashboardDesignStore.exportConfig());
|
||||||
|
|
||||||
|
const data: PageEditorData = {
|
||||||
|
basicForm: { ...basicForm.value },
|
||||||
|
pageConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (props.workflowMode) {
|
||||||
|
data.publishData = { ...publishData.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('save', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消
|
||||||
|
function handleCancel() {
|
||||||
|
emit('cancel');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前数据(供外部调用)
|
||||||
|
function getData(): PageEditorData {
|
||||||
|
const pageConfig = JSON.parse(dashboardDesignStore.exportConfig());
|
||||||
|
|
||||||
|
const data: PageEditorData = {
|
||||||
|
basicForm: { ...basicForm.value },
|
||||||
|
pageConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (props.workflowMode) {
|
||||||
|
data.publishData = { ...publishData.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置数据(供外部调用)
|
||||||
|
function setData(data: Partial<PageEditorData>) {
|
||||||
|
if (data.basicForm) {
|
||||||
|
basicForm.value = { ...data.basicForm };
|
||||||
|
}
|
||||||
|
if (data.pageConfig && Object.keys(data.pageConfig).length > 0) {
|
||||||
|
dashboardDesignStore.importConfig(JSON.stringify(data.pageConfig));
|
||||||
|
}
|
||||||
|
if (data.publishData) {
|
||||||
|
publishData.value = { ...data.publishData };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
function reset() {
|
||||||
|
basicForm.value = {
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
category: 'dashboard',
|
||||||
|
description: '',
|
||||||
|
sort: 0,
|
||||||
|
};
|
||||||
|
publishData.value = {
|
||||||
|
menu_name: '',
|
||||||
|
menu_parent_id: undefined,
|
||||||
|
menu_icon: 'lucide:layout-dashboard',
|
||||||
|
menu_order: 0,
|
||||||
|
};
|
||||||
|
dashboardDesignStore.clearCanvas();
|
||||||
|
currentStep.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法
|
||||||
|
defineExpose({
|
||||||
|
getData,
|
||||||
|
setData,
|
||||||
|
reset,
|
||||||
|
currentStep,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-editor-content flex h-full flex-col">
|
||||||
|
<!-- 步骤条 -->
|
||||||
|
<div
|
||||||
|
v-if="showSteps"
|
||||||
|
class="border-border bg-background flex-shrink-0 border-b px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<template v-for="(step, index) in steps" :key="step.index">
|
||||||
|
<div
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded-full px-3 py-1.5 text-sm transition-colors"
|
||||||
|
:class="{
|
||||||
|
'bg-primary text-primary-foreground': index === currentStep,
|
||||||
|
'text-muted-foreground hover:text-foreground':
|
||||||
|
index !== currentStep,
|
||||||
|
}"
|
||||||
|
@click="index <= currentStep ? (currentStep = index) : null"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-5 w-5 items-center justify-center rounded-full text-xs"
|
||||||
|
:class="{
|
||||||
|
'bg-primary-foreground text-primary': index === currentStep,
|
||||||
|
'bg-muted': index !== currentStep,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ step.index }}
|
||||||
|
</span>
|
||||||
|
{{ step.title }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="index < steps.length - 1"
|
||||||
|
class="bg-border h-[1px] w-6"
|
||||||
|
:class="{ 'bg-primary/50': index < currentStep }"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤内容 -->
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<!-- 步骤1: 基础信息 -->
|
||||||
|
<div
|
||||||
|
v-show="currentStep === 0"
|
||||||
|
class="flex h-full items-center justify-center overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-[600px] px-4">
|
||||||
|
<div class="border-border bg-card rounded-lg border p-6 shadow-sm">
|
||||||
|
<PageBasicInfoEditor
|
||||||
|
v-model="basicForm"
|
||||||
|
:is-edit-mode="isEditMode"
|
||||||
|
:show-title="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤2: 页面设计 -->
|
||||||
|
<div v-show="currentStep === 1" class="h-full overflow-hidden">
|
||||||
|
<DashboardDesign :readonly="readonly" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤3: 发布(仅工作流模式) -->
|
||||||
|
<div
|
||||||
|
v-if="workflowMode"
|
||||||
|
v-show="currentStep === 2"
|
||||||
|
class="flex h-full items-center justify-center overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-[600px] px-4">
|
||||||
|
<div class="border-border bg-card rounded-lg border p-6 shadow-sm">
|
||||||
|
<h3 class="mb-6 text-center text-lg font-medium">
|
||||||
|
{{ $t('page-manager.editor.steps.publish') }}
|
||||||
|
</h3>
|
||||||
|
<PagePublishInfoEditor
|
||||||
|
v-model="publishData"
|
||||||
|
:page-code="basicForm.code"
|
||||||
|
:show-route-info="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div
|
||||||
|
v-if="showActions"
|
||||||
|
class="border-border bg-background flex-shrink-0 border-t px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<ElButton v-if="canGoPrev" @click="handlePrev">
|
||||||
|
{{ $t('common.prev') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
v-if="!isLastStep"
|
||||||
|
type="primary"
|
||||||
|
:disabled="!canGoNext"
|
||||||
|
@click="handleNext"
|
||||||
|
>
|
||||||
|
{{ $t('common.next') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton v-if="isLastStep" type="primary" @click="handleSave">
|
||||||
|
{{ $t('common.save') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton @click="handleCancel">{{ $t('common.cancel') }}</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-editor-content {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 页面发布信息编辑器
|
||||||
|
* 供 page-manager 和工作流节点共用
|
||||||
|
*/
|
||||||
|
import type { MenuItem } from '#/components/zq-form/zq-menu-selector/types';
|
||||||
|
|
||||||
|
import { watch } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElForm, ElFormItem, ElInput, ElInputNumber } from 'element-plus';
|
||||||
|
|
||||||
|
import { ZqIconPicker } from '#/components/zq-form/zq-icon-picker';
|
||||||
|
import { ZqMenuSelector } from '#/components/zq-form/zq-menu-selector';
|
||||||
|
import { useAppContextStore } from '#/store/app-context';
|
||||||
|
|
||||||
|
export interface PagePublishInfo {
|
||||||
|
menu_name: string;
|
||||||
|
menu_parent_id?: string;
|
||||||
|
menu_icon: string;
|
||||||
|
menu_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: PagePublishInfo;
|
||||||
|
pageCode?: string;
|
||||||
|
showRouteInfo?: boolean;
|
||||||
|
routePrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
pageCode: '',
|
||||||
|
showRouteInfo: true,
|
||||||
|
routePrefix: '/page/',
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: PagePublishInfo];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const appContextStore = useAppContextStore();
|
||||||
|
|
||||||
|
// 更新表单数据
|
||||||
|
function updateFormData(key: keyof PagePublishInfo, value: any) {
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
[key]: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 菜单选择回调
|
||||||
|
function handleMenuChange(menu: MenuItem | MenuItem[] | null) {
|
||||||
|
if (menu && !Array.isArray(menu)) {
|
||||||
|
updateFormData('menu_parent_id', menu.id);
|
||||||
|
} else {
|
||||||
|
updateFormData('menu_parent_id', undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听 modelValue 变化
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
() => {},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElForm :model="modelValue" label-width="100px">
|
||||||
|
<!-- 菜单配置 -->
|
||||||
|
<div class="mb-4 font-medium">
|
||||||
|
{{ $t('page-manager.publishDialog.menuConfig') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('page-manager.publishDialog.menuName')" required>
|
||||||
|
<ElInput
|
||||||
|
:model-value="modelValue.menu_name"
|
||||||
|
:placeholder="$t('page-manager.placeholder.name')"
|
||||||
|
@update:model-value="updateFormData('menu_name', $event)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('page-manager.publishDialog.parentMenu')">
|
||||||
|
<ZqMenuSelector
|
||||||
|
:model-value="modelValue.menu_parent_id || null"
|
||||||
|
mode="dialog"
|
||||||
|
:placeholder="$t('page-manager.publishDialog.parentMenuPlaceholder')"
|
||||||
|
:application-id="appContextStore.currentApp?.id"
|
||||||
|
@change="handleMenuChange"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('page-manager.publishDialog.menuIcon')">
|
||||||
|
<ZqIconPicker
|
||||||
|
:model-value="modelValue.menu_icon"
|
||||||
|
prefix="lucide"
|
||||||
|
:auto-fetch-api="false"
|
||||||
|
class="w-full"
|
||||||
|
@update:model-value="updateFormData('menu_icon', $event)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('common.sort')">
|
||||||
|
<ElInputNumber
|
||||||
|
:model-value="modelValue.menu_order"
|
||||||
|
:min="0"
|
||||||
|
:max="9999"
|
||||||
|
class="w-full"
|
||||||
|
@update:model-value="updateFormData('menu_order', $event ?? 0)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<!-- 路由信息 -->
|
||||||
|
<template v-if="showRouteInfo && pageCode">
|
||||||
|
<div class="mb-4 mt-6 font-medium">
|
||||||
|
{{ $t('page-manager.publishDialog.routeInfo') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('page-manager.publishDialog.accessPath')">
|
||||||
|
<ElInput
|
||||||
|
:model-value="`${routePrefix}${pageCode}`"
|
||||||
|
disabled
|
||||||
|
class="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</ElForm>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { FormPublishInput } from '#/api/online-dev/form-manager';
|
||||||
|
import type { MenuItem } from '#/components/zq-form/zq-menu-selector/types';
|
||||||
|
|
||||||
|
import { watch } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElForm, ElFormItem, ElInput, ElInputNumber } from 'element-plus';
|
||||||
|
|
||||||
|
import { ZqIconPicker } from '#/components/zq-form/zq-icon-picker';
|
||||||
|
import { ZqMenuSelector } from '#/components/zq-form/zq-menu-selector';
|
||||||
|
import { useAppContextStore } from '#/store/app-context';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: FormPublishInput;
|
||||||
|
formCode?: string;
|
||||||
|
showRouteInfo?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
formCode: '',
|
||||||
|
showRouteInfo: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: FormPublishInput];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const appContextStore = useAppContextStore();
|
||||||
|
|
||||||
|
// 更新表单数据
|
||||||
|
function updateFormData(key: keyof FormPublishInput, value: any) {
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
[key]: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 菜单选择回调
|
||||||
|
function handleMenuChange(menu: MenuItem | MenuItem[] | null) {
|
||||||
|
if (menu && !Array.isArray(menu)) {
|
||||||
|
updateFormData('menu_parent_id', menu.id);
|
||||||
|
} else {
|
||||||
|
updateFormData('menu_parent_id', undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听 modelValue 变化,确保响应式
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
() => {},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElForm :model="modelValue" label-width="100px">
|
||||||
|
<!-- 菜单配置 -->
|
||||||
|
<div class="mb-4 font-medium">
|
||||||
|
{{ $t('form-manager.publishDialog.menuConfig') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('form-manager.publishDialog.menuName')" required>
|
||||||
|
<ElInput
|
||||||
|
:model-value="modelValue.menu_name"
|
||||||
|
:placeholder="$t('form-manager.placeholder.name')"
|
||||||
|
@update:model-value="updateFormData('menu_name', $event)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('form-manager.publishDialog.parentMenu')">
|
||||||
|
<ZqMenuSelector
|
||||||
|
:model-value="modelValue.menu_parent_id || null"
|
||||||
|
mode="dialog"
|
||||||
|
:placeholder="$t('form-manager.publishDialog.parentMenuPlaceholder')"
|
||||||
|
:application-id="appContextStore.currentApp?.id"
|
||||||
|
@change="handleMenuChange"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('form-manager.publishDialog.menuIcon')">
|
||||||
|
<ZqIconPicker
|
||||||
|
:model-value="modelValue.menu_icon"
|
||||||
|
prefix="lucide"
|
||||||
|
:auto-fetch-api="false"
|
||||||
|
class="w-full"
|
||||||
|
@update:model-value="updateFormData('menu_icon', $event)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('common.sort')">
|
||||||
|
<ElInputNumber
|
||||||
|
:model-value="modelValue.menu_order"
|
||||||
|
:min="0"
|
||||||
|
:max="999"
|
||||||
|
class="w-full"
|
||||||
|
@update:model-value="updateFormData('menu_order', $event)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<!-- 路由信息 -->
|
||||||
|
<template v-if="showRouteInfo && formCode">
|
||||||
|
<div class="mb-4 mt-6 font-medium">
|
||||||
|
{{ $t('form-manager.publishDialog.routeInfo') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('form-manager.publishDialog.accessPath')">
|
||||||
|
<ElInput
|
||||||
|
:model-value="`/form-render/${formCode}`"
|
||||||
|
disabled
|
||||||
|
class="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
</ElForm>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AppWindow,
|
||||||
|
CircleCheck,
|
||||||
|
ExternalLink,
|
||||||
|
FileText,
|
||||||
|
LayoutDashboard,
|
||||||
|
Maximize,
|
||||||
|
Minimize,
|
||||||
|
} from '@vben/icons';
|
||||||
|
import { useI18n } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElButton, ElCard, ElEmpty } from 'element-plus';
|
||||||
|
|
||||||
|
export interface SystemSummaryData {
|
||||||
|
type: 'system_summary';
|
||||||
|
title: string;
|
||||||
|
data: {
|
||||||
|
app?: {
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
id: string;
|
||||||
|
link: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
base_url?: string;
|
||||||
|
created_at: string;
|
||||||
|
dashboard?: {
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
id: string;
|
||||||
|
link: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
forms: Array<{
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
id: string;
|
||||||
|
link: string;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
statistics?: {
|
||||||
|
forms_count: number;
|
||||||
|
has_app: boolean;
|
||||||
|
has_dashboard: boolean;
|
||||||
|
total_modules: number;
|
||||||
|
};
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
nodeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data?: SystemSummaryData;
|
||||||
|
visible: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
confirm: [data: any];
|
||||||
|
'update:visible': [value: boolean];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// 全屏状态
|
||||||
|
const isFullscreen = ref(false);
|
||||||
|
|
||||||
|
// 切换全屏
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
isFullscreen.value = !isFullscreen.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogVisible = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (val) => emit('update:visible', val),
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryData = computed(() => props.data?.data || ({} as any));
|
||||||
|
const statistics = computed(() => summaryData.value.statistics);
|
||||||
|
const app = computed(() => summaryData.value.app);
|
||||||
|
const forms = computed(() => summaryData.value.forms || []);
|
||||||
|
const dashboard = computed(() => summaryData.value.dashboard);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
emit('confirm', summaryData.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
dialogVisible.value = false;
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const openLink = (link: string) => {
|
||||||
|
if (link) {
|
||||||
|
const baseUrl = summaryData.value.base_url || '';
|
||||||
|
const fullUrl = baseUrl ? `${baseUrl}${link}` : link;
|
||||||
|
window.open(fullUrl, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="border-border bg-card flex flex-col rounded-lg"
|
||||||
|
:class="[isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full']"
|
||||||
|
>
|
||||||
|
<!-- 头部 -->
|
||||||
|
<div
|
||||||
|
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="text-foreground font-medium">
|
||||||
|
{{ data?.title || t('ai.systemSummary.title') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElButton size="small" @click="handleClose">
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton size="small" type="primary" @click="handleConfirm">
|
||||||
|
<CircleCheck class="mr-1 size-4" />
|
||||||
|
{{ t('ai.systemSummary.complete') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
:icon="isFullscreen ? Minimize : Maximize"
|
||||||
|
:title="isFullscreen ? '退出全屏' : '全屏'"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容 -->
|
||||||
|
<div class="flex-1 overflow-auto p-6">
|
||||||
|
<div
|
||||||
|
class="mb-6 flex items-center gap-3 border-b border-green-200 pb-4 dark:border-green-800"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex size-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900"
|
||||||
|
>
|
||||||
|
<CircleCheck class="size-6 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-green-700 dark:text-green-300">
|
||||||
|
{{ summaryData.title || t('ai.systemSummary.title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('ai.systemSummary.subtitle') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="statistics" class="mb-6 grid grid-cols-3 gap-4">
|
||||||
|
<div
|
||||||
|
class="rounded-lg border bg-blue-50 p-4 text-center dark:bg-blue-950"
|
||||||
|
>
|
||||||
|
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{{ statistics.has_app ? 1 : 0 }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground mt-1 text-sm">
|
||||||
|
{{ t('ai.systemSummary.app') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-lg border bg-purple-50 p-4 text-center dark:bg-purple-950"
|
||||||
|
>
|
||||||
|
<div class="text-3xl font-bold text-purple-600 dark:text-purple-400">
|
||||||
|
{{ statistics.forms_count }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground mt-1 text-sm">
|
||||||
|
{{ t('ai.systemSummary.formModules') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-lg border bg-orange-50 p-4 text-center dark:bg-orange-950"
|
||||||
|
>
|
||||||
|
<div class="text-3xl font-bold text-orange-600 dark:text-orange-400">
|
||||||
|
{{ statistics.has_dashboard ? 1 : 0 }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground mt-1 text-sm">
|
||||||
|
{{ t('ai.systemSummary.dashboard') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="app" class="mb-6">
|
||||||
|
<h3 class="mb-3 flex items-center gap-2 font-medium">
|
||||||
|
<AppWindow class="size-5 text-blue-500" />
|
||||||
|
{{ t('ai.systemSummary.appInfo') }}
|
||||||
|
</h3>
|
||||||
|
<ElCard
|
||||||
|
shadow="hover"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="openLink(app.link)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex size-10 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900"
|
||||||
|
>
|
||||||
|
<AppWindow class="size-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ app.name }}</div>
|
||||||
|
<div class="text-muted-foreground text-sm">
|
||||||
|
{{ app.description || app.code }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElButton
|
||||||
|
v-if="app.link"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click.stop="openLink(app.link)"
|
||||||
|
>
|
||||||
|
<ExternalLink class="mr-1 size-4" />
|
||||||
|
{{ t('ai.systemSummary.visit') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="forms.length > 0" class="mb-6">
|
||||||
|
<h3 class="mb-3 flex items-center gap-2 font-medium">
|
||||||
|
<FileText class="size-5 text-purple-500" />
|
||||||
|
{{ t('ai.systemSummary.formModulesTitle') }}
|
||||||
|
<span class="text-muted-foreground text-sm">({{ forms.length }})</span>
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<ElCard
|
||||||
|
v-for="form in forms"
|
||||||
|
:key="form.id || form.code"
|
||||||
|
shadow="hover"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="openLink(form.link)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex size-10 items-center justify-center rounded-lg bg-purple-100 dark:bg-purple-900"
|
||||||
|
>
|
||||||
|
<FileText
|
||||||
|
class="size-5 text-purple-600 dark:text-purple-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ form.name }}</div>
|
||||||
|
<div class="text-muted-foreground text-sm">
|
||||||
|
{{ form.description || form.code }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElButton
|
||||||
|
v-if="form.link"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click.stop="openLink(form.link)"
|
||||||
|
>
|
||||||
|
<ExternalLink class="mr-1 size-4" />
|
||||||
|
{{ t('ai.systemSummary.visit') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="dashboard" class="mb-6">
|
||||||
|
<h3 class="mb-3 flex items-center gap-2 font-medium">
|
||||||
|
<LayoutDashboard class="size-5 text-orange-500" />
|
||||||
|
{{ t('ai.systemSummary.dashboardTitle') }}
|
||||||
|
</h3>
|
||||||
|
<ElCard
|
||||||
|
shadow="hover"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="openLink(dashboard.link)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex size-10 items-center justify-center rounded-lg bg-orange-100 dark:bg-orange-900"
|
||||||
|
>
|
||||||
|
<LayoutDashboard
|
||||||
|
class="size-5 text-orange-600 dark:text-orange-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ dashboard.name }}</div>
|
||||||
|
<div class="text-muted-foreground text-sm">
|
||||||
|
{{ dashboard.description || dashboard.code }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElButton
|
||||||
|
v-if="dashboard.link"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click.stop="openLink(dashboard.link)"
|
||||||
|
>
|
||||||
|
<ExternalLink class="mr-1 size-4" />
|
||||||
|
{{ t('ai.systemSummary.visit') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElEmpty
|
||||||
|
v-if="!app && forms.length === 0 && !dashboard"
|
||||||
|
:description="t('ai.systemSummary.noData')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export { default as AppDesignPanel } from './LightConfirmPanel.vue';
|
||||||
|
export { default as AppSettingsPanel } from './LightConfirmPanel.vue';
|
||||||
|
export { default as BasicInfoEditor } from './LightConfirmPanel.vue';
|
||||||
|
export { default as DashboardBasicInfoConfirmPanel } from './LightConfirmPanel.vue';
|
||||||
|
export { default as DashboardDesignConfirmPanel } from './LightConfirmPanel.vue';
|
||||||
|
export { default as DashboardPublishConfirmPanel } from './LightConfirmPanel.vue';
|
||||||
|
export { default as DesignEditorPanel } from './LightConfirmPanel.vue';
|
||||||
|
export { default as FormEditorContent } from './LightConfirmPanel.vue';
|
||||||
|
export { default as PageBasicInfoEditor } from './LightConfirmPanel.vue';
|
||||||
|
export { default as PageEditorContent } from './LightConfirmPanel.vue';
|
||||||
|
export { default as PagePublishInfoEditor } from './LightConfirmPanel.vue';
|
||||||
|
export { default as SystemSummaryConfirmPanel } from './LightConfirmPanel.vue';
|
||||||
|
|
||||||
|
export type AppDesignData = Record<string, any>;
|
||||||
|
export type AppSettingsData = Record<string, any>;
|
||||||
|
export type BasicFormData = Record<string, any>;
|
||||||
|
export type DashboardBasicInfoData = Record<string, any>;
|
||||||
|
export type DashboardDesignData = Record<string, any>;
|
||||||
|
export type DashboardPublishData = Record<string, any>;
|
||||||
|
export type DesignData = Record<string, any>;
|
||||||
|
export type DesignType = string;
|
||||||
|
export type FormBasicData = Record<string, any>;
|
||||||
|
export type FormEditorData = Record<string, any>;
|
||||||
|
export type PageBasicInfo = Record<string, any>;
|
||||||
|
export type PageEditorData = Record<string, any>;
|
||||||
|
export type PagePublishInfo = Record<string, any>;
|
||||||
|
export type SystemSummaryData = Record<string, any>;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { Page } from '@vben/common-ui/es/page';
|
import { Page } from '@vben/common-ui';
|
||||||
import { PanelLeft } from '@vben/icons';
|
import { ChevronRight, PanelLeft } from '@vben/icons';
|
||||||
|
|
||||||
import { ElCard, ElScrollbar, ElSplitter, ElSplitterPanel } from 'element-plus';
|
import { ElCard, ElScrollbar, ElSplitter, ElSplitterPanel } from 'element-plus';
|
||||||
|
|
||||||
@@ -132,16 +132,37 @@ interface Props {
|
|||||||
rightContentClass?: string;
|
rightContentClass?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否显示左侧面板折叠按钮
|
* 是否在右侧面板 header 显示折叠按钮
|
||||||
* @default true
|
* @default true
|
||||||
*/
|
*/
|
||||||
showLeftToggle?: boolean;
|
showLeftToggle?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 左侧面板收起后,是否在左边缘显示悬停展开条
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
showCollapsedExpandRail?: boolean;
|
||||||
|
|
||||||
|
/** 收起后悬停展开条提示文案 */
|
||||||
|
collapsedExpandLabel?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 左侧面板默认是否折叠
|
* 左侧面板默认是否折叠
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
leftCollapsed?: boolean;
|
leftCollapsed?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 透传给内部 Page 的内容区高度偏移(px)
|
||||||
|
* @default 24
|
||||||
|
*/
|
||||||
|
heightOffset?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 透传给内部 Page 是否显示外边距
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
showMargin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -161,12 +182,26 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
leftUseScrollbar: undefined,
|
leftUseScrollbar: undefined,
|
||||||
rightUseScrollbar: undefined,
|
rightUseScrollbar: undefined,
|
||||||
showLeftToggle: true,
|
showLeftToggle: true,
|
||||||
|
showCollapsedExpandRail: true,
|
||||||
leftCollapsed: false,
|
leftCollapsed: false,
|
||||||
|
heightOffset: 24,
|
||||||
|
showMargin: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 左侧面板折叠状态
|
// 左侧面板折叠状态
|
||||||
const isLeftCollapsed = ref(props.leftCollapsed);
|
const isLeftCollapsed = ref(props.leftCollapsed);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.leftCollapsed,
|
||||||
|
(val) => {
|
||||||
|
isLeftCollapsed.value = val;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'left-collapsed-change', collapsed: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
// 切换左侧面板折叠状态
|
// 切换左侧面板折叠状态
|
||||||
const toggleLeftPanel = () => {
|
const toggleLeftPanel = () => {
|
||||||
isLeftCollapsed.value = !isLeftCollapsed.value;
|
isLeftCollapsed.value = !isLeftCollapsed.value;
|
||||||
@@ -177,6 +212,10 @@ const setLeftCollapsed = (collapsed: boolean) => {
|
|||||||
isLeftCollapsed.value = collapsed;
|
isLeftCollapsed.value = collapsed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
watch(isLeftCollapsed, (collapsed) => {
|
||||||
|
emit('left-collapsed-change', collapsed);
|
||||||
|
});
|
||||||
|
|
||||||
// 暴露方法给父组件
|
// 暴露方法给父组件
|
||||||
defineExpose({
|
defineExpose({
|
||||||
isLeftCollapsed,
|
isLeftCollapsed,
|
||||||
@@ -226,6 +265,8 @@ const rightScrollbar = computed(() => {
|
|||||||
:auto-content-height="autoContentHeight"
|
:auto-content-height="autoContentHeight"
|
||||||
:title="pageTitle"
|
:title="pageTitle"
|
||||||
:description="pageDescription"
|
:description="pageDescription"
|
||||||
|
:height-offset="heightOffset"
|
||||||
|
:show-margin="showMargin"
|
||||||
>
|
>
|
||||||
<!-- Page 头部插槽 -->
|
<!-- Page 头部插槽 -->
|
||||||
<template v-if="$slots.header" #header>
|
<template v-if="$slots.header" #header>
|
||||||
@@ -238,7 +279,24 @@ const rightScrollbar = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 主内容区域 -->
|
<!-- 主内容区域 -->
|
||||||
<div class="fu-page-container h-full">
|
<div class="fu-page-container relative h-full">
|
||||||
|
<!-- 侧栏收起后:左边缘悬停显示展开箭头 -->
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
isLeftCollapsed &&
|
||||||
|
leftCollapsible &&
|
||||||
|
showCollapsedExpandRail
|
||||||
|
"
|
||||||
|
class="fu-page-collapsed-rail"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
:title="collapsedExpandLabel"
|
||||||
|
@click="toggleLeftPanel"
|
||||||
|
@keydown.enter="toggleLeftPanel"
|
||||||
|
>
|
||||||
|
<ChevronRight class="fu-page-collapsed-rail__icon h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<ElSplitter>
|
<ElSplitter>
|
||||||
<!-- 左侧面板 -->
|
<!-- 左侧面板 -->
|
||||||
<ElSplitterPanel
|
<ElSplitterPanel
|
||||||
@@ -281,9 +339,12 @@ const rightScrollbar = computed(() => {
|
|||||||
:class="[rightPaddingClass, isLeftCollapsed ? '' : 'ml-1.5']"
|
:class="[rightPaddingClass, isLeftCollapsed ? '' : 'ml-1.5']"
|
||||||
>
|
>
|
||||||
<!-- 右侧卡片头部 -->
|
<!-- 右侧卡片头部 -->
|
||||||
<template v-if="rightTitle || $slots['right-header']" #header>
|
<template
|
||||||
|
v-if="rightTitle || $slots['right-header'] || showLeftToggle"
|
||||||
|
#header
|
||||||
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<!-- 左侧折叠/展开按钮 -->
|
<!-- 左侧折叠/展开按钮(允许收起侧栏时显示) -->
|
||||||
<div
|
<div
|
||||||
v-if="showLeftToggle"
|
v-if="showLeftToggle"
|
||||||
class="left-toggle-btn-inline"
|
class="left-toggle-btn-inline"
|
||||||
@@ -295,9 +356,9 @@ const rightScrollbar = computed(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- 右侧标题或自定义内容 -->
|
<!-- 右侧标题或自定义内容 -->
|
||||||
<div class="flex-1">
|
<div class="flex-1 min-w-0">
|
||||||
<slot name="right-header">
|
<slot name="right-header">
|
||||||
<span>{{ rightTitle }}</span>
|
<span v-if="rightTitle">{{ rightTitle }}</span>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -403,4 +464,37 @@ const rightScrollbar = computed(() => {
|
|||||||
.fu-page-container :deep(.el-splitter-panel:last-child .el-card) {
|
.fu-page-container :deep(.el-splitter-panel:last-child .el-card) {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 收起后左侧悬停展开条 */
|
||||||
|
.fu-page-collapsed-rail {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
width: 14px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: transparent;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fu-page-collapsed-rail__icon {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fu-page-collapsed-rail:hover {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fu-page-collapsed-rail:hover .fu-page-collapsed-rail__icon {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { ElButton, ElDialog, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
interface ImageCropperProps {
|
||||||
|
modelValue: boolean;
|
||||||
|
imageSrc: string;
|
||||||
|
aspectRatio?: number;
|
||||||
|
shape?: 'circle' | 'rect';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageCropperEmits {
|
||||||
|
(e: 'update:modelValue', value: boolean): void;
|
||||||
|
(e: 'confirm', result: { blob: Blob; canvas: HTMLCanvasElement }): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ImageCropperProps>(), {
|
||||||
|
aspectRatio: undefined,
|
||||||
|
shape: 'rect',
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<ImageCropperEmits>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newVal) => {
|
||||||
|
visible.value = newVal;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(visible, (newVal) => {
|
||||||
|
emit('update:modelValue', newVal);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(src: string) {
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error('Image load failed'));
|
||||||
|
image.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
try {
|
||||||
|
const image = await loadImage(props.imageSrc);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = image.naturalWidth || image.width;
|
||||||
|
canvas.height = image.naturalHeight || image.height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('Canvas context unavailable');
|
||||||
|
|
||||||
|
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
ElMessage.error('图片处理失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('confirm', { blob, canvas });
|
||||||
|
visible.value = false;
|
||||||
|
}, 'image/png');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Image preview confirm error:', error);
|
||||||
|
ElMessage.error('图片处理失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElDialog
|
||||||
|
v-model="visible"
|
||||||
|
title="预览图片"
|
||||||
|
width="720px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
append-to-body
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="image-preview-wrap" :class="{ 'is-circle': shape === 'circle' }">
|
||||||
|
<img :src="imageSrc" alt="" class="image-preview" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="cropper-footer">
|
||||||
|
<ElButton @click="handleCancel">取消</ElButton>
|
||||||
|
<ElButton type="primary" @click="handleConfirm">确认使用</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.image-preview-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: min(56vh, 480px);
|
||||||
|
overflow: hidden;
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&.is-circle {
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as ImageCropper } from './image-cropper.vue';
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts" setup generic="T extends Record<string, any>">
|
<script lang="ts" setup generic="T extends Record<string, any>">
|
||||||
import type { VbenFormProps } from '@vben-core/form-ui';
|
import type { VbenFormProps } from '@vben/common-ui';
|
||||||
|
|
||||||
import type { PaginationProps } from './types';
|
import type { PaginationProps } from './types';
|
||||||
|
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { useVbenForm } from '@vben-core/form-ui';
|
import { useVbenForm } from '@vben/common-ui';
|
||||||
import { LayoutGrid, List, RefreshCw } from '@vben/icons';
|
import { LayoutGrid, List, RefreshCw } from '@vben/icons';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { ChatMessage } from '#/api/core/chat';
|
||||||
import type {
|
import type {
|
||||||
AnnouncementItem,
|
AnnouncementItem,
|
||||||
NotificationItem,
|
NotificationItem,
|
||||||
@@ -6,11 +7,12 @@ import type {
|
|||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import { useVbenDrawer } from '@vben-core/popup-ui';
|
import { useVbenDrawer } from '@vben/common-ui';
|
||||||
import { Mail, MailCheck, Megaphone, Trash2 } from '@vben/icons';
|
import { Mail, MailCheck, Megaphone, MessageSquare, Trash2 } from '@vben/icons';
|
||||||
|
|
||||||
import { ElButton, ElEmpty, ElScrollbar, ElTooltip } from 'element-plus';
|
import { ElButton, ElEmpty, ElScrollbar, ElTooltip } from 'element-plus';
|
||||||
|
|
||||||
|
import UserAvatar from '#/components/user-avatar/index.vue';
|
||||||
import { ZqTabs } from '#/components/zq-tabs';
|
import { ZqTabs } from '#/components/zq-tabs';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
@@ -23,8 +25,12 @@ interface Props {
|
|||||||
announcements?: AnnouncementItem[];
|
announcements?: AnnouncementItem[];
|
||||||
// 公告未读数
|
// 公告未读数
|
||||||
announcementUnreadCount?: number;
|
announcementUnreadCount?: number;
|
||||||
|
// 未读聊天消息列表
|
||||||
|
unreadChats?: ChatMessage[];
|
||||||
|
// 聊天未读总数
|
||||||
|
chatUnreadCount?: number;
|
||||||
// 当前激活的 Tab
|
// 当前激活的 Tab
|
||||||
activeTab?: 'announcement' | 'message';
|
activeTab?: 'announcement' | 'chat' | 'message';
|
||||||
}
|
}
|
||||||
|
|
||||||
defineOptions({ name: 'NotificationDrawer' });
|
defineOptions({ name: 'NotificationDrawer' });
|
||||||
@@ -34,15 +40,18 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
messageUnreadCount: 0,
|
messageUnreadCount: 0,
|
||||||
announcements: () => [],
|
announcements: () => [],
|
||||||
announcementUnreadCount: 0,
|
announcementUnreadCount: 0,
|
||||||
|
unreadChats: () => [],
|
||||||
|
chatUnreadCount: 0,
|
||||||
activeTab: 'message',
|
activeTab: 'message',
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
clear: [];
|
clear: [];
|
||||||
|
clickChat: [ChatMessage];
|
||||||
makeAll: [];
|
makeAll: [];
|
||||||
readAnnouncement: [AnnouncementItem];
|
readAnnouncement: [AnnouncementItem];
|
||||||
readMessage: [NotificationItem];
|
readMessage: [NotificationItem];
|
||||||
'update:activeTab': ['announcement' | 'message'];
|
'update:activeTab': ['announcement' | 'chat' | 'message'];
|
||||||
viewAll: [];
|
viewAll: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -56,6 +65,11 @@ const tabItems = computed(() => [
|
|||||||
label: `${$t('message.drawer.messageTab')}${props.messageUnreadCount > 0 ? ` (${props.messageUnreadCount})` : ''}`,
|
label: `${$t('message.drawer.messageTab')}${props.messageUnreadCount > 0 ? ` (${props.messageUnreadCount})` : ''}`,
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'chat',
|
||||||
|
label: `${$t('message.drawer.chatTab')}${props.chatUnreadCount > 0 ? ` (${props.chatUnreadCount})` : ''}`,
|
||||||
|
icon: MessageSquare,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'announcement',
|
key: 'announcement',
|
||||||
label: `${$t('message.drawer.announcementTab')}${props.announcementUnreadCount > 0 ? ` (${props.announcementUnreadCount})` : ''}`,
|
label: `${$t('message.drawer.announcementTab')}${props.announcementUnreadCount > 0 ? ` (${props.announcementUnreadCount})` : ''}`,
|
||||||
@@ -64,10 +78,34 @@ const tabItems = computed(() => [
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
function handleTabChange(value: string) {
|
function handleTabChange(value: string) {
|
||||||
currentTab.value = value as 'announcement' | 'message';
|
currentTab.value = value as 'announcement' | 'chat' | 'message';
|
||||||
emit('update:activeTab', currentTab.value);
|
emit('update:activeTab', currentTab.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleChatClick(msg: ChatMessage) {
|
||||||
|
emit('clickChat', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessagePreview(msg: ChatMessage): string {
|
||||||
|
if (msg.msg_type === 'text') return msg.content || '';
|
||||||
|
if (msg.msg_type === 'image') return `[${$t('chat.image')}]`;
|
||||||
|
if (msg.msg_type === 'file')
|
||||||
|
return `[${$t('chat.file')}] ${msg.file_name || ''}`;
|
||||||
|
if (msg.msg_type === 'voice') return `[${$t('chat.voice')}]`;
|
||||||
|
return `[${msg.msg_type}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChatTime(datetime?: string): string {
|
||||||
|
if (!datetime) return '';
|
||||||
|
const date = new Date(datetime);
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = date.toDateString() === now.toDateString();
|
||||||
|
if (isToday) {
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString([], { month: '2-digit', day: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
function handleViewAll() {
|
function handleViewAll() {
|
||||||
emit('viewAll');
|
emit('viewAll');
|
||||||
}
|
}
|
||||||
@@ -202,6 +240,59 @@ function getPriorityClass(priority: number): string {
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 聊天未读列表 -->
|
||||||
|
<template v-else-if="currentTab === 'chat'">
|
||||||
|
<ElScrollbar v-if="unreadChats.length > 0" class="flex-1">
|
||||||
|
<ul class="flex w-full flex-col gap-2">
|
||||||
|
<template v-for="msg in unreadChats" :key="msg.id">
|
||||||
|
<li
|
||||||
|
class="relative flex w-full cursor-pointer items-center gap-3 rounded-lg border border-[var(--el-border-color)] p-3 transition-colors hover:bg-[var(--el-fill-color-light)]"
|
||||||
|
@click="handleChatClick(msg)"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
:user-id="msg.sender_id"
|
||||||
|
:name="msg.sender_name || ''"
|
||||||
|
:avatar="msg.sender_avatar"
|
||||||
|
:size="40"
|
||||||
|
:font-size="16"
|
||||||
|
:show-popover="false"
|
||||||
|
class="shrink-0"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-1 flex-col gap-1 leading-none">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm font-medium">
|
||||||
|
{{ msg.sender_name || '' }}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
class="text-xs text-[var(--el-text-color-placeholder)]"
|
||||||
|
>
|
||||||
|
{{ formatChatTime(msg.sys_create_datetime) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="truncate text-xs text-[var(--el-text-color-secondary)]"
|
||||||
|
>
|
||||||
|
{{ getMessagePreview(msg) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</ElScrollbar>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-1 items-center justify-center">
|
||||||
|
<ElEmpty :description="$t('message.drawer.noChats')">
|
||||||
|
<template #image>
|
||||||
|
<MessageSquare
|
||||||
|
class="size-16 text-[var(--el-text-color-placeholder)]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ElEmpty>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 公告列表 -->
|
<!-- 公告列表 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ElScrollbar v-if="announcements.length > 0" class="flex-1">
|
<ElScrollbar v-if="announcements.length > 0" class="flex-1">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
import { useVbenDrawer } from '@vben-core/popup-ui';
|
import { useVbenDrawer } from '@vben/common-ui';
|
||||||
import { Bell } from '@vben/icons';
|
import { Bell } from '@vben/icons';
|
||||||
|
|
||||||
import { ElBadge } from 'element-plus';
|
import { ElBadge } from 'element-plus';
|
||||||
@@ -50,7 +50,6 @@ const emit = defineEmits<{
|
|||||||
clear: [];
|
clear: [];
|
||||||
clickChat: [ChatMessage];
|
clickChat: [ChatMessage];
|
||||||
makeAll: [];
|
makeAll: [];
|
||||||
open: [];
|
|
||||||
readAnnouncement: [AnnouncementItem];
|
readAnnouncement: [AnnouncementItem];
|
||||||
readMessage: [NotificationItem];
|
readMessage: [NotificationItem];
|
||||||
'update:activeTab': ['announcement' | 'chat' | 'message'];
|
'update:activeTab': ['announcement' | 'chat' | 'message'];
|
||||||
@@ -92,7 +91,6 @@ const drawerListeners = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function openDrawer() {
|
function openDrawer() {
|
||||||
emit('open');
|
|
||||||
drawerApi.open();
|
drawerApi.open();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,14 +3,20 @@ import type { User } from '#/api/core/user';
|
|||||||
|
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { ElAvatar, ElPopover } from 'element-plus';
|
import { Network } from '@vben/icons';
|
||||||
|
|
||||||
|
import { ElAvatar, ElPopover, ElTooltip } from 'element-plus';
|
||||||
|
|
||||||
import { getUserDetailApi } from '#/api/core/user';
|
import { getUserDetailApi } from '#/api/core/user';
|
||||||
import DeptTag from '#/components/dept-tag/index.vue';
|
import DeptTag from '#/components/dept-tag/index.vue';
|
||||||
|
import PostTag from '#/components/post-tag/index.vue';
|
||||||
import RoleTag from '#/components/role-tag/index.vue';
|
import RoleTag from '#/components/role-tag/index.vue';
|
||||||
import { getFileUrl } from '#/composables/useFileUrl';
|
import { getFileUrl } from '#/composables/useFileUrl';
|
||||||
|
import { $t } from '#/locales';
|
||||||
import { generateAvatarGradient, generateAvatarText } from '#/utils/avatar';
|
import { generateAvatarGradient, generateAvatarText } from '#/utils/avatar';
|
||||||
|
|
||||||
|
import UserProfileDialog from './UserProfileDialog.vue';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'UserAvatar',
|
name: 'UserAvatar',
|
||||||
});
|
});
|
||||||
@@ -92,6 +98,14 @@ const userDetail = ref<User>();
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const hasLoaded = ref(false);
|
const hasLoaded = ref(false);
|
||||||
|
|
||||||
|
// 用户详情 Dialog
|
||||||
|
const profileDialogVisible = ref(false);
|
||||||
|
|
||||||
|
function openProfileDialog() {
|
||||||
|
popoverVisible.value = false;
|
||||||
|
profileDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
// 获取有效的 ID
|
// 获取有效的 ID
|
||||||
const effectiveUserId = computed(() => {
|
const effectiveUserId = computed(() => {
|
||||||
return props.userId || props.user?.id;
|
return props.userId || props.user?.id;
|
||||||
@@ -319,6 +333,18 @@ watch(
|
|||||||
<div class="user-name">{{ userDetail.name }}</div>
|
<div class="user-name">{{ userDetail.name }}</div>
|
||||||
<div class="user-username">@{{ userDetail.username }}</div>
|
<div class="user-username">@{{ userDetail.username }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="user-actions">
|
||||||
|
<ElTooltip
|
||||||
|
:content="$t('user-avatar.profile.organization')"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<button class="action-btn" @click="openProfileDialog">
|
||||||
|
<Network :size="15" />
|
||||||
|
</button>
|
||||||
|
</ElTooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 详细信息 -->
|
<!-- 详细信息 -->
|
||||||
@@ -344,7 +370,7 @@ watch(
|
|||||||
<div v-if="userDetail.post_id" class="detail-item">
|
<div v-if="userDetail.post_id" class="detail-item">
|
||||||
<span class="detail-label">岗位:</span>
|
<span class="detail-label">岗位:</span>
|
||||||
<span class="detail-value">
|
<span class="detail-value">
|
||||||
{{ userDetail.post_name || userDetail.post_id }}
|
<PostTag :post-id="userDetail.post_id" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -391,6 +417,13 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</ElPopover>
|
</ElPopover>
|
||||||
|
|
||||||
|
<!-- 用户详情 Dialog -->
|
||||||
|
<UserProfileDialog
|
||||||
|
v-if="effectiveUserId"
|
||||||
|
v-model="profileDialogVisible"
|
||||||
|
:user-id="effectiveUserId"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 无 Popover 时 -->
|
<!-- 无 Popover 时 -->
|
||||||
<div
|
<div
|
||||||
v-if="!(showPopover && effectiveUserId)"
|
v-if="!(showPopover && effectiveUserId)"
|
||||||
@@ -705,6 +738,32 @@ watch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 0;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
// background: var(--el-fill-color-light);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.user-details {
|
.user-details {
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
ElTooltip,
|
ElTooltip,
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import { getPostUsersApi } from '#/api/core/post';
|
||||||
import { getRoleUsersApi } from '#/api/core/role';
|
import { getRoleUsersApi } from '#/api/core/role';
|
||||||
import { getUserListApi } from '#/api/core/user';
|
import { getUserListApi } from '#/api/core/user';
|
||||||
|
|
||||||
@@ -55,10 +56,10 @@ const UserCard = defineAsyncComponent(() => import('./user-card.vue'));
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const UserListItem = defineAsyncComponent(() => import('./user-list-item.vue'));
|
const UserListItem = defineAsyncComponent(() => import('./user-list-item.vue'));
|
||||||
|
|
||||||
type DataSource = 'all' | 'dept' | 'role';
|
type DataSource = 'all' | 'dept' | 'post' | 'role';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dataSource?: DataSource; // 数据源类型:all-所有用户, dept-部门用户, role-角色用户
|
dataSource?: DataSource; // 数据源类型:all-所有用户, dept-部门用户, post-岗位用户, role-角色用户
|
||||||
sourceId?: string; // 数据源ID(部门ID或岗位ID)
|
sourceId?: string; // 数据源ID(部门ID或岗位ID)
|
||||||
tempSelectedUsers?: Set<string>;
|
tempSelectedUsers?: Set<string>;
|
||||||
filterable?: boolean;
|
filterable?: boolean;
|
||||||
@@ -110,6 +111,10 @@ async function loadUsers(isLoadMore = false) {
|
|||||||
// 使用 getUserListApi 并传入 dept_ids
|
// 使用 getUserListApi 并传入 dept_ids
|
||||||
params.dept_ids = [props.sourceId];
|
params.dept_ids = [props.sourceId];
|
||||||
result = await getUserListApi(params);
|
result = await getUserListApi(params);
|
||||||
|
} else if (props.dataSource === 'post' && props.sourceId) {
|
||||||
|
params.search = params.name;
|
||||||
|
delete params.name;
|
||||||
|
result = await getPostUsersApi(props.sourceId, params);
|
||||||
} else if (props.dataSource === 'role' && props.sourceId) {
|
} else if (props.dataSource === 'role' && props.sourceId) {
|
||||||
params.username = params.name;
|
params.username = params.name;
|
||||||
delete params.name;
|
delete params.name;
|
||||||
|
|||||||
@@ -0,0 +1,807 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AiOcrConfig } from '#/components/form-design/store/formDesignStore';
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
FileImageOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
Sparkles,
|
||||||
|
} from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElDialog,
|
||||||
|
ElImageViewer,
|
||||||
|
ElMessage,
|
||||||
|
ElProgress,
|
||||||
|
ElTag,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import { uploadFile as uploadFileApi } from '#/api/core/file';
|
||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
import { getFileUrl } from '#/composables/useFileUrl';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'AiImageOcr',
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
multiple: false,
|
||||||
|
placeholder: () => $t('form-design.aiImageOcr.uploadPlaceholder'),
|
||||||
|
disabled: false,
|
||||||
|
clearable: true,
|
||||||
|
maxSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: string | string[];
|
||||||
|
multiple?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
clearable?: boolean;
|
||||||
|
maxSize?: number;
|
||||||
|
height?: number;
|
||||||
|
aiOcrConfig?: AiOcrConfig;
|
||||||
|
formData?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:modelValue', value: string | string[] | undefined): void;
|
||||||
|
(e: 'change', value: string | string[] | undefined): void;
|
||||||
|
(
|
||||||
|
e: 'ocr-success',
|
||||||
|
data: { extractedData: null | Record<string, any>; rawText: string },
|
||||||
|
): void;
|
||||||
|
(e: 'fill-fields', data: Record<string, any>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const uploadInputRef = ref<HTMLInputElement>();
|
||||||
|
const isUploading = ref(false);
|
||||||
|
const isRecognizing = ref(false);
|
||||||
|
const uploadProgress = ref(0);
|
||||||
|
const currentImageId = ref<string>('');
|
||||||
|
const currentImageUrl = ref<string>('');
|
||||||
|
const currentFileMimeType = ref<string>('');
|
||||||
|
const previewVisible = ref(false);
|
||||||
|
const ocrResult = ref<null | {
|
||||||
|
extractedData: null | Record<string, any>;
|
||||||
|
rawText: string;
|
||||||
|
}>(null);
|
||||||
|
|
||||||
|
// 文件类型到accept属性的映射
|
||||||
|
const FILE_TYPE_ACCEPT_MAP: Record<string, string[]> = {
|
||||||
|
image: ['image/*'],
|
||||||
|
text: [
|
||||||
|
'.txt',
|
||||||
|
'.md',
|
||||||
|
'.log',
|
||||||
|
'.py',
|
||||||
|
'.js',
|
||||||
|
'.ts',
|
||||||
|
'.jsx',
|
||||||
|
'.tsx',
|
||||||
|
'.vue',
|
||||||
|
'.html',
|
||||||
|
'.css',
|
||||||
|
'.json',
|
||||||
|
'.yaml',
|
||||||
|
'.yml',
|
||||||
|
'.xml',
|
||||||
|
'.csv',
|
||||||
|
],
|
||||||
|
pdf: ['.pdf', 'application/pdf'],
|
||||||
|
word: [
|
||||||
|
'.doc',
|
||||||
|
'.docx',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
],
|
||||||
|
excel: [
|
||||||
|
'.xls',
|
||||||
|
'.xlsx',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const hasFile = computed(() => !!currentImageId.value);
|
||||||
|
|
||||||
|
// 计算接受的文件类型
|
||||||
|
const acceptString = computed(() => {
|
||||||
|
const types = props.aiOcrConfig?.acceptFileTypes || ['image'];
|
||||||
|
if (types.includes('all')) {
|
||||||
|
return '*/*';
|
||||||
|
}
|
||||||
|
const accepts: string[] = [];
|
||||||
|
types.forEach((type) => {
|
||||||
|
if (FILE_TYPE_ACCEPT_MAP[type]) {
|
||||||
|
accepts.push(...FILE_TYPE_ACCEPT_MAP[type]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return accepts.join(',') || 'image/*';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 判断是否为图片文件
|
||||||
|
const isImageFile = computed(() => {
|
||||||
|
if (!currentImageId.value) return false;
|
||||||
|
const url = currentImageUrl.value.toLowerCase();
|
||||||
|
const mimeType = currentFileMimeType.value.toLowerCase();
|
||||||
|
return (
|
||||||
|
mimeType.startsWith('image/') ||
|
||||||
|
url.includes('image/') ||
|
||||||
|
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(url)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 判断是否为PDF文件
|
||||||
|
const isPdfFile = computed(() => {
|
||||||
|
if (!currentImageId.value) return false;
|
||||||
|
const url = currentImageUrl.value.toLowerCase();
|
||||||
|
const mimeType = currentFileMimeType.value.toLowerCase();
|
||||||
|
return (
|
||||||
|
mimeType === 'application/pdf' ||
|
||||||
|
url.includes('application/pdf') ||
|
||||||
|
url.endsWith('.pdf')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateTypeLabel = computed(() => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
custom: $t('form-design.aiImageOcr.customTemplate'),
|
||||||
|
id_card: $t('form-design.aiImageOcr.idCard'),
|
||||||
|
business_license: $t('form-design.aiImageOcr.businessLicense'),
|
||||||
|
invoice: $t('form-design.aiImageOcr.invoice'),
|
||||||
|
receipt: $t('form-design.aiImageOcr.receipt'),
|
||||||
|
contract: $t('form-design.aiImageOcr.contract'),
|
||||||
|
};
|
||||||
|
return labels[props.aiOcrConfig?.templateType || 'custom'] || labels.custom;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取支持的文件类型标签
|
||||||
|
const acceptedFileTypesLabel = computed(() => {
|
||||||
|
const types = props.aiOcrConfig?.acceptFileTypes || ['image'];
|
||||||
|
if (types.includes('all')) {
|
||||||
|
return $t('form-design.aiImageOcr.allFiles');
|
||||||
|
}
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
image: $t('form-design.aiImageOcr.imageFile'),
|
||||||
|
text: $t('form-design.aiImageOcr.textFile'),
|
||||||
|
pdf: 'PDF',
|
||||||
|
word: 'Word',
|
||||||
|
excel: 'Excel',
|
||||||
|
};
|
||||||
|
return types.map((t) => labels[t] || t).join(', ');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听 modelValue 变化
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
async (newValue) => {
|
||||||
|
if (!newValue) {
|
||||||
|
currentImageId.value = '';
|
||||||
|
currentImageUrl.value = '';
|
||||||
|
currentFileMimeType.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = Array.isArray(newValue) ? newValue[0] : newValue;
|
||||||
|
if (id && id !== currentImageId.value) {
|
||||||
|
currentImageId.value = id;
|
||||||
|
currentImageUrl.value = await getFileUrl(id);
|
||||||
|
// 从后端获取文件信息
|
||||||
|
try {
|
||||||
|
const fileInfo = await requestClient.get<{ mime_type?: string }>(
|
||||||
|
`/api/core/file_manager/${id}`,
|
||||||
|
);
|
||||||
|
currentFileMimeType.value = fileInfo.mime_type || '';
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch file info:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 打开文件选择器
|
||||||
|
function openFileSelector() {
|
||||||
|
if (props.disabled) return;
|
||||||
|
uploadInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理文件选择
|
||||||
|
async function handleFileChange(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const file = target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
target.value = '';
|
||||||
|
|
||||||
|
// 验证文件类型
|
||||||
|
const acceptedTypes = props.aiOcrConfig?.acceptFileTypes || ['image'];
|
||||||
|
const isValidType = validateFileType(file, acceptedTypes);
|
||||||
|
if (!isValidType) {
|
||||||
|
ElMessage.error($t('form-design.aiImageOcr.invalidFileType'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件大小
|
||||||
|
if (props.maxSize && file.size > props.maxSize * 1024 * 1024) {
|
||||||
|
ElMessage.error(
|
||||||
|
$t('form-design.aiImageOcr.fileTooLarge', { size: props.maxSize }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadAndRecognize(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件类型
|
||||||
|
function validateFileType(file: File, acceptedTypes: string[]): boolean {
|
||||||
|
if (acceptedTypes.includes('all')) return true;
|
||||||
|
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const mimeType = file.type.toLowerCase();
|
||||||
|
|
||||||
|
for (const type of acceptedTypes) {
|
||||||
|
const accepts = FILE_TYPE_ACCEPT_MAP[type] || [];
|
||||||
|
for (const accept of accepts) {
|
||||||
|
if (accept.startsWith('.')) {
|
||||||
|
// 扩展名匹配
|
||||||
|
if (fileName.endsWith(accept)) return true;
|
||||||
|
} else if (accept.endsWith('/*')) {
|
||||||
|
// MIME 类型通配符匹配
|
||||||
|
const prefix = accept.replace('/*', '');
|
||||||
|
if (mimeType.startsWith(prefix)) return true;
|
||||||
|
} else {
|
||||||
|
// 完整 MIME 类型匹配
|
||||||
|
if (mimeType === accept) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传并识别
|
||||||
|
async function uploadAndRecognize(file: File) {
|
||||||
|
try {
|
||||||
|
isUploading.value = true;
|
||||||
|
uploadProgress.value = 0;
|
||||||
|
|
||||||
|
// 上传文件
|
||||||
|
const response = await uploadFileApi(file, {
|
||||||
|
source: 'form',
|
||||||
|
onProgress: (progressEvent) => {
|
||||||
|
uploadProgress.value = progressEvent.percentage;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response?.id) {
|
||||||
|
throw new Error($t('form-design.aiImageOcr.uploadFailed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
currentImageId.value = response.id;
|
||||||
|
currentImageUrl.value = await getFileUrl(response.id);
|
||||||
|
currentFileMimeType.value = file.type;
|
||||||
|
isUploading.value = false;
|
||||||
|
|
||||||
|
// 更新 modelValue
|
||||||
|
emit('update:modelValue', response.id);
|
||||||
|
emit('change', response.id);
|
||||||
|
|
||||||
|
// 如果启用了 AI 识别,开始识别
|
||||||
|
if (props.aiOcrConfig?.enabled) {
|
||||||
|
await recognizeImage(response.id);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Upload failed:', error);
|
||||||
|
ElMessage.error(error.message || $t('form-design.aiImageOcr.uploadFailed'));
|
||||||
|
isUploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 识别文件
|
||||||
|
async function recognizeImage(fileId: string) {
|
||||||
|
if (!props.aiOcrConfig?.enabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isRecognizing.value = true;
|
||||||
|
|
||||||
|
// 构建请求参数
|
||||||
|
const requestData: any = {
|
||||||
|
fileId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果有结构化输出配置
|
||||||
|
if (
|
||||||
|
props.aiOcrConfig.outputSchema &&
|
||||||
|
props.aiOcrConfig.outputSchema.length > 0
|
||||||
|
) {
|
||||||
|
requestData.outputSchema = props.aiOcrConfig.outputSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有自定义提示词
|
||||||
|
if (props.aiOcrConfig.customPrompt) {
|
||||||
|
requestData.prompt = props.aiOcrConfig.customPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 OCR API
|
||||||
|
const result = await requestClient.post<{
|
||||||
|
error: null | string;
|
||||||
|
extractedData: null | Record<string, any>;
|
||||||
|
rawText: null | string;
|
||||||
|
success: boolean;
|
||||||
|
}>('/api/core/file_manager/ocr/recognize', requestData);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(
|
||||||
|
result.error || $t('form-design.aiImageOcr.recognizeFailed'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ocrResult.value = {
|
||||||
|
rawText: result.rawText || '',
|
||||||
|
extractedData: result.extractedData,
|
||||||
|
};
|
||||||
|
|
||||||
|
emit('ocr-success', ocrResult.value);
|
||||||
|
|
||||||
|
if (result.extractedData) {
|
||||||
|
applyFieldMapping(result.extractedData);
|
||||||
|
} else {
|
||||||
|
ElMessage.success($t('form-design.aiImageOcr.recognizeSuccess'));
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('OCR failed:', error);
|
||||||
|
ElMessage.error(
|
||||||
|
error.message || $t('form-design.aiImageOcr.recognizeFailed'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isRecognizing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用字段映射
|
||||||
|
function applyFieldMapping(extractedData: Record<string, any>) {
|
||||||
|
if (!props.aiOcrConfig?.fieldMapping?.length) {
|
||||||
|
// 没有配置映射,直接发送原始数据
|
||||||
|
emit('fill-fields', extractedData);
|
||||||
|
ElMessage.success($t('form-design.aiImageOcr.fillSuccess'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedData: Record<string, any> = {};
|
||||||
|
for (const mapping of props.aiOcrConfig.fieldMapping) {
|
||||||
|
const value = extractedData[mapping.source];
|
||||||
|
if (value !== undefined) {
|
||||||
|
mappedData[mapping.target] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('fill-fields', mappedData);
|
||||||
|
ElMessage.success($t('form-design.aiImageOcr.fillSuccess'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览图片
|
||||||
|
function handlePreview() {
|
||||||
|
if (currentImageUrl.value) {
|
||||||
|
previewVisible.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除图片
|
||||||
|
function handleDelete() {
|
||||||
|
currentImageId.value = '';
|
||||||
|
currentImageUrl.value = '';
|
||||||
|
ocrResult.value = null;
|
||||||
|
emit('update:modelValue', undefined);
|
||||||
|
emit('change', undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新识别
|
||||||
|
async function handleReRecognize() {
|
||||||
|
if (currentImageId.value) {
|
||||||
|
await recognizeImage(currentImageId.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ai-image-ocr">
|
||||||
|
<!-- 上传区域 -->
|
||||||
|
<div
|
||||||
|
class="upload-area"
|
||||||
|
:class="{
|
||||||
|
'has-file': hasFile,
|
||||||
|
'is-disabled': disabled,
|
||||||
|
'is-uploading': isUploading,
|
||||||
|
'is-recognizing': isRecognizing,
|
||||||
|
}"
|
||||||
|
:style="{ height: height ? `${height}px` : undefined }"
|
||||||
|
@click="!hasFile && openFileSelector()"
|
||||||
|
>
|
||||||
|
<!-- 隐藏的文件输入 -->
|
||||||
|
<input
|
||||||
|
ref="uploadInputRef"
|
||||||
|
type="file"
|
||||||
|
:accept="acceptString"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleFileChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<template v-if="!hasFile && !isUploading">
|
||||||
|
<div class="upload-placeholder">
|
||||||
|
<div class="upload-icon">
|
||||||
|
<Sparkles class="ai-icon" />
|
||||||
|
<FileImageOutlined class="image-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="upload-text">{{ placeholder }}</div>
|
||||||
|
<div class="upload-hint">
|
||||||
|
<ElTag size="small" type="primary">
|
||||||
|
{{ templateTypeLabel }}
|
||||||
|
</ElTag>
|
||||||
|
<ElTag size="small" type="info" class="ml-1">
|
||||||
|
{{ acceptedFileTypesLabel }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 上传中 -->
|
||||||
|
<template v-else-if="isUploading">
|
||||||
|
<div class="upload-progress">
|
||||||
|
<ElProgress
|
||||||
|
type="circle"
|
||||||
|
:percentage="uploadProgress"
|
||||||
|
:width="80"
|
||||||
|
:stroke-width="6"
|
||||||
|
/>
|
||||||
|
<div class="progress-text">
|
||||||
|
{{ $t('form-design.aiImageOcr.uploading') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 已上传文件 -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="file-preview">
|
||||||
|
<!-- 图片预览 -->
|
||||||
|
<img
|
||||||
|
v-if="isImageFile"
|
||||||
|
:src="currentImageUrl"
|
||||||
|
alt="uploaded"
|
||||||
|
class="preview-img"
|
||||||
|
/>
|
||||||
|
<!-- PDF预览 -->
|
||||||
|
<iframe
|
||||||
|
v-else-if="isPdfFile"
|
||||||
|
:src="`${currentImageUrl}#toolbar=0&navpanes=0&scrollbar=1`"
|
||||||
|
class="pdf-preview"
|
||||||
|
frameborder="0"
|
||||||
|
></iframe>
|
||||||
|
<!-- 其他文件显示图标 -->
|
||||||
|
<div v-else class="file-icon-wrapper">
|
||||||
|
<FileOutlined class="file-icon" />
|
||||||
|
<div class="file-name">
|
||||||
|
{{ $t('form-design.aiImageOcr.fileUploaded') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 识别中遮罩 -->
|
||||||
|
<div v-if="isRecognizing" class="recognizing-mask">
|
||||||
|
<div class="recognizing-content">
|
||||||
|
<Sparkles class="recognizing-icon" />
|
||||||
|
<div class="recognizing-text">
|
||||||
|
{{ $t('form-design.aiImageOcr.recognizing') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div v-if="!isRecognizing" class="file-actions">
|
||||||
|
<ElButton
|
||||||
|
v-if="isImageFile || isPdfFile"
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
@click.stop="handlePreview"
|
||||||
|
>
|
||||||
|
<EyeOutlined />
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
v-if="aiOcrConfig?.enabled"
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
@click.stop="handleReRecognize"
|
||||||
|
>
|
||||||
|
<Sparkles />
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
v-if="clearable"
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
@click.stop="handleDelete"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI 标识 -->
|
||||||
|
<div v-if="aiOcrConfig?.enabled" class="ai-badge">
|
||||||
|
<Sparkles class="badge-icon" />
|
||||||
|
AI
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图片预览 -->
|
||||||
|
<ElImageViewer
|
||||||
|
v-if="previewVisible && isImageFile"
|
||||||
|
:url-list="[currentImageUrl]"
|
||||||
|
:z-index="3000"
|
||||||
|
@close="previewVisible = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- PDF预览对话框 -->
|
||||||
|
<ElDialog
|
||||||
|
v-model="previewVisible"
|
||||||
|
v-if="isPdfFile"
|
||||||
|
:title="$t('form-design.aiImageOcr.preview')"
|
||||||
|
width="80%"
|
||||||
|
top="5vh"
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
:src="`${currentImageUrl}#toolbar=0&navpanes=0&scrollbar=1`"
|
||||||
|
style="width: 100%; height: 70vh; border: none"
|
||||||
|
></iframe>
|
||||||
|
</ElDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.ai-image-ocr {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
border: 2px dashed var(--el-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
background: var(--el-fill-color-lighter);
|
||||||
|
|
||||||
|
&:hover:not(.is-disabled):not(.has-file) {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.has-file {
|
||||||
|
cursor: default;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.image-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -12px;
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
animation: sparkle 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
.preview-img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .file-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--el-color-primary),
|
||||||
|
var(--el-color-primary-light-3)
|
||||||
|
);
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.badge-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognizing-mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.recognizing-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
.recognizing-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
animation: sparkle 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recognizing-text {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-content {
|
||||||
|
.extracted-data {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.data-item {
|
||||||
|
display: flex;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 120px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-value {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sparkle {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as AiImageOcr } from './ai-image-ocr.vue';
|
||||||
|
export type { AiImageOcrEmits, AiImageOcrProps } from './types';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export interface AiImageOcrProps {
|
||||||
|
modelValue?: string | string[];
|
||||||
|
multiple?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
clearable?: boolean;
|
||||||
|
maxSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiImageOcrEmits {
|
||||||
|
(e: 'update:modelValue', value: string | string[] | undefined): void;
|
||||||
|
(e: 'change', value: string | string[] | undefined): void;
|
||||||
|
(
|
||||||
|
e: 'ocr-success',
|
||||||
|
data: { extractedData: null | Record<string, any>; rawText: string },
|
||||||
|
): void;
|
||||||
|
(e: 'fill-fields', data: Record<string, any>): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { BarcodeGeneratorEmits, BarcodeGeneratorProps } from './types';
|
||||||
|
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Barcode, Copy, Download } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElButton, ElMessage, ElTooltip } from 'element-plus';
|
||||||
|
|
||||||
|
import { useCodeContent } from '../shared/useCodeContent';
|
||||||
|
import {
|
||||||
|
getBarcodeValidationErrorKey,
|
||||||
|
validateBarcodeContent,
|
||||||
|
} from '../shared/validateBarcode';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'BarcodeGenerator',
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<BarcodeGeneratorProps>(), {
|
||||||
|
dataSource: 'static',
|
||||||
|
format: 'code128',
|
||||||
|
height: 80,
|
||||||
|
width: 2,
|
||||||
|
margin: 10,
|
||||||
|
displayValue: true,
|
||||||
|
lineColor: '#000000',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
showContent: false,
|
||||||
|
enableDownload: false,
|
||||||
|
enableCopy: false,
|
||||||
|
downloadFilename: 'barcode',
|
||||||
|
disabled: false,
|
||||||
|
readonly: false,
|
||||||
|
placeholder: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<BarcodeGeneratorEmits>();
|
||||||
|
|
||||||
|
const contentOptions = computed(() => ({
|
||||||
|
boundField: props.boundField,
|
||||||
|
contentType: 'barcode' as const,
|
||||||
|
dataSource: props.dataSource,
|
||||||
|
formData: props.formData,
|
||||||
|
formula: props.formula,
|
||||||
|
modelValue: props.modelValue,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const barcodeContent = useCodeContent(contentOptions);
|
||||||
|
|
||||||
|
const isValid = computed(() =>
|
||||||
|
validateBarcodeContent(props.format ?? 'code128', barcodeContent.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidMessageKey = computed(() =>
|
||||||
|
getBarcodeValidationErrorKey(props.format ?? 'code128', barcodeContent.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const placeholderText = computed(
|
||||||
|
() => props.placeholder || $t('form-design.barcode.placeholder'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const svgMarkup = computed(() => {
|
||||||
|
const content = barcodeContent.value;
|
||||||
|
if (!content || !isValid.value) return '';
|
||||||
|
|
||||||
|
const barWidth = Math.max(1, props.width || 2);
|
||||||
|
const height = Math.max(40, props.height || 80);
|
||||||
|
const margin = Math.max(0, props.margin || 0);
|
||||||
|
const chars = [...content];
|
||||||
|
const encodedBars = chars.flatMap((char, index) => {
|
||||||
|
const code = char.charCodeAt(0) + index;
|
||||||
|
return Array.from({ length: 7 }, (_, bit) => ((code >> bit) & 1) === 1);
|
||||||
|
});
|
||||||
|
const totalWidth = encodedBars.length * barWidth + margin * 2;
|
||||||
|
const textOffset = props.displayValue ? 20 : 0;
|
||||||
|
const barHeight = Math.max(24, height - textOffset);
|
||||||
|
const bars = encodedBars
|
||||||
|
.map((enabled, index) => {
|
||||||
|
if (!enabled) return '';
|
||||||
|
const x = margin + index * barWidth;
|
||||||
|
return `<rect x="${x}" y="${margin}" width="${barWidth}" height="${barHeight}" fill="${props.lineColor}" />`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
const text = props.displayValue
|
||||||
|
? `<text x="${totalWidth / 2}" y="${margin + barHeight + 15}" text-anchor="middle" font-family="monospace" font-size="14" fill="${props.lineColor}">${escapeXml(content)}</text>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height + margin * 2}" viewBox="0 0 ${totalWidth} ${height + margin * 2}"><rect width="100%" height="100%" fill="${props.backgroundColor}" />${bars}${text}</svg>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const barcodeUrl = computed(() =>
|
||||||
|
svgMarkup.value
|
||||||
|
? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup.value)}`
|
||||||
|
: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
function escapeXml(value: string) {
|
||||||
|
return value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBarcode() {
|
||||||
|
if (!barcodeUrl.value) return;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `${props.downloadFilename}.svg`;
|
||||||
|
link.href = barcodeUrl.value;
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
emit('downloaded');
|
||||||
|
ElMessage.success($t('form-design.barcode.downloadSuccess'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyContent() {
|
||||||
|
const content = barcodeContent.value;
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content);
|
||||||
|
emit('copied');
|
||||||
|
ElMessage.success($t('form-design.barcode.copySuccess'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Copy failed:', error);
|
||||||
|
ElMessage.error($t('form-design.barcode.copyError'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => barcodeUrl.value,
|
||||||
|
(url) => {
|
||||||
|
if (url) emit('generated', barcodeContent.value);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="barcode-generator">
|
||||||
|
<div
|
||||||
|
class="barcode-generator__container"
|
||||||
|
:class="{
|
||||||
|
'barcode-generator__container--empty': !barcodeContent || !isValid,
|
||||||
|
'barcode-generator__container--disabled': disabled,
|
||||||
|
}"
|
||||||
|
:style="{ backgroundColor }"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="barcodeUrl && isValid"
|
||||||
|
:src="barcodeUrl"
|
||||||
|
alt="Barcode"
|
||||||
|
class="barcode-generator__image"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else class="barcode-generator__placeholder">
|
||||||
|
<Barcode class="barcode-generator__placeholder-icon" />
|
||||||
|
<span class="barcode-generator__placeholder-text">{{
|
||||||
|
invalidMessageKey
|
||||||
|
? $t(`form-design.barcode.${invalidMessageKey}`)
|
||||||
|
: placeholderText
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showContent && barcodeContent" class="barcode-generator__content">
|
||||||
|
<ElTooltip :content="barcodeContent" placement="top" :show-after="300">
|
||||||
|
<span class="barcode-generator__content-text">{{ barcodeContent }}</span>
|
||||||
|
</ElTooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="(enableDownload || enableCopy) && barcodeUrl && !disabled && isValid"
|
||||||
|
class="barcode-generator__actions"
|
||||||
|
>
|
||||||
|
<ElButton
|
||||||
|
v-if="enableDownload"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:icon="Download"
|
||||||
|
@click="downloadBarcode"
|
||||||
|
>
|
||||||
|
{{ $t('form-design.barcode.download') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
v-if="enableCopy"
|
||||||
|
size="small"
|
||||||
|
:icon="Copy"
|
||||||
|
@click="copyContent"
|
||||||
|
>
|
||||||
|
{{ $t('form-design.barcode.copy') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.barcode-generator {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-generator__container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 80px;
|
||||||
|
padding: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-generator__container--empty {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-generator__container--disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-generator__image {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-generator__placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-generator__placeholder-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-generator__placeholder-text {
|
||||||
|
max-width: 80%;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-generator__content {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-generator__content-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-generator__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as BarcodeGenerator } from './barcode-generator.vue';
|
||||||
|
export type * from './types';
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type {
|
||||||
|
BarcodeFormat,
|
||||||
|
CodeDisplayBaseProps,
|
||||||
|
} from '../shared/types';
|
||||||
|
|
||||||
|
export interface BarcodeGeneratorProps extends CodeDisplayBaseProps {
|
||||||
|
modelValue?: null | string;
|
||||||
|
format?: BarcodeFormat;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
margin?: number;
|
||||||
|
displayValue?: boolean;
|
||||||
|
lineColor?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarcodeGeneratorEmits {
|
||||||
|
(e: 'update:modelValue', value: null | string): void;
|
||||||
|
(e: 'change', value: null | string): void;
|
||||||
|
(e: 'generated', content: string): void;
|
||||||
|
(e: 'downloaded'): void;
|
||||||
|
(e: 'copied'): void;
|
||||||
|
}
|
||||||
@@ -1,195 +1,136 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import type {
|
||||||
|
CodeEditorEmits,
|
||||||
|
CodeEditorExpose,
|
||||||
|
CodeEditorProps,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
const props = withDefaults(
|
import { computed, onMounted, ref } from 'vue';
|
||||||
defineProps<{
|
|
||||||
autocompletion?: boolean;
|
const props = withDefaults(defineProps<CodeEditorProps>(), {
|
||||||
bracketMatching?: boolean;
|
|
||||||
foldGutter?: boolean;
|
|
||||||
height?: number | string;
|
|
||||||
language?: string;
|
|
||||||
lineNumbers?: boolean;
|
|
||||||
modelValue?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
readonly?: boolean;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
autocompletion: false,
|
autocompletion: false,
|
||||||
bracketMatching: false,
|
bracketMatching: false,
|
||||||
|
disabled: false,
|
||||||
foldGutter: false,
|
foldGutter: false,
|
||||||
height: '240px',
|
height: 'auto',
|
||||||
language: 'text',
|
highlightActiveLine: false,
|
||||||
|
indentGuide: false,
|
||||||
|
language: 'javascript',
|
||||||
lineNumbers: false,
|
lineNumbers: false,
|
||||||
|
lineWrapping: true,
|
||||||
|
minHeight: '100px',
|
||||||
modelValue: '',
|
modelValue: '',
|
||||||
placeholder: '',
|
|
||||||
readonly: false,
|
readonly: false,
|
||||||
},
|
tabSize: 2,
|
||||||
);
|
theme: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<CodeEditorEmits>();
|
||||||
change: [value: string];
|
|
||||||
'update:modelValue': [value: string];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const lineNumbersRef = ref<HTMLPreElement | null>(null);
|
|
||||||
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
const editorStyle = computed(() => ({
|
const editorStyle = computed(() => {
|
||||||
height: typeof props.height === 'number' ? `${props.height}px` : props.height,
|
const style: Record<string, string> = {
|
||||||
}));
|
minHeight:
|
||||||
|
typeof props.minHeight === 'number'
|
||||||
|
? `${props.minHeight}px`
|
||||||
|
: props.minHeight,
|
||||||
|
tabSize: String(props.tabSize),
|
||||||
|
whiteSpace: props.lineWrapping ? 'pre-wrap' : 'pre',
|
||||||
|
};
|
||||||
|
|
||||||
const lineNumbersText = computed(() => {
|
if (props.height !== 'auto') {
|
||||||
const count = Math.max(1, props.modelValue.split(/\r\n|\r|\n/).length);
|
style.height =
|
||||||
return Array.from({ length: count }, (_, index) => index + 1).join('\n');
|
typeof props.height === 'number' ? `${props.height}px` : props.height;
|
||||||
|
}
|
||||||
|
if (props.maxHeight) {
|
||||||
|
style.maxHeight =
|
||||||
|
typeof props.maxHeight === 'number'
|
||||||
|
? `${props.maxHeight}px`
|
||||||
|
: props.maxHeight;
|
||||||
|
}
|
||||||
|
return style;
|
||||||
});
|
});
|
||||||
|
|
||||||
const languageLabel = computed(() => {
|
function emitValue(value: string) {
|
||||||
const language = props.language === 'python3' ? 'python' : props.language;
|
|
||||||
return language.toUpperCase();
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleInput(event: Event) {
|
|
||||||
const value = (event.target as HTMLTextAreaElement).value;
|
|
||||||
emit('update:modelValue', value);
|
emit('update:modelValue', value);
|
||||||
}
|
|
||||||
|
|
||||||
function handleChange(event: Event) {
|
|
||||||
const value = (event.target as HTMLTextAreaElement).value;
|
|
||||||
emit('change', value);
|
emit('change', value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function focus() {
|
||||||
if (props.readonly || event.key !== 'Tab') {
|
textareaRef.value?.focus();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
const target = event.target as HTMLTextAreaElement;
|
|
||||||
const start = target.selectionStart;
|
|
||||||
const end = target.selectionEnd;
|
|
||||||
const value = props.modelValue || '';
|
|
||||||
const nextValue = `${value.slice(0, start)} ${value.slice(end)}`;
|
|
||||||
emit('update:modelValue', nextValue);
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
target.selectionStart = start + 2;
|
|
||||||
target.selectionEnd = start + 2;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScroll(event: Event) {
|
function getValue() {
|
||||||
if (!lineNumbersRef.value) {
|
return props.modelValue || '';
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lineNumbersRef.value.scrollTop = (event.target as HTMLTextAreaElement).scrollTop;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
function setValue(value: string) {
|
||||||
focus: () => textareaRef.value?.focus(),
|
emitValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function format() {
|
||||||
|
if (props.language !== 'json') return;
|
||||||
|
try {
|
||||||
|
emitValue(JSON.stringify(JSON.parse(getValue()), null, props.tabSize));
|
||||||
|
} catch {
|
||||||
|
// 保持轻量实现,格式化失败时不打断表单编辑。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
emit('ready', textareaRef.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose<CodeEditorExpose>({
|
||||||
|
focus,
|
||||||
|
format,
|
||||||
|
getValue,
|
||||||
|
getView: () => textareaRef.value,
|
||||||
|
setValue,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
|
||||||
class="code-editor"
|
|
||||||
:class="{ 'code-editor--readonly': readonly }"
|
|
||||||
:style="editorStyle"
|
|
||||||
>
|
|
||||||
<div class="code-editor__body">
|
|
||||||
<pre
|
|
||||||
v-if="lineNumbers"
|
|
||||||
ref="lineNumbersRef"
|
|
||||||
class="code-editor__lines"
|
|
||||||
aria-hidden="true"
|
|
||||||
>{{ lineNumbersText }}</pre
|
|
||||||
>
|
|
||||||
<textarea
|
<textarea
|
||||||
ref="textareaRef"
|
ref="textareaRef"
|
||||||
class="code-editor__textarea"
|
class="zq-code-editor"
|
||||||
|
:disabled="disabled"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:spellcheck="false"
|
:style="editorStyle"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
@change="handleChange"
|
spellcheck="false"
|
||||||
@input="handleInput"
|
@blur="emit('blur')"
|
||||||
@keydown="handleKeydown"
|
@focus="emit('focus')"
|
||||||
@scroll="handleScroll"
|
@input="emitValue(($event.target as HTMLTextAreaElement).value)"
|
||||||
></textarea>
|
></textarea>
|
||||||
<span v-if="language" class="code-editor__language">
|
|
||||||
{{ languageLabel }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.code-editor {
|
.zq-code-editor {
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: 6px;
|
|
||||||
background: hsl(var(--muted) / 45%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-editor__body {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-editor__lines {
|
|
||||||
min-width: 42px;
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 10px 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
border-right: 1px solid hsl(var(--border));
|
|
||||||
color: hsl(var(--muted-foreground) / 70%);
|
|
||||||
font-family:
|
|
||||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
|
||||||
'Courier New', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
text-align: right;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-editor__textarea {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
resize: vertical;
|
||||||
min-width: 0;
|
border: 1px solid var(--el-border-color);
|
||||||
padding: 10px 12px;
|
border-radius: 6px;
|
||||||
overflow: auto;
|
background: var(--el-fill-color-blank);
|
||||||
border: 0;
|
color: var(--el-text-color-primary);
|
||||||
outline: none;
|
|
||||||
resize: none;
|
|
||||||
background: transparent;
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
font-family:
|
font-family:
|
||||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||||
'Courier New', monospace;
|
'Courier New', monospace;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
tab-size: 2;
|
outline: none;
|
||||||
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-editor__textarea::placeholder {
|
.zq-code-editor:focus {
|
||||||
color: hsl(var(--muted-foreground) / 55%);
|
border-color: var(--el-color-primary);
|
||||||
|
box-shadow: 0 0 0 1px var(--el-color-primary-light-7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-editor__language {
|
.zq-code-editor:disabled {
|
||||||
position: absolute;
|
cursor: not-allowed;
|
||||||
right: 8px;
|
background: var(--el-disabled-bg-color);
|
||||||
bottom: 6px;
|
color: var(--el-disabled-text-color);
|
||||||
pointer-events: none;
|
|
||||||
color: hsl(var(--muted-foreground) / 55%);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-editor--readonly .code-editor__textarea {
|
|
||||||
cursor: default;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
export { default as CodeEditor } from './code-editor.vue';
|
export { default as CodeEditor } from './code-editor.vue';
|
||||||
|
export { supportedLanguages } from './languages';
|
||||||
|
export * from './types';
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { CodeLanguage } from './types';
|
||||||
|
|
||||||
|
export const supportedLanguages: CodeLanguage[] = [
|
||||||
|
'javascript',
|
||||||
|
'typescript',
|
||||||
|
'python',
|
||||||
|
'sql',
|
||||||
|
'json',
|
||||||
|
'html',
|
||||||
|
'css',
|
||||||
|
'markdown',
|
||||||
|
'xml',
|
||||||
|
'yaml',
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function getLanguageExtension() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export type CodeLanguage =
|
||||||
|
| 'css'
|
||||||
|
| 'html'
|
||||||
|
| 'javascript'
|
||||||
|
| 'json'
|
||||||
|
| 'markdown'
|
||||||
|
| 'python'
|
||||||
|
| 'sql'
|
||||||
|
| 'typescript'
|
||||||
|
| 'xml'
|
||||||
|
| 'yaml';
|
||||||
|
|
||||||
|
export interface CodeEditorProps {
|
||||||
|
modelValue?: string;
|
||||||
|
language?: CodeLanguage | string;
|
||||||
|
theme?: 'auto' | 'dark' | 'light';
|
||||||
|
readonly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
height?: number | string;
|
||||||
|
minHeight?: number | string;
|
||||||
|
maxHeight?: number | string;
|
||||||
|
placeholder?: string;
|
||||||
|
tabSize?: number;
|
||||||
|
lineNumbers?: boolean;
|
||||||
|
lineWrapping?: boolean;
|
||||||
|
foldGutter?: boolean;
|
||||||
|
highlightActiveLine?: boolean;
|
||||||
|
bracketMatching?: boolean;
|
||||||
|
autocompletion?: boolean;
|
||||||
|
indentGuide?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeEditorEmits {
|
||||||
|
(e: 'update:modelValue', value: string): void;
|
||||||
|
(e: 'change', value: string): void;
|
||||||
|
(e: 'focus'): void;
|
||||||
|
(e: 'blur'): void;
|
||||||
|
(e: 'ready', value: HTMLTextAreaElement | null): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeEditorExpose {
|
||||||
|
getView: () => HTMLTextAreaElement | null;
|
||||||
|
focus: () => void;
|
||||||
|
getValue: () => string;
|
||||||
|
setValue: (value: string) => void;
|
||||||
|
format: () => void;
|
||||||
|
}
|
||||||
@@ -3,17 +3,14 @@ import type { FormSelectorEmits, FormSelectorProps } from './types';
|
|||||||
|
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { Table2 } from '@vben/icons';
|
import { Search, Table2 } from '@vben/icons';
|
||||||
import { $t } from '@vben/locales';
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ElButton,
|
ElButton,
|
||||||
ElCheckbox,
|
|
||||||
ElEmpty,
|
|
||||||
ElInput,
|
ElInput,
|
||||||
ElOption,
|
ElOption,
|
||||||
ElPagination,
|
ElPagination,
|
||||||
ElRadio,
|
|
||||||
ElSelect,
|
ElSelect,
|
||||||
ElTable,
|
ElTable,
|
||||||
ElTableColumn,
|
ElTableColumn,
|
||||||
@@ -23,145 +20,167 @@ import { requestClient } from '#/api/request';
|
|||||||
import { ZqDialog } from '#/components/zq-dialog';
|
import { ZqDialog } from '#/components/zq-dialog';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'FormSelector',
|
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
|
name: 'FormSelector',
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectorText = {
|
|
||||||
clickToSelect: '点击选择',
|
|
||||||
nodeLabel: '名称',
|
|
||||||
nodeValue: '值',
|
|
||||||
search: '搜索',
|
|
||||||
selectData: '选择数据',
|
|
||||||
selectFormFirst: '请先选择表单',
|
|
||||||
selectedCount: (count: number) => `已选择 ${count} 项`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<FormSelectorProps>(), {
|
const props = withDefaults(defineProps<FormSelectorProps>(), {
|
||||||
multiple: false,
|
|
||||||
collapseTags: false,
|
|
||||||
maxCollapseTags: 1,
|
|
||||||
placeholder: selectorText.clickToSelect,
|
|
||||||
disabled: false,
|
|
||||||
clearable: true,
|
clearable: true,
|
||||||
dialogTitle: selectorText.selectData,
|
collapseTags: false,
|
||||||
dialogWidth: '1200px',
|
dialogTitle: () => $t('form-design.attribute.selectData'),
|
||||||
valueField: 'id',
|
dialogWidth: '960px',
|
||||||
labelField: 'name',
|
disabled: false,
|
||||||
expandMultipleToRows: false,
|
expandMultipleToRows: false,
|
||||||
externalSelectedValues: () => [],
|
externalSelectedValues: () => [],
|
||||||
initialFilters: undefined,
|
initialFilters: undefined,
|
||||||
|
labelField: 'name',
|
||||||
|
maxCollapseTags: 1,
|
||||||
|
multiple: false,
|
||||||
|
placeholder: () => $t('form-design.attribute.clickToSelect'),
|
||||||
|
valueField: 'id',
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<FormSelectorEmits>();
|
const emit = defineEmits<FormSelectorEmits>();
|
||||||
|
|
||||||
const dialogVisible = ref(false);
|
const dialogVisible = ref(false);
|
||||||
const loading = ref(false);
|
const selectedValues = ref<Set<string>>(new Set());
|
||||||
|
const selectedItemsMap = ref<Map<string, any>>(new Map());
|
||||||
const labelsLoading = ref(false);
|
const labelsLoading = ref(false);
|
||||||
const tableData = ref<Array<Record<string, any>>>([]);
|
const listLoading = ref(false);
|
||||||
const total = ref(0);
|
const listData = ref<any[]>([]);
|
||||||
|
const listTotal = ref(0);
|
||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
const pageSize = ref(10);
|
const pageSize = ref(10);
|
||||||
const searchKeyword = ref('');
|
const keyword = ref('');
|
||||||
const selectedValues = ref<Set<string>>(new Set());
|
|
||||||
const selectedItemsMap = ref<Map<string, Record<string, any>>>(new Map());
|
|
||||||
|
|
||||||
function normalizeValues(value: null | string | string[] | undefined) {
|
function toSelectedSet(source: unknown): Set<string> {
|
||||||
if (!value) return [];
|
if (source instanceof Set) return source;
|
||||||
return Array.isArray(value) ? value.map(String) : [String(value)];
|
if (Array.isArray(source)) return new Set(source.map(String));
|
||||||
|
return new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toStringArray(value: unknown) {
|
function toStringArray(source: unknown): string[] {
|
||||||
if (!value) return [];
|
if (!source) return [];
|
||||||
return Array.isArray(value) ? value.map(String) : [String(value)];
|
if (Array.isArray(source)) return source.map(String);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getItemLabel(item: undefined | Record<string, any>, value: string) {
|
function buildFilterParams() {
|
||||||
if (!item) return value;
|
|
||||||
return (
|
|
||||||
item[props.labelField] ??
|
|
||||||
item.name ??
|
|
||||||
item.title ??
|
|
||||||
item.label ??
|
|
||||||
item.text ??
|
|
||||||
value
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickList(response: any) {
|
|
||||||
return Array.isArray(response)
|
|
||||||
? response
|
|
||||||
: response?.items || response?.list || response?.data || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeFilters() {
|
|
||||||
const params: Record<string, any> = {};
|
const params: Record<string, any> = {};
|
||||||
for (const [field, filter] of Object.entries(props.initialFilters || {})) {
|
for (const [field, config] of Object.entries(props.initialFilters || {})) {
|
||||||
if (filter?.value === undefined || filter.value === null || filter.value === '') {
|
if (config?.value !== undefined && config?.value !== '') {
|
||||||
continue;
|
params[`filter_${field}`] = config.value;
|
||||||
}
|
}
|
||||||
const op = filter.type || 'eq';
|
|
||||||
params[op === 'eq' ? field : `${field}__${op}`] = filter.value;
|
|
||||||
}
|
}
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadInitialLabels(values: string[]) {
|
function getItemLabel(item: any, value: string): string {
|
||||||
if (!props.formCode || values.length === 0) return;
|
if (!item) return value;
|
||||||
|
if (props.labelField && item[props.labelField]) return item[props.labelField];
|
||||||
|
for (const field of ['name', 'label', 'title', 'text']) {
|
||||||
|
if (item[field]) return item[field];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInitialLabels(values: any[]) {
|
||||||
|
if (!props.formCode) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stringValues = values.map(String);
|
||||||
|
const response = await requestClient.get(
|
||||||
|
`/api/online_dev/form-data/${props.formCode}/list`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
[`filter_${props.valueField}`]: stringValues.join(','),
|
||||||
|
pageSize: stringValues.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
for (const item of response?.items || []) {
|
||||||
|
selectedItemsMap.value.set(String(item[props.valueField]), item);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load form selector labels failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDialogList() {
|
||||||
|
if (!props.formCode) return;
|
||||||
|
|
||||||
|
listLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await requestClient.get(
|
const response = await requestClient.get(
|
||||||
`/api/online_dev/form-data/${props.formCode}/list`,
|
`/api/online_dev/form-data/${props.formCode}/list`,
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
...normalizeFilters(),
|
...buildFilterParams(),
|
||||||
[`${props.valueField}__in`]: values.join(','),
|
keyword: keyword.value || undefined,
|
||||||
pageSize: values.length,
|
page: currentPage.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
for (const item of pickList(response)) {
|
listData.value = response?.items || [];
|
||||||
selectedItemsMap.value.set(String(item[props.valueField]), item);
|
listTotal.value = response?.total || listData.value.length;
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('load form selector labels failed:', error);
|
console.error('Load form selector list failed:', error);
|
||||||
|
listData.value = [];
|
||||||
|
listTotal.value = 0;
|
||||||
|
} finally {
|
||||||
|
listLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitSelectItem() {
|
function emitSelectItem() {
|
||||||
const items = [...selectedValues.value]
|
const items = [...toSelectedSet(selectedValues.value)]
|
||||||
.map((value) => selectedItemsMap.value.get(value))
|
.map((value) => selectedItemsMap.value.get(value))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
emit('select-item', props.multiple ? items : items[0]);
|
emit('select-item', props.multiple ? (items.length ? items : undefined) : items[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
async (value) => {
|
async (value) => {
|
||||||
const values = normalizeValues(value);
|
if (value) {
|
||||||
selectedValues.value = new Set(values);
|
const values = Array.isArray(value) ? value : [value];
|
||||||
const missingValues = values.filter((v) => !selectedItemsMap.value.has(v));
|
selectedValues.value = new Set(values.map(String));
|
||||||
|
const missingValues = values.filter(
|
||||||
|
(item) => !selectedItemsMap.value.has(String(item)),
|
||||||
|
);
|
||||||
|
|
||||||
if (missingValues.length > 0) {
|
if (missingValues.length > 0) {
|
||||||
labelsLoading.value = true;
|
labelsLoading.value = true;
|
||||||
await loadInitialLabels(missingValues);
|
await loadInitialLabels(missingValues);
|
||||||
labelsLoading.value = false;
|
labelsLoading.value = false;
|
||||||
if (!props.expandMultipleToRows) emitSelectItem();
|
if (!props.expandMultipleToRows) emitSelectItem();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
selectedValues.value = new Set();
|
||||||
|
selectedItemsMap.value.clear();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.formCode,
|
() => props.formCode,
|
||||||
async () => {
|
async (formCode) => {
|
||||||
const values = normalizeValues(props.modelValue);
|
if (formCode && props.modelValue) {
|
||||||
const missingValues = values.filter((v) => !selectedItemsMap.value.has(v));
|
const values = Array.isArray(props.modelValue)
|
||||||
|
? props.modelValue
|
||||||
|
: [props.modelValue];
|
||||||
|
const missingValues = values.filter(
|
||||||
|
(item) => !selectedItemsMap.value.has(String(item)),
|
||||||
|
);
|
||||||
if (missingValues.length > 0) {
|
if (missingValues.length > 0) {
|
||||||
labelsLoading.value = true;
|
labelsLoading.value = true;
|
||||||
await loadInitialLabels(missingValues);
|
await loadInitialLabels(missingValues);
|
||||||
labelsLoading.value = false;
|
labelsLoading.value = false;
|
||||||
if (!props.expandMultipleToRows) emitSelectItem();
|
if (!props.expandMultipleToRows) emitSelectItem();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -169,137 +188,59 @@ const selectValue = computed(() => {
|
|||||||
if (labelsLoading.value) return props.multiple ? [] : '';
|
if (labelsLoading.value) return props.multiple ? [] : '';
|
||||||
|
|
||||||
if (props.expandMultipleToRows) {
|
if (props.expandMultipleToRows) {
|
||||||
const values = normalizeValues(props.modelValue);
|
if (!props.modelValue) return props.multiple ? [] : '';
|
||||||
return props.multiple ? values : values[0] || '';
|
return props.multiple
|
||||||
|
? Array.isArray(props.modelValue)
|
||||||
|
? props.modelValue
|
||||||
|
: [props.modelValue]
|
||||||
|
: Array.isArray(props.modelValue)
|
||||||
|
? props.modelValue[0] || ''
|
||||||
|
: props.modelValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const values = [...selectedValues.value];
|
const values = [...toSelectedSet(selectedValues.value)];
|
||||||
return props.multiple ? values : values[0] || '';
|
return props.multiple ? values : values[0] || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectOptions = computed(() => {
|
const selectOptions = computed(() => {
|
||||||
const values = props.expandMultipleToRows
|
if (labelsLoading.value) return [];
|
||||||
? normalizeValues(props.modelValue)
|
|
||||||
: [...selectedValues.value];
|
if (props.expandMultipleToRows) {
|
||||||
return values.map((value) => ({
|
if (!props.modelValue) return [];
|
||||||
value,
|
const values = Array.isArray(props.modelValue)
|
||||||
|
? props.modelValue
|
||||||
|
: [props.modelValue];
|
||||||
|
return values.filter(Boolean).map((value) => {
|
||||||
|
const stringValue = String(value);
|
||||||
|
return {
|
||||||
|
label: getItemLabel(selectedItemsMap.value.get(stringValue), stringValue),
|
||||||
|
value: stringValue,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...toSelectedSet(selectedValues.value)].map((value) => ({
|
||||||
label: getItemLabel(selectedItemsMap.value.get(value), value),
|
label: getItemLabel(selectedItemsMap.value.get(value), value),
|
||||||
|
value,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
if (!props.formCode) {
|
|
||||||
tableData.value = [];
|
|
||||||
total.value = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const params: Record<string, any> = {
|
|
||||||
...normalizeFilters(),
|
|
||||||
page: currentPage.value,
|
|
||||||
pageSize: pageSize.value,
|
|
||||||
};
|
|
||||||
if (searchKeyword.value) {
|
|
||||||
params[`${props.labelField}__like`] = searchKeyword.value;
|
|
||||||
}
|
|
||||||
const response = await requestClient.get(
|
|
||||||
`/api/online_dev/form-data/${props.formCode}/list`,
|
|
||||||
{ params },
|
|
||||||
);
|
|
||||||
const rows = pickList(response);
|
|
||||||
tableData.value = rows;
|
|
||||||
total.value = Number(response?.total ?? rows.length);
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const value = String(row[props.valueField]);
|
|
||||||
if (selectedValues.value.has(value)) selectedItemsMap.value.set(value, row);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('load form selector data failed:', error);
|
|
||||||
tableData.value = [];
|
|
||||||
total.value = 0;
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDialog() {
|
function openDialog() {
|
||||||
if (props.disabled) return;
|
if (props.disabled) return;
|
||||||
|
|
||||||
if (props.expandMultipleToRows) {
|
const externalValues = toStringArray(props.externalSelectedValues);
|
||||||
for (const value of toStringArray(props.externalSelectedValues)) {
|
if (props.expandMultipleToRows && externalValues.length > 0) {
|
||||||
selectedValues.value.add(value);
|
for (const value of externalValues) selectedValues.value.add(value);
|
||||||
}
|
|
||||||
selectedValues.value = new Set(selectedValues.value);
|
selectedValues.value = new Set(selectedValues.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearch() {
|
|
||||||
currentPage.value = 1;
|
currentPage.value = 1;
|
||||||
loadData();
|
loadDialogList();
|
||||||
}
|
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
|
||||||
currentPage.value = page;
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSizeChange(size: number) {
|
|
||||||
pageSize.value = size;
|
|
||||||
currentPage.value = 1;
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRowValue(row: Record<string, any>) {
|
|
||||||
return String(row[props.valueField]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRowSelected(row: Record<string, any>) {
|
|
||||||
return selectedValues.value.has(getRowValue(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleRow(row: Record<string, any>, checked?: boolean | number | string) {
|
|
||||||
const value = getRowValue(row);
|
|
||||||
if (props.multiple) {
|
|
||||||
const nextChecked = checked ?? !selectedValues.value.has(value);
|
|
||||||
if (nextChecked) {
|
|
||||||
selectedValues.value.add(value);
|
|
||||||
selectedItemsMap.value.set(value, row);
|
|
||||||
} else {
|
|
||||||
selectedValues.value.delete(value);
|
|
||||||
selectedItemsMap.value.delete(value);
|
|
||||||
}
|
|
||||||
selectedValues.value = new Set(selectedValues.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedValues.value = new Set([value]);
|
|
||||||
selectedItemsMap.value.clear();
|
|
||||||
selectedItemsMap.value.set(value, row);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAllSelected = computed(
|
|
||||||
() =>
|
|
||||||
tableData.value.length > 0 &&
|
|
||||||
tableData.value.every((row) => isRowSelected(row)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const isIndeterminate = computed(() => {
|
|
||||||
const selectedCount = tableData.value.filter((row) => isRowSelected(row)).length;
|
|
||||||
return selectedCount > 0 && selectedCount < tableData.value.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleSelectAll(checked: boolean | number | string) {
|
|
||||||
for (const row of tableData.value) toggleRow(row, checked);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleConfirm() {
|
function handleConfirm() {
|
||||||
const values = [...selectedValues.value];
|
const values = [...toSelectedSet(selectedValues.value)];
|
||||||
const result = props.multiple ? values : values[0] || null;
|
const result = props.multiple ? values : values[0] || null;
|
||||||
|
|
||||||
emit('update:modelValue', result);
|
emit('update:modelValue', result);
|
||||||
@@ -321,12 +262,53 @@ function handleRemoveTag(value: string) {
|
|||||||
selectedItemsMap.value.delete(value);
|
selectedItemsMap.value.delete(value);
|
||||||
selectedValues.value = new Set(selectedValues.value);
|
selectedValues.value = new Set(selectedValues.value);
|
||||||
|
|
||||||
const values = [...selectedValues.value];
|
const values = [...toSelectedSet(selectedValues.value)];
|
||||||
const result = props.multiple ? values : values[0] || null;
|
const result = props.multiple ? values : values[0] || null;
|
||||||
emit('update:modelValue', result);
|
emit('update:modelValue', result);
|
||||||
emit('change', result);
|
emit('change', result);
|
||||||
emitSelectItem();
|
emitSelectItem();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRowSelect(row: any) {
|
||||||
|
const value = String(row[props.valueField]);
|
||||||
|
|
||||||
|
if (props.multiple) {
|
||||||
|
if (selectedValues.value.has(value)) {
|
||||||
|
selectedValues.value.delete(value);
|
||||||
|
selectedItemsMap.value.delete(value);
|
||||||
|
} else {
|
||||||
|
selectedValues.value.add(value);
|
||||||
|
selectedItemsMap.value.set(value, row);
|
||||||
|
}
|
||||||
|
selectedValues.value = new Set(selectedValues.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedValues.value.has(value)) {
|
||||||
|
selectedValues.value.clear();
|
||||||
|
selectedItemsMap.value.clear();
|
||||||
|
} else {
|
||||||
|
selectedValues.value = new Set([value]);
|
||||||
|
selectedItemsMap.value.clear();
|
||||||
|
selectedItemsMap.value.set(value, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
currentPage.value = 1;
|
||||||
|
loadDialogList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSizeChange(size: number) {
|
||||||
|
pageSize.value = size;
|
||||||
|
currentPage.value = 1;
|
||||||
|
loadDialogList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCurrentChange(page: number) {
|
||||||
|
currentPage.value = page;
|
||||||
|
loadDialogList();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -363,97 +345,83 @@ function handleRemoveTag(value: string) {
|
|||||||
append-to-body
|
append-to-body
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
:show-fullscreen-button="false"
|
:show-fullscreen-button="false"
|
||||||
class="form-selector-dialog h-[90%]"
|
class="form-selector-dialog"
|
||||||
>
|
>
|
||||||
<div v-if="formCode" class="form-selector-content flex h-[650px] flex-col">
|
<div v-if="formCode" class="form-selector-panel">
|
||||||
<div class="mb-4 flex shrink-0 items-center gap-2">
|
<div class="form-selector-toolbar">
|
||||||
<ElInput
|
<ElInput
|
||||||
v-model="searchKeyword"
|
v-model="keyword"
|
||||||
:placeholder="selectorText.search"
|
|
||||||
clearable
|
clearable
|
||||||
class="w-64"
|
:prefix-icon="Search"
|
||||||
@change="handleSearch"
|
:placeholder="$t('common.search')"
|
||||||
@keyup.enter="handleSearch"
|
@keyup.enter="handleSearch"
|
||||||
|
@clear="handleSearch"
|
||||||
/>
|
/>
|
||||||
<ElButton type="primary" @click="handleSearch">
|
<ElButton type="primary" @click="handleSearch">
|
||||||
{{ $t('common.search') }}
|
{{ $t('common.search') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-h-0 flex-1">
|
|
||||||
<ElTable
|
<ElTable
|
||||||
v-loading="loading"
|
v-loading="listLoading"
|
||||||
:data="tableData"
|
:data="listData"
|
||||||
|
height="420"
|
||||||
border
|
border
|
||||||
stripe
|
highlight-current-row
|
||||||
height="100%"
|
@row-click="handleRowSelect"
|
||||||
:row-class-name="
|
|
||||||
({ row }: any) => (isRowSelected(row) ? 'selected-row' : '')
|
|
||||||
"
|
|
||||||
style="width: 100%"
|
|
||||||
@row-click="toggleRow"
|
|
||||||
>
|
>
|
||||||
<ElTableColumn v-if="!multiple" width="55" align="center">
|
<ElTableColumn width="56" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<ElRadio
|
<span
|
||||||
:model-value="isRowSelected(row) ? getRowValue(row) : ''"
|
class="form-selector-check"
|
||||||
:value="getRowValue(row)"
|
:class="{
|
||||||
@click.stop
|
'is-selected': selectedValues.has(String(row[valueField])),
|
||||||
@change="toggleRow(row)"
|
}"
|
||||||
>
|
>
|
||||||
<span></span>
|
{{ selectedValues.has(String(row[valueField])) ? '✓' : '' }}
|
||||||
</ElRadio>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
<ElTableColumn v-else width="55" align="center">
|
<ElTableColumn
|
||||||
<template #header>
|
:prop="labelField"
|
||||||
<ElCheckbox
|
:label="labelField"
|
||||||
:model-value="isAllSelected"
|
min-width="180"
|
||||||
:indeterminate="isIndeterminate"
|
show-overflow-tooltip
|
||||||
@change="handleSelectAll"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
<ElTableColumn
|
||||||
<template #default="{ row }">
|
:prop="valueField"
|
||||||
<ElCheckbox
|
:label="valueField"
|
||||||
:model-value="isRowSelected(row)"
|
min-width="160"
|
||||||
@click.stop
|
show-overflow-tooltip
|
||||||
@change="toggleRow(row, $event)"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
|
||||||
</ElTableColumn>
|
|
||||||
<ElTableColumn :prop="labelField" :label="selectorText.nodeLabel" />
|
|
||||||
<ElTableColumn :prop="valueField" :label="selectorText.nodeValue" width="180" />
|
|
||||||
<template #empty>
|
|
||||||
<ElEmpty :description="$t('common.noData')" />
|
|
||||||
</template>
|
|
||||||
</ElTable>
|
</ElTable>
|
||||||
|
|
||||||
|
<div class="form-selector-pagination">
|
||||||
|
<ElPagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="listTotal"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="no-form-code">
|
<div v-else class="no-form-code">
|
||||||
{{ selectorText.selectFormFirst }}
|
{{ $t('form-design.attribute.selectFormFirst') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex w-full items-center">
|
<div class="flex w-full items-center justify-between">
|
||||||
<div class="flex-shrink-0">
|
<div class="text-sm text-[var(--el-text-color-secondary)]">
|
||||||
<ElPagination
|
|
||||||
v-if="formCode"
|
|
||||||
v-model:current-page="currentPage"
|
|
||||||
v-model:page-size="pageSize"
|
|
||||||
:total="total"
|
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
|
||||||
layout="total, sizes, prev, pager, next"
|
|
||||||
small
|
|
||||||
@current-change="handlePageChange"
|
|
||||||
@size-change="handleSizeChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-1 justify-center text-sm text-[var(--el-text-color-secondary)]">
|
|
||||||
{{
|
{{
|
||||||
selectorText.selectedCount(selectedValues.size)
|
$t('form-design.attribute.selectedCount', {
|
||||||
|
count: selectedValues.size,
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-shrink-0 gap-2">
|
<div class="flex gap-2">
|
||||||
<ElButton @click="dialogVisible = false">
|
<ElButton @click="dialogVisible = false">
|
||||||
{{ $t('common.cancel') }}
|
{{ $t('common.cancel') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
@@ -472,12 +440,37 @@ function handleRemoveTag(value: string) {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-selector-content :deep(.selected-row) {
|
.form-selector-panel {
|
||||||
background-color: var(--el-color-primary-light-9) !important;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-selector-content :deep(.el-table__row) {
|
.form-selector-toolbar {
|
||||||
cursor: pointer;
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-selector-check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-selector-check.is-selected {
|
||||||
|
color: var(--el-color-white);
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-selector-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-form-code {
|
.no-form-code {
|
||||||
|
|||||||
@@ -1,18 +1,31 @@
|
|||||||
export interface FormSelectorProps {
|
export interface FormSelectorProps {
|
||||||
modelValue?: null | string | string[];
|
modelValue?: null | string | string[];
|
||||||
formCode?: string;
|
formCode?: string;
|
||||||
|
/** 值字段,默认 id */
|
||||||
valueField?: string;
|
valueField?: string;
|
||||||
|
/** 显示字段,默认 name */
|
||||||
labelField?: string;
|
labelField?: string;
|
||||||
|
/** 是否多选 */
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
|
/** 多选时是否折叠标签 */
|
||||||
collapseTags?: boolean;
|
collapseTags?: boolean;
|
||||||
|
/** 多选时折叠标签的最大显示数量 */
|
||||||
maxCollapseTags?: number;
|
maxCollapseTags?: number;
|
||||||
|
/** 占位符 */
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** 是否禁用 */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** 是否可清空 */
|
||||||
clearable?: boolean;
|
clearable?: boolean;
|
||||||
|
/** 弹窗标题 */
|
||||||
dialogTitle?: string;
|
dialogTitle?: string;
|
||||||
|
/** 弹窗宽度 */
|
||||||
dialogWidth?: string;
|
dialogWidth?: string;
|
||||||
|
/** 多选展开为多行模式:输入框不实时显示弹窗内的选中项 */
|
||||||
expandMultipleToRows?: boolean;
|
expandMultipleToRows?: boolean;
|
||||||
|
/** 外部预设的已选中值(用于 expandMultipleToRows 模式下同步子表已有行) */
|
||||||
externalSelectedValues?: string[];
|
externalSelectedValues?: string[];
|
||||||
|
/** 弹窗列表初始过滤条件(FormDataList.initialFilters 格式) */
|
||||||
initialFilters?: Record<string, { type: string; value: any }>;
|
initialFilters?: Record<string, { type: string; value: any }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -87,7 +87,9 @@ watch(
|
|||||||
<div class="linked-field-wrapper">
|
<div class="linked-field-wrapper">
|
||||||
<ElInput
|
<ElInput
|
||||||
:model-value="displayValue || props.modelValue"
|
:model-value="displayValue || props.modelValue"
|
||||||
:placeholder="placeholder || '选择数据后自动带出'"
|
:placeholder="
|
||||||
|
placeholder || $t('form-design.attribute.linkedFieldPlaceholder')
|
||||||
|
"
|
||||||
:disabled="true"
|
:disabled="true"
|
||||||
readonly
|
readonly
|
||||||
class="linked-field"
|
class="linked-field"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+224
@@ -0,0 +1,224 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlignJustify,
|
||||||
|
Bold,
|
||||||
|
Code,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
Highlighter,
|
||||||
|
Italic,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Minus,
|
||||||
|
Quote,
|
||||||
|
} from '@vben/icons';
|
||||||
|
|
||||||
|
interface CommandItem {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
command: (props: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: CommandItem[];
|
||||||
|
command: (item: CommandItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const selectedIndex = ref(0);
|
||||||
|
|
||||||
|
const iconComponents: Record<string, any> = {
|
||||||
|
Text: AlignJustify,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Quote,
|
||||||
|
Code,
|
||||||
|
Minus,
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Highlighter,
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredItems = computed(() => props.items || []);
|
||||||
|
|
||||||
|
function selectItem(index: number) {
|
||||||
|
const item = filteredItems.value[index];
|
||||||
|
if (item) {
|
||||||
|
props.command(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
selectedIndex.value =
|
||||||
|
(selectedIndex.value + filteredItems.value.length - 1) %
|
||||||
|
filteredItems.value.length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
selectedIndex.value =
|
||||||
|
(selectedIndex.value + 1) % filteredItems.value.length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
selectItem(selectedIndex.value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
selectedIndex.value = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
selectedIndex.value = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
onKeyDown,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="slash-command-menu">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in filteredItems"
|
||||||
|
:key="index"
|
||||||
|
class="slash-command-item"
|
||||||
|
:class="{ 'is-selected': index === selectedIndex }"
|
||||||
|
@click="selectItem(index)"
|
||||||
|
@mouseenter="selectedIndex = index"
|
||||||
|
>
|
||||||
|
<div class="slash-command-icon">
|
||||||
|
<component :is="iconComponents[item.icon]" class="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div class="slash-command-content">
|
||||||
|
<div class="slash-command-title">{{ item.title }}</div>
|
||||||
|
<div class="slash-command-description">{{ item.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="filteredItems.length === 0" class="slash-command-empty">
|
||||||
|
没有找到匹配的命令
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 覆盖 tippy.js 的默认样式 */
|
||||||
|
.tippy-box {
|
||||||
|
background-color: transparent !important;
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippy-content {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippy-arrow {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.slash-command-menu {
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
/* border: 1px solid var(--el-border-color); */
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 美化滚动条 - 鼠标悬停时显示 */
|
||||||
|
.slash-command-menu::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-menu::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-menu::-webkit-scrollbar-thumb {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-menu:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--el-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-menu::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--el-border-color-darker);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-item:hover,
|
||||||
|
.slash-command-item.is-selected {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: var(--el-fill-color);
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-description {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-empty {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Editor } from '@tiptap/vue-3';
|
||||||
|
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { GripVertical } from '@vben/icons';
|
||||||
|
|
||||||
|
import TableMenu from './TableMenu.vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
editor: Editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const showRowHandle = ref(false);
|
||||||
|
const showColumnHandle = ref(false);
|
||||||
|
const rowHandlePosition = ref({ top: 0, left: 0, height: 0 });
|
||||||
|
const columnHandlePosition = ref({ top: 0, left: 0, width: 0 });
|
||||||
|
const currentRowIndex = ref(-1);
|
||||||
|
const currentColumnIndex = ref(-1);
|
||||||
|
|
||||||
|
// 保存当前悬停的单元格,用于点击手柄时聚焦
|
||||||
|
let currentCell: HTMLElement | null = null;
|
||||||
|
|
||||||
|
const showMenu = ref(false);
|
||||||
|
const menuType = ref<'column' | 'row'>('row');
|
||||||
|
const menuPosition = ref({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
// 用于追踪鼠标是否在手柄区域
|
||||||
|
const isMouseOverHandle = ref(false);
|
||||||
|
let hideTimeout: null | ReturnType<typeof setTimeout> = null;
|
||||||
|
|
||||||
|
let editorElement: HTMLElement | null = null;
|
||||||
|
|
||||||
|
function handleMouseMove(e: MouseEvent) {
|
||||||
|
if (!props.editor || !editorElement) return;
|
||||||
|
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const cell = target.closest('td, th') as HTMLElement;
|
||||||
|
const table = target.closest('table') as HTMLElement;
|
||||||
|
|
||||||
|
if (!cell || !table) {
|
||||||
|
// 不立即隐藏,给用户时间移动到手柄
|
||||||
|
scheduleHide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消隐藏计时器
|
||||||
|
cancelHide();
|
||||||
|
const row = cell.closest('tr') as HTMLElement;
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
const tableRect = table.getBoundingClientRect();
|
||||||
|
const rowRect = row.getBoundingClientRect();
|
||||||
|
const cellRect = cell.getBoundingClientRect();
|
||||||
|
|
||||||
|
// 计算行索引
|
||||||
|
const rows = [...table.querySelectorAll('tr')] as HTMLElement[];
|
||||||
|
currentRowIndex.value = rows.indexOf(row);
|
||||||
|
|
||||||
|
// 计算列索引
|
||||||
|
const cells = [...row.querySelectorAll('td, th')];
|
||||||
|
currentColumnIndex.value = cells.indexOf(cell);
|
||||||
|
|
||||||
|
// 保存当前单元格引用
|
||||||
|
currentCell = cell;
|
||||||
|
|
||||||
|
// 行手柄位置(左侧)- 高度与行高一致
|
||||||
|
rowHandlePosition.value = {
|
||||||
|
top: rowRect.top,
|
||||||
|
left: tableRect.left - 20,
|
||||||
|
height: rowRect.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 列手柄位置(顶部)- 宽度与列宽一致
|
||||||
|
columnHandlePosition.value = {
|
||||||
|
top: tableRect.top - 20,
|
||||||
|
left: cellRect.left,
|
||||||
|
width: cellRect.width,
|
||||||
|
};
|
||||||
|
|
||||||
|
showRowHandle.value = true;
|
||||||
|
showColumnHandle.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleHide() {
|
||||||
|
if (hideTimeout) return;
|
||||||
|
hideTimeout = setTimeout(() => {
|
||||||
|
if (!isMouseOverHandle.value && !showMenu.value) {
|
||||||
|
showRowHandle.value = false;
|
||||||
|
showColumnHandle.value = false;
|
||||||
|
}
|
||||||
|
hideTimeout = null;
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelHide() {
|
||||||
|
if (hideTimeout) {
|
||||||
|
clearTimeout(hideTimeout);
|
||||||
|
hideTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
scheduleHide();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHandleMouseEnter() {
|
||||||
|
isMouseOverHandle.value = true;
|
||||||
|
cancelHide();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHandleMouseLeave() {
|
||||||
|
isMouseOverHandle.value = false;
|
||||||
|
scheduleHide();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowClick(e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// 选中当前行
|
||||||
|
if (currentRowIndex.value >= 0) {
|
||||||
|
selectRow(currentRowIndex.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
menuType.value = 'row';
|
||||||
|
menuPosition.value = { x: e.clientX, y: e.clientY };
|
||||||
|
showMenu.value = true;
|
||||||
|
|
||||||
|
// 点击其他地方关闭菜单
|
||||||
|
const closeMenu = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
// 如果点击的是菜单内部,不关闭
|
||||||
|
if (target.closest('.table-menu')) return;
|
||||||
|
|
||||||
|
showMenu.value = false;
|
||||||
|
showRowHandle.value = false;
|
||||||
|
showColumnHandle.value = false;
|
||||||
|
document.removeEventListener('click', closeMenu);
|
||||||
|
};
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('click', closeMenu);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColumnClick(e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// 选中当前列
|
||||||
|
if (currentColumnIndex.value >= 0) {
|
||||||
|
selectColumn(currentColumnIndex.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
menuType.value = 'column';
|
||||||
|
menuPosition.value = { x: e.clientX, y: e.clientY };
|
||||||
|
showMenu.value = true;
|
||||||
|
|
||||||
|
// 点击其他地方关闭菜单
|
||||||
|
const closeMenu = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
// 如果点击的是菜单内部,不关闭
|
||||||
|
if (target.closest('.table-menu')) return;
|
||||||
|
|
||||||
|
showMenu.value = false;
|
||||||
|
showRowHandle.value = false;
|
||||||
|
showColumnHandle.value = false;
|
||||||
|
document.removeEventListener('click', closeMenu);
|
||||||
|
};
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('click', closeMenu);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusCurrentCell() {
|
||||||
|
if (!currentCell || !props.editor) return;
|
||||||
|
|
||||||
|
// 获取单元格在编辑器中的位置并聚焦
|
||||||
|
const view = props.editor.view;
|
||||||
|
const pos = view.posAtDOM(currentCell, 0);
|
||||||
|
if (pos >= 0) {
|
||||||
|
props.editor.chain().focus().setTextSelection(pos).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRow(_rowIndex: number) {
|
||||||
|
focusCurrentCell();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectColumn(_colIndex: number) {
|
||||||
|
focusCurrentCell();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
showMenu.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
editorElement = document.querySelector(
|
||||||
|
'.notion-editor-wrapper .ProseMirror',
|
||||||
|
);
|
||||||
|
if (editorElement) {
|
||||||
|
editorElement.addEventListener('mousemove', handleMouseMove);
|
||||||
|
editorElement.addEventListener('mouseleave', handleMouseLeave);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (editorElement) {
|
||||||
|
editorElement.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
editorElement.removeEventListener('mouseleave', handleMouseLeave);
|
||||||
|
}
|
||||||
|
cancelHide();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- 行手柄 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="showRowHandle"
|
||||||
|
class="table-handle row-handle"
|
||||||
|
:style="{
|
||||||
|
top: `${rowHandlePosition.top}px`,
|
||||||
|
left: `${rowHandlePosition.left}px`,
|
||||||
|
height: `${rowHandlePosition.height}px`,
|
||||||
|
}"
|
||||||
|
@click="handleRowClick"
|
||||||
|
@mouseenter="handleHandleMouseEnter"
|
||||||
|
@mouseleave="handleHandleMouseLeave"
|
||||||
|
>
|
||||||
|
<GripVertical class="handle-icon" />
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- 列手柄 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="showColumnHandle"
|
||||||
|
class="table-handle column-handle"
|
||||||
|
:style="{
|
||||||
|
top: `${columnHandlePosition.top}px`,
|
||||||
|
left: `${columnHandlePosition.left}px`,
|
||||||
|
width: `${columnHandlePosition.width}px`,
|
||||||
|
}"
|
||||||
|
@click="handleColumnClick"
|
||||||
|
@mouseenter="handleHandleMouseEnter"
|
||||||
|
@mouseleave="handleHandleMouseLeave"
|
||||||
|
>
|
||||||
|
<GripVertical class="handle-icon rotated" />
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- 菜单 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<TableMenu
|
||||||
|
v-if="showMenu"
|
||||||
|
:editor="editor"
|
||||||
|
:type="menuType"
|
||||||
|
:position="menuPosition"
|
||||||
|
@close="closeMenu"
|
||||||
|
/>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.table-handle {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-handle:hover {
|
||||||
|
background: var(--el-fill-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-handle {
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-handle {
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-icon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-icon.rotated {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Editor } from '@tiptap/vue-3';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
ChevronRight,
|
||||||
|
Copy,
|
||||||
|
MoveDown,
|
||||||
|
MoveUp,
|
||||||
|
Paintbrush,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from '@vben/icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
editor: Editor;
|
||||||
|
type: 'column' | 'row';
|
||||||
|
position: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const showColorSubmenu = ref(false);
|
||||||
|
|
||||||
|
const isRow = computed(() => props.type === 'row');
|
||||||
|
|
||||||
|
const cellColors = [
|
||||||
|
{ name: '默认', value: '' },
|
||||||
|
{ name: '浅灰', value: '#f1f5f9' },
|
||||||
|
{ name: '浅红', value: '#fee2e2' },
|
||||||
|
{ name: '浅橙', value: '#ffedd5' },
|
||||||
|
{ name: '浅黄', value: '#fef9c3' },
|
||||||
|
{ name: '浅绿', value: '#dcfce7' },
|
||||||
|
{ name: '浅蓝', value: '#dbeafe' },
|
||||||
|
{ name: '浅紫', value: '#f3e8ff' },
|
||||||
|
{ name: '浅粉', value: '#fce7f3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function moveUp() {
|
||||||
|
// Tiptap 没有直接的移动行/列命令
|
||||||
|
// 暂不实现
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDown() {
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertBefore() {
|
||||||
|
if (isRow.value) {
|
||||||
|
(props.editor.chain().focus() as any).addRowBefore().run();
|
||||||
|
} else {
|
||||||
|
(props.editor.chain().focus() as any).addColumnBefore().run();
|
||||||
|
}
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertAfter() {
|
||||||
|
if (isRow.value) {
|
||||||
|
(props.editor.chain().focus() as any).addRowAfter().run();
|
||||||
|
} else {
|
||||||
|
(props.editor.chain().focus() as any).addColumnAfter().run();
|
||||||
|
}
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRowOrColumn() {
|
||||||
|
if (isRow.value) {
|
||||||
|
(props.editor.chain().focus() as any).deleteRow().run();
|
||||||
|
} else {
|
||||||
|
(props.editor.chain().focus() as any).deleteColumn().run();
|
||||||
|
}
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateRowOrColumn() {
|
||||||
|
// 复制行/列:先插入,然后复制内容(简化实现)
|
||||||
|
if (isRow.value) {
|
||||||
|
(props.editor.chain().focus() as any).addRowAfter().run();
|
||||||
|
} else {
|
||||||
|
(props.editor.chain().focus() as any).addColumnAfter().run();
|
||||||
|
}
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearContents() {
|
||||||
|
// 清空内容:选中单元格后删除内容
|
||||||
|
// 简化实现
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCellBackground(color: string) {
|
||||||
|
if (color) {
|
||||||
|
(props.editor.chain().focus() as any)
|
||||||
|
.setCellAttribute('backgroundColor', color)
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
(props.editor.chain().focus() as any)
|
||||||
|
.setCellAttribute('backgroundColor', null)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
showColorSubmenu.value = false;
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="table-menu"
|
||||||
|
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<div class="menu-content">
|
||||||
|
<!-- 移动操作 -->
|
||||||
|
<button class="menu-item" @click="moveUp" :disabled="true">
|
||||||
|
<component :is="isRow ? MoveUp : ArrowLeft" class="menu-icon" />
|
||||||
|
<span>{{ isRow ? '上移行' : '左移列' }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="menu-item" @click="moveDown" :disabled="true">
|
||||||
|
<component :is="isRow ? MoveDown : ArrowRight" class="menu-icon" />
|
||||||
|
<span>{{ isRow ? '下移行' : '右移列' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="menu-divider"></div>
|
||||||
|
|
||||||
|
<!-- 插入操作 -->
|
||||||
|
<button class="menu-item" @click="insertBefore">
|
||||||
|
<Plus class="menu-icon" />
|
||||||
|
<span>{{ isRow ? '在上方插入行' : '在左侧插入列' }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="menu-item" @click="insertAfter">
|
||||||
|
<Plus class="menu-icon" />
|
||||||
|
<span>{{ isRow ? '在下方插入行' : '在右侧插入列' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="menu-divider"></div>
|
||||||
|
|
||||||
|
<!-- 颜色 -->
|
||||||
|
<div
|
||||||
|
class="menu-item-with-submenu"
|
||||||
|
@mouseenter="showColorSubmenu = true"
|
||||||
|
@mouseleave="showColorSubmenu = false"
|
||||||
|
>
|
||||||
|
<button class="menu-item">
|
||||||
|
<Paintbrush class="menu-icon" />
|
||||||
|
<span>颜色</span>
|
||||||
|
<ChevronRight class="submenu-arrow" />
|
||||||
|
</button>
|
||||||
|
<div v-if="showColorSubmenu" class="submenu color-submenu">
|
||||||
|
<button
|
||||||
|
v-for="color in cellColors"
|
||||||
|
:key="color.value"
|
||||||
|
class="color-item"
|
||||||
|
@click="setCellBackground(color.value)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="color-preview"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: color.value || '#ffffff',
|
||||||
|
border: color.value
|
||||||
|
? 'none'
|
||||||
|
: '1px solid var(--el-border-color)',
|
||||||
|
}"
|
||||||
|
></span>
|
||||||
|
<span>{{ color.name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 清空内容(仅列) -->
|
||||||
|
<button
|
||||||
|
v-if="!isRow"
|
||||||
|
class="menu-item"
|
||||||
|
@click="clearContents"
|
||||||
|
:disabled="true"
|
||||||
|
>
|
||||||
|
<X class="menu-icon" />
|
||||||
|
<span>清空列内容</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="menu-divider"></div>
|
||||||
|
|
||||||
|
<!-- 复制 -->
|
||||||
|
<button class="menu-item" @click="duplicateRowOrColumn">
|
||||||
|
<Copy class="menu-icon" />
|
||||||
|
<span>{{ isRow ? '复制行' : '复制列' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 删除 -->
|
||||||
|
<button class="menu-item menu-item-danger" @click="deleteRowOrColumn">
|
||||||
|
<Trash2 class="menu-icon" />
|
||||||
|
<span>{{ isRow ? '删除行' : '删除列' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.table-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
min-width: 180px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover:not(:disabled) {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-danger {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-danger:hover:not(:disabled) {
|
||||||
|
background-color: var(--el-color-danger-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--el-border-color-lighter);
|
||||||
|
margin: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-with-submenu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-arrow {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin-left: auto;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% - 4px);
|
||||||
|
top: -4px;
|
||||||
|
min-width: 140px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
padding: 4px;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-submenu {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-item:hover {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-preview {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import type { Instance as TippyInstance } from 'tippy.js';
|
||||||
|
|
||||||
|
import { Extension } from '@tiptap/core';
|
||||||
|
import Suggestion from '@tiptap/suggestion';
|
||||||
|
import { VueRenderer } from '@tiptap/vue-3';
|
||||||
|
import tippy from 'tippy.js';
|
||||||
|
|
||||||
|
import SlashCommandMenu from '../components/SlashCommandMenu.vue';
|
||||||
|
|
||||||
|
export const SlashCommand = Extension.create({
|
||||||
|
name: 'slashCommand',
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
suggestion: {
|
||||||
|
char: '/',
|
||||||
|
startOfLine: false,
|
||||||
|
command: ({ editor, range, props }: any) => {
|
||||||
|
props.command({ editor, range });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion({
|
||||||
|
editor: this.editor,
|
||||||
|
...this.options.suggestion,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createSlashCommandSuggestion() {
|
||||||
|
return {
|
||||||
|
items: ({ query }: { query: string }) => {
|
||||||
|
const commands = [
|
||||||
|
{
|
||||||
|
title: '正文',
|
||||||
|
description: '普通段落文本',
|
||||||
|
icon: 'Text',
|
||||||
|
command: ({ editor, range }: any) => {
|
||||||
|
editor.chain().focus().deleteRange(range).setParagraph().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '标题 1',
|
||||||
|
description: '大标题',
|
||||||
|
icon: 'Heading1',
|
||||||
|
command: ({ editor, range }: any) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setNode('heading', { level: 1 })
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '标题 2',
|
||||||
|
description: '中标题',
|
||||||
|
icon: 'Heading2',
|
||||||
|
command: ({ editor, range }: any) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setNode('heading', { level: 2 })
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '标题 3',
|
||||||
|
description: '小标题',
|
||||||
|
icon: 'Heading3',
|
||||||
|
command: ({ editor, range }: any) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setNode('heading', { level: 3 })
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '无序列表',
|
||||||
|
description: '创建无序列表',
|
||||||
|
icon: 'List',
|
||||||
|
command: ({ editor, range }: any) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '有序列表',
|
||||||
|
description: '创建有序列表',
|
||||||
|
icon: 'ListOrdered',
|
||||||
|
command: ({ editor, range }: any) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '引用',
|
||||||
|
description: '创建引用块',
|
||||||
|
icon: 'Quote',
|
||||||
|
command: ({ editor, range }: any) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '代码块',
|
||||||
|
description: '创建代码块',
|
||||||
|
icon: 'Code',
|
||||||
|
command: ({ editor, range }: any) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '分割线',
|
||||||
|
description: '插入分割线',
|
||||||
|
icon: 'Minus',
|
||||||
|
command: ({ editor, range }: any) => {
|
||||||
|
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '表格',
|
||||||
|
description: '插入表格',
|
||||||
|
icon: 'Table',
|
||||||
|
command: ({ editor, range }: any) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '加粗',
|
||||||
|
description: '加粗文本',
|
||||||
|
icon: 'Bold',
|
||||||
|
command: ({ editor, range }: any) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleBold().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '斜体',
|
||||||
|
description: '斜体文本',
|
||||||
|
icon: 'Italic',
|
||||||
|
command: ({ editor, range }: any) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleItalic().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '高亮',
|
||||||
|
description: '高亮文本',
|
||||||
|
icon: 'Highlighter',
|
||||||
|
command: ({ editor, range }: any) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.toggleHighlight({ color: '#fef08a' })
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return commands.filter((item) =>
|
||||||
|
item.title.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
render: () => {
|
||||||
|
let component: null | VueRenderer = null;
|
||||||
|
let popup: null | TippyInstance[] = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: (props: any) => {
|
||||||
|
component = new VueRenderer(SlashCommandMenu, {
|
||||||
|
props,
|
||||||
|
editor: props.editor,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!props.clientRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceElement = document.createElement('div');
|
||||||
|
const instances = tippy(referenceElement, {
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
appendTo: () => document.body,
|
||||||
|
content: component.element as HTMLElement,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: 'manual',
|
||||||
|
placement: 'bottom-start',
|
||||||
|
});
|
||||||
|
popup = Array.isArray(instances) ? instances : [instances];
|
||||||
|
},
|
||||||
|
|
||||||
|
onUpdate(props: any) {
|
||||||
|
if (!component) return;
|
||||||
|
|
||||||
|
component.updateProps(props);
|
||||||
|
|
||||||
|
if (!props.clientRect || !popup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
popup[0]?.setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onKeyDown(props: any) {
|
||||||
|
if (props.event.key === 'Escape') {
|
||||||
|
popup?.[0]?.hide();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (component?.ref as any)?.onKeyDown(props);
|
||||||
|
},
|
||||||
|
|
||||||
|
onExit() {
|
||||||
|
if (popup) {
|
||||||
|
popup[0]?.destroy();
|
||||||
|
}
|
||||||
|
if (component) {
|
||||||
|
component.destroy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as NotionEditor } from './NotionEditor.vue';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as QRCodeGenerator } from './qrcode-generator.vue';
|
||||||
|
export * from './types';
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { QRCodeGeneratorEmits, QRCodeGeneratorProps } from './types';
|
||||||
|
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Copy, Download, QrCode } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElButton, ElMessage, ElTooltip } from 'element-plus';
|
||||||
|
|
||||||
|
import { useCodeContent } from '../shared/useCodeContent';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'QRCodeGenerator',
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<QRCodeGeneratorProps>(), {
|
||||||
|
dataSource: 'static',
|
||||||
|
qrcodeType: 'text',
|
||||||
|
size: 200,
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
foregroundColor: '#000000',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
logoSize: 40,
|
||||||
|
margin: 2,
|
||||||
|
showContent: false,
|
||||||
|
enableDownload: false,
|
||||||
|
enableCopy: false,
|
||||||
|
downloadFilename: 'qrcode',
|
||||||
|
disabled: false,
|
||||||
|
readonly: false,
|
||||||
|
placeholder: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<QRCodeGeneratorEmits>();
|
||||||
|
|
||||||
|
const contentOptions = computed(() => ({
|
||||||
|
boundField: props.boundField,
|
||||||
|
contentType: props.qrcodeType,
|
||||||
|
dataSource: props.dataSource,
|
||||||
|
formData: props.formData,
|
||||||
|
formula: props.formula,
|
||||||
|
modelValue: props.modelValue,
|
||||||
|
vcardInfo: props.vcardInfo,
|
||||||
|
wifiInfo: props.wifiInfo,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const qrcodeContent = useCodeContent(contentOptions);
|
||||||
|
|
||||||
|
const placeholderText = computed(
|
||||||
|
() => props.placeholder || $t('form-design.qrcode.placeholder'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const containerStyle = computed(() => ({
|
||||||
|
backgroundColor: props.backgroundColor,
|
||||||
|
height: `${props.size}px`,
|
||||||
|
width: `${props.size}px`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const svgMarkup = computed(() => {
|
||||||
|
const content = qrcodeContent.value;
|
||||||
|
if (!content) return '';
|
||||||
|
|
||||||
|
const cells = 29;
|
||||||
|
const margin = Math.max(0, props.margin || 0);
|
||||||
|
const cellSize = Math.max(2, Math.floor((props.size - margin * 2) / cells));
|
||||||
|
const canvasSize = cellSize * cells + margin * 2;
|
||||||
|
const hash = hashString(content);
|
||||||
|
const rects: string[] = [];
|
||||||
|
|
||||||
|
for (let y = 0; y < cells; y += 1) {
|
||||||
|
for (let x = 0; x < cells; x += 1) {
|
||||||
|
if (isFinderCell(x, y, cells) || shouldFillCell(x, y, hash, content)) {
|
||||||
|
rects.push(
|
||||||
|
`<rect x="${margin + x * cellSize}" y="${margin + y * cellSize}" width="${cellSize}" height="${cellSize}" fill="${props.foregroundColor}" />`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logo = props.logoUrl
|
||||||
|
? `<image href="${escapeXml(props.logoUrl)}" x="${(canvasSize - props.logoSize) / 2}" y="${(canvasSize - props.logoSize) / 2}" width="${props.logoSize}" height="${props.logoSize}" />`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${canvasSize}" height="${canvasSize}" viewBox="0 0 ${canvasSize} ${canvasSize}"><rect width="100%" height="100%" fill="${props.backgroundColor}" />${rects.join('')}${logo}</svg>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const qrcodeUrl = computed(() =>
|
||||||
|
svgMarkup.value
|
||||||
|
? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup.value)}`
|
||||||
|
: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
function hashString(value: string) {
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (const char of value) {
|
||||||
|
hash ^= char.charCodeAt(0);
|
||||||
|
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFinderCell(x: number, y: number, cells: number) {
|
||||||
|
const inBox = (startX: number, startY: number) =>
|
||||||
|
x >= startX && x < startX + 7 && y >= startY && y < startY + 7;
|
||||||
|
const local = (startX: number, startY: number) => ({
|
||||||
|
x: x - startX,
|
||||||
|
y: y - startY,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [startX, startY] of [
|
||||||
|
[0, 0],
|
||||||
|
[cells - 7, 0],
|
||||||
|
[0, cells - 7],
|
||||||
|
]) {
|
||||||
|
if (!inBox(startX, startY)) continue;
|
||||||
|
const point = local(startX, startY);
|
||||||
|
return (
|
||||||
|
point.x === 0 ||
|
||||||
|
point.x === 6 ||
|
||||||
|
point.y === 0 ||
|
||||||
|
point.y === 6 ||
|
||||||
|
(point.x >= 2 && point.x <= 4 && point.y >= 2 && point.y <= 4)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldFillCell(x: number, y: number, hash: number, content: string) {
|
||||||
|
const charCode = content.charCodeAt((x + y * 7) % content.length);
|
||||||
|
const mixed = hash + x * 73_856_093 + y * 19_349_663 + charCode * 83_492_791;
|
||||||
|
return (mixed & 3) === 0 || ((mixed >> 3) & 5) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(value: string) {
|
||||||
|
return value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadQRCode() {
|
||||||
|
if (!qrcodeUrl.value) return;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `${props.downloadFilename}.svg`;
|
||||||
|
link.href = qrcodeUrl.value;
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
emit('downloaded');
|
||||||
|
ElMessage.success($t('form-design.qrcode.downloadSuccess'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyContent() {
|
||||||
|
const content = qrcodeContent.value;
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content);
|
||||||
|
emit('copied');
|
||||||
|
ElMessage.success($t('form-design.qrcode.copySuccess'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Copy failed:', error);
|
||||||
|
ElMessage.error($t('form-design.qrcode.copyError'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => qrcodeUrl.value,
|
||||||
|
(url) => {
|
||||||
|
if (url) emit('generated', qrcodeContent.value);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="qrcode-generator">
|
||||||
|
<div
|
||||||
|
class="qrcode-generator__container"
|
||||||
|
:class="{
|
||||||
|
'qrcode-generator__container--empty': !qrcodeContent,
|
||||||
|
'qrcode-generator__container--disabled': disabled,
|
||||||
|
}"
|
||||||
|
:style="containerStyle"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="qrcodeUrl"
|
||||||
|
:src="qrcodeUrl"
|
||||||
|
alt="QRCode"
|
||||||
|
class="qrcode-generator__image"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else class="qrcode-generator__placeholder">
|
||||||
|
<QrCode class="qrcode-generator__placeholder-icon" />
|
||||||
|
<span class="qrcode-generator__placeholder-text">{{
|
||||||
|
placeholderText
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showContent && qrcodeContent" class="qrcode-generator__content">
|
||||||
|
<ElTooltip :content="qrcodeContent" placement="top" :show-after="300">
|
||||||
|
<span class="qrcode-generator__content-text">{{ qrcodeContent }}</span>
|
||||||
|
</ElTooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="(enableDownload || enableCopy) && qrcodeUrl && !disabled"
|
||||||
|
class="qrcode-generator__actions"
|
||||||
|
>
|
||||||
|
<ElButton
|
||||||
|
v-if="enableDownload"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:icon="Download"
|
||||||
|
@click="downloadQRCode"
|
||||||
|
>
|
||||||
|
{{ $t('form-design.qrcode.download') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
v-if="enableCopy"
|
||||||
|
size="small"
|
||||||
|
:icon="Copy"
|
||||||
|
@click="copyContent"
|
||||||
|
>
|
||||||
|
{{ $t('form-design.qrcode.copy') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.qrcode-generator {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-generator__container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-generator__container--empty {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-generator__container--disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-generator__image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-generator__placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-generator__placeholder-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-generator__placeholder-text {
|
||||||
|
max-width: 80%;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-generator__content {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-generator__content-text {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
word-break: break-all;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-generator__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type {
|
||||||
|
CodeDisplayBaseProps,
|
||||||
|
ErrorCorrectionLevel,
|
||||||
|
QRCodeType,
|
||||||
|
VCardInfo,
|
||||||
|
WiFiInfo,
|
||||||
|
} from '../shared/types';
|
||||||
|
|
||||||
|
export type { QRCodeType, ErrorCorrectionLevel, VCardInfo, WiFiInfo };
|
||||||
|
|
||||||
|
export type QRCodeDataSource = 'field' | 'formula' | 'static';
|
||||||
|
|
||||||
|
export interface QRCodeGeneratorProps extends CodeDisplayBaseProps {
|
||||||
|
modelValue?: null | string;
|
||||||
|
qrcodeType?: QRCodeType;
|
||||||
|
size?: number;
|
||||||
|
errorCorrectionLevel?: ErrorCorrectionLevel;
|
||||||
|
foregroundColor?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
logoSize?: number;
|
||||||
|
margin?: number;
|
||||||
|
vcardInfo?: VCardInfo;
|
||||||
|
wifiInfo?: WiFiInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QRCodeGeneratorEmits {
|
||||||
|
(e: 'update:modelValue', value: null | string): void;
|
||||||
|
(e: 'change', value: null | string): void;
|
||||||
|
(e: 'generated', content: string): void;
|
||||||
|
(e: 'downloaded'): void;
|
||||||
|
(e: 'copied'): void;
|
||||||
|
}
|
||||||
+654
@@ -0,0 +1,654 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlignCenter,
|
||||||
|
AlignLeft,
|
||||||
|
AlignRight,
|
||||||
|
Maximize2,
|
||||||
|
RotateCw,
|
||||||
|
Trash2,
|
||||||
|
} from '@vben/icons';
|
||||||
|
|
||||||
|
import { NodeViewWrapper } from '@tiptap/vue-3';
|
||||||
|
import { ElInputNumber } from 'element-plus';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
deleteNode: () => void;
|
||||||
|
editor: any;
|
||||||
|
node: any;
|
||||||
|
selected: boolean;
|
||||||
|
updateAttributes: (attrs: Record<string, any>) => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 图片属性
|
||||||
|
const imgRef = ref<HTMLImageElement | null>(null);
|
||||||
|
const isResizing = ref(false);
|
||||||
|
const startX = ref(0);
|
||||||
|
const startY = ref(0);
|
||||||
|
const startWidth = ref(0);
|
||||||
|
const startHeight = ref(0);
|
||||||
|
const resizeDirection = ref<string>('');
|
||||||
|
|
||||||
|
// 右键菜单
|
||||||
|
const contextMenuVisible = ref(false);
|
||||||
|
const contextMenuPosition = ref({ x: 0, y: 0 });
|
||||||
|
const showSizePanel = ref(false);
|
||||||
|
|
||||||
|
// 尺寸输入
|
||||||
|
const widthInput = ref<number>(0);
|
||||||
|
const heightInput = ref<number>(0);
|
||||||
|
const keepRatio = ref(true);
|
||||||
|
const originalRatio = ref(1);
|
||||||
|
|
||||||
|
// 预设尺寸
|
||||||
|
const presetSizes = [
|
||||||
|
{ label: '25%', value: 25 },
|
||||||
|
{ label: '50%', value: 50 },
|
||||||
|
{ label: '75%', value: 75 },
|
||||||
|
{ label: '100%', value: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 计算图片样式
|
||||||
|
const imageStyle = computed(() => {
|
||||||
|
const style: Record<string, string> = {};
|
||||||
|
if (props.node.attrs.width) {
|
||||||
|
style.width =
|
||||||
|
typeof props.node.attrs.width === 'number'
|
||||||
|
? `${props.node.attrs.width}px`
|
||||||
|
: props.node.attrs.width;
|
||||||
|
}
|
||||||
|
if (props.node.attrs.height) {
|
||||||
|
style.height =
|
||||||
|
typeof props.node.attrs.height === 'number'
|
||||||
|
? `${props.node.attrs.height}px`
|
||||||
|
: props.node.attrs.height;
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算容器样式(对齐)
|
||||||
|
const wrapperStyle = computed(() => {
|
||||||
|
const alignment = props.node.attrs.alignment || 'center';
|
||||||
|
const justifyMap: Record<string, string> = {
|
||||||
|
left: 'flex-start',
|
||||||
|
center: 'center',
|
||||||
|
right: 'flex-end',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
justifyContent: justifyMap[alignment] || 'center',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 图片加载完成后获取原始尺寸
|
||||||
|
const handleImageLoad = () => {
|
||||||
|
if (imgRef.value) {
|
||||||
|
const img = imgRef.value;
|
||||||
|
originalRatio.value = img.naturalWidth / img.naturalHeight;
|
||||||
|
|
||||||
|
if (props.node.attrs.width) {
|
||||||
|
widthInput.value =
|
||||||
|
typeof props.node.attrs.width === 'number'
|
||||||
|
? props.node.attrs.width
|
||||||
|
: Number.parseInt(props.node.attrs.width) || img.naturalWidth;
|
||||||
|
heightInput.value =
|
||||||
|
typeof props.node.attrs.height === 'number'
|
||||||
|
? props.node.attrs.height
|
||||||
|
: Number.parseInt(props.node.attrs.height) || img.naturalHeight;
|
||||||
|
} else {
|
||||||
|
widthInput.value = img.naturalWidth;
|
||||||
|
heightInput.value = img.naturalHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 右键菜单
|
||||||
|
const handleContextMenu = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
contextMenuPosition.value = { x: e.clientX, y: e.clientY };
|
||||||
|
contextMenuVisible.value = true;
|
||||||
|
showSizePanel.value = false;
|
||||||
|
|
||||||
|
// 点击其他地方关闭菜单
|
||||||
|
nextTick(() => {
|
||||||
|
document.addEventListener('click', closeContextMenu);
|
||||||
|
document.addEventListener('contextmenu', closeContextMenu);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeContextMenu = () => {
|
||||||
|
contextMenuVisible.value = false;
|
||||||
|
showSizePanel.value = false;
|
||||||
|
document.removeEventListener('click', closeContextMenu);
|
||||||
|
document.removeEventListener('contextmenu', closeContextMenu);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 开始调整大小
|
||||||
|
const startResize = (e: MouseEvent, direction: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
isResizing.value = true;
|
||||||
|
resizeDirection.value = direction;
|
||||||
|
startX.value = e.clientX;
|
||||||
|
startY.value = e.clientY;
|
||||||
|
|
||||||
|
if (imgRef.value) {
|
||||||
|
startWidth.value = imgRef.value.offsetWidth;
|
||||||
|
startHeight.value = imgRef.value.offsetHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleResize);
|
||||||
|
document.addEventListener('mouseup', stopResize);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理调整大小
|
||||||
|
const handleResize = (e: MouseEvent) => {
|
||||||
|
if (!isResizing.value) return;
|
||||||
|
|
||||||
|
const deltaX = e.clientX - startX.value;
|
||||||
|
const deltaY = e.clientY - startY.value;
|
||||||
|
|
||||||
|
let newWidth = startWidth.value;
|
||||||
|
let newHeight = startHeight.value;
|
||||||
|
|
||||||
|
if (resizeDirection.value.includes('e')) {
|
||||||
|
newWidth = Math.max(50, startWidth.value + deltaX);
|
||||||
|
}
|
||||||
|
if (resizeDirection.value.includes('w')) {
|
||||||
|
newWidth = Math.max(50, startWidth.value - deltaX);
|
||||||
|
}
|
||||||
|
if (resizeDirection.value.includes('s')) {
|
||||||
|
newHeight = Math.max(50, startHeight.value + deltaY);
|
||||||
|
}
|
||||||
|
if (resizeDirection.value.includes('n')) {
|
||||||
|
newHeight = Math.max(50, startHeight.value - deltaY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保持比例
|
||||||
|
if (keepRatio.value && originalRatio.value) {
|
||||||
|
if (
|
||||||
|
resizeDirection.value.includes('e') ||
|
||||||
|
resizeDirection.value.includes('w')
|
||||||
|
) {
|
||||||
|
newHeight = Math.round(newWidth / originalRatio.value);
|
||||||
|
} else {
|
||||||
|
newWidth = Math.round(newHeight * originalRatio.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
props.updateAttributes({
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
widthInput.value = newWidth;
|
||||||
|
heightInput.value = newHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 停止调整大小
|
||||||
|
const stopResize = () => {
|
||||||
|
isResizing.value = false;
|
||||||
|
resizeDirection.value = '';
|
||||||
|
document.removeEventListener('mousemove', handleResize);
|
||||||
|
document.removeEventListener('mouseup', stopResize);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置对齐方式
|
||||||
|
const setAlignment = (alignment: 'center' | 'left' | 'right') => {
|
||||||
|
props.updateAttributes({ alignment });
|
||||||
|
closeContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置预设尺寸(百分比)
|
||||||
|
const setPresetSize = (percent: number) => {
|
||||||
|
if (imgRef.value) {
|
||||||
|
const editorEl = props.editor?.view?.dom?.parentElement;
|
||||||
|
const containerWidth = editorEl?.clientWidth || 800;
|
||||||
|
const maxWidth = containerWidth - 32;
|
||||||
|
|
||||||
|
const newWidth = Math.round((maxWidth * percent) / 100);
|
||||||
|
const newHeight = Math.round(newWidth / originalRatio.value);
|
||||||
|
|
||||||
|
props.updateAttributes({
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
widthInput.value = newWidth;
|
||||||
|
heightInput.value = newHeight;
|
||||||
|
}
|
||||||
|
closeContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换自定义尺寸面板
|
||||||
|
const toggleSizePanel = () => {
|
||||||
|
showSizePanel.value = !showSizePanel.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 手动输入宽度
|
||||||
|
const handleWidthChange = (value: number | undefined) => {
|
||||||
|
if (!value) return;
|
||||||
|
const newWidth = value;
|
||||||
|
let newHeight = heightInput.value;
|
||||||
|
|
||||||
|
if (keepRatio.value && originalRatio.value) {
|
||||||
|
newHeight = Math.round(newWidth / originalRatio.value);
|
||||||
|
heightInput.value = newHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
props.updateAttributes({
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 手动输入高度
|
||||||
|
const handleHeightChange = (value: number | undefined) => {
|
||||||
|
if (!value) return;
|
||||||
|
const newHeight = value;
|
||||||
|
let newWidth = widthInput.value;
|
||||||
|
|
||||||
|
if (keepRatio.value && originalRatio.value) {
|
||||||
|
newWidth = Math.round(newHeight * originalRatio.value);
|
||||||
|
widthInput.value = newWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
props.updateAttributes({
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置为原始尺寸
|
||||||
|
const resetSize = () => {
|
||||||
|
if (imgRef.value) {
|
||||||
|
const img = imgRef.value;
|
||||||
|
props.updateAttributes({
|
||||||
|
width: img.naturalWidth,
|
||||||
|
height: img.naturalHeight,
|
||||||
|
});
|
||||||
|
widthInput.value = img.naturalWidth;
|
||||||
|
heightInput.value = img.naturalHeight;
|
||||||
|
}
|
||||||
|
closeContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除图片
|
||||||
|
const handleDelete = () => {
|
||||||
|
props.deleteNode();
|
||||||
|
closeContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('mousemove', handleResize);
|
||||||
|
document.removeEventListener('mouseup', stopResize);
|
||||||
|
document.removeEventListener('click', closeContextMenu);
|
||||||
|
document.removeEventListener('contextmenu', closeContextMenu);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NodeViewWrapper class="resizable-image-wrapper flex" :style="wrapperStyle">
|
||||||
|
<div
|
||||||
|
class="resizable-image-container relative inline-block"
|
||||||
|
:class="{ 'is-selected': selected, 'is-resizing': isResizing }"
|
||||||
|
>
|
||||||
|
<!-- 图片 -->
|
||||||
|
<img
|
||||||
|
ref="imgRef"
|
||||||
|
:src="node.attrs.src"
|
||||||
|
:alt="node.attrs.alt"
|
||||||
|
:title="node.attrs.title"
|
||||||
|
:style="imageStyle"
|
||||||
|
class="block max-w-full rounded"
|
||||||
|
draggable="false"
|
||||||
|
@load="handleImageLoad"
|
||||||
|
@contextmenu="handleContextMenu"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 选中时显示调整手柄 -->
|
||||||
|
<template v-if="selected">
|
||||||
|
<div
|
||||||
|
class="resize-handle resize-handle-nw"
|
||||||
|
@mousedown="(e) => startResize(e, 'nw')"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="resize-handle resize-handle-ne"
|
||||||
|
@mousedown="(e) => startResize(e, 'ne')"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="resize-handle resize-handle-sw"
|
||||||
|
@mousedown="(e) => startResize(e, 'sw')"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="resize-handle resize-handle-se"
|
||||||
|
@mousedown="(e) => startResize(e, 'se')"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右键菜单 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="contextMenuVisible"
|
||||||
|
class="image-context-menu"
|
||||||
|
:style="{
|
||||||
|
left: `${contextMenuPosition.x}px`,
|
||||||
|
top: `${contextMenuPosition.y}px`,
|
||||||
|
}"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<!-- 对齐方式 -->
|
||||||
|
<div class="menu-group">
|
||||||
|
<div class="menu-group-title">对齐方式</div>
|
||||||
|
<div class="menu-row">
|
||||||
|
<button
|
||||||
|
class="menu-icon-btn"
|
||||||
|
:class="{ active: node.attrs.alignment === 'left' }"
|
||||||
|
title="左对齐"
|
||||||
|
@click="setAlignment('left')"
|
||||||
|
>
|
||||||
|
<AlignLeft class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="menu-icon-btn"
|
||||||
|
:class="{ active: node.attrs.alignment === 'center' }"
|
||||||
|
title="居中"
|
||||||
|
@click="setAlignment('center')"
|
||||||
|
>
|
||||||
|
<AlignCenter class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="menu-icon-btn"
|
||||||
|
:class="{ active: node.attrs.alignment === 'right' }"
|
||||||
|
title="右对齐"
|
||||||
|
@click="setAlignment('right')"
|
||||||
|
>
|
||||||
|
<AlignRight class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-divider"></div>
|
||||||
|
|
||||||
|
<!-- 快速缩放 -->
|
||||||
|
<div class="menu-group">
|
||||||
|
<div class="menu-group-title">快速缩放</div>
|
||||||
|
<div class="menu-row">
|
||||||
|
<button
|
||||||
|
v-for="size in presetSizes"
|
||||||
|
:key="size.value"
|
||||||
|
class="menu-size-btn"
|
||||||
|
@click="setPresetSize(size.value)"
|
||||||
|
>
|
||||||
|
{{ size.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-divider"></div>
|
||||||
|
|
||||||
|
<!-- 自定义尺寸 -->
|
||||||
|
<button class="menu-item" @click="toggleSizePanel">
|
||||||
|
<Maximize2 class="menu-item-icon" />
|
||||||
|
<span>自定义尺寸</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 尺寸面板 -->
|
||||||
|
<div v-if="showSizePanel" class="size-panel">
|
||||||
|
<div class="size-row">
|
||||||
|
<span class="size-label">宽</span>
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="widthInput"
|
||||||
|
:min="50"
|
||||||
|
:max="2000"
|
||||||
|
size="small"
|
||||||
|
controls-position="right"
|
||||||
|
@change="handleWidthChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="size-row">
|
||||||
|
<span class="size-label">高</span>
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="heightInput"
|
||||||
|
:min="50"
|
||||||
|
:max="2000"
|
||||||
|
size="small"
|
||||||
|
controls-position="right"
|
||||||
|
@change="handleHeightChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label class="size-checkbox">
|
||||||
|
<input v-model="keepRatio" type="checkbox" />
|
||||||
|
<span>保持比例</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-divider"></div>
|
||||||
|
|
||||||
|
<!-- 重置尺寸 -->
|
||||||
|
<button class="menu-item" @click="resetSize">
|
||||||
|
<RotateCw class="menu-item-icon" />
|
||||||
|
<span>重置尺寸</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 删除 -->
|
||||||
|
<button class="menu-item menu-item-danger" @click="handleDelete">
|
||||||
|
<Trash2 class="menu-item-icon" />
|
||||||
|
<span>删除图片</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.resizable-image-wrapper {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizable-image-container {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 0;
|
||||||
|
|
||||||
|
&.is-selected {
|
||||||
|
img {
|
||||||
|
outline: 2px solid var(--el-color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-resizing {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 调整手柄样式 - 只保留四个角 */
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background-color: var(--el-color-primary);
|
||||||
|
border: 2px solid var(--el-bg-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--el-color-primary-dark-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle-nw {
|
||||||
|
top: -5px;
|
||||||
|
left: -5px;
|
||||||
|
cursor: nw-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle-ne {
|
||||||
|
top: -5px;
|
||||||
|
right: -5px;
|
||||||
|
cursor: ne-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle-sw {
|
||||||
|
bottom: -5px;
|
||||||
|
left: -5px;
|
||||||
|
cursor: sw-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle-se {
|
||||||
|
right: -5px;
|
||||||
|
bottom: -5px;
|
||||||
|
cursor: se-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右键菜单样式 */
|
||||||
|
.image-context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 6px 0;
|
||||||
|
background-color: var(--el-bg-color);
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-group {
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-group-title {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 28px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #fff;
|
||||||
|
background-color: var(--el-color-primary);
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-size-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background-color: var(--el-color-primary-light-9);
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 6px 0;
|
||||||
|
background-color: var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.menu-item-danger {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--el-color-danger-light-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 尺寸面板 */
|
||||||
|
.size-panel {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: var(--el-fill-color-lighter);
|
||||||
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-label {
|
||||||
|
width: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-checkbox {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input-number--small) {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+536
@@ -0,0 +1,536 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlignCenter,
|
||||||
|
AlignLeft,
|
||||||
|
AlignRight,
|
||||||
|
RotateCcw,
|
||||||
|
Trash2,
|
||||||
|
} from '@vben/icons';
|
||||||
|
|
||||||
|
import { NodeViewWrapper } from '@tiptap/vue-3';
|
||||||
|
import { ElButton, ElCheckbox, ElInputNumber } from 'element-plus';
|
||||||
|
|
||||||
|
import { getFileUrl } from '#/composables/useFileUrl';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
deleteNode: () => void;
|
||||||
|
node: any;
|
||||||
|
selected: boolean;
|
||||||
|
updateAttributes: (attrs: Record<string, any>) => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const isSelected = ref(false);
|
||||||
|
const showContextMenu = ref(false);
|
||||||
|
const contextMenuPosition = ref({ x: 0, y: 0 });
|
||||||
|
const showCustomSize = ref(false);
|
||||||
|
const keepRatio = ref(true);
|
||||||
|
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||||
|
const originalSize = ref({ width: 0, height: 0 });
|
||||||
|
|
||||||
|
// 视频URL(响应式,支持token刷新)
|
||||||
|
const resolvedVideoSrc = ref('');
|
||||||
|
|
||||||
|
// 异步解析视频URL
|
||||||
|
async function resolveVideoUrl() {
|
||||||
|
const id = props.node.attrs.id;
|
||||||
|
const src = props.node.attrs.src;
|
||||||
|
|
||||||
|
// 如果有文件ID,优先使用ID获取带token的URL
|
||||||
|
if (id) {
|
||||||
|
resolvedVideoSrc.value = await getFileUrl(id);
|
||||||
|
} else if (src) {
|
||||||
|
resolvedVideoSrc.value = src;
|
||||||
|
} else {
|
||||||
|
resolvedVideoSrc.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听属性变化
|
||||||
|
watch(
|
||||||
|
() => [props.node.attrs.id, props.node.attrs.src],
|
||||||
|
() => {
|
||||||
|
resolveVideoUrl();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const videoSrc = computed(() => resolvedVideoSrc.value);
|
||||||
|
const currentWidth = computed(() => {
|
||||||
|
const w = props.node.attrs.width;
|
||||||
|
if (typeof w === 'string' && w.endsWith('%')) {
|
||||||
|
return Number.parseInt(w);
|
||||||
|
}
|
||||||
|
return w || 100;
|
||||||
|
});
|
||||||
|
const currentHeight = computed(() => props.node.attrs.height || 'auto');
|
||||||
|
const alignment = computed(() => props.node.attrs.alignment || 'center');
|
||||||
|
|
||||||
|
const nodeStyle = computed(() => {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
display: 'flex',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (alignment.value === 'left') {
|
||||||
|
styles.justifyContent = 'flex-start';
|
||||||
|
} else if (alignment.value === 'right') {
|
||||||
|
styles.justifyContent = 'flex-end';
|
||||||
|
} else {
|
||||||
|
styles.justifyContent = 'center';
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles;
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerStyle = computed(() => {
|
||||||
|
const styles: Record<string, string> = {};
|
||||||
|
|
||||||
|
const w = props.node.attrs.width;
|
||||||
|
if (typeof w === 'number') {
|
||||||
|
styles.width = `${w}px`;
|
||||||
|
} else if (typeof w === 'string') {
|
||||||
|
styles.width = w;
|
||||||
|
} else {
|
||||||
|
styles.width = '100%';
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const handleClick = () => {
|
||||||
|
isSelected.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextMenu = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
isSelected.value = true;
|
||||||
|
showContextMenu.value = true;
|
||||||
|
contextMenuPosition.value = { x: e.clientX, y: e.clientY };
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideContextMenu = () => {
|
||||||
|
showContextMenu.value = false;
|
||||||
|
showCustomSize.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
!target.closest('.resizable-video-wrapper') &&
|
||||||
|
!target.closest('.video-context-menu')
|
||||||
|
) {
|
||||||
|
isSelected.value = false;
|
||||||
|
hideContextMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAlignment = (align: 'center' | 'left' | 'right') => {
|
||||||
|
props.updateAttributes({ alignment: align });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPresetSize = (percent: number) => {
|
||||||
|
props.updateAttributes({ width: `${percent}%`, height: 'auto' });
|
||||||
|
hideContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateWidth = (value: number | undefined) => {
|
||||||
|
if (!value) return;
|
||||||
|
if (
|
||||||
|
keepRatio.value &&
|
||||||
|
originalSize.value.width &&
|
||||||
|
originalSize.value.height
|
||||||
|
) {
|
||||||
|
const ratio = originalSize.value.height / originalSize.value.width;
|
||||||
|
props.updateAttributes({ width: value, height: Math.round(value * ratio) });
|
||||||
|
} else {
|
||||||
|
props.updateAttributes({ width: value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHeight = (value: number | undefined) => {
|
||||||
|
if (!value) return;
|
||||||
|
if (
|
||||||
|
keepRatio.value &&
|
||||||
|
originalSize.value.width &&
|
||||||
|
originalSize.value.height
|
||||||
|
) {
|
||||||
|
const ratio = originalSize.value.width / originalSize.value.height;
|
||||||
|
props.updateAttributes({ height: value, width: Math.round(value * ratio) });
|
||||||
|
} else {
|
||||||
|
props.updateAttributes({ height: value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSize = () => {
|
||||||
|
props.updateAttributes({ width: '100%', height: 'auto' });
|
||||||
|
hideContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
props.deleteNode();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 拖拽调整大小
|
||||||
|
const isResizing = ref(false);
|
||||||
|
const resizeStartPos = ref({ x: 0, y: 0 });
|
||||||
|
const resizeStartSize = ref({ width: 0, height: 0 });
|
||||||
|
const resizeCorner = ref('');
|
||||||
|
|
||||||
|
const startResize = (e: MouseEvent, corner: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
isResizing.value = true;
|
||||||
|
resizeCorner.value = corner;
|
||||||
|
resizeStartPos.value = { x: e.clientX, y: e.clientY };
|
||||||
|
|
||||||
|
const wrapper = (e.target as HTMLElement).closest(
|
||||||
|
'.resizable-video-wrapper',
|
||||||
|
) as HTMLElement;
|
||||||
|
if (wrapper) {
|
||||||
|
resizeStartSize.value = {
|
||||||
|
width: wrapper.offsetWidth,
|
||||||
|
height: wrapper.offsetHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleResize);
|
||||||
|
document.addEventListener('mouseup', stopResize);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = (e: MouseEvent) => {
|
||||||
|
if (!isResizing.value) return;
|
||||||
|
|
||||||
|
const deltaX = e.clientX - resizeStartPos.value.x;
|
||||||
|
|
||||||
|
// 左侧控制点需要反向计算
|
||||||
|
const isLeftCorner =
|
||||||
|
resizeCorner.value === 'nw' || resizeCorner.value === 'sw';
|
||||||
|
const adjustedDeltaX = isLeftCorner ? -deltaX : deltaX;
|
||||||
|
|
||||||
|
let newWidth = resizeStartSize.value.width + adjustedDeltaX;
|
||||||
|
let newHeight = resizeStartSize.value.height;
|
||||||
|
|
||||||
|
// 保持比例
|
||||||
|
if (originalSize.value.width && originalSize.value.height) {
|
||||||
|
const ratio = originalSize.value.height / originalSize.value.width;
|
||||||
|
newHeight = Math.round(newWidth * ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制最小尺寸
|
||||||
|
newWidth = Math.max(100, newWidth);
|
||||||
|
newHeight = Math.max(60, newHeight);
|
||||||
|
|
||||||
|
props.updateAttributes({ width: newWidth, height: newHeight });
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopResize = () => {
|
||||||
|
isResizing.value = false;
|
||||||
|
document.removeEventListener('mousemove', handleResize);
|
||||||
|
document.removeEventListener('mouseup', stopResize);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取视频原始尺寸
|
||||||
|
const handleVideoLoaded = () => {
|
||||||
|
if (videoRef.value) {
|
||||||
|
originalSize.value = {
|
||||||
|
width: videoRef.value.videoWidth,
|
||||||
|
height: videoRef.value.videoHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
document.removeEventListener('mousemove', handleResize);
|
||||||
|
document.removeEventListener('mouseup', stopResize);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NodeViewWrapper class="resizable-video-node" :style="nodeStyle">
|
||||||
|
<div
|
||||||
|
class="resizable-video-wrapper"
|
||||||
|
:class="{ 'is-selected': isSelected || selected }"
|
||||||
|
:style="containerStyle"
|
||||||
|
@click="handleClick"
|
||||||
|
@contextmenu="handleContextMenu"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref="videoRef"
|
||||||
|
:src="videoSrc"
|
||||||
|
controls
|
||||||
|
class="resizable-video"
|
||||||
|
@loadedmetadata="handleVideoLoaded"
|
||||||
|
>
|
||||||
|
您的浏览器不支持视频播放
|
||||||
|
</video>
|
||||||
|
|
||||||
|
<!-- 调整大小的控制点 -->
|
||||||
|
<template v-if="isSelected || selected">
|
||||||
|
<div
|
||||||
|
class="resize-handle resize-handle-se"
|
||||||
|
@mousedown="startResize($event, 'se')"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="resize-handle resize-handle-sw"
|
||||||
|
@mousedown="startResize($event, 'sw')"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="resize-handle resize-handle-ne"
|
||||||
|
@mousedown="startResize($event, 'ne')"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="resize-handle resize-handle-nw"
|
||||||
|
@mousedown="startResize($event, 'nw')"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右键菜单 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="showContextMenu"
|
||||||
|
class="video-context-menu"
|
||||||
|
:style="{
|
||||||
|
left: `${contextMenuPosition.x}px`,
|
||||||
|
top: `${contextMenuPosition.y}px`,
|
||||||
|
}"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<!-- 对齐方式 -->
|
||||||
|
<div class="menu-section">
|
||||||
|
<div class="menu-label">对齐方式</div>
|
||||||
|
<div class="menu-buttons">
|
||||||
|
<ElButton
|
||||||
|
:type="alignment === 'left' ? 'primary' : 'default'"
|
||||||
|
size="small"
|
||||||
|
@click="setAlignment('left')"
|
||||||
|
>
|
||||||
|
<AlignLeft class="h-4 w-4" />
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
:type="alignment === 'center' ? 'primary' : 'default'"
|
||||||
|
size="small"
|
||||||
|
@click="setAlignment('center')"
|
||||||
|
>
|
||||||
|
<AlignCenter class="h-4 w-4" />
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
:type="alignment === 'right' ? 'primary' : 'default'"
|
||||||
|
size="small"
|
||||||
|
@click="setAlignment('right')"
|
||||||
|
>
|
||||||
|
<AlignRight class="h-4 w-4" />
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 快速缩放 -->
|
||||||
|
<div class="menu-section">
|
||||||
|
<div class="menu-label">快速缩放</div>
|
||||||
|
<div class="menu-buttons">
|
||||||
|
<ElButton size="small" @click="setPresetSize(25)">25%</ElButton>
|
||||||
|
<ElButton size="small" @click="setPresetSize(50)">50%</ElButton>
|
||||||
|
<ElButton size="small" @click="setPresetSize(75)">75%</ElButton>
|
||||||
|
<ElButton size="small" @click="setPresetSize(100)">100%</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 自定义尺寸 -->
|
||||||
|
<div class="menu-section">
|
||||||
|
<ElButton
|
||||||
|
size="small"
|
||||||
|
class="w-full"
|
||||||
|
@click="showCustomSize = !showCustomSize"
|
||||||
|
>
|
||||||
|
自定义尺寸
|
||||||
|
</ElButton>
|
||||||
|
<div v-if="showCustomSize" class="custom-size-panel">
|
||||||
|
<div class="size-inputs">
|
||||||
|
<div class="size-input-group">
|
||||||
|
<span class="size-label">宽:</span>
|
||||||
|
<ElInputNumber
|
||||||
|
:model-value="
|
||||||
|
typeof currentWidth === 'number' ? currentWidth : undefined
|
||||||
|
"
|
||||||
|
:min="100"
|
||||||
|
:max="1920"
|
||||||
|
size="small"
|
||||||
|
controls-position="right"
|
||||||
|
@update:model-value="updateWidth"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="size-input-group">
|
||||||
|
<span class="size-label">高:</span>
|
||||||
|
<ElInputNumber
|
||||||
|
:model-value="
|
||||||
|
typeof currentHeight === 'number'
|
||||||
|
? currentHeight
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:min="60"
|
||||||
|
:max="1080"
|
||||||
|
size="small"
|
||||||
|
controls-position="right"
|
||||||
|
@update:model-value="updateHeight"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElCheckbox v-model="keepRatio" size="small">保持比例</ElCheckbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="menu-section menu-actions">
|
||||||
|
<ElButton size="small" @click="resetSize">
|
||||||
|
<RotateCcw class="mr-1 h-3 w-3" />
|
||||||
|
重置尺寸
|
||||||
|
</ElButton>
|
||||||
|
<ElButton size="small" type="danger" @click="handleDelete">
|
||||||
|
<Trash2 class="mr-1 h-3 w-3" />
|
||||||
|
删除视频
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.resizable-video-node {
|
||||||
|
display: block;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizable-video-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizable-video-wrapper:hover {
|
||||||
|
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizable-video-wrapper.is-selected {
|
||||||
|
box-shadow: 0 0 0 2px var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizable-video {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
background-color: var(--el-fill-color-darker);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 调整大小控制点 */
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background-color: var(--el-color-primary);
|
||||||
|
border: 2px solid var(--el-bg-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle-se {
|
||||||
|
right: -6px;
|
||||||
|
bottom: -6px;
|
||||||
|
cursor: se-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle-sw {
|
||||||
|
bottom: -6px;
|
||||||
|
left: -6px;
|
||||||
|
cursor: sw-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle-ne {
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
cursor: ne-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle-nw {
|
||||||
|
top: -6px;
|
||||||
|
left: -6px;
|
||||||
|
cursor: nw-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右键菜单 */
|
||||||
|
.video-context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: var(--el-bg-color);
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-section {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-label {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-size-panel {
|
||||||
|
padding: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-inputs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-label {
|
||||||
|
min-width: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { mergeAttributes, Node } from '@tiptap/core';
|
||||||
|
|
||||||
|
export interface AttachmentOptions {
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentAttributes {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
attachment: {
|
||||||
|
setAttachment: (options: AttachmentAttributes) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Attachment = Node.create<AttachmentOptions>({
|
||||||
|
name: 'attachment',
|
||||||
|
|
||||||
|
group: 'block',
|
||||||
|
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
id: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'div[data-type="attachment"]',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, any> }) {
|
||||||
|
const { id, name, size, type, url } = HTMLAttributes;
|
||||||
|
return [
|
||||||
|
'div',
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, {
|
||||||
|
'data-type': 'attachment',
|
||||||
|
'data-id': id,
|
||||||
|
'data-name': name,
|
||||||
|
'data-size': size,
|
||||||
|
'data-file-type': type,
|
||||||
|
'data-url': url,
|
||||||
|
class: 'attachment-node',
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
'a',
|
||||||
|
{
|
||||||
|
href: url,
|
||||||
|
target: '_blank',
|
||||||
|
download: name,
|
||||||
|
class: 'attachment-link',
|
||||||
|
},
|
||||||
|
['span', { class: 'attachment-icon' }, '📎'],
|
||||||
|
['span', { class: 'attachment-name' }, name],
|
||||||
|
['span', { class: 'attachment-size' }, formatFileSize(size)],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setAttachment:
|
||||||
|
(options: AttachmentAttributes) =>
|
||||||
|
({ commands }: { commands: any }) => {
|
||||||
|
return commands.insertContent({
|
||||||
|
type: this.name,
|
||||||
|
attrs: options,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 格式化文件大小
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Attachment;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Extension } from '@tiptap/core';
|
||||||
|
|
||||||
|
export interface FontSizeOptions {
|
||||||
|
types: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
fontSize: {
|
||||||
|
setFontSize: (size: string) => ReturnType;
|
||||||
|
unsetFontSize: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FontSize = Extension.create<FontSizeOptions>({
|
||||||
|
name: 'fontSize',
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
types: ['textStyle'],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addGlobalAttributes() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
types: this.options.types,
|
||||||
|
attributes: {
|
||||||
|
fontSize: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) =>
|
||||||
|
element.style.fontSize?.replace(/['"]+/g, ''),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.fontSize) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
style: `font-size: ${attributes.fontSize}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setFontSize:
|
||||||
|
(fontSize) =>
|
||||||
|
({ chain }) => {
|
||||||
|
return chain().setMark('textStyle', { fontSize }).run();
|
||||||
|
},
|
||||||
|
unsetFontSize:
|
||||||
|
() =>
|
||||||
|
({ chain }) => {
|
||||||
|
return chain()
|
||||||
|
.setMark('textStyle', { fontSize: null })
|
||||||
|
.removeEmptyTextStyle()
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
import Image from '@tiptap/extension-image';
|
||||||
|
import { VueNodeViewRenderer } from '@tiptap/vue-3';
|
||||||
|
|
||||||
|
import ResizableImageComponent from './ResizableImageComponent.vue';
|
||||||
|
|
||||||
|
export const ResizableImage = Image.extend({
|
||||||
|
name: 'resizableImage',
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
width: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) =>
|
||||||
|
element.getAttribute('width') ||
|
||||||
|
element.style.width?.replace('px', ''),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.width) return {};
|
||||||
|
return { width: attributes.width };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) =>
|
||||||
|
element.getAttribute('height') ||
|
||||||
|
element.style.height?.replace('px', ''),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.height) return {};
|
||||||
|
return { height: attributes.height };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
alignment: {
|
||||||
|
default: 'center',
|
||||||
|
parseHTML: (element) => element.dataset.alignment || 'center',
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
const alignment = attributes.alignment || 'center';
|
||||||
|
// 同时输出 data-alignment 和 style
|
||||||
|
const styleMap: Record<string, string> = {
|
||||||
|
left: 'display: block; margin-left: 0; margin-right: auto;',
|
||||||
|
center: 'display: block; margin-left: auto; margin-right: auto;',
|
||||||
|
right: 'display: block; margin-left: auto; margin-right: 0;',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
'data-alignment': alignment,
|
||||||
|
style: styleMap[alignment] || styleMap.center,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return VueNodeViewRenderer(ResizableImageComponent as any);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ResizableImage;
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { mergeAttributes, Node } from '@tiptap/core';
|
||||||
|
import { VueNodeViewRenderer } from '@tiptap/vue-3';
|
||||||
|
|
||||||
|
import ResizableVideoComponent from './ResizableVideoComponent.vue';
|
||||||
|
|
||||||
|
export interface VideoOptions {
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
allowFullscreen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoAttributes {
|
||||||
|
id?: string;
|
||||||
|
src: string;
|
||||||
|
width?: number | string;
|
||||||
|
height?: number | string;
|
||||||
|
alignment?: 'center' | 'left' | 'right';
|
||||||
|
poster?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
video: {
|
||||||
|
setVideo: (options: VideoAttributes) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Video = Node.create<VideoOptions>({
|
||||||
|
name: 'video',
|
||||||
|
|
||||||
|
group: 'block',
|
||||||
|
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
draggable: true,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
allowFullscreen: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
id: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
src: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
default: '100%',
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
default: 'auto',
|
||||||
|
},
|
||||||
|
alignment: {
|
||||||
|
default: 'center',
|
||||||
|
},
|
||||||
|
poster: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'div[data-type="video"]',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, any> }) {
|
||||||
|
const { id, src, width, poster, alignment } = HTMLAttributes;
|
||||||
|
|
||||||
|
let marginStyle = 'margin-left: auto; margin-right: auto;';
|
||||||
|
if (alignment === 'left') {
|
||||||
|
marginStyle = 'margin-right: auto;';
|
||||||
|
} else if (alignment === 'right') {
|
||||||
|
marginStyle = 'margin-left: auto;';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'div',
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, {
|
||||||
|
'data-type': 'video',
|
||||||
|
'data-id': id,
|
||||||
|
class: 'video-node',
|
||||||
|
style: `max-width: ${typeof width === 'number' ? `${width}px` : width}; ${marginStyle}`,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
'video',
|
||||||
|
{
|
||||||
|
src,
|
||||||
|
controls: true,
|
||||||
|
poster,
|
||||||
|
style: 'width: 100%; height: auto; border-radius: 8px;',
|
||||||
|
},
|
||||||
|
'您的浏览器不支持视频播放',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return VueNodeViewRenderer(ResizableVideoComponent as any);
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setVideo:
|
||||||
|
(options: VideoAttributes) =>
|
||||||
|
({ commands }: { commands: any }) => {
|
||||||
|
return commands.insertContent({
|
||||||
|
type: this.name,
|
||||||
|
attrs: options,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Video;
|
||||||
@@ -1 +1,2 @@
|
|||||||
export { default as RichTextEditor } from './rich-text-editor.vue';
|
export { default as RichTextEditor } from './rich-text-editor.vue';
|
||||||
|
export type * from './types';
|
||||||
|
|||||||
@@ -1,159 +1,59 @@
|
|||||||
<script lang="ts" setup>
|
<script setup lang="ts">
|
||||||
import { nextTick, ref, watch } from 'vue';
|
import type {
|
||||||
|
LightRichTextEditorInstance,
|
||||||
|
RichTextEditorEmits,
|
||||||
|
RichTextEditorProps,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
import {
|
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||||
AlignCenter,
|
|
||||||
AlignLeft,
|
|
||||||
AlignRight,
|
|
||||||
Bold,
|
|
||||||
Eraser,
|
|
||||||
Heading1,
|
|
||||||
Image,
|
|
||||||
Italic,
|
|
||||||
Link,
|
|
||||||
List,
|
|
||||||
ListOrdered,
|
|
||||||
Quote,
|
|
||||||
Redo2,
|
|
||||||
Table,
|
|
||||||
Underline,
|
|
||||||
Undo2,
|
|
||||||
} from '@vben/icons';
|
|
||||||
|
|
||||||
import {
|
import { Bold, Code, Eye, EyeOff, Italic, Link, List } from '@vben/icons';
|
||||||
ElButton,
|
|
||||||
ElButtonGroup,
|
|
||||||
ElColorPicker,
|
|
||||||
ElDivider,
|
|
||||||
ElInput,
|
|
||||||
ElMessage,
|
|
||||||
ElPopover,
|
|
||||||
ElSelect,
|
|
||||||
ElOption,
|
|
||||||
} from 'element-plus';
|
|
||||||
|
|
||||||
const props = withDefaults(
|
import { ElButton, ElInput } from 'element-plus';
|
||||||
defineProps<{
|
|
||||||
disabled?: boolean;
|
const props = withDefaults(defineProps<RichTextEditorProps>(), {
|
||||||
maxHeight?: number;
|
|
||||||
minHeight?: number;
|
|
||||||
modelValue?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
readonly?: boolean;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
disabled: false,
|
|
||||||
maxHeight: 500,
|
|
||||||
minHeight: 200,
|
|
||||||
modelValue: '',
|
modelValue: '',
|
||||||
placeholder: '',
|
placeholder: '请输入内容...',
|
||||||
|
disabled: false,
|
||||||
readonly: false,
|
readonly: false,
|
||||||
},
|
minHeight: 200,
|
||||||
|
maxHeight: 500,
|
||||||
|
showToolbar: true,
|
||||||
|
showWordCount: true,
|
||||||
|
maxLength: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<RichTextEditorEmits>();
|
||||||
|
|
||||||
|
const inputRef = ref<any>();
|
||||||
|
const localValue = ref(props.modelValue || '');
|
||||||
|
const previewMode = ref(false);
|
||||||
|
|
||||||
|
const disabledOrReadonly = computed(() => props.disabled || props.readonly);
|
||||||
|
|
||||||
|
const editorStyle = computed(() => ({
|
||||||
|
minHeight:
|
||||||
|
typeof props.minHeight === 'number'
|
||||||
|
? `${props.minHeight}px`
|
||||||
|
: props.minHeight,
|
||||||
|
maxHeight:
|
||||||
|
typeof props.maxHeight === 'number'
|
||||||
|
? `${props.maxHeight}px`
|
||||||
|
: props.maxHeight,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const wordCount = computed(() =>
|
||||||
|
localValue.value.replaceAll(/<[^>]*>/g, '').trim().length,
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const textarea = computed<HTMLTextAreaElement | undefined>(
|
||||||
blur: [event: FocusEvent];
|
() => inputRef.value?.textarea,
|
||||||
change: [value: string];
|
|
||||||
focus: [event: FocusEvent];
|
|
||||||
'update:modelValue': [value: string];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const editorRef = ref<HTMLElement>();
|
|
||||||
const linkPopoverVisible = ref(false);
|
|
||||||
const imagePopoverVisible = ref(false);
|
|
||||||
const tablePopoverVisible = ref(false);
|
|
||||||
const linkUrl = ref('');
|
|
||||||
const imageUrl = ref('');
|
|
||||||
const tableRows = ref(3);
|
|
||||||
const tableCols = ref(3);
|
|
||||||
const currentColor = ref('#1f2937');
|
|
||||||
const currentBlock = ref('P');
|
|
||||||
let isSyncing = false;
|
|
||||||
|
|
||||||
function syncHtml(value = props.modelValue || '') {
|
|
||||||
if (!editorRef.value || editorRef.value.innerHTML === value) return;
|
|
||||||
isSyncing = true;
|
|
||||||
editorRef.value.innerHTML = value;
|
|
||||||
nextTick(() => {
|
|
||||||
isSyncing = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(value) => syncHtml(value || ''),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function emitChange() {
|
function syncValue(value: string) {
|
||||||
if (isSyncing || !editorRef.value) return;
|
localValue.value = value;
|
||||||
const html = editorRef.value.innerHTML;
|
emit('update:modelValue', value);
|
||||||
emit('update:modelValue', html);
|
emit('change', value);
|
||||||
emit('change', html);
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusEditor() {
|
|
||||||
editorRef.value?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function exec(command: string, value?: string) {
|
|
||||||
if (props.disabled || props.readonly) return;
|
|
||||||
focusEditor();
|
|
||||||
document.execCommand(command, false, value);
|
|
||||||
emitChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyBlock(value: string) {
|
|
||||||
currentBlock.value = value;
|
|
||||||
exec('formatBlock', value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyColor(value: string | null) {
|
|
||||||
if (!value) return;
|
|
||||||
currentColor.value = value;
|
|
||||||
exec('foreColor', value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertLink() {
|
|
||||||
const url = linkUrl.value.trim();
|
|
||||||
if (!url) {
|
|
||||||
ElMessage.warning('请输入链接地址');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
exec('createLink', url);
|
|
||||||
linkPopoverVisible.value = false;
|
|
||||||
linkUrl.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertImage() {
|
|
||||||
const url = imageUrl.value.trim();
|
|
||||||
if (!url) {
|
|
||||||
ElMessage.warning('请输入图片地址');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
exec('insertHTML', `<img src="${url}" alt="" />`);
|
|
||||||
imagePopoverVisible.value = false;
|
|
||||||
imageUrl.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertTable() {
|
|
||||||
const rows = Math.max(1, Math.min(10, tableRows.value));
|
|
||||||
const cols = Math.max(1, Math.min(8, tableCols.value));
|
|
||||||
const cells = Array.from({ length: cols })
|
|
||||||
.map(() => '<td><br></td>')
|
|
||||||
.join('');
|
|
||||||
const body = Array.from({ length: rows })
|
|
||||||
.map(() => `<tr>${cells}</tr>`)
|
|
||||||
.join('');
|
|
||||||
exec('insertHTML', `<table><tbody>${body}</tbody></table><p><br></p>`);
|
|
||||||
tablePopoverVisible.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePaste(event: ClipboardEvent) {
|
|
||||||
const html = event.clipboardData?.getData('text/html');
|
|
||||||
const text = event.clipboardData?.getData('text/plain');
|
|
||||||
if (!html && !text) return;
|
|
||||||
event.preventDefault();
|
|
||||||
exec('insertHTML', html || (text || '').replaceAll('\n', '<br>'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFocus(event: FocusEvent) {
|
function handleFocus(event: FocusEvent) {
|
||||||
@@ -161,298 +61,224 @@ function handleFocus(event: FocusEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleBlur(event: FocusEvent) {
|
function handleBlur(event: FocusEvent) {
|
||||||
emitChange();
|
|
||||||
emit('blur', event);
|
emit('blur', event);
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTick(() => syncHtml());
|
async function insertSnippet(before: string, after = '') {
|
||||||
|
if (disabledOrReadonly.value || previewMode.value) return;
|
||||||
|
|
||||||
|
const input = textarea.value;
|
||||||
|
if (!input) {
|
||||||
|
syncValue(`${localValue.value}${before}${after}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = input.selectionStart ?? localValue.value.length;
|
||||||
|
const end = input.selectionEnd ?? start;
|
||||||
|
const selected = localValue.value.slice(start, end);
|
||||||
|
const nextValue = `${localValue.value.slice(0, start)}${before}${selected}${after}${localValue.value.slice(end)}`;
|
||||||
|
const nextCursor = start + before.length + selected.length + after.length;
|
||||||
|
|
||||||
|
syncValue(nextValue);
|
||||||
|
await nextTick();
|
||||||
|
input.focus();
|
||||||
|
input.setSelectionRange(nextCursor, nextCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFormat() {
|
||||||
|
if (disabledOrReadonly.value) return;
|
||||||
|
syncValue(localValue.value.replaceAll(/<[^>]*>/g, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorApi: LightRichTextEditorInstance = {
|
||||||
|
blur: () => textarea.value?.blur(),
|
||||||
|
focus: () => textarea.value?.focus(),
|
||||||
|
getHTML: () => localValue.value,
|
||||||
|
getMarkdown: () => localValue.value,
|
||||||
|
getText: () => localValue.value.replaceAll(/<[^>]*>/g, ''),
|
||||||
|
setContent: (value: string) => syncValue(value || ''),
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
if ((value || '') !== localValue.value) {
|
||||||
|
localValue.value = value || '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
emit('ready', editorApi);
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose(editorApi);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rich-text-editor" :class="{ 'is-disabled': disabled || readonly }">
|
<div
|
||||||
<div class="rich-text-toolbar">
|
class="rich-text-editor rounded border border-[var(--el-border-color)] bg-[var(--el-bg-color)]"
|
||||||
<ElSelect
|
:class="{ 'is-disabled': disabled, 'is-readonly': readonly }"
|
||||||
v-model="currentBlock"
|
>
|
||||||
size="small"
|
<div
|
||||||
class="block-select"
|
v-if="showToolbar"
|
||||||
:disabled="disabled || readonly"
|
class="toolbar flex flex-wrap items-center gap-1 border-b border-[var(--el-border-color)] bg-[var(--el-fill-color-lighter)] px-2 py-2"
|
||||||
@change="applyBlock"
|
|
||||||
>
|
>
|
||||||
<ElOption label="正文" value="P" />
|
|
||||||
<ElOption label="标题 1" value="H1" />
|
|
||||||
<ElOption label="标题 2" value="H2" />
|
|
||||||
<ElOption label="标题 3" value="H3" />
|
|
||||||
</ElSelect>
|
|
||||||
|
|
||||||
<ElDivider direction="vertical" />
|
|
||||||
|
|
||||||
<ElButtonGroup>
|
|
||||||
<ElButton
|
<ElButton
|
||||||
:icon="Undo2"
|
|
||||||
size="small"
|
|
||||||
text
|
text
|
||||||
title="撤销"
|
|
||||||
@click="exec('undo')"
|
|
||||||
/>
|
|
||||||
<ElButton
|
|
||||||
:icon="Redo2"
|
|
||||||
size="small"
|
size="small"
|
||||||
text
|
|
||||||
title="重做"
|
|
||||||
@click="exec('redo')"
|
|
||||||
/>
|
|
||||||
</ElButtonGroup>
|
|
||||||
|
|
||||||
<ElDivider direction="vertical" />
|
|
||||||
|
|
||||||
<ElButtonGroup>
|
|
||||||
<ElButton
|
|
||||||
:icon="Bold"
|
|
||||||
size="small"
|
|
||||||
text
|
|
||||||
title="加粗"
|
title="加粗"
|
||||||
@click="exec('bold')"
|
:disabled="disabledOrReadonly || previewMode"
|
||||||
/>
|
@click="insertSnippet('<strong>', '</strong>')"
|
||||||
|
>
|
||||||
|
<Bold class="h-4 w-4" />
|
||||||
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
:icon="Italic"
|
|
||||||
size="small"
|
|
||||||
text
|
text
|
||||||
|
size="small"
|
||||||
title="斜体"
|
title="斜体"
|
||||||
@click="exec('italic')"
|
:disabled="disabledOrReadonly || previewMode"
|
||||||
/>
|
@click="insertSnippet('<em>', '</em>')"
|
||||||
|
>
|
||||||
|
<Italic class="h-4 w-4" />
|
||||||
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
:icon="Underline"
|
|
||||||
size="small"
|
|
||||||
text
|
text
|
||||||
title="下划线"
|
size="small"
|
||||||
@click="exec('underline')"
|
title="链接"
|
||||||
/>
|
:disabled="disabledOrReadonly || previewMode"
|
||||||
</ElButtonGroup>
|
@click="insertSnippet('<a href="">', '</a>')"
|
||||||
|
>
|
||||||
<ElDivider direction="vertical" />
|
<Link class="h-4 w-4" />
|
||||||
|
</ElButton>
|
||||||
<ElButtonGroup>
|
|
||||||
<ElButton
|
<ElButton
|
||||||
:icon="AlignLeft"
|
|
||||||
size="small"
|
|
||||||
text
|
text
|
||||||
title="左对齐"
|
size="small"
|
||||||
@click="exec('justifyLeft')"
|
title="列表"
|
||||||
/>
|
:disabled="disabledOrReadonly || previewMode"
|
||||||
|
@click="insertSnippet('<ul><li>', '</li></ul>')"
|
||||||
|
>
|
||||||
|
<List class="h-4 w-4" />
|
||||||
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
:icon="AlignCenter"
|
|
||||||
size="small"
|
|
||||||
text
|
text
|
||||||
title="居中"
|
size="small"
|
||||||
@click="exec('justifyCenter')"
|
title="代码"
|
||||||
/>
|
:disabled="disabledOrReadonly || previewMode"
|
||||||
|
@click="insertSnippet('<code>', '</code>')"
|
||||||
|
>
|
||||||
|
<Code class="h-4 w-4" />
|
||||||
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
:icon="AlignRight"
|
|
||||||
size="small"
|
|
||||||
text
|
text
|
||||||
title="右对齐"
|
size="small"
|
||||||
@click="exec('justifyRight')"
|
:disabled="disabledOrReadonly || previewMode"
|
||||||
/>
|
@click="clearFormat"
|
||||||
</ElButtonGroup>
|
>
|
||||||
|
清除格式
|
||||||
<ElDivider direction="vertical" />
|
</ElButton>
|
||||||
|
|
||||||
<ElButtonGroup>
|
|
||||||
<ElButton
|
<ElButton
|
||||||
:icon="List"
|
|
||||||
size="small"
|
|
||||||
text
|
text
|
||||||
title="无序列表"
|
|
||||||
@click="exec('insertUnorderedList')"
|
|
||||||
/>
|
|
||||||
<ElButton
|
|
||||||
:icon="ListOrdered"
|
|
||||||
size="small"
|
size="small"
|
||||||
text
|
class="ml-auto"
|
||||||
title="有序列表"
|
@click="previewMode = !previewMode"
|
||||||
@click="exec('insertOrderedList')"
|
>
|
||||||
/>
|
<EyeOff v-if="previewMode" class="mr-1 h-4 w-4" />
|
||||||
<ElButton
|
<Eye v-else class="mr-1 h-4 w-4" />
|
||||||
:icon="Quote"
|
{{ previewMode ? '编辑' : '预览' }}
|
||||||
size="small"
|
</ElButton>
|
||||||
text
|
|
||||||
title="引用"
|
|
||||||
@click="exec('formatBlock', 'BLOCKQUOTE')"
|
|
||||||
/>
|
|
||||||
</ElButtonGroup>
|
|
||||||
|
|
||||||
<ElDivider direction="vertical" />
|
|
||||||
|
|
||||||
<ElColorPicker
|
|
||||||
v-model="currentColor"
|
|
||||||
size="small"
|
|
||||||
:disabled="disabled || readonly"
|
|
||||||
@change="applyColor"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ElDivider direction="vertical" />
|
|
||||||
|
|
||||||
<ElPopover v-model:visible="linkPopoverVisible" width="300" trigger="click">
|
|
||||||
<template #reference>
|
|
||||||
<ElButton :icon="Link" size="small" text title="插入链接" />
|
|
||||||
</template>
|
|
||||||
<div class="popover-form">
|
|
||||||
<ElInput v-model="linkUrl" placeholder="https://example.com" />
|
|
||||||
<ElButton type="primary" size="small" @click="insertLink">插入链接</ElButton>
|
|
||||||
</div>
|
</div>
|
||||||
</ElPopover>
|
|
||||||
|
|
||||||
<ElPopover v-model:visible="imagePopoverVisible" width="300" trigger="click">
|
<div class="editor-content" :style="editorStyle">
|
||||||
<template #reference>
|
<div
|
||||||
<ElButton :icon="Image" size="small" text title="插入图片" />
|
v-if="previewMode"
|
||||||
</template>
|
class="preview-content h-full overflow-auto px-4 py-3 text-[var(--el-text-color-primary)]"
|
||||||
<div class="popover-form">
|
v-html="localValue || `<span class='empty'>${placeholder}</span>`"
|
||||||
<ElInput v-model="imageUrl" placeholder="图片 URL" />
|
></div>
|
||||||
<ElButton type="primary" size="small" @click="insertImage">插入图片</ElButton>
|
<ElInput
|
||||||
</div>
|
v-else
|
||||||
</ElPopover>
|
ref="inputRef"
|
||||||
|
:model-value="localValue"
|
||||||
<ElPopover v-model:visible="tablePopoverVisible" width="260" trigger="click">
|
type="textarea"
|
||||||
<template #reference>
|
resize="none"
|
||||||
<ElButton :icon="Table" size="small" text title="插入表格" />
|
:disabled="disabled"
|
||||||
</template>
|
:readonly="readonly"
|
||||||
<div class="table-popover">
|
:placeholder="placeholder"
|
||||||
<ElInput v-model.number="tableRows" type="number" min="1" max="10">
|
:maxlength="maxLength > 0 ? maxLength : undefined"
|
||||||
<template #prepend>行</template>
|
@blur="handleBlur"
|
||||||
</ElInput>
|
@focus="handleFocus"
|
||||||
<ElInput v-model.number="tableCols" type="number" min="1" max="8">
|
@update:model-value="syncValue"
|
||||||
<template #prepend>列</template>
|
|
||||||
</ElInput>
|
|
||||||
<ElButton type="primary" size="small" @click="insertTable">插入表格</ElButton>
|
|
||||||
</div>
|
|
||||||
</ElPopover>
|
|
||||||
|
|
||||||
<ElButton
|
|
||||||
:icon="Eraser"
|
|
||||||
size="small"
|
|
||||||
text
|
|
||||||
title="清除格式"
|
|
||||||
@click="exec('removeFormat')"
|
|
||||||
/>
|
|
||||||
<ElButton
|
|
||||||
:icon="Heading1"
|
|
||||||
size="small"
|
|
||||||
text
|
|
||||||
title="标题"
|
|
||||||
@click="applyBlock('H2')"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref="editorRef"
|
v-if="showWordCount"
|
||||||
class="rich-text-content"
|
class="status-bar flex items-center justify-end border-t border-[var(--el-border-color)] bg-[var(--el-fill-color-light)] px-3 py-1 text-xs text-[var(--el-text-color-secondary)]"
|
||||||
:contenteditable="!disabled && !readonly"
|
>
|
||||||
:data-placeholder="placeholder"
|
<span>
|
||||||
:style="{ minHeight: `${minHeight}px`, maxHeight: `${maxHeight}px` }"
|
字数: {{ wordCount }}
|
||||||
role="textbox"
|
<template v-if="maxLength > 0"> / {{ maxLength }}</template>
|
||||||
aria-multiline="true"
|
</span>
|
||||||
@blur="handleBlur"
|
</div>
|
||||||
@focus="handleFocus"
|
|
||||||
@input="emitChange"
|
|
||||||
@paste="handlePaste"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.rich-text-editor {
|
.rich-text-editor {
|
||||||
border: 1px solid var(--el-border-color);
|
overflow: hidden;
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--el-bg-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-text-editor.is-disabled {
|
.rich-text-editor.is-disabled {
|
||||||
opacity: 0.72;
|
cursor: not-allowed;
|
||||||
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-text-toolbar {
|
.toolbar :deep(.el-button) {
|
||||||
display: flex;
|
min-width: 28px;
|
||||||
min-height: 42px;
|
height: 28px;
|
||||||
align-items: center;
|
padding: 4px 6px;
|
||||||
gap: 2px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
|
||||||
padding: 5px 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-select {
|
.editor-content {
|
||||||
width: 96px;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-text-content {
|
.editor-content :deep(.el-textarea) {
|
||||||
overflow: auto;
|
height: 100%;
|
||||||
padding: 12px 14px;
|
}
|
||||||
outline: none;
|
|
||||||
|
.editor-content :deep(.el-textarea__inner) {
|
||||||
|
height: 100%;
|
||||||
|
min-height: inherit;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content {
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-text-content:empty::before {
|
.preview-content :deep(.empty) {
|
||||||
color: var(--el-text-color-placeholder);
|
color: var(--el-text-color-placeholder);
|
||||||
content: attr(data-placeholder);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-text-content :deep(h1),
|
.preview-content :deep(a) {
|
||||||
.rich-text-content :deep(h2),
|
|
||||||
.rich-text-content :deep(h3) {
|
|
||||||
margin: 0.8em 0 0.45em;
|
|
||||||
font-weight: 650;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rich-text-content :deep(h1) {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rich-text-content :deep(h2) {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rich-text-content :deep(h3) {
|
|
||||||
font-size: 17px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rich-text-content :deep(p) {
|
|
||||||
margin: 0.45em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rich-text-content :deep(blockquote) {
|
|
||||||
margin: 8px 0;
|
|
||||||
border-left: 4px solid var(--el-border-color);
|
|
||||||
padding-left: 14px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rich-text-content :deep(img) {
|
|
||||||
max-width: 100%;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rich-text-content :deep(table) {
|
|
||||||
width: 100%;
|
|
||||||
margin: 8px 0;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rich-text-content :deep(td),
|
|
||||||
.rich-text-content :deep(th) {
|
|
||||||
min-width: 48px;
|
|
||||||
border: 1px solid var(--el-border-color);
|
|
||||||
padding: 6px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rich-text-content :deep(a) {
|
|
||||||
color: var(--el-color-primary);
|
color: var(--el-color-primary);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popover-form,
|
.preview-content :deep(ul),
|
||||||
.table-popover {
|
.preview-content :deep(ol) {
|
||||||
display: grid;
|
padding-left: 1.4em;
|
||||||
gap: 8px;
|
}
|
||||||
|
|
||||||
|
.preview-content :deep(code) {
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
background: var(--el-fill-color);
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
export interface LightRichTextEditorInstance {
|
||||||
|
blur: () => void;
|
||||||
|
focus: () => void;
|
||||||
|
getHTML: () => string;
|
||||||
|
getMarkdown: () => string;
|
||||||
|
getText: () => string;
|
||||||
|
setContent: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RichTextEditorProps {
|
||||||
|
modelValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
minHeight?: number | string;
|
||||||
|
maxHeight?: number | string;
|
||||||
|
showToolbar?: boolean;
|
||||||
|
showWordCount?: boolean;
|
||||||
|
maxLength?: number;
|
||||||
|
toolbarConfig?: ToolbarConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RichTextEditorEmits {
|
||||||
|
(e: 'update:modelValue', value: string): void;
|
||||||
|
(e: 'change', value: string): void;
|
||||||
|
(e: 'focus', event: FocusEvent): void;
|
||||||
|
(e: 'blur', event: FocusEvent): void;
|
||||||
|
(e: 'ready', editor: LightRichTextEditorInstance): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolbarConfig {
|
||||||
|
groups?: ToolbarGroup[];
|
||||||
|
insert?: {
|
||||||
|
attachment?: boolean;
|
||||||
|
image?: boolean;
|
||||||
|
link?: boolean;
|
||||||
|
table?: boolean;
|
||||||
|
video?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolbarGroup =
|
||||||
|
| 'align'
|
||||||
|
| 'blockquote'
|
||||||
|
| 'clear'
|
||||||
|
| 'code'
|
||||||
|
| 'color'
|
||||||
|
| 'divider'
|
||||||
|
| 'format'
|
||||||
|
| 'heading'
|
||||||
|
| 'history'
|
||||||
|
| 'indent'
|
||||||
|
| 'insert'
|
||||||
|
| 'list';
|
||||||
|
|
||||||
|
export const defaultToolbarGroups: ToolbarGroup[] = [
|
||||||
|
'history',
|
||||||
|
'heading',
|
||||||
|
'format',
|
||||||
|
'color',
|
||||||
|
'align',
|
||||||
|
'list',
|
||||||
|
'insert',
|
||||||
|
'blockquote',
|
||||||
|
'code',
|
||||||
|
'divider',
|
||||||
|
'clear',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const defaultInsertConfig = {
|
||||||
|
attachment: false,
|
||||||
|
image: true,
|
||||||
|
link: true,
|
||||||
|
table: true,
|
||||||
|
video: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ScanMode } from '../shared/types';
|
||||||
|
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElButton, ElInput } from 'element-plus';
|
||||||
|
|
||||||
|
import { ZqDialog } from '#/components/zq-dialog';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
continuousScan?: boolean;
|
||||||
|
modelValue?: boolean;
|
||||||
|
scanMode?: ScanMode;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
continuousScan: false,
|
||||||
|
modelValue: false,
|
||||||
|
scanMode: 'all',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'scan', value: string): void;
|
||||||
|
(e: 'update:modelValue', value: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const manualValue = ref('');
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) manualValue.value = '';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
emit('update:modelValue', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
const value = manualValue.value.trim();
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
emit('scan', value);
|
||||||
|
if (!props.continuousScan) {
|
||||||
|
closeDialog();
|
||||||
|
} else {
|
||||||
|
manualValue.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ZqDialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
:title="$t('form-design.scanInput.scanDialogTitle')"
|
||||||
|
width="480px"
|
||||||
|
:show-footer="false"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<div class="scan-dialog">
|
||||||
|
<div class="scan-dialog__notice">
|
||||||
|
{{ $t('form-design.scanInput.uploadImage') }}
|
||||||
|
</div>
|
||||||
|
<ElInput
|
||||||
|
v-model="manualValue"
|
||||||
|
autofocus
|
||||||
|
:placeholder="$t('form-design.scanInput.scanButton')"
|
||||||
|
@keyup.enter="handleSubmit"
|
||||||
|
/>
|
||||||
|
<div class="scan-dialog__actions">
|
||||||
|
<ElButton @click="closeDialog">取消</ElButton>
|
||||||
|
<ElButton type="primary" @click="handleSubmit">确定</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ZqDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scan-dialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-dialog__notice {
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-dialog__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as ScanInput } from './scan-input.vue';
|
||||||
|
export type * from './types';
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ScanInputEmits, ScanInputProps } from './types';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { Scan } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ElButton, ElInput, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import ScanDialog from './ScanDialog.vue';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ScanInput',
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ScanInputProps>(), {
|
||||||
|
scanMode: 'all',
|
||||||
|
autoClose: true,
|
||||||
|
continuousScan: false,
|
||||||
|
trimValue: true,
|
||||||
|
uppercase: false,
|
||||||
|
lowercase: false,
|
||||||
|
dedupeInterval: 0,
|
||||||
|
showManualInput: true,
|
||||||
|
clearable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<ScanInputEmits>();
|
||||||
|
|
||||||
|
const scanDialogVisible = ref(false);
|
||||||
|
const lastScanValue = ref('');
|
||||||
|
const lastScanAt = ref(0);
|
||||||
|
|
||||||
|
const isReadonlyInput = computed(
|
||||||
|
() => props.readonly || !props.showManualInput,
|
||||||
|
);
|
||||||
|
|
||||||
|
function normalizeValue(value: string): string {
|
||||||
|
let result = props.trimValue ? value.trim() : value;
|
||||||
|
if (props.uppercase) result = result.toUpperCase();
|
||||||
|
if (props.lowercase) result = result.toLowerCase();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateValue(value: string): boolean {
|
||||||
|
if (!props.validatePattern) return true;
|
||||||
|
try {
|
||||||
|
return new RegExp(props.validatePattern).test(value);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateValue(value: null | string) {
|
||||||
|
emit('update:modelValue', value);
|
||||||
|
emit('change', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(value: string) {
|
||||||
|
updateValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openScanDialog() {
|
||||||
|
if (props.disabled || props.readonly) return;
|
||||||
|
scanDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScan(rawValue: string) {
|
||||||
|
const value = normalizeValue(rawValue);
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (
|
||||||
|
props.dedupeInterval > 0 &&
|
||||||
|
value === lastScanValue.value &&
|
||||||
|
now - lastScanAt.value < props.dedupeInterval
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateValue(value)) {
|
||||||
|
ElMessage.warning($t('form-design.scanInput.validateFailed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastScanValue.value = value;
|
||||||
|
lastScanAt.value = now;
|
||||||
|
|
||||||
|
updateValue(value);
|
||||||
|
emit('scan', value);
|
||||||
|
ElMessage.success($t('form-design.scanInput.scanSuccess'));
|
||||||
|
|
||||||
|
if (props.autoClose && !props.continuousScan) {
|
||||||
|
scanDialogVisible.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="scan-input flex w-full items-center gap-2">
|
||||||
|
<ElInput
|
||||||
|
v-if="showManualInput"
|
||||||
|
:model-value="modelValue ?? ''"
|
||||||
|
class="flex-1"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:readonly="isReadonlyInput"
|
||||||
|
:clearable="clearable"
|
||||||
|
:maxlength="maxlength"
|
||||||
|
@update:model-value="handleInput"
|
||||||
|
/>
|
||||||
|
<ElButton
|
||||||
|
:icon="Scan"
|
||||||
|
:disabled="disabled || readonly"
|
||||||
|
@click="openScanDialog"
|
||||||
|
>
|
||||||
|
{{ $t('form-design.scanInput.scanButton') }}
|
||||||
|
</ElButton>
|
||||||
|
|
||||||
|
<ScanDialog
|
||||||
|
v-model="scanDialogVisible"
|
||||||
|
:scan-mode="scanMode"
|
||||||
|
:continuous-scan="continuousScan"
|
||||||
|
@scan="handleScan"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scan-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ScanMode } from '../shared/types';
|
||||||
|
|
||||||
|
export interface ScanInputProps {
|
||||||
|
modelValue?: null | string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
clearable?: boolean;
|
||||||
|
maxlength?: number;
|
||||||
|
scanMode?: ScanMode;
|
||||||
|
autoClose?: boolean;
|
||||||
|
continuousScan?: boolean;
|
||||||
|
trimValue?: boolean;
|
||||||
|
uppercase?: boolean;
|
||||||
|
lowercase?: boolean;
|
||||||
|
validatePattern?: string;
|
||||||
|
dedupeInterval?: number;
|
||||||
|
showManualInput?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanInputEmits {
|
||||||
|
(e: 'update:modelValue', value: null | string): void;
|
||||||
|
(e: 'change', value: null | string): void;
|
||||||
|
(e: 'scan', value: string): void;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user