feat: harden ai agent admin workflow experience

This commit is contained in:
2026-06-22 04:50:11 +08:00
parent ae29c24529
commit 4ec846a702
385 changed files with 61461 additions and 5886 deletions
+23
View File
@@ -187,8 +187,15 @@ docker/web/index/*
uniapp-zq/
web/apps/web-ele/src/components/screen-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-smart-table/
web/apps/web-ele/src/components/zq-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/*
@@ -252,11 +259,26 @@ web/docs/src/public/guide/
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/*/*.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/*/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/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/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/
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/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/redis-manager/
web/apps/web-ele/src/views/_core/redis-monitor/
+1
View File
@@ -54,6 +54,7 @@
"grid-layout-plus": "^1.1.1",
"pinia": "catalog:",
"vue": "catalog:",
"vuedraggable": "^4.1.0",
"vue-router": "catalog:"
},
"devDependencies": {
+201 -82
View File
@@ -5,84 +5,72 @@
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 { 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 { ApiComponent } from '@vben/common-ui/es/api-component';
import { ElNotification } from 'element-plus';
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(() =>
import('element-plus/es/components/checkbox/index').then(
(res) => res.ElCheckbox,
),
Promise.all([
import('element-plus/es/components/checkbox/index'),
import('element-plus/es/components/checkbox/style/css'),
]).then(([res]) => res.ElCheckbox),
);
const ElCheckboxButton = defineAsyncComponent(() =>
import('element-plus/es/components/checkbox/index').then(
(res) => res.ElCheckboxButton,
),
Promise.all([
import('element-plus/es/components/checkbox/index'),
import('element-plus/es/components/checkbox-button/style/css'),
]).then(([res]) => res.ElCheckboxButton),
);
const ElCheckboxGroup = defineAsyncComponent(() =>
import('element-plus/es/components/checkbox/index').then(
(res) => res.ElCheckboxGroup,
),
Promise.all([
import('element-plus/es/components/checkbox/index'),
import('element-plus/es/components/checkbox-group/style/css'),
]).then(([res]) => res.ElCheckboxGroup),
);
const ElDivider = defineAsyncComponent(() =>
import('element-plus/es/components/divider/index').then(
(res) => res.ElDivider,
),
);
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 ElCascader = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/cascader/index'),
import('element-plus/es/components/cascader/style/css'),
]).then(([res]) => res.ElCascader),
);
const ElDatePicker = defineAsyncComponent(() =>
import('element-plus/es/components/date-picker/index').then(
(res) => res.ElDatePicker,
Promise.all([
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(() =>
import('element-plus/es/components/input-number/index').then(
(res) => res.ElInputNumber,
const PostSelector = defineAsyncComponent(() =>
import('#/components/zq-form/post-selector/post-selector.vue').then(
(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(() =>
import('#/components/zq-form/role-selector/role-selector.vue').then(
(res) => res.default,
),
);
const FileSelector = defineAsyncComponent(() =>
import('#/components/zq-form/file-selector/file-selector.vue').then(
(res) => res.default,
),
);
const ImageSelector = defineAsyncComponent(() =>
import('#/components/zq-form/image-selector/image-selector.vue').then(
(res) => res.default,
@@ -93,11 +81,106 @@ const UserSelector = defineAsyncComponent(() =>
(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(() =>
import('#/components/zq-form/zq-icon-picker/zq-icon-picker.vue').then(
(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>(
component: T,
@@ -105,7 +188,7 @@ const withDefaultPlaceholder = <T extends Component>(
componentProps: Recordable<any> = {},
) => {
return defineComponent({
name: (component as any).name,
name: component.name,
inheritAttrs: false,
setup: (props: any, { attrs, expose, slots }) => {
const placeholder =
@@ -134,33 +217,41 @@ const withDefaultPlaceholder = <T extends Component>(
};
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
let initialized = false;
export type ComponentType =
| 'ApiSelect'
| 'ApiTreeSelect'
| 'Cascader'
| 'Checkbox'
| 'CheckboxGroup'
| 'CodeEditor'
| 'CronSelector'
| 'DatePicker'
| 'DeptSelector'
| 'Divider'
| 'FileSelector'
| 'FormSelector'
| 'IconPicker'
| 'ImageSelector'
| 'Input'
| 'InputNumber'
| 'JsonEditor'
| 'PostSelector'
| 'RadioGroup'
| 'RoleSelector'
| 'Select'
| 'Space'
| 'Switch'
| 'TableSelector'
| 'TaskArgsEditor'
| 'TaskKwargsEditor'
| 'Textarea'
| 'TimePicker'
| 'TreeSelect'
| 'Upload'
| 'UserSelector'
| BaseFormComponentType;
function initComponentAdapter() {
if (initialized) {
return;
}
async function initComponentAdapter() {
const components: Partial<Record<ComponentType, Component>> = {
// 如果你的组件体积比较大,可以使用异步加载
// Button: () =>
@@ -192,26 +283,24 @@ function initComponentAdapter() {
visibleEvent: 'onVisibleChange',
},
),
Cascader: withDefaultPlaceholder(ElCascader, 'select'),
Checkbox: ElCheckbox,
CheckboxGroup: (props, { attrs, slots }) => {
const mergedProps = { ...props, ...attrs } as Recordable<any>;
let defaultSlot;
if (Reflect.has(slots, 'default')) {
defaultSlot = slots.default;
} else {
const { options, isButton } = mergedProps;
const { options, isButton } = attrs;
if (Array.isArray(options)) {
defaultSlot = () =>
options.map((option) =>
h(isButton ? ElCheckboxButton : ElCheckbox, option, () =>
option.label ?? option.value,
),
h(isButton ? ElCheckboxButton : ElCheckbox, option),
);
}
}
return h(
ElCheckboxGroup,
mergedProps,
{ ...props, ...attrs },
{ ...slots, default: defaultSlot },
);
},
@@ -223,36 +312,47 @@ function initComponentAdapter() {
PrimaryButton: (props, { attrs, slots }) => {
return h(ElButton, { ...props, attrs, type: 'primary' }, slots);
},
DeptSelector: withDefaultPlaceholder(DeptSelector, 'select'),
PostSelector: withDefaultPlaceholder(PostSelector, 'select'),
RoleSelector: withDefaultPlaceholder(RoleSelector, 'select'),
Divider: ElDivider,
FileSelector: withDefaultPlaceholder(FileSelector, 'select'),
FormSelector: withDefaultPlaceholder(FormSelector, 'select'),
TableSelector: withDefaultPlaceholder(TableSelector, 'select'),
IconPicker: withDefaultPlaceholder(ZqIconPicker, 'select'),
ImageSelector: withDefaultPlaceholder(ImageSelector, 'select'),
Input: withDefaultPlaceholder(ElInput, 'input'),
Textarea: withDefaultPlaceholder(ElInput, 'input', { type: 'textarea' }),
CodeEditor: withDefaultPlaceholder(ElInput, 'input', {
autosize: { minRows: 8 },
type: 'textarea',
}),
JsonEditor,
CodeEditor,
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'),
RadioGroup: (props, { attrs, slots }) => {
const mergedProps = { ...props, ...attrs } as Recordable<any>;
let defaultSlot;
if (Reflect.has(slots, 'default')) {
defaultSlot = slots.default;
} else {
const { options, isButton } = mergedProps;
const { options } = attrs;
if (Array.isArray(options)) {
defaultSlot = () =>
options.map((option) =>
h(isButton ? ElRadioButton : ElRadio, option, () =>
option.label ?? option.value,
),
h(attrs.isButton ? ElRadioButton : ElRadio, option),
);
}
}
return h(
ElRadioGroup,
mergedProps,
{ ...props, ...attrs },
{ ...slots, default: defaultSlot },
);
},
@@ -260,6 +360,28 @@ function initComponentAdapter() {
return h(ElSelectV2, { ...props, attrs }, slots);
},
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 }) => {
const { name, id, type } = props;
const extraProps: Recordable<any> = {};
@@ -281,19 +403,18 @@ function initComponentAdapter() {
slots,
);
},
TreeSelect: withDefaultPlaceholder(ElTreeSelect, 'select'),
Upload: ElUpload,
UserSelector: withDefaultPlaceholder(UserSelector, 'select'),
};
// 将组件注册到全局共享状态中
globalShareState.setComponents(components);
initialized = true;
// 定义全局共享状态中的消息提示
globalShareState.defineMessage({
// 复制成功消息提示
copyPreferencesSuccess: (title: string, content: string) => {
void import('element-plus/es/components/notification/index').then(
({ ElNotification }) => {
copyPreferencesSuccess: (title, content) => {
ElNotification({
title,
message: content,
@@ -302,8 +423,6 @@ function initComponentAdapter() {
type: 'success',
});
},
);
},
});
}
+4 -17
View File
@@ -1,23 +1,14 @@
import type {
VbenFormSchema as FormSchema,
VbenFormProps,
} from '@vben-core/form-ui';
} from '@vben/common-ui';
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 { initComponentAdapter } from './component';
let initialized = false;
function initSetupVbenForm() {
if (initialized) {
return;
}
initComponentAdapter();
async function initSetupVbenForm() {
setupVbenForm<ComponentType>({
config: {
modelPropNameMap: {
@@ -40,13 +31,9 @@ function initSetupVbenForm() {
},
},
});
initialized = true;
}
const useVbenForm = ((...args: Parameters<typeof useForm<ComponentType>>) => {
initSetupVbenForm();
return useForm<ComponentType>(...args);
}) as typeof useForm<ComponentType>;
const useVbenForm = useForm<ComponentType>;
export { initSetupVbenForm, useVbenForm, z };
+70 -38
View File
@@ -27,16 +27,17 @@ setupVbenVxeTable({
columnConfig: {
resizable: true,
},
minHeight: 180,
formConfig: {
// 全局禁用vxe-table的表单配置,使用formOptions
enabled: false,
},
minHeight: 180,
proxyConfig: {
autoLoad: true,
response: {
list: '',
result: 'items',
total: 'total',
list: '',
},
showActionMsg: true,
showResponseMsg: false,
@@ -47,31 +48,34 @@ setupVbenVxeTable({
} as VxeTableGridOptions,
});
// 表格配置项可以用 cellRender: { name: 'CellImage' },
vxeUI.renderer.add('CellImage', {
renderTableDefault(_renderOpts, params) {
const { column, row } = params;
const src = row[column.field];
return h(ElImage, { previewSrcList: [src], src });
return h(ElImage, { src, previewSrcList: [src] });
},
});
// 表格配置项可以用 cellRender: { name: 'CellLink' },
vxeUI.renderer.add('CellLink', {
renderTableDefault(renderOpts) {
const { props } = renderOpts;
return h(
ElButton,
{ link: true, size: 'small' },
{ size: 'small', link: true },
{ default: () => props?.text },
);
},
});
// 单元格渲染: Tag
vxeUI.renderer.add('CellTag', {
renderTableDefault({ options, props }, { column, row }) {
const value = get(row, column.field);
const tagOptions = options ?? [
{ label: $t('common.enabled'), type: 'success', value: true },
{ label: $t('common.disabled'), type: 'danger', value: false },
{ type: 'success', label: $t('common.enabled'), value: true },
{ type: 'danger', label: $t('common.disabled'), value: false },
];
const tagItem = tagOptions.find((item) => item.value === value);
return h(
@@ -85,24 +89,36 @@ setupVbenVxeTable({
},
});
/**
* 注册表格的操作按钮渲染器
*/
vxeUI.renderer.add('CellOperation', {
renderTableDefault({ attrs, options, props }, { column, row }) {
const defaultProps = { link: true, size: 'small', ...props };
const align =
column.align === 'center'
? 'center'
: column.align === 'left'
? 'start'
: 'end';
const defaultProps = { size: 'small', link: true, ...props };
let align = 'end';
switch (column.align) {
case 'center': {
align = 'center';
break;
}
case 'left': {
align = 'start';
break;
}
default: {
align = 'end';
break;
}
}
const presets: Recordable<Recordable<any>> = {
delete: {
icon: 'ep:delete',
text: $t('common.delete'),
type: 'danger',
text: $t('common.delete'),
icon: 'ep:delete',
},
edit: {
icon: 'ep:edit',
text: $t('common.edit'),
icon: 'ep:edit',
type: 'primary',
},
};
@@ -116,11 +132,11 @@ setupVbenVxeTable({
: {
code: opt,
text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
type: 'primary',
type: 'primary', // 自定义按钮默认使用主题色
...defaultProps,
};
}
} else {
// 对象配置的按钮,如果没有 type,也默认为 primary
const buttonConfig = {
...defaultProps,
...presets[opt.code],
@@ -130,6 +146,7 @@ setupVbenVxeTable({
buttonConfig.type = 'primary';
}
return buttonConfig;
}
})
.map((opt) => {
const optBtn: Recordable<any> = {};
@@ -141,20 +158,22 @@ setupVbenVxeTable({
.filter((opt) => opt.show !== false);
function renderBtn(opt: Recordable<any>, listen = true) {
const { code, icon, text, ...btnProps } = opt;
const { icon, text, code, ...btnProps } = opt;
const buttonType =
btnProps.type === 'danger'
? 'danger'
: btnProps.type === 'primary'
: (btnProps.type === 'primary'
? 'primary'
: 'default';
: 'default');
const button = h(
ElButton,
{
...btnProps,
circle: !!icon,
type: buttonType,
size: 'small',
link: true,
circle: !!icon,
onClick: listen
? () =>
attrs?.onClick?.({
@@ -162,17 +181,19 @@ setupVbenVxeTable({
row,
})
: undefined,
size: 'small',
type: buttonType,
},
{
default: () =>
icon ? h(IconifyIcon, { class: 'size-4', icon }) : text,
default: () => {
if (icon) {
return h(IconifyIcon, { class: 'size-4', icon });
}
return text;
},
},
);
if (!icon) return button;
// 如果有图标,用 Tooltip 包装;否则直接返回按钮
if (icon) {
return h(
ElTooltip,
{
@@ -185,29 +206,37 @@ setupVbenVxeTable({
);
}
return button;
}
function renderConfirm(opt: Recordable<any>) {
const { icon, text } = opt;
const button = h(
ElButton,
{
circle: !!icon,
link: true,
size: 'small',
title: icon ? text : undefined,
type: 'danger',
size: 'small',
link: true,
circle: !!icon,
title: icon ? text : undefined,
},
{
default: () =>
icon ? h(IconifyIcon, { class: 'size-4', icon }) : text,
default: () => {
if (icon) {
return h(IconifyIcon, { class: 'size-4', icon });
}
return text;
},
},
);
return h(
ElPopconfirm,
{
cancelButtonText: $t('common.cancel'),
title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
confirmButtonType: 'danger',
onConfirm: () => {
attrs?.onClick?.({
@@ -215,14 +244,13 @@ setupVbenVxeTable({
row,
});
},
title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),
},
{
reference: () => button,
default: () =>
$t('ui.actionMessage.deleteConfirm', [
row[attrs?.nameField || 'name'],
]),
reference: () => button,
},
);
}
@@ -240,10 +268,14 @@ setupVbenVxeTable({
);
},
});
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
// vxeUI.formats.add
},
useVbenForm,
});
// 自定义类型定义
export type OnActionClickParams<T = Recordable<any>> = {
code: string;
row: T;
@@ -64,6 +64,16 @@ export interface ParamDefinition {
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_content?: string;
db_connection?: string;
/** SQL 执行目标库/catalog */
db_database?: string;
/** SQL 执行目标模式/schema */
db_schema?: string;
// 静态数据
static_data?: any[];
// 参数定义
params?: ParamDefinition[];
// 结果处理
result_type?: ResultType;
list_config?: ListConfig;
tree_config?: TreeConfig;
field_mapping?: Record<string, string>;
chart_config?: ChartConfig;
@@ -195,12 +210,17 @@ export interface DataSourceInput {
// SQL 配置
sql_content?: string;
db_connection?: string;
/** SQL 执行目标库/catalog */
db_database?: string;
/** SQL 执行目标模式/schema */
db_schema?: string;
// 静态数据
static_data?: any[];
// 参数定义
params?: ParamDefinition[];
// 结果处理
result_type?: ResultType;
list_config?: ListConfig;
tree_config?: TreeConfig;
field_mapping?: Record<string, string>;
chart_config?: ChartConfig;
@@ -268,6 +288,10 @@ export interface TestRequest {
// SQL 配置
sql_content?: string;
db_connection?: string;
/** SQL 执行目标库/catalog */
db_database?: string;
/** SQL 执行目标模式/schema */
db_schema?: string;
// 静态数据
static_data?: any[];
// 参数
@@ -275,6 +299,7 @@ export interface TestRequest {
params?: Record<string, any>;
// 结果处理
result_type?: ResultType;
list_config?: ListConfig;
tree_config?: TreeConfig;
field_mapping?: Record<string, string>;
chart_config?: ChartConfig;
+14 -8
View File
@@ -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 './auth';
export * from './data-source';
export * from './database-connection';
export * from './database-manager';
export * from './dept';
export * from './dict';
export * from './file';
export * from './field-permission';
export * from './menu';
export * from './message';
export * from './oauth';
export * from './permission';
export * from './post';
export * from './role';
export * from './resource-scope';
export * from './server-health';
export * from './ui-config';
export * from './scheduler';
export * from './server-monitor';
export * from './user';
export * from './websocket';
@@ -10,7 +10,6 @@ export interface LoginLog {
user_id?: string;
username: string;
status: number;
login_type?: string;
failure_reason?: number;
failure_message?: string;
login_ip: string;
@@ -34,7 +33,6 @@ export interface LoginLogListParams {
username?: string;
user_id?: string;
status?: number;
login_type?: string;
failure_reason?: number;
login_ip?: string;
device_type?: string;
+5
View File
@@ -110,14 +110,19 @@ export interface MenuStats {
/**
* 获取用户所有菜单(路由树)
* @param applicationCode 应用编码,用于过滤应用专属菜单
* @param devMode 开发模式:true只返回系统菜单,false只返回应用菜单
*/
export async function getAllMenusApi(
applicationCode?: string,
devMode?: boolean,
) {
const params: Record<string, any> = {};
if (applicationCode) {
params.application_code = applicationCode;
}
if (devMode !== undefined) {
params.devMode = devMode;
}
return requestClient.get<RouteRecordStringComponent[]>(
'/api/core/menu/route/tree',
{ params },
+6 -7
View File
@@ -29,7 +29,6 @@ export interface PostCreateInput {
status?: boolean;
description?: string;
dept_id?: string;
sort?: number;
}
export interface PostUpdateInput extends Partial<PostCreateInput> {}
@@ -121,7 +120,7 @@ export async function deletePostApi(postId: string) {
*/
export async function batchDeletePostApi(data: PostBatchDeleteInput) {
return requestClient.post<{ count: number }>(
'/api/core/post/batch/delete',
'/api/core/post/batch_delete',
data,
);
}
@@ -133,7 +132,7 @@ export async function batchUpdatePostStatusApi(
data: PostBatchUpdateStatusInput,
) {
return requestClient.post<{ count: number }>(
'/api/core/post/batch/status',
'/api/core/post/batch_update_status',
data,
);
}
@@ -142,7 +141,7 @@ export async function batchUpdatePostStatusApi(
* 根据部门ID获取岗位列表
*/
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) {
return requestClient.get<Blob>('/api/core/post/export/excel', {
return requestClient.get<Blob>('/api/core/post/export', {
params,
responseType: 'blob',
});
@@ -208,7 +207,7 @@ export async function importPostApi(file: File) {
const formData = new FormData();
formData.append('file', file);
return requestClient.post<{ error_count: number; success_count: number }>(
'/api/core/post/import/excel',
'/api/core/post/import',
formData,
{
headers: {
@@ -222,7 +221,7 @@ export async function importPostApi(file: File) {
* 获取简单岗位列表(用于选择器)
*/
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';
/**
* 服务器监控相关类型定义
*/
// 基础信息
export interface BasicInfo {
hostname: string;
ip_address: string;
@@ -14,6 +19,7 @@ export interface BasicInfo {
version: string;
}
// CPU信息
export interface CpuInfo {
physical_cores: number;
total_cores: number;
@@ -26,6 +32,7 @@ export interface CpuInfo {
cpu_stats: Record<string, any>;
}
// 内存信息
export interface MemoryVirtual {
total: number;
available: number;
@@ -53,6 +60,7 @@ export interface MemoryInfo {
swap: MemorySwap;
}
// 磁盘信息
export interface DiskPartition {
device: string;
mountpoint: string;
@@ -73,6 +81,7 @@ export interface DiskInfo {
total_write_time?: number;
}
// 网络信息
export interface NetworkTotal {
bytes_sent: number;
bytes_recv: number;
@@ -127,6 +136,7 @@ export interface NetworkInfo {
connections: NetworkConnection[];
}
// 进程信息
export interface Process {
pid: number;
name: string;
@@ -143,6 +153,7 @@ export interface ProcessInfo {
sleeping_processes: number;
}
// 系统负载
export interface SystemLoad {
load_1min: number;
load_5min: number;
@@ -150,6 +161,7 @@ export interface SystemLoad {
cpu_count: number;
}
// 启动时间
export interface BootTime {
boot_time: string;
uptime_seconds: number;
@@ -159,6 +171,7 @@ export interface BootTime {
uptime_minutes: number;
}
// 用户信息
export interface UserInfo {
name: string;
terminal?: string;
@@ -167,12 +180,14 @@ export interface UserInfo {
pid?: number;
}
// 电池信息
export interface BatteryInfo {
percent: number;
power_plugged: boolean;
seconds_left?: number;
}
// 实时统计
export interface RealtimeStats {
cpu_percent: number;
memory_percent: number;
@@ -202,6 +217,7 @@ export interface RealtimeStats {
timestamp: string;
}
// 服务器监控完整响应
export interface ServerMonitorResponse {
basic_info: BasicInfo;
cpu_info: CpuInfo;
@@ -215,62 +231,105 @@ export interface ServerMonitorResponse {
timestamp: string;
}
/**
* API调用函数
*/
/**
* 获取服务器完整监控信息
*/
export async function getServerOverviewApi() {
return requestClient.get<ServerMonitorResponse>(
'/api/core/server_monitor/overview',
);
}
/**
* 获取实时统计信息
*/
export async function getRealtimeStatsApi() {
return requestClient.get<RealtimeStats>('/api/core/server_monitor/realtime');
}
/**
* 获取基础系统信息
*/
export async function getBasicInfoApi() {
return requestClient.get<BasicInfo>('/api/core/server_monitor/basic_info');
}
/**
* 获取CPU信息
*/
export async function getCpuInfoApi() {
return requestClient.get<CpuInfo>('/api/core/server_monitor/cpu_info');
}
/**
* 获取内存信息
*/
export async function getMemoryInfoApi() {
return requestClient.get<MemoryInfo>('/api/core/server_monitor/memory_info');
}
/**
* 获取磁盘信息
*/
export async function getDiskInfoApi() {
return requestClient.get<DiskInfo>('/api/core/server_monitor/disk_info');
}
/**
* 获取网络信息
*/
export async function getNetworkInfoApi() {
return requestClient.get<NetworkInfo>(
'/api/core/server_monitor/network_info',
);
}
/**
* 获取进程信息
*/
export async function getProcessInfoApi() {
return requestClient.get<ProcessInfo>(
'/api/core/server_monitor/process_info',
);
}
/**
* 获取系统负载信息
*/
export async function getSystemLoadApi() {
return requestClient.get<SystemLoad>('/api/core/server_monitor/system_load');
}
/**
* 获取系统启动时间信息
*/
export async function getBootTimeApi() {
return requestClient.get<BootTime>('/api/core/server_monitor/boot_time');
}
/**
* 获取用户信息
*/
export async function getUsersInfoApi() {
return requestClient.get<UserInfo[]>('/api/core/server_monitor/users_info');
}
/**
* 获取温度信息
*/
export async function getTemperatureInfoApi() {
return requestClient.get<Record<string, any>>(
'/api/core/server_monitor/temperature_info',
);
}
/**
* 获取电池信息
*/
export async function getBatteryInfoApi() {
return requestClient.get<BatteryInfo>(
'/api/core/server_monitor/battery_info',
+1 -1
View File
@@ -71,7 +71,7 @@ export function mergePreferencesConfig(
current: PreferencesConfig,
updates: PreferencesConfig,
): PreferencesConfig {
return merge({}, current, updates) as PreferencesConfig;
return merge({}, updates, current) as PreferencesConfig;
}
// ============ 前端偏好配置API ============
+253 -18
View File
@@ -39,6 +39,46 @@ export namespace WebSocketApi {
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连接
*/
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(
endpoint: string,
callbacks?: WebSocketApi.WebSocketCallbacks,
): 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 = {
url: wsUrl,
url: buildWebSocketUrl(endpoint),
reconnect: true,
maxReconnectAttempts: 5,
reconnectInterval: 3000,
@@ -390,7 +438,7 @@ export function createWebSocket(
}
/**
* Create test WebSocket connection
* 创建测试WebSocket连接
*/
export function createTestWebSocket(
callbacks?: WebSocketApi.WebSocketCallbacks,
@@ -399,7 +447,7 @@ export function createTestWebSocket(
}
/**
* Create notification WebSocket connection
* 创建通知WebSocket连接
*/
export function createNotificationWebSocket(
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(
callbacks?: WebSocketApi.WebSocketCallbacks,
): WebSocketManager {
return createWebSocket('/ws/chat/', callbacks);
}
/**
* 创建数据库监控WebSocket管理器
*/
export function createDatabaseMonitorManager(
callbacks?: WebSocketApi.WebSocketCallbacks,
): DatabaseMonitorWebSocketManager {
const wsManager = createDatabaseMonitorWebSocket(callbacks);
// 创建增强版管理器
return Object.setPrototypeOf(
wsManager,
DatabaseMonitorWebSocketManager.prototype,
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { useElementPlusDesignTokens } from '@vben/hooks';
import { ElConfigProvider } from 'element-plus/es/components/config-provider/index';
import { ElConfigProvider } from 'element-plus';
import { elementLocale } from '#/locales';
+30 -51
View File
@@ -1,68 +1,35 @@
import { createApp, watchEffect } from 'vue';
import { registerAccessDirective } from '@vben/access';
import { registerLoadingDirective } from '@vben/common-ui';
import { preferences } from '@vben/preferences';
import { initStores } from '@vben/stores';
import '@vben/styles';
import '@vben/styles/ele';
import { useTitle } from '@vueuse/core';
import ElementPlus from 'element-plus';
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 { initMessageAdapter } from './adapter/message';
import { setupElementPlus } from './plugins/element-plus';
import { setupZqTable } from './components/zq-table';
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) {
// 初始化组件适配器
await initComponentAdapter();
// 初始化表单组件
await initSetupVbenForm();
// 初始化 ZqTable (注入 form)
setupZqTable({
useVbenForm,
});
// // 设置弹窗的默认配置
// setDefaultModalProps({
// fullscreenButton: false,
@@ -73,8 +40,15 @@ async function bootstrap(namespace: string) {
// });
const app = createApp(App);
setupElementPlus(app);
initMessageAdapter();
app.use(ElementPlus);
// 注册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 配置
await setupI18n(app);
@@ -153,6 +127,7 @@ async function bootstrap(namespace: string) {
});
// 初始化应用上下文(从 URL 参数中获取 appCode
const { useAppContextStore } = await import('#/store/app-context');
const appContextStore = useAppContextStore();
await appContextStore.initFromUrl();
@@ -166,6 +141,10 @@ async function bootstrap(namespace: string) {
// 配置路由及路由守卫
app.use(router);
// 配置Motion插件
const { MotionPlugin } = await import('@vben/plugins/motion');
app.use(MotionPlugin);
// 动态更新标题
watchEffect(() => {
if (preferences.app.dynamicTitle) {
@@ -139,9 +139,6 @@ const getStepLabel = (step: ReasoningStep) => {
if (step.status === 'running') {
return '执行中';
}
if (step.status === 'failed') {
return '执行失败';
}
return '已完成';
}
@@ -153,14 +150,6 @@ const getStepLabel = (step: ReasoningStep) => {
tool_result: '工具结果',
knowledge_retrieval: '知识库检索',
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;
};
@@ -172,9 +161,6 @@ const getStepColor = (step: ReasoningStep) => {
if (step.status === 'running') {
return 'text-blue-500';
}
if (step.status === 'failed') {
return 'text-red-500';
}
return 'text-emerald-500';
}
@@ -186,69 +172,10 @@ const getStepColor = (step: ReasoningStep) => {
tool_result: 'text-teal-500',
knowledge_retrieval: 'text-cyan-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';
};
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) => {
if (!bytes) return '';
@@ -350,16 +277,6 @@ const formatVoiceDuration = (seconds: number) => {
</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 v-if="message.reasoning_steps?.length" class="bubble-steps">
@@ -384,18 +301,7 @@ const formatVoiceDuration = (seconds: number) => {
<span class="step-indicator" :class="getStepColor(step)">
{{ getStepLabel(step) }}
</span>
<span class="step-text" :title="step.content">
{{ 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>
<span class="step-text">{{ step.content }}</span>
</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 {
width: 100%;
margin-bottom: 8px;
@@ -1354,11 +1239,9 @@ const formatVoiceDuration = (seconds: number) => {
}
.bubble-step {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
display: flex;
align-items: center;
column-gap: 8px;
row-gap: 4px;
gap: 8px;
padding: 3px 0;
font-size: 12px;
color: var(--el-text-color-secondary);
@@ -1384,24 +1267,4 @@ const formatVoiceDuration = (seconds: number) => {
text-overflow: ellipsis;
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>
@@ -11,11 +11,12 @@ import type { Attachment, ChatMessage, VoiceMessage } from './types';
*/
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 { ElScrollbar } from 'element-plus';
import AiWorkingAnimation from '#/components/ai-loading/AiWorkingAnimation.vue';
import { getFileUrl } from '#/composables/useFileUrl';
import InputArea from './InputArea.vue';
@@ -245,17 +246,16 @@ defineExpose({
<!-- 内容区域欢迎页或消息列表 -->
<div v-if="messages.length === 0" class="chatbox-content">
<div class="chatbox-center">
<div class="chatbox-welcome">
<div class="welcome-icon">
<Bot class="size-8" />
</div>
<h3 v-if="welcomeConfig?.title" class="welcome-title">
{{ welcomeConfig.title }}
</h3>
<p class="welcome-desc">
{{ welcomeConfig?.description || emptyText || '开始对话吧' }}
</p>
<!-- AI 工作动画 -->
<AiWorkingAnimation
:title="welcomeConfig?.title || ''"
:subtitle="welcomeConfig?.description || emptyText || '开始对话吧'"
:show-progress="false"
:show-steps="false"
:show-background="false"
class="h-full min-h-[400px] flex-1"
>
<!-- 建议问题 -->
<div
v-if="welcomeConfig?.suggestions?.length"
class="welcome-suggestions"
@@ -287,7 +287,7 @@ defineExpose({
/>
</button>
</div>
</div>
</AiWorkingAnimation>
</div>
</div>
@@ -41,50 +41,22 @@ export interface ReasoningStep {
type:
| 'action'
| 'annotation_reply'
| 'error'
| 'knowledge_retrieval'
| 'loop_complete'
| 'loop_iteration_complete'
| 'loop_iteration_error'
| 'loop_iteration_start'
| 'node_complete'
| 'node_start'
| 'observation'
| 'parallel_complete'
| 'parallel_start'
| 'thought'
| 'tool_call'
| 'tool_result'
| 'waiting_input';
| 'tool_result';
content: string;
tool?: string;
params?: Record<string, any>;
result?: any;
node_id?: 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;
/** 步骤状态:running 执行中,completed 已完成failed 执行失败 */
status?: 'completed' | 'failed' | 'running';
/** 步骤状态:running 执行中,completed 已完成 */
status?: 'completed' | 'running';
timestamp?: string;
}
@@ -122,12 +94,10 @@ export interface InteractionConfig {
// 设计预览类型
export type DesignPreviewType =
| 'agent_capability'
| 'database_design'
| 'knowledge_retrieval'
| 'list_config'
| 'llm_config'
| 'workflow_result';
| 'form_basic_info'
| 'form_ui_design'
| 'list_config';
// 设计预览配置
export interface DesignPreview {
@@ -157,10 +127,6 @@ export interface ChatMessage {
error_message?: string;
feedback?: 'dislike' | 'like' | null;
model_name?: string;
agent_code?: string;
agent_name?: string;
collaboration_mode?: string;
model_id?: string;
/** 语音消息 */
voice?: VoiceMessage;
/** 对话流交互配置(等待用户输入时) */
@@ -18,7 +18,7 @@ import type {
*/
import { computed, onUnmounted, ref, watch } from 'vue';
import { Close, Setting } from '@element-plus/icons-vue';
import { Settings2, X } from '@vben/icons';
import {
ElButton,
@@ -31,10 +31,19 @@ import {
import { AiWorkingAnimation } from '#/components/ai-loading';
import { ChatBox } from '#/components/ChatBox';
import {
AppDesignPanel,
AppSettingsPanel,
DashboardBasicInfoConfirmPanel,
DashboardDesignConfirmPanel,
DashboardPublishConfirmPanel,
DesignEditorPanel,
SystemSummaryConfirmPanel,
} from '#/components/form-editor';
import { useChatApi } from './composables/useChatApi';
import { useEventHandler } from './composables/useEventHandler';
// ==================== Props ====================
const props = withDefaults(defineProps<AiChatPanelProps>(), {
mode: 'workflow',
@@ -184,13 +193,44 @@ const variablesDialogVisible = ref(false);
const variablesForm = ref<Record<string, any>>({});
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 类型工作流)
const showLoadingAnimation = ref(false);
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'));
@@ -234,16 +274,45 @@ const generateId = () =>
`msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const handleDesignPreview = (data: DesignPreviewData) => {
// 关闭加载动画
showLoadingAnimation.value = false;
const content = [data.title, data.message].filter(Boolean).join('\n\n');
if (content) {
messages.value.push({
id: generateId(),
role: 'assistant',
content,
timestamp: new Date(),
status: 'completed',
});
switch (data.type) {
case 'app_design': {
currentAppDesign.value = data;
showAppDesignPanel.value = true;
break;
}
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({
clearChat: handleClear,
sendMessage: handleSend,
@@ -724,14 +919,14 @@ onUnmounted(() => {
mode === 'workflow' && enableVariablesForm && hasMultipleVariables
"
link
:icon="Setting"
:icon="Settings2"
title="配置输入变量"
@click="variablesDialogVisible = true"
/>
<ElButton
v-if="showCloseButton"
link
:icon="Close"
:icon="X"
@click="$emit('close')"
/>
</div>
@@ -750,7 +945,6 @@ onUnmounted(() => {
:enable-image="enableImage"
:enable-voice="enableVoice"
:enable-feedback="enableFeedback"
:show-reasoning-steps="true"
:show-clear-button="showClearButton"
:empty-text="emptyText"
: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>
<!-- 侧边栏布局时的设计面板(保持原有行为) -->
<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>
</template>
@@ -91,7 +91,7 @@ export function useChatApi(props: AiChatPanelProps): {
const adapter: ChatApiAdapter = {
sendMessage: (
message: string,
_inputs: Record<string, any> | undefined,
inputs: Record<string, any> | undefined,
onEvent: (event: StreamEvent) => void,
onError: (error: Error) => void,
onComplete: () => void,
@@ -110,12 +110,16 @@ export function useChatApi(props: AiChatPanelProps): {
type: att.type,
}));
// 从 inputs 中提取 form_code(如果存在)
const formCode = inputs?.form_code;
return sendAgentMessageStream(
props.agentId,
{
message,
conversation_id: conversationId.value || undefined,
attachments: attachmentInputs,
form_code: formCode,
},
(event) => {
// 捕获 conversation_id
@@ -231,17 +235,6 @@ export function useChatApi(props: AiChatPanelProps): {
params: step.params,
node_id: step.node_id,
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,
status: step.status,
timestamp: step.timestamp,
@@ -56,12 +56,16 @@ export interface AgentInfo {
/** 设计预览类型 */
export type DesignPreviewType =
| 'agent_capability'
| 'app_design'
| 'app_settings'
| 'dashboard_basic_info'
| 'dashboard_design'
| 'dashboard_publish'
| 'database_design'
| 'knowledge_retrieval'
| 'form_basic_info'
| 'form_ui_design'
| 'list_config'
| 'llm_config'
| 'workflow_result';
| 'system_summary';
/** 设计预览数据 */
export interface DesignPreviewData {
@@ -69,9 +73,7 @@ export interface DesignPreviewData {
title: string;
data: Record<string, any>;
nodeId: string;
data_sources?: any[];
form_fields?: any[];
schema_fields?: any[];
table_configs?: any[];
layoutOptions?: any[];
themeOptions?: any[];
@@ -90,7 +92,6 @@ export interface NodeEvent {
node_id: string;
node_type?: string;
node_label?: string;
event_type?: string;
status?: 'failed' | 'running' | 'success';
inputs?: Record<string, any>;
outputs?: Record<string, any>;
@@ -98,7 +99,6 @@ export interface NodeEvent {
tokens_used?: number;
error_message?: string;
loop_iteration?: number;
warnings?: any[];
}
/** 循环迭代事件 */
@@ -135,7 +135,7 @@ export interface AiChatPanelProps {
agent?: AgentInfo | null;
/** 当前对话 IDagent-chat 模式用于加载历史) */
conversationId?: null | string;
/** 初始输入参数 */
/** 初始输入参数(如 form_code 等系统变量) */
initialInputs?: Record<string, any>;
// ========== 功能开关 ==========
@@ -122,7 +122,7 @@ onUnmounted(() => {
</div>
<!-- 飘过的代码行 - 左侧 -->
<div v-if="showBackground" class="flying-code left-side">
<div class="flying-code left-side">
<div class="code-row" style="--i: 0">
const handleSubmit = async () => {
</div>
@@ -138,7 +138,7 @@ onUnmounted(() => {
</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: 1">const state = reactive({});</div>
<div class="code-row" style="--i: 2">watch(() => props.value, cb);</div>
@@ -152,7 +152,7 @@ onUnmounted(() => {
</div>
<!-- 数据粒子流 -->
<div v-if="showBackground" class="particles">
<div class="particles">
<span
v-for="p in particles"
:key="p.id"
@@ -178,7 +178,7 @@ onUnmounted(() => {
<!-- 机器人工作台 -->
<div class="workstation">
<!-- 思考气泡 -->
<div v-if="showBackground" class="thought-bubble">
<div class="thought-bubble">
<span class="thought-text">{{ thoughts[currentThought] }}</span>
<div class="bubble-dots"><span></span><span></span><span></span></div>
</div>
@@ -284,7 +284,7 @@ onUnmounted(() => {
<!-- 桌子 -->
<div class="desk">
<!-- 小显示器在桌上 -->
<div v-if="showBackground" class="mini-monitor">
<div class="mini-monitor">
<div class="monitor-frame">
<div class="mini-screen">
<div class="py-code">
@@ -340,7 +340,7 @@ onUnmounted(() => {
</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: 78%; --y: 20%; --d: 0.8s">async () =></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) => {
if (!dashboardConfig.value) return 0;
const index = dashboardConfig.value.widgets.findIndex((w) => w.i === i);
return index * 20;
return index * 80; // 每个组件延迟 80ms
};
</script>
@@ -39,7 +39,35 @@ const widgetComponents: Record<
string,
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')),
'ranking-list': defineAsyncComponent(
() => import('./widgets/RankingList.vue'),
),
'announcement-list': defineAsyncComponent(
() => import('./widgets/AnnouncementList.vue'),
),
@@ -53,6 +81,18 @@ const widgetComponents: Record<
),
clock: defineAsyncComponent(() => import('./widgets/ClockWidget.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(
() => import('./widgets/ApprovalCenter.vue'),
),
@@ -60,6 +100,18 @@ const widgetComponents: Record<
'server-monitor': defineAsyncComponent(
() => 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();
@@ -249,7 +301,7 @@ const animationClass = computed(() => {
<!-- 加载状态 -->
<div
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" />
</div>
@@ -296,9 +348,7 @@ const animationClass = computed(() => {
.widget-enter {
opacity: 0;
transform: translateY(20px) scale(0.95);
transition:
opacity 0.22s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.22s cubic-bezier(0.22, 1, 0.36, 1);
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.widget-entered {
@@ -312,8 +362,4 @@ const animationClass = computed(() => {
}
/* 入场动画 */
.widget-loading-mask {
background: color-mix(in srgb, var(--el-bg-color) 78%, transparent);
}
</style>
@@ -1,136 +1,88 @@
<script setup lang="ts">
import type { Component } from 'vue';
import type { DashboardWidget } from '#/components/dashboard-design';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import {
ChevronRight,
ClipboardCheck,
ClipboardList,
FilePen,
IconifyIcon,
Play,
Send,
UserCheck,
} from '@vben/icons';
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<{
widget: DashboardWidget;
}>();
const router = useRouter();
const appContextStore = useAppContextStore();
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[]>(() => [
// 审批中心菜单项
const menuItems = computed(() => [
{
key: 'initiated',
title: $t('dashboard-design.widgets.approvalCenter.initiated'),
icon: Send,
color: defaultColors[0]!,
color: 'rgba(59, 130, 246, 0.85)',
path: '/workflow/initiated',
},
{
key: 'pending',
title: $t('dashboard-design.widgets.approvalCenter.pending'),
icon: ClipboardList,
color: defaultColors[1]!,
color: 'rgba(245, 158, 11, 0.85)',
path: '/workflow/pending',
},
{
key: 'handling',
title: $t('dashboard-design.widgets.approvalCenter.handling'),
icon: FilePen,
color: defaultColors[2]!,
color: 'rgba(139, 92, 246, 0.85)',
path: '/workflow/pending',
},
{
key: 'handled',
title: $t('dashboard-design.widgets.approvalCenter.handled'),
icon: ClipboardCheck,
color: defaultColors[3]!,
color: 'rgba(14, 165, 233, 0.85)',
path: '/workflow/handled',
},
{
key: 'copy',
title: $t('dashboard-design.widgets.approvalCenter.copy'),
icon: UserCheck,
color: defaultColors[4]!,
color: 'rgba(249, 115, 22, 0.85)',
path: '/workflow/copy',
},
{
key: 'start',
title: $t('dashboard-design.widgets.approvalCenter.start'),
icon: Play,
color: defaultColors[5]!,
color: 'rgba(20, 184, 166, 0.85)',
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(
() => props.widget.props.routePrefix || '/app/workflow_center',
);
function handleClick(item: ApprovalMenuItem) {
if (!item.path) return;
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;
}
// 点击菜单项
const handleClick = (item: { path: string }) => {
window.open(`${routePrefix.value}${item.path}`, '_blank');
}
};
function handleMore() {
// 点击更多
const handleMore = () => {
window.open(`${routePrefix.value}/workflow/pending`, '_blank');
}
};
</script>
<template>
<div class="approval-center flex h-full flex-col p-4">
<!-- 头部 -->
<div class="mb-4 flex items-center justify-between">
<span class="text-sm font-medium">{{ widget.props.title }}</span>
<button
@@ -143,10 +95,10 @@ function handleMore() {
<ChevronRight class="h-3.5 w-3.5" />
</button>
</div>
<!-- 菜单网格 -->
<div class="flex flex-1 items-center justify-around gap-2">
<div
v-for="item in displayMenuItems"
v-for="item in menuItems"
:key="item.key"
class="flex cursor-pointer flex-col items-center gap-2 transition-transform hover:scale-105"
@click="handleClick(item)"
@@ -155,16 +107,11 @@ function handleMore() {
class="flex h-11 w-11 items-center justify-center rounded-full"
:style="{ backgroundColor: item.color }"
>
<IconifyIcon
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" />
<component :is="item.icon" class="h-5 w-5 text-white" />
</div>
<span class="text-muted-foreground whitespace-nowrap text-xs">
{{ item.title }}
</span>
<span class="text-muted-foreground whitespace-nowrap text-xs">{{
item.title
}}</span>
</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>
@@ -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>
@@ -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">
import type { ApplicationListItem } from '#/api/core/application';
import type { DashboardWidget } from '#/components/dashboard-design';
import type { ApplicationListItem } from '#/api/core/application';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { AppWindow, ChevronRight, IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
@@ -11,42 +10,23 @@ import { $t } from '@vben/locales';
import { ElEmpty, ElScrollbar } from 'element-plus';
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<{
widget: DashboardWidget;
}>();
const router = useRouter();
const appContextStore = useAppContextStore();
// 应用列表
const appList = ref<ApplicationListItem[]>([]);
const loading = ref(false);
const staticMenus = computed<StaticAppItem[]>(
() => props.widget.props.menus || [],
);
// 最大显示数量
const maxCount = computed(() => props.widget.props.maxCount || 8);
const displayApps = computed(() => {
if (staticMenus.value.length) {
return staticMenus.value.slice(0, maxCount.value);
}
return appList.value.slice(0, maxCount.value);
});
async function loadApps() {
if (staticMenus.value.length) return;
// 显示的应用列表
const displayApps = computed(() => appList.value.slice(0, maxCount.value));
// 加载已发布的应用列表
const loadApps = async () => {
loading.value = true;
try {
const res = await getApplicationListApi({
@@ -60,50 +40,17 @@ async function loadApps() {
} finally {
loading.value = false;
}
}
};
function handleClick(item: ApplicationListItem | StaticAppItem) {
const staticPath = (item as StaticAppItem).path;
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;
// 点击应用
const handleClick = (app: ApplicationListItem) => {
window.open(`${window.location.origin}/app/${app.code}`, '_blank');
}
};
function handleMore() {
const morePath = props.widget.props.morePath;
if (morePath) {
router.push(morePath);
return;
}
// 点击更多
const handleMore = () => {
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(() => {
loadApps();
@@ -112,6 +59,7 @@ onMounted(() => {
<template>
<div class="my-apps flex h-full flex-col p-4" v-loading="loading">
<!-- 头部 -->
<div class="mb-4 flex items-center justify-between">
<span class="text-sm font-medium">{{ widget.props.title }}</span>
<button
@@ -124,30 +72,31 @@ onMounted(() => {
<ChevronRight class="h-3.5 w-3.5" />
</button>
</div>
<!-- 应用网格 -->
<ElScrollbar class="flex-1">
<div v-if="displayApps.length > 0" class="flex flex-wrap gap-4">
<div
v-for="item in displayApps"
:key="(item as any).id || getItemTitle(item)"
class="m-4 flex cursor-pointer flex-col items-center gap-2 transition-transform hover:scale-105"
v-if="displayApps.length > 0"
class="flex flex-wrap gap-4"
>
<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"
@click="handleClick(item)"
@click="handleClick(app)"
>
<div
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
v-if="getItemIcon(item)"
:icon="getItemIcon(item)"
v-if="app.icon"
:icon="app.icon"
class="h-6 w-6 text-white"
/>
<AppWindow v-else class="h-6 w-6 text-white" />
</div>
<span class="text-muted-foreground w-full truncate text-center text-xs">
{{ getItemTitle(item) }}
</span>
<span class="text-muted-foreground w-full truncate text-center text-xs">{{ app.name }}</span>
</div>
</div>
<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>
@@ -131,10 +131,7 @@ onUnmounted(() => {
</script>
<template>
<div
class="server-monitor flex h-full flex-col p-4"
v-loading="loading && widget.props.blockingLoading"
>
<div class="server-monitor flex h-full flex-col p-4" v-loading="loading">
<!-- 头部 -->
<div class="mb-6 flex items-center justify-between">
<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 DashboardDesign } from './index.vue';
export type {
DashboardConfig,
DashboardWidget,
DataSourceConfig,
DataSourceType,
WidgetMaterial,
WidgetType,
// Store 和类型导出
export {
type DashboardConfig,
type DashboardWidget,
type DataSourceConfig,
type DataSourceType,
getWidgetMaterials,
useDashboardDesignStore,
type WidgetMaterial,
widgetMaterials,
type WidgetType,
} from './store/dashboardDesignStore';
// 工具函数导出
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">
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { Page } from '@vben/common-ui/es/page';
import { PanelLeft } from '@vben/icons';
import { Page } from '@vben/common-ui';
import { ChevronRight, PanelLeft } from '@vben/icons';
import { ElCard, ElScrollbar, ElSplitter, ElSplitterPanel } from 'element-plus';
@@ -132,16 +132,37 @@ interface Props {
rightContentClass?: string;
/**
* 是否显示左侧面板折叠按钮
* 是否在右侧面板 header 显示折叠按钮
* @default true
*/
showLeftToggle?: boolean;
/**
* 左侧面板收起后,是否在左边缘显示悬停展开条
* @default true
*/
showCollapsedExpandRail?: boolean;
/** 收起后悬停展开条提示文案 */
collapsedExpandLabel?: string;
/**
* 左侧面板默认是否折叠
* @default false
*/
leftCollapsed?: boolean;
/**
* 透传给内部 Page 的内容区高度偏移(px)
* @default 24
*/
heightOffset?: number;
/**
* 透传给内部 Page 是否显示外边距
* @default true
*/
showMargin?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
@@ -161,12 +182,26 @@ const props = withDefaults(defineProps<Props>(), {
leftUseScrollbar: undefined,
rightUseScrollbar: undefined,
showLeftToggle: true,
showCollapsedExpandRail: true,
leftCollapsed: false,
heightOffset: 24,
showMargin: true,
});
// 左侧面板折叠状态
const isLeftCollapsed = ref(props.leftCollapsed);
watch(
() => props.leftCollapsed,
(val) => {
isLeftCollapsed.value = val;
},
);
const emit = defineEmits<{
(e: 'left-collapsed-change', collapsed: boolean): void;
}>();
// 切换左侧面板折叠状态
const toggleLeftPanel = () => {
isLeftCollapsed.value = !isLeftCollapsed.value;
@@ -177,6 +212,10 @@ const setLeftCollapsed = (collapsed: boolean) => {
isLeftCollapsed.value = collapsed;
};
watch(isLeftCollapsed, (collapsed) => {
emit('left-collapsed-change', collapsed);
});
// 暴露方法给父组件
defineExpose({
isLeftCollapsed,
@@ -226,6 +265,8 @@ const rightScrollbar = computed(() => {
:auto-content-height="autoContentHeight"
:title="pageTitle"
:description="pageDescription"
:height-offset="heightOffset"
:show-margin="showMargin"
>
<!-- Page 头部插槽 -->
<template v-if="$slots.header" #header>
@@ -238,7 +279,24 @@ const rightScrollbar = computed(() => {
</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>
<!-- 左侧面板 -->
<ElSplitterPanel
@@ -281,9 +339,12 @@ const rightScrollbar = computed(() => {
: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
v-if="showLeftToggle"
class="left-toggle-btn-inline"
@@ -295,9 +356,9 @@ const rightScrollbar = computed(() => {
/>
</div>
<!-- 右侧标题或自定义内容 -->
<div class="flex-1">
<div class="flex-1 min-w-0">
<slot name="right-header">
<span>{{ rightTitle }}</span>
<span v-if="rightTitle">{{ rightTitle }}</span>
</slot>
</div>
</div>
@@ -403,4 +464,37 @@ const rightScrollbar = computed(() => {
.fu-page-container :deep(.el-splitter-panel:last-child .el-card) {
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>
@@ -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>">
import type { VbenFormProps } from '@vben-core/form-ui';
import type { VbenFormProps } from '@vben/common-ui';
import type { PaginationProps } from './types';
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 {
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { ChatMessage } from '#/api/core/chat';
import type {
AnnouncementItem,
NotificationItem,
@@ -6,11 +7,12 @@ import type {
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben-core/popup-ui';
import { Mail, MailCheck, Megaphone, Trash2 } from '@vben/icons';
import { useVbenDrawer } from '@vben/common-ui';
import { Mail, MailCheck, Megaphone, MessageSquare, Trash2 } from '@vben/icons';
import { ElButton, ElEmpty, ElScrollbar, ElTooltip } from 'element-plus';
import UserAvatar from '#/components/user-avatar/index.vue';
import { ZqTabs } from '#/components/zq-tabs';
import { $t } from '#/locales';
@@ -23,8 +25,12 @@ interface Props {
announcements?: AnnouncementItem[];
//
announcementUnreadCount?: number;
//
unreadChats?: ChatMessage[];
//
chatUnreadCount?: number;
// Tab
activeTab?: 'announcement' | 'message';
activeTab?: 'announcement' | 'chat' | 'message';
}
defineOptions({ name: 'NotificationDrawer' });
@@ -34,15 +40,18 @@ const props = withDefaults(defineProps<Props>(), {
messageUnreadCount: 0,
announcements: () => [],
announcementUnreadCount: 0,
unreadChats: () => [],
chatUnreadCount: 0,
activeTab: 'message',
});
const emit = defineEmits<{
clear: [];
clickChat: [ChatMessage];
makeAll: [];
readAnnouncement: [AnnouncementItem];
readMessage: [NotificationItem];
'update:activeTab': ['announcement' | 'message'];
'update:activeTab': ['announcement' | 'chat' | 'message'];
viewAll: [];
}>();
@@ -56,6 +65,11 @@ const tabItems = computed(() => [
label: `${$t('message.drawer.messageTab')}${props.messageUnreadCount > 0 ? ` (${props.messageUnreadCount})` : ''}`,
icon: Mail,
},
{
key: 'chat',
label: `${$t('message.drawer.chatTab')}${props.chatUnreadCount > 0 ? ` (${props.chatUnreadCount})` : ''}`,
icon: MessageSquare,
},
{
key: 'announcement',
label: `${$t('message.drawer.announcementTab')}${props.announcementUnreadCount > 0 ? ` (${props.announcementUnreadCount})` : ''}`,
@@ -64,10 +78,34 @@ const tabItems = computed(() => [
]);
function handleTabChange(value: string) {
currentTab.value = value as 'announcement' | 'message';
currentTab.value = value as 'announcement' | 'chat' | 'message';
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() {
emit('viewAll');
}
@@ -202,6 +240,59 @@ function getPriorityClass(priority: number): string {
</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>
<ElScrollbar v-if="announcements.length > 0" class="flex-1">
@@ -7,7 +7,7 @@ import type {
import { computed } from 'vue';
import { useVbenDrawer } from '@vben-core/popup-ui';
import { useVbenDrawer } from '@vben/common-ui';
import { Bell } from '@vben/icons';
import { ElBadge } from 'element-plus';
@@ -50,7 +50,6 @@ const emit = defineEmits<{
clear: [];
clickChat: [ChatMessage];
makeAll: [];
open: [];
readAnnouncement: [AnnouncementItem];
readMessage: [NotificationItem];
'update:activeTab': ['announcement' | 'chat' | 'message'];
@@ -92,7 +91,6 @@ const drawerListeners = {
};
function openDrawer() {
emit('open');
drawerApi.open();
}
</script>
@@ -3,14 +3,20 @@ import type { User } from '#/api/core/user';
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 DeptTag from '#/components/dept-tag/index.vue';
import PostTag from '#/components/post-tag/index.vue';
import RoleTag from '#/components/role-tag/index.vue';
import { getFileUrl } from '#/composables/useFileUrl';
import { $t } from '#/locales';
import { generateAvatarGradient, generateAvatarText } from '#/utils/avatar';
import UserProfileDialog from './UserProfileDialog.vue';
defineOptions({
name: 'UserAvatar',
});
@@ -92,6 +98,14 @@ const userDetail = ref<User>();
const loading = ref(false);
const hasLoaded = ref(false);
// Dialog
const profileDialogVisible = ref(false);
function openProfileDialog() {
popoverVisible.value = false;
profileDialogVisible.value = true;
}
// ID
const effectiveUserId = computed(() => {
return props.userId || props.user?.id;
@@ -319,6 +333,18 @@ watch(
<div class="user-name">{{ userDetail.name }}</div>
<div class="user-username">@{{ userDetail.username }}</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>
<!-- 详细信息 -->
@@ -344,7 +370,7 @@ watch(
<div v-if="userDetail.post_id" class="detail-item">
<span class="detail-label">岗位</span>
<span class="detail-value">
{{ userDetail.post_name || userDetail.post_id }}
<PostTag :post-id="userDetail.post_id" />
</span>
</div>
<div
@@ -391,6 +417,13 @@ watch(
</div>
</ElPopover>
<!-- 用户详情 Dialog -->
<UserProfileDialog
v-if="effectiveUserId"
v-model="profileDialogVisible"
:user-id="effectiveUserId"
/>
<!-- Popover -->
<div
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 {
padding-top: 12px;
@@ -27,6 +27,7 @@ import {
ElTooltip,
} from 'element-plus';
import { getPostUsersApi } from '#/api/core/post';
import { getRoleUsersApi } from '#/api/core/role';
import { getUserListApi } from '#/api/core/user';
@@ -55,10 +56,10 @@ const UserCard = defineAsyncComponent(() => import('./user-card.vue'));
// @ts-ignore
const UserListItem = defineAsyncComponent(() => import('./user-list-item.vue'));
type DataSource = 'all' | 'dept' | 'role';
type DataSource = 'all' | 'dept' | 'post' | 'role';
interface Props {
dataSource?: DataSource; // all-, dept-, role-
dataSource?: DataSource; // all-, dept-, post-, role-
sourceId?: string; // IDIDID
tempSelectedUsers?: Set<string>;
filterable?: boolean;
@@ -110,6 +111,10 @@ async function loadUsers(isLoadMore = false) {
// 使 getUserListApi dept_ids
params.dept_ids = [props.sourceId];
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) {
params.username = 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
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">
import { computed, ref } from 'vue';
import type {
CodeEditorEmits,
CodeEditorExpose,
CodeEditorProps,
} from './types';
const props = withDefaults(
defineProps<{
autocompletion?: boolean;
bracketMatching?: boolean;
foldGutter?: boolean;
height?: number | string;
language?: string;
lineNumbers?: boolean;
modelValue?: string;
placeholder?: string;
readonly?: boolean;
}>(),
{
import { computed, onMounted, ref } from 'vue';
const props = withDefaults(defineProps<CodeEditorProps>(), {
autocompletion: false,
bracketMatching: false,
disabled: false,
foldGutter: false,
height: '240px',
language: 'text',
height: 'auto',
highlightActiveLine: false,
indentGuide: false,
language: 'javascript',
lineNumbers: false,
lineWrapping: true,
minHeight: '100px',
modelValue: '',
placeholder: '',
readonly: false,
},
);
tabSize: 2,
theme: 'auto',
});
const emit = defineEmits<{
change: [value: string];
'update:modelValue': [value: string];
}>();
const lineNumbersRef = ref<HTMLPreElement | null>(null);
const emit = defineEmits<CodeEditorEmits>();
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const editorStyle = computed(() => ({
height: typeof props.height === 'number' ? `${props.height}px` : props.height,
}));
const editorStyle = computed(() => {
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(() => {
const count = Math.max(1, props.modelValue.split(/\r\n|\r|\n/).length);
return Array.from({ length: count }, (_, index) => index + 1).join('\n');
if (props.height !== 'auto') {
style.height =
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(() => {
const language = props.language === 'python3' ? 'python' : props.language;
return language.toUpperCase();
});
function handleInput(event: Event) {
const value = (event.target as HTMLTextAreaElement).value;
function emitValue(value: string) {
emit('update:modelValue', value);
}
function handleChange(event: Event) {
const value = (event.target as HTMLTextAreaElement).value;
emit('change', value);
}
function handleKeydown(event: KeyboardEvent) {
if (props.readonly || event.key !== 'Tab') {
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 focus() {
textareaRef.value?.focus();
}
function handleScroll(event: Event) {
if (!lineNumbersRef.value) {
return;
}
lineNumbersRef.value.scrollTop = (event.target as HTMLTextAreaElement).scrollTop;
function getValue() {
return props.modelValue || '';
}
defineExpose({
focus: () => textareaRef.value?.focus(),
function setValue(value: string) {
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>
<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
ref="textareaRef"
class="code-editor__textarea"
class="zq-code-editor"
:disabled="disabled"
:placeholder="placeholder"
:readonly="readonly"
:spellcheck="false"
:style="editorStyle"
:value="modelValue"
@change="handleChange"
@input="handleInput"
@keydown="handleKeydown"
@scroll="handleScroll"
spellcheck="false"
@blur="emit('blur')"
@focus="emit('focus')"
@input="emitValue(($event.target as HTMLTextAreaElement).value)"
></textarea>
<span v-if="language" class="code-editor__language">
{{ languageLabel }}
</span>
</div>
</div>
</template>
<style scoped>
.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 {
.zq-code-editor {
width: 100%;
height: 100%;
min-width: 0;
padding: 10px 12px;
overflow: auto;
border: 0;
outline: none;
resize: none;
background: transparent;
color: hsl(var(--foreground));
resize: vertical;
border: 1px solid var(--el-border-color);
border-radius: 6px;
background: var(--el-fill-color-blank);
color: var(--el-text-color-primary);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 12px;
font-size: 13px;
line-height: 1.6;
tab-size: 2;
outline: none;
padding: 10px 12px;
}
.code-editor__textarea::placeholder {
color: hsl(var(--muted-foreground) / 55%);
.zq-code-editor:focus {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary-light-7);
}
.code-editor__language {
position: absolute;
right: 8px;
bottom: 6px;
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;
.zq-code-editor:disabled {
cursor: not-allowed;
background: var(--el-disabled-bg-color);
color: var(--el-disabled-text-color);
}
</style>
@@ -1 +1,3 @@
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 { Table2 } from '@vben/icons';
import { Search, Table2 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElCheckbox,
ElEmpty,
ElInput,
ElOption,
ElPagination,
ElRadio,
ElSelect,
ElTable,
ElTableColumn,
@@ -23,145 +20,167 @@ import { requestClient } from '#/api/request';
import { ZqDialog } from '#/components/zq-dialog';
defineOptions({
name: 'FormSelector',
inheritAttrs: false,
name: 'FormSelector',
});
const selectorText = {
clickToSelect: '点击选择',
nodeLabel: '名称',
nodeValue: '值',
search: '搜索',
selectData: '选择数据',
selectFormFirst: '请先选择表单',
selectedCount: (count: number) => `已选择 ${count}`,
};
const props = withDefaults(defineProps<FormSelectorProps>(), {
multiple: false,
collapseTags: false,
maxCollapseTags: 1,
placeholder: selectorText.clickToSelect,
disabled: false,
clearable: true,
dialogTitle: selectorText.selectData,
dialogWidth: '1200px',
valueField: 'id',
labelField: 'name',
collapseTags: false,
dialogTitle: () => $t('form-design.attribute.selectData'),
dialogWidth: '960px',
disabled: false,
expandMultipleToRows: false,
externalSelectedValues: () => [],
initialFilters: undefined,
labelField: 'name',
maxCollapseTags: 1,
multiple: false,
placeholder: () => $t('form-design.attribute.clickToSelect'),
valueField: 'id',
});
const emit = defineEmits<FormSelectorEmits>();
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 tableData = ref<Array<Record<string, any>>>([]);
const total = ref(0);
const listLoading = ref(false);
const listData = ref<any[]>([]);
const listTotal = ref(0);
const currentPage = ref(1);
const pageSize = ref(10);
const searchKeyword = ref('');
const selectedValues = ref<Set<string>>(new Set());
const selectedItemsMap = ref<Map<string, Record<string, any>>>(new Map());
const keyword = ref('');
function normalizeValues(value: null | string | string[] | undefined) {
if (!value) return [];
return Array.isArray(value) ? value.map(String) : [String(value)];
function toSelectedSet(source: unknown): Set<string> {
if (source instanceof Set) return source;
if (Array.isArray(source)) return new Set(source.map(String));
return new Set();
}
function toStringArray(value: unknown) {
if (!value) return [];
return Array.isArray(value) ? value.map(String) : [String(value)];
function toStringArray(source: unknown): string[] {
if (!source) return [];
if (Array.isArray(source)) return source.map(String);
return [];
}
function getItemLabel(item: undefined | Record<string, any>, value: string) {
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() {
function buildFilterParams() {
const params: Record<string, any> = {};
for (const [field, filter] of Object.entries(props.initialFilters || {})) {
if (filter?.value === undefined || filter.value === null || filter.value === '') {
continue;
for (const [field, config] of Object.entries(props.initialFilters || {})) {
if (config?.value !== undefined && config?.value !== '') {
params[`filter_${field}`] = config.value;
}
const op = filter.type || 'eq';
params[op === 'eq' ? field : `${field}__${op}`] = filter.value;
}
return params;
}
async function loadInitialLabels(values: string[]) {
if (!props.formCode || values.length === 0) return;
function getItemLabel(item: any, value: string): string {
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 {
const response = await requestClient.get(
`/api/online_dev/form-data/${props.formCode}/list`,
{
params: {
...normalizeFilters(),
[`${props.valueField}__in`]: values.join(','),
pageSize: values.length,
...buildFilterParams(),
keyword: keyword.value || undefined,
page: currentPage.value,
pageSize: pageSize.value,
},
},
);
for (const item of pickList(response)) {
selectedItemsMap.value.set(String(item[props.valueField]), item);
}
listData.value = response?.items || [];
listTotal.value = response?.total || listData.value.length;
} 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() {
const items = [...selectedValues.value]
const items = [...toSelectedSet(selectedValues.value)]
.map((value) => selectedItemsMap.value.get(value))
.filter(Boolean);
emit('select-item', props.multiple ? items : items[0]);
emit('select-item', props.multiple ? (items.length ? items : undefined) : items[0]);
}
watch(
() => props.modelValue,
async (value) => {
const values = normalizeValues(value);
selectedValues.value = new Set(values);
const missingValues = values.filter((v) => !selectedItemsMap.value.has(v));
if (value) {
const values = Array.isArray(value) ? value : [value];
selectedValues.value = new Set(values.map(String));
const missingValues = values.filter(
(item) => !selectedItemsMap.value.has(String(item)),
);
if (missingValues.length > 0) {
labelsLoading.value = true;
await loadInitialLabels(missingValues);
labelsLoading.value = false;
if (!props.expandMultipleToRows) emitSelectItem();
}
} else {
selectedValues.value = new Set();
selectedItemsMap.value.clear();
}
},
{ immediate: true },
);
watch(
() => props.formCode,
async () => {
const values = normalizeValues(props.modelValue);
const missingValues = values.filter((v) => !selectedItemsMap.value.has(v));
async (formCode) => {
if (formCode && props.modelValue) {
const values = Array.isArray(props.modelValue)
? props.modelValue
: [props.modelValue];
const missingValues = values.filter(
(item) => !selectedItemsMap.value.has(String(item)),
);
if (missingValues.length > 0) {
labelsLoading.value = true;
await loadInitialLabels(missingValues);
labelsLoading.value = false;
if (!props.expandMultipleToRows) emitSelectItem();
}
}
},
);
@@ -169,137 +188,59 @@ const selectValue = computed(() => {
if (labelsLoading.value) return props.multiple ? [] : '';
if (props.expandMultipleToRows) {
const values = normalizeValues(props.modelValue);
return props.multiple ? values : values[0] || '';
if (!props.modelValue) return props.multiple ? [] : '';
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] || '';
});
const selectOptions = computed(() => {
const values = props.expandMultipleToRows
? normalizeValues(props.modelValue)
: [...selectedValues.value];
return values.map((value) => ({
value,
if (labelsLoading.value) return [];
if (props.expandMultipleToRows) {
if (!props.modelValue) return [];
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),
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() {
if (props.disabled) return;
if (props.expandMultipleToRows) {
for (const value of toStringArray(props.externalSelectedValues)) {
selectedValues.value.add(value);
}
const externalValues = toStringArray(props.externalSelectedValues);
if (props.expandMultipleToRows && externalValues.length > 0) {
for (const value of externalValues) selectedValues.value.add(value);
selectedValues.value = new Set(selectedValues.value);
}
dialogVisible.value = true;
loadData();
}
function handleSearch() {
currentPage.value = 1;
loadData();
}
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);
loadDialogList();
}
function handleConfirm() {
const values = [...selectedValues.value];
const values = [...toSelectedSet(selectedValues.value)];
const result = props.multiple ? values : values[0] || null;
emit('update:modelValue', result);
@@ -321,12 +262,53 @@ function handleRemoveTag(value: string) {
selectedItemsMap.value.delete(value);
selectedValues.value = new Set(selectedValues.value);
const values = [...selectedValues.value];
const values = [...toSelectedSet(selectedValues.value)];
const result = props.multiple ? values : values[0] || null;
emit('update:modelValue', result);
emit('change', result);
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>
<template>
@@ -363,97 +345,83 @@ function handleRemoveTag(value: string) {
append-to-body
destroy-on-close
: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 class="mb-4 flex shrink-0 items-center gap-2">
<div v-if="formCode" class="form-selector-panel">
<div class="form-selector-toolbar">
<ElInput
v-model="searchKeyword"
:placeholder="selectorText.search"
v-model="keyword"
clearable
class="w-64"
@change="handleSearch"
:prefix-icon="Search"
:placeholder="$t('common.search')"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<ElButton type="primary" @click="handleSearch">
{{ $t('common.search') }}
</ElButton>
</div>
<div class="min-h-0 flex-1">
<ElTable
v-loading="loading"
:data="tableData"
v-loading="listLoading"
:data="listData"
height="420"
border
stripe
height="100%"
:row-class-name="
({ row }: any) => (isRowSelected(row) ? 'selected-row' : '')
"
style="width: 100%"
@row-click="toggleRow"
highlight-current-row
@row-click="handleRowSelect"
>
<ElTableColumn v-if="!multiple" width="55" align="center">
<ElTableColumn width="56" align="center">
<template #default="{ row }">
<ElRadio
:model-value="isRowSelected(row) ? getRowValue(row) : ''"
:value="getRowValue(row)"
@click.stop
@change="toggleRow(row)"
<span
class="form-selector-check"
:class="{
'is-selected': selectedValues.has(String(row[valueField])),
}"
>
<span></span>
</ElRadio>
{{ selectedValues.has(String(row[valueField])) ? '✓' : '' }}
</span>
</template>
</ElTableColumn>
<ElTableColumn v-else width="55" align="center">
<template #header>
<ElCheckbox
:model-value="isAllSelected"
:indeterminate="isIndeterminate"
@change="handleSelectAll"
<ElTableColumn
:prop="labelField"
:label="labelField"
min-width="180"
show-overflow-tooltip
/>
</template>
<template #default="{ row }">
<ElCheckbox
:model-value="isRowSelected(row)"
@click.stop
@change="toggleRow(row, $event)"
<ElTableColumn
:prop="valueField"
:label="valueField"
min-width="160"
show-overflow-tooltip
/>
</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>
<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 v-else class="no-form-code">
{{ selectorText.selectFormFirst }}
{{ $t('form-design.attribute.selectFormFirst') }}
</div>
<template #footer>
<div class="flex w-full items-center">
<div class="flex-shrink-0">
<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)]">
<div class="flex w-full items-center justify-between">
<div class="text-sm text-[var(--el-text-color-secondary)]">
{{
selectorText.selectedCount(selectedValues.size)
$t('form-design.attribute.selectedCount', {
count: selectedValues.size,
})
}}
</div>
<div class="flex flex-shrink-0 gap-2">
<div class="flex gap-2">
<ElButton @click="dialogVisible = false">
{{ $t('common.cancel') }}
</ElButton>
@@ -472,12 +440,37 @@ function handleRemoveTag(value: string) {
width: 100%;
}
.form-selector-content :deep(.selected-row) {
background-color: var(--el-color-primary-light-9) !important;
.form-selector-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.form-selector-content :deep(.el-table__row) {
cursor: pointer;
.form-selector-toolbar {
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 {
@@ -1,18 +1,31 @@
export interface FormSelectorProps {
modelValue?: null | string | string[];
formCode?: string;
/** 值字段,默认 id */
valueField?: string;
/** 显示字段,默认 name */
labelField?: string;
/** 是否多选 */
multiple?: boolean;
/** 多选时是否折叠标签 */
collapseTags?: boolean;
/** 多选时折叠标签的最大显示数量 */
maxCollapseTags?: number;
/** 占位符 */
placeholder?: string;
/** 是否禁用 */
disabled?: boolean;
/** 是否可清空 */
clearable?: boolean;
/** 弹窗标题 */
dialogTitle?: string;
/** 弹窗宽度 */
dialogWidth?: string;
/** 多选展开为多行模式:输入框不实时显示弹窗内的选中项 */
expandMultipleToRows?: boolean;
/** 外部预设的已选中值(用于 expandMultipleToRows 模式下同步子表已有行) */
externalSelectedValues?: string[];
/** 弹窗列表初始过滤条件(FormDataList.initialFilters 格式) */
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">
<ElInput
:model-value="displayValue || props.modelValue"
:placeholder="placeholder || '选择数据后自动带出'"
:placeholder="
placeholder || $t('form-design.attribute.linkedFieldPlaceholder')
"
:disabled="true"
readonly
class="linked-field"
File diff suppressed because it is too large Load Diff
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
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;
}
@@ -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>
@@ -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 });
// URLtoken
const resolvedVideoSrc = ref('');
// URL
async function resolveVideoUrl() {
const id = props.node.attrs.id;
const src = props.node.attrs.src;
// ID使IDtokenURL
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();
},
};
},
});
@@ -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 type * from './types';
@@ -1,159 +1,59 @@
<script lang="ts" setup>
import { nextTick, ref, watch } from 'vue';
<script setup lang="ts">
import type {
LightRichTextEditorInstance,
RichTextEditorEmits,
RichTextEditorProps,
} from './types';
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Eraser,
Heading1,
Image,
Italic,
Link,
List,
ListOrdered,
Quote,
Redo2,
Table,
Underline,
Undo2,
} from '@vben/icons';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import {
ElButton,
ElButtonGroup,
ElColorPicker,
ElDivider,
ElInput,
ElMessage,
ElPopover,
ElSelect,
ElOption,
} from 'element-plus';
import { Bold, Code, Eye, EyeOff, Italic, Link, List } from '@vben/icons';
const props = withDefaults(
defineProps<{
disabled?: boolean;
maxHeight?: number;
minHeight?: number;
modelValue?: string;
placeholder?: string;
readonly?: boolean;
}>(),
{
disabled: false,
maxHeight: 500,
minHeight: 200,
import { ElButton, ElInput } from 'element-plus';
const props = withDefaults(defineProps<RichTextEditorProps>(), {
modelValue: '',
placeholder: '',
placeholder: '请输入内容...',
disabled: 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<{
blur: [event: FocusEvent];
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 || ''),
const textarea = computed<HTMLTextAreaElement | undefined>(
() => inputRef.value?.textarea,
);
function emitChange() {
if (isSyncing || !editorRef.value) return;
const html = editorRef.value.innerHTML;
emit('update:modelValue', html);
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 syncValue(value: string) {
localValue.value = value;
emit('update:modelValue', value);
emit('change', value);
}
function handleFocus(event: FocusEvent) {
@@ -161,298 +61,224 @@ function handleFocus(event: FocusEvent) {
}
function handleBlur(event: FocusEvent) {
emitChange();
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>
<template>
<div class="rich-text-editor" :class="{ 'is-disabled': disabled || readonly }">
<div class="rich-text-toolbar">
<ElSelect
v-model="currentBlock"
size="small"
class="block-select"
:disabled="disabled || readonly"
@change="applyBlock"
<div
class="rich-text-editor rounded border border-[var(--el-border-color)] bg-[var(--el-bg-color)]"
:class="{ 'is-disabled': disabled, 'is-readonly': readonly }"
>
<div
v-if="showToolbar"
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"
>
<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
:icon="Undo2"
size="small"
text
title="撤销"
@click="exec('undo')"
/>
<ElButton
:icon="Redo2"
size="small"
text
title="重做"
@click="exec('redo')"
/>
</ElButtonGroup>
<ElDivider direction="vertical" />
<ElButtonGroup>
<ElButton
:icon="Bold"
size="small"
text
title="加粗"
@click="exec('bold')"
/>
:disabled="disabledOrReadonly || previewMode"
@click="insertSnippet('<strong>', '</strong>')"
>
<Bold class="h-4 w-4" />
</ElButton>
<ElButton
:icon="Italic"
size="small"
text
size="small"
title="斜体"
@click="exec('italic')"
/>
:disabled="disabledOrReadonly || previewMode"
@click="insertSnippet('<em>', '</em>')"
>
<Italic class="h-4 w-4" />
</ElButton>
<ElButton
:icon="Underline"
size="small"
text
title="下划线"
@click="exec('underline')"
/>
</ElButtonGroup>
<ElDivider direction="vertical" />
<ElButtonGroup>
size="small"
title="链接"
:disabled="disabledOrReadonly || previewMode"
@click="insertSnippet('<a href=&quot;&quot;>', '</a>')"
>
<Link class="h-4 w-4" />
</ElButton>
<ElButton
:icon="AlignLeft"
size="small"
text
title="左对齐"
@click="exec('justifyLeft')"
/>
size="small"
title="列表"
:disabled="disabledOrReadonly || previewMode"
@click="insertSnippet('<ul><li>', '</li></ul>')"
>
<List class="h-4 w-4" />
</ElButton>
<ElButton
:icon="AlignCenter"
size="small"
text
title="居中"
@click="exec('justifyCenter')"
/>
size="small"
title="代码"
:disabled="disabledOrReadonly || previewMode"
@click="insertSnippet('<code>', '</code>')"
>
<Code class="h-4 w-4" />
</ElButton>
<ElButton
:icon="AlignRight"
size="small"
text
title="右对齐"
@click="exec('justifyRight')"
/>
</ElButtonGroup>
<ElDivider direction="vertical" />
<ElButtonGroup>
size="small"
:disabled="disabledOrReadonly || previewMode"
@click="clearFormat"
>
清除格式
</ElButton>
<ElButton
:icon="List"
size="small"
text
title="无序列表"
@click="exec('insertUnorderedList')"
/>
<ElButton
:icon="ListOrdered"
size="small"
text
title="有序列表"
@click="exec('insertOrderedList')"
/>
<ElButton
:icon="Quote"
size="small"
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>
class="ml-auto"
@click="previewMode = !previewMode"
>
<EyeOff v-if="previewMode" class="mr-1 h-4 w-4" />
<Eye v-else class="mr-1 h-4 w-4" />
{{ previewMode ? '编辑' : '预览' }}
</ElButton>
</div>
</ElPopover>
<ElPopover v-model:visible="imagePopoverVisible" width="300" trigger="click">
<template #reference>
<ElButton :icon="Image" size="small" text title="插入图片" />
</template>
<div class="popover-form">
<ElInput v-model="imageUrl" placeholder="图片 URL" />
<ElButton type="primary" size="small" @click="insertImage">插入图片</ElButton>
</div>
</ElPopover>
<ElPopover v-model:visible="tablePopoverVisible" width="260" trigger="click">
<template #reference>
<ElButton :icon="Table" size="small" text title="插入表格" />
</template>
<div class="table-popover">
<ElInput v-model.number="tableRows" type="number" min="1" max="10">
<template #prepend></template>
</ElInput>
<ElInput v-model.number="tableCols" type="number" min="1" max="8">
<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 class="editor-content" :style="editorStyle">
<div
v-if="previewMode"
class="preview-content h-full overflow-auto px-4 py-3 text-[var(--el-text-color-primary)]"
v-html="localValue || `<span class='empty'>${placeholder}</span>`"
></div>
<ElInput
v-else
ref="inputRef"
:model-value="localValue"
type="textarea"
resize="none"
:disabled="disabled"
:readonly="readonly"
:placeholder="placeholder"
:maxlength="maxLength > 0 ? maxLength : undefined"
@blur="handleBlur"
@focus="handleFocus"
@update:model-value="syncValue"
/>
</div>
<div
ref="editorRef"
class="rich-text-content"
:contenteditable="!disabled && !readonly"
:data-placeholder="placeholder"
:style="{ minHeight: `${minHeight}px`, maxHeight: `${maxHeight}px` }"
role="textbox"
aria-multiline="true"
@blur="handleBlur"
@focus="handleFocus"
@input="emitChange"
@paste="handlePaste"
></div>
v-if="showWordCount"
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)]"
>
<span>
字数: {{ wordCount }}
<template v-if="maxLength > 0"> / {{ maxLength }}</template>
</span>
</div>
</div>
</template>
<style scoped>
.rich-text-editor {
border: 1px solid var(--el-border-color);
border-radius: 6px;
background: var(--el-bg-color);
overflow: hidden;
}
.rich-text-editor.is-disabled {
opacity: 0.72;
cursor: not-allowed;
opacity: 0.65;
}
.rich-text-toolbar {
display: flex;
min-height: 42px;
align-items: center;
gap: 2px;
flex-wrap: wrap;
border-bottom: 1px solid var(--el-border-color-lighter);
padding: 5px 8px;
.toolbar :deep(.el-button) {
min-width: 28px;
height: 28px;
padding: 4px 6px;
}
.block-select {
width: 96px;
.editor-content {
overflow: hidden;
}
.rich-text-content {
overflow: auto;
padding: 12px 14px;
outline: none;
.editor-content :deep(.el-textarea) {
height: 100%;
}
.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;
}
.rich-text-content:empty::before {
.preview-content :deep(.empty) {
color: var(--el-text-color-placeholder);
content: attr(data-placeholder);
}
.rich-text-content :deep(h1),
.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) {
.preview-content :deep(a) {
color: var(--el-color-primary);
text-decoration: underline;
}
.popover-form,
.table-popover {
display: grid;
gap: 8px;
.preview-content :deep(ul),
.preview-content :deep(ol) {
padding-left: 1.4em;
}
.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>
@@ -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