feat: restore source parity and harden agent runtime

This commit is contained in:
2026-06-22 11:17:26 +08:00
parent e33f08277b
commit 0793eb82d6
596 changed files with 168879 additions and 290 deletions
-2
View File
@@ -16,5 +16,3 @@ VITE_DEVTOOLS=false
# 是否注入全局loading
VITE_INJECT_APP_LOADING=true
VITE_ENABLE_ONLINE_DEV_DESIGN=false
VITE_ENABLE_DASHBOARD_ADVANCED_WIDGETS=false
-2
View File
@@ -7,5 +7,3 @@ VITE_PWA=false
VITE_ROUTER_HISTORY=history
VITE_INJECT_APP_LOADING=true
VITE_ARCHIVER=false
VITE_ENABLE_ONLINE_DEV_DESIGN=false
VITE_ENABLE_DASHBOARD_ADVANCED_WIDGETS=false
+675
View File
@@ -0,0 +1,675 @@
import { requestClient } from '#/api/request';
const BASE = '/api/smart-table';
// ==================== Types ====================
export interface SmartTableItem {
id: string;
name: string;
icon: string;
type?: string;
parent_id?: string | null;
sort?: number;
description?: string;
active_view_id?: string;
sys_create_datetime?: string;
sys_update_datetime?: string;
}
export interface SmartFieldItem {
id: string;
table_id: string;
name: string;
type: string;
width: number;
visible: boolean;
required: boolean;
description?: string;
config: Record<string, any>;
sort: number;
sys_create_datetime?: string;
}
export interface SmartRecordItem {
id: string;
table_id: string;
values: Record<string, any>;
sys_create_datetime?: string;
sys_update_datetime?: string;
sys_creator_id?: string;
sys_modifier_id?: string;
}
export interface SmartViewItem {
id: string;
table_id: string;
name: string;
type: string;
config: Record<string, any>;
sort: number;
sys_create_datetime?: string;
}
export interface SmartTableFull extends SmartTableItem {
type?: string;
content?: any;
fields: SmartFieldItem[];
records: SmartRecordItem[];
views: SmartViewItem[];
record_total: number;
next_cursor: string | null;
has_more: boolean;
sys_creator_id?: string;
creator_name?: string;
creator_avatar?: string;
}
export interface CursorPaginatedRecords {
items: SmartRecordItem[];
total: number;
next_cursor: string | null;
has_more: boolean;
}
export interface RecordFilterParam {
field_id: string;
operator: string;
value?: any;
}
export interface RecordSortParam {
field_id: string;
direction: 'asc' | 'desc';
}
export interface RecordQueryParam {
filters?: RecordFilterParam[];
filter_logic?: 'and' | 'or';
sorts?: RecordSortParam[];
search?: string;
search_field_ids?: string[];
group_field_id?: string;
cursor?: string | null;
limit?: number;
}
export interface RecordGroupItem {
key: string;
label: string;
records: SmartRecordItem[];
}
export interface GroupedRecordsResponse {
groups: RecordGroupItem[];
total: number;
}
// ==================== Table API ====================
export function getTableListApi(wikiSpaceId?: string | null) {
const params: Record<string, any> = {};
if (wikiSpaceId) params.wiki_space_id = wikiSpaceId;
return requestClient.get<SmartTableItem[]>(`${BASE}/tables`, { params });
}
export function getTableFullApi(
tableId: string,
opts?: { filters?: RecordFilterParam[]; sorts?: RecordSortParam[]; search?: string; filter_logic?: string },
) {
const params: Record<string, any> = {};
if (opts?.filters?.length) params.filters = JSON.stringify(opts.filters);
if (opts?.sorts?.length) params.sorts = JSON.stringify(opts.sorts);
if (opts?.search) params.search = opts.search;
if (opts?.filter_logic) params.filter_logic = opts.filter_logic;
return requestClient.get<SmartTableFull>(`${BASE}/tables/${tableId}/full`, { params });
}
export function createTableApi(data: { name: string; icon?: string; description?: string; type?: string; content?: any; parent_id?: string | null; wiki_space_id?: string | null }) {
return requestClient.post<SmartTableItem>(`${BASE}/tables`, data);
}
export function updateTableApi(tableId: string, data: Partial<SmartTableItem>) {
return requestClient.put<SmartTableItem>(`${BASE}/tables/${tableId}`, data);
}
export function updateDocumentContentApi(tableId: string, content: any) {
return requestClient.patch(`${BASE}/tables/${tableId}/content`, { content });
}
export function exportDocumentPdfApi(tableId: string, html: string, title: string) {
return requestClient.post<Blob>(`${BASE}/tables/${tableId}/export-pdf`, { html, title }, {
responseType: 'blob',
});
}
export function deleteTableApi(tableId: string) {
return requestClient.delete(`${BASE}/tables/${tableId}`);
}
export function moveTableApi(tableId: string, parentId: string | null, afterId?: string | null) {
return requestClient.put<SmartTableItem>(`${BASE}/tables/${tableId}/move`, {
parent_id: parentId,
after_id: afterId ?? null,
});
}
// ==================== Field API ====================
export function getFieldListApi(tableId: string) {
return requestClient.get<SmartFieldItem[]>(`${BASE}/tables/${tableId}/fields`);
}
export function createFieldApi(tableId: string, data: Omit<SmartFieldItem, 'id' | 'table_id' | 'sys_create_datetime'>) {
return requestClient.post<SmartFieldItem>(`${BASE}/tables/${tableId}/fields`, data);
}
export function updateFieldApi(fieldId: string, data: Partial<SmartFieldItem>) {
return requestClient.put<SmartFieldItem>(`${BASE}/fields/${fieldId}`, data);
}
export function deleteFieldApi(fieldId: string) {
return requestClient.delete(`${BASE}/fields/${fieldId}`);
}
export function reorderFieldsApi(tableId: string, fieldIds: string[]) {
return requestClient.put(`${BASE}/tables/${tableId}/fields/reorder`, { field_ids: fieldIds });
}
// ==================== Record API ====================
export function getRecordListApi(
tableId: string,
cursor?: string | null,
limit = 200,
opts?: { filters?: RecordFilterParam[]; sorts?: RecordSortParam[]; search?: string; filter_logic?: string },
) {
const params: Record<string, any> = { cursor: cursor ?? undefined, limit };
if (opts?.filters?.length) params.filters = JSON.stringify(opts.filters);
if (opts?.sorts?.length) params.sorts = JSON.stringify(opts.sorts);
if (opts?.search) params.search = opts.search;
if (opts?.filter_logic) params.filter_logic = opts.filter_logic;
return requestClient.get<CursorPaginatedRecords>(
`${BASE}/tables/${tableId}/records`,
{ params },
);
}
export function queryRecordsApi(tableId: string, query: RecordQueryParam) {
return requestClient.post<CursorPaginatedRecords | GroupedRecordsResponse>(
`${BASE}/tables/${tableId}/records/query`,
query,
);
}
export function reorderRecordsApi(tableId: string, recordIds: string[]) {
return requestClient.put(`${BASE}/tables/${tableId}/records/reorder`, { record_ids: recordIds });
}
export function createRecordApi(tableId: string, values: Record<string, any> = {}) {
return requestClient.post<SmartRecordItem>(`${BASE}/tables/${tableId}/records`, { table_id: tableId, values });
}
export function updateRecordApi(recordId: string, values: Record<string, any>) {
return requestClient.put<SmartRecordItem>(`${BASE}/records/${recordId}`, { values });
}
export function updateCellApi(recordId: string, fieldId: string, value: any) {
return requestClient.patch<SmartRecordItem>(`${BASE}/records/${recordId}/cells`, {
field_id: fieldId,
value,
});
}
export function batchUpdateCellsApi(recordId: string, cells: Record<string, any>) {
return requestClient.patch<SmartRecordItem>(`${BASE}/records/${recordId}/cells/batch`, { cells });
}
export function batchUpdateMultiRecordCellsApi(
tableId: string,
updates: Array<{ record_id: string; cells: Record<string, any> }>,
) {
return requestClient.patch(`${BASE}/tables/${tableId}/records/batch-cells`, { updates });
}
export function deleteRecordApi(recordId: string) {
return requestClient.delete(`${BASE}/records/${recordId}`);
}
export function batchDeleteRecordsApi(tableId: string, ids: string[]) {
return requestClient.post(`${BASE}/tables/${tableId}/records/batch-delete`, { ids });
}
// ==================== Trash / Recycle Bin ====================
export interface TrashRecordItem {
id: string;
table_id: string;
values: Record<string, any>;
sys_create_datetime?: string;
sys_update_datetime?: string;
sys_creator_id?: string;
}
export function getTrashRecordsApi(tableId: string, page = 1, pageSize = 50) {
return requestClient.get<{ items: TrashRecordItem[]; total: number }>(
`${BASE}/tables/${tableId}/trash`,
{ params: { page, page_size: pageSize } },
);
}
export function restoreTrashRecordsApi(tableId: string, ids: string[]) {
return requestClient.post(`${BASE}/tables/${tableId}/trash/restore`, { ids });
}
export function permanentDeleteTrashRecordApi(tableId: string, recordId: string) {
return requestClient.delete(`${BASE}/tables/${tableId}/trash/${recordId}`);
}
export function emptyTrashApi(tableId: string) {
return requestClient.delete(`${BASE}/tables/${tableId}/trash`);
}
export function exportTableApi(tableId: string, format: 'csv' | 'xlsx' = 'csv') {
return requestClient.get(`${BASE}/tables/${tableId}/export`, {
params: { format },
responseType: 'blob',
});
}
export function importTableApi(tableId: string, file: File) {
const formData = new FormData();
formData.append('file', file);
return requestClient.post(`${BASE}/tables/${tableId}/import`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
export interface RecordSearchResultItem {
id: string;
title: string;
}
export function searchRecordsApi(tableId: string, keyword: string = '', limit: number = 20) {
return requestClient.post<RecordSearchResultItem[]>(
`${BASE}/tables/${tableId}/records/search`,
{ keyword, limit },
);
}
// ==================== Summary API ====================
export interface SummaryResult {
summaries: Record<string, any>;
total_count: number;
}
export function getSummaryApi(
tableId: string,
data: {
aggregations: Record<string, string>;
filters?: RecordFilterParam[];
filter_logic?: string;
search?: string;
},
) {
return requestClient.post<SummaryResult>(`${BASE}/tables/${tableId}/summary`, data);
}
// ==================== Comment API ====================
export interface CommentItem {
id: string;
record_id: string;
user_id: string;
content: string;
mentions: string[];
parent_id: string | null;
sys_create_datetime?: string;
sys_update_datetime?: string;
user_name?: string;
user_avatar?: string;
replies: CommentItem[];
}
export function getCommentsApi(recordId: string) {
return requestClient.get<CommentItem[]>(`${BASE}/records/${recordId}/comments`);
}
export function createCommentApi(recordId: string, data: { content: string; mentions?: string[]; parent_id?: string }) {
return requestClient.post<CommentItem>(`${BASE}/records/${recordId}/comments`, data);
}
export function updateCommentApi(commentId: string, data: { content: string; mentions?: string[] }) {
return requestClient.put<CommentItem>(`${BASE}/comments/${commentId}`, data);
}
export function deleteCommentApi(commentId: string) {
return requestClient.delete(`${BASE}/comments/${commentId}`);
}
// ==================== View API ====================
export function getViewListApi(tableId: string) {
return requestClient.get<SmartViewItem[]>(`${BASE}/tables/${tableId}/views`);
}
export function createViewApi(tableId: string, data: { name: string; type: string; config?: Record<string, any> }) {
return requestClient.post<SmartViewItem>(`${BASE}/tables/${tableId}/views`, { ...data, table_id: tableId });
}
export function updateViewApi(viewId: string, data: Partial<SmartViewItem>) {
return requestClient.put<SmartViewItem>(`${BASE}/views/${viewId}`, data);
}
export function deleteViewApi(viewId: string) {
return requestClient.delete(`${BASE}/views/${viewId}`);
}
// ==================== Permission API ====================
export interface MyPermission {
role_type: string;
role_name: string;
capabilities: Record<string, boolean>;
field_permissions: Record<string, string>;
row_view_mode: string;
row_edit_mode: string;
}
export interface TableRole {
id: string;
table_id: string | null;
name: string;
role_type: string;
capabilities: Record<string, boolean>;
is_system: boolean;
sys_create_datetime?: string;
}
export interface Collaborator {
id: string;
table_id: string;
subject_type: string;
subject_id: string;
role_id: string;
role_name?: string;
role_type?: string;
subject_name?: string;
subject_avatar?: string;
sys_create_datetime?: string;
}
export interface FieldPermItem {
field_id: string;
access: string;
}
export interface FieldPermMatrix {
role_id: string;
role_name: string;
role_type: string;
fields: FieldPermItem[];
}
export interface RowRule {
id: string;
table_id: string;
role_id: string;
rule_type: string;
mode: string;
conditions: Record<string, any>[];
}
export function getMyPermissionApi(tableId: string) {
return requestClient.get<MyPermission>(`${BASE}/tables/${tableId}/my-permission`);
}
export function getRolesApi(tableId: string) {
return requestClient.get<TableRole[]>(`${BASE}/tables/${tableId}/roles`);
}
export function createRoleApi(tableId: string, data: { name: string; capabilities: Record<string, boolean> }) {
return requestClient.post<TableRole>(`${BASE}/tables/${tableId}/roles`, data);
}
export function updateRoleApi(tableId: string, roleId: string, data: { name?: string; capabilities?: Record<string, boolean> }) {
return requestClient.put<TableRole>(`${BASE}/tables/${tableId}/roles/${roleId}`, data);
}
export function deleteRoleApi(tableId: string, roleId: string) {
return requestClient.delete(`${BASE}/tables/${tableId}/roles/${roleId}`);
}
export function getCollaboratorsApi(tableId: string) {
return requestClient.get<Collaborator[]>(`${BASE}/tables/${tableId}/collaborators`);
}
export function addCollaboratorApi(tableId: string, data: { subject_type: string; subject_id: string; role_id: string }) {
return requestClient.post<Collaborator>(`${BASE}/tables/${tableId}/collaborators`, data);
}
export function updateCollaboratorApi(tableId: string, collabId: string, data: { role_id: string }) {
return requestClient.put<Collaborator>(`${BASE}/tables/${tableId}/collaborators/${collabId}`, data);
}
export function removeCollaboratorApi(tableId: string, collabId: string) {
return requestClient.delete(`${BASE}/tables/${tableId}/collaborators/${collabId}`);
}
export function getFieldPermissionsApi(tableId: string) {
return requestClient.get<FieldPermMatrix[]>(`${BASE}/tables/${tableId}/field-permissions`);
}
export function updateFieldPermissionsApi(tableId: string, data: { role_id: string; permissions: FieldPermItem[] }) {
return requestClient.put(`${BASE}/tables/${tableId}/field-permissions`, data);
}
export function getRowRulesApi(tableId: string) {
return requestClient.get<RowRule[]>(`${BASE}/tables/${tableId}/row-rules`);
}
export function updateRowRuleApi(tableId: string, data: { role_id: string; rule_type: string; mode: string; conditions: Record<string, any>[] }) {
return requestClient.put<RowRule>(`${BASE}/tables/${tableId}/row-rules`, data);
}
// ==================== Document Version API ====================
export interface DocumentVersionItem {
id: string;
document_id: string;
version: number;
title?: string;
change_summary?: string;
content_size: number;
sys_create_datetime?: string;
sys_creator_id?: string;
creator_name?: string;
creator_avatar?: string;
}
export interface DocumentVersionDetail extends DocumentVersionItem {
content: Record<string, any>;
}
export interface DocumentVersionCompare {
version_from: DocumentVersionDetail;
version_to: DocumentVersionDetail;
}
export function getDocumentVersionsApi(tableId: string, page = 1, pageSize = 20) {
return requestClient.get<{ items: DocumentVersionItem[]; total: number }>(
`${BASE}/tables/${tableId}/versions`,
{ params: { page, pageSize } },
);
}
export function getDocumentVersionDetailApi(versionId: string) {
return requestClient.get<DocumentVersionDetail>(`${BASE}/versions/${versionId}`);
}
export function createDocumentVersionApi(tableId: string, changeSummary?: string) {
return requestClient.post<DocumentVersionItem>(
`${BASE}/tables/${tableId}/versions`,
{ change_summary: changeSummary },
);
}
export function restoreDocumentVersionApi(tableId: string, versionId: string) {
return requestClient.post(`${BASE}/tables/${tableId}/versions/${versionId}/restore`);
}
export function compareDocumentVersionsApi(tableId: string, fromId: string, toId: string) {
return requestClient.get<DocumentVersionCompare>(
`${BASE}/tables/${tableId}/versions/compare`,
{ params: { from: fromId, to: toId } },
);
}
export function deleteDocumentVersionApi(versionId: string) {
return requestClient.delete(`${BASE}/versions/${versionId}`);
}
// ==================== Document Template API ====================
export interface DocumentTemplateItem {
id: string;
name: string;
description?: string;
icon: string;
category: string;
preview_image?: string;
is_system: boolean;
use_count: number;
sys_create_datetime?: string;
sys_update_datetime?: string;
sys_creator_id?: string;
creator_name?: string;
}
export interface DocumentTemplateDetail extends DocumentTemplateItem {
content: Record<string, any>;
}
export function getDocumentTemplatesApi(params?: { category?: string; keyword?: string; page?: number; pageSize?: number }) {
return requestClient.get<{ items: DocumentTemplateItem[]; total: number }>(
`${BASE}/document-templates`,
{ params },
);
}
export function getDocumentTemplateCategoriesApi() {
return requestClient.get<string[]>(`${BASE}/document-templates/categories`);
}
export function getDocumentTemplateDetailApi(templateId: string) {
return requestClient.get<DocumentTemplateDetail>(`${BASE}/document-templates/${templateId}`);
}
export function createDocumentTemplateApi(data: {
name: string;
description?: string;
icon?: string;
category?: string;
content: Record<string, any>;
preview_image?: string;
}) {
return requestClient.post<DocumentTemplateItem>(`${BASE}/document-templates`, data);
}
export function createTemplateFromDocumentApi(documentId: string, data: {
name: string;
description?: string;
category?: string;
content: Record<string, any>;
}) {
return requestClient.post<DocumentTemplateItem>(
`${BASE}/document-templates/from-document/${documentId}`,
data,
);
}
export function updateDocumentTemplateApi(templateId: string, data: {
name?: string;
description?: string;
icon?: string;
category?: string;
content?: Record<string, any>;
preview_image?: string;
}) {
return requestClient.put<DocumentTemplateItem>(`${BASE}/document-templates/${templateId}`, data);
}
export function deleteDocumentTemplateApi(templateId: string) {
return requestClient.delete(`${BASE}/document-templates/${templateId}`);
}
export function useDocumentTemplateApi(templateId: string) {
return requestClient.post(`${BASE}/document-templates/${templateId}/use`);
}
// ==================== Wiki Space API ====================
export interface WikiSpaceItem {
id: string;
name: string;
icon: string;
avatar?: string | null;
description?: string;
cover?: string;
category: string;
visibility: string;
sort: number;
document_count?: number;
sys_create_datetime?: string;
sys_update_datetime?: string;
sys_creator_id?: string;
creator_name?: string;
}
export interface WikiSpaceDetail extends WikiSpaceItem {
documents: SmartTableItem[];
}
export function getWikiSpacesApi() {
return requestClient.get<WikiSpaceItem[]>(`${BASE}/wiki-spaces`);
}
export function getWikiSpaceDetailApi(spaceId: string) {
return requestClient.get<WikiSpaceDetail>(`${BASE}/wiki-spaces/${spaceId}`);
}
export function createWikiSpaceApi(data: {
name: string;
icon?: string;
avatar?: string | null;
description?: string;
cover?: string;
category?: string;
visibility?: string;
}) {
return requestClient.post<WikiSpaceItem>(`${BASE}/wiki-spaces`, data);
}
export function updateWikiSpaceApi(spaceId: string, data: Partial<WikiSpaceItem>) {
return requestClient.put<WikiSpaceItem>(`${BASE}/wiki-spaces/${spaceId}`, data);
}
export function deleteWikiSpaceApi(spaceId: string) {
return requestClient.delete(`${BASE}/wiki-spaces/${spaceId}`);
}
export function getWikiSpaceDocumentsApi(spaceId: string) {
return requestClient.get<SmartTableItem[]>(`${BASE}/wiki-spaces/${spaceId}/documents`);
}
export function createWikiDocumentApi(spaceId: string, data: {
name: string;
parent_id?: string | null;
content?: any;
}) {
return requestClient.post<SmartTableItem>(
`${BASE}/wiki-spaces/${spaceId}/documents`,
{ ...data, type: 'document', icon: 'FileText' },
);
}
@@ -35,44 +35,27 @@ import { ChatBox } from '#/components/ChatBox';
import { useChatApi } from './composables/useChatApi';
import { useEventHandler } from './composables/useEventHandler';
const ENABLE_ONLINE_DEV_DESIGN =
import.meta.env.VITE_ENABLE_ONLINE_DEV_DESIGN === 'true';
const AppDesignPanel = ENABLE_ONLINE_DEV_DESIGN
? defineAsyncComponent(() =>
import('#/components/form-editor/AppDesignPanel.vue'),
)
: undefined;
const AppSettingsPanel = ENABLE_ONLINE_DEV_DESIGN
? defineAsyncComponent(() =>
import('#/components/form-editor/AppSettingsPanel.vue'),
)
: undefined;
const DashboardBasicInfoConfirmPanel = ENABLE_ONLINE_DEV_DESIGN
? defineAsyncComponent(() =>
import('#/components/form-editor/DashboardBasicInfoConfirmPanel.vue'),
)
: undefined;
const DashboardDesignConfirmPanel = ENABLE_ONLINE_DEV_DESIGN
? defineAsyncComponent(() =>
import('#/components/form-editor/DashboardDesignConfirmPanel.vue'),
)
: undefined;
const DashboardPublishConfirmPanel = ENABLE_ONLINE_DEV_DESIGN
? defineAsyncComponent(() =>
import('#/components/form-editor/DashboardPublishConfirmPanel.vue'),
)
: undefined;
const DesignEditorPanel = ENABLE_ONLINE_DEV_DESIGN
? defineAsyncComponent(() =>
import('#/components/form-editor/DesignEditorPanel.vue'),
)
: undefined;
const SystemSummaryConfirmPanel = ENABLE_ONLINE_DEV_DESIGN
? defineAsyncComponent(() =>
import('#/components/form-editor/SystemSummaryConfirmPanel.vue'),
)
: undefined;
const AppDesignPanel = defineAsyncComponent(() =>
import('#/components/form-editor/AppDesignPanel.vue'),
);
const AppSettingsPanel = defineAsyncComponent(() =>
import('#/components/form-editor/AppSettingsPanel.vue'),
);
const DashboardBasicInfoConfirmPanel = defineAsyncComponent(() =>
import('#/components/form-editor/DashboardBasicInfoConfirmPanel.vue'),
);
const DashboardDesignConfirmPanel = defineAsyncComponent(() =>
import('#/components/form-editor/DashboardDesignConfirmPanel.vue'),
);
const DashboardPublishConfirmPanel = defineAsyncComponent(() =>
import('#/components/form-editor/DashboardPublishConfirmPanel.vue'),
);
const DesignEditorPanel = defineAsyncComponent(() =>
import('#/components/form-editor/DesignEditorPanel.vue'),
);
const SystemSummaryConfirmPanel = defineAsyncComponent(() =>
import('#/components/form-editor/SystemSummaryConfirmPanel.vue'),
);
// ==================== Props ====================
const props = withDefaults(defineProps<AiChatPanelProps>(), {
@@ -252,14 +235,13 @@ const loadingAnimationTitle = ref('AI 正在开发...');
// 是否有任何设计面板打开
const hasAnyDesignPanelOpen = computed(
() =>
(ENABLE_ONLINE_DEV_DESIGN &&
(showDesignPanel.value ||
showAppDesignPanel.value ||
showAppSettingsPanel.value ||
showDashboardBasicInfoPanel.value ||
showDashboardDesignPanel.value ||
showDashboardPublishPanel.value ||
showSystemSummaryPanel.value)) ||
showDesignPanel.value ||
showAppDesignPanel.value ||
showAppSettingsPanel.value ||
showDashboardBasicInfoPanel.value ||
showDashboardDesignPanel.value ||
showDashboardPublishPanel.value ||
showSystemSummaryPanel.value ||
showLoadingAnimation.value,
);
@@ -308,8 +290,6 @@ const handleDesignPreview = (data: DesignPreviewData) => {
// 关闭加载动画
showLoadingAnimation.value = false;
if (!ENABLE_ONLINE_DEV_DESIGN) return;
switch (data.type) {
case 'app_design': {
currentAppDesign.value = data;
@@ -363,7 +343,7 @@ const { handleStreamEvent, updateAssistantMessage } = useEventHandler({
waitingConfig,
running,
conversationId: safeConversationId,
onDesignPreview: ENABLE_ONLINE_DEV_DESIGN ? handleDesignPreview : undefined,
onDesignPreview: handleDesignPreview,
});
// ==================== 加载历史消息 ====================
@@ -482,7 +462,6 @@ const runMessageWithExistingUserMsg = (
) => {
// 如果是 application 类型工作流且是全屏布局,显示加载动画
if (
ENABLE_ONLINE_DEV_DESIGN &&
props.layout === 'fullscreen' &&
props.agent?.workflow_type === 'application'
) {
@@ -554,7 +533,6 @@ const runMessage = (
// 如果是 application 类型工作流且是全屏布局,显示加载动画
if (
ENABLE_ONLINE_DEV_DESIGN &&
props.layout === 'fullscreen' &&
props.agent?.workflow_type === 'application'
) {
@@ -651,7 +629,6 @@ const handleInteractionSubmit = (messageId: string, value: any) => {
// 全屏布局且是 application 类型工作流时,显示加载动画
if (
ENABLE_ONLINE_DEV_DESIGN &&
props.layout === 'fullscreen' &&
props.agent?.workflow_type === 'application'
) {
@@ -690,7 +667,6 @@ const handleInteractionCancel = (messageId: string) => {
// 全屏布局且是 application 类型工作流时,显示加载动画
if (
ENABLE_ONLINE_DEV_DESIGN &&
props.layout === 'fullscreen' &&
props.agent?.workflow_type === 'application'
) {
@@ -802,7 +778,6 @@ const createDesignConfirmHandler = (
// 全屏布局且是 application 类型工作流时,显示加载动画
if (
ENABLE_ONLINE_DEV_DESIGN &&
props.layout === 'fullscreen' &&
props.agent?.workflow_type === 'application'
) {
@@ -1044,7 +1019,7 @@ onUnmounted(() => {
<!-- 设计编辑面板 -->
<DesignEditorPanel
v-if="ENABLE_ONLINE_DEV_DESIGN && showDesignPanel"
v-if="showDesignPanel"
:visible="showDesignPanel"
:design="currentDesign as any"
@update:visible="showDesignPanel = $event"
@@ -1054,7 +1029,7 @@ onUnmounted(() => {
<!-- 应用设计面板 -->
<AppDesignPanel
v-if="ENABLE_ONLINE_DEV_DESIGN && showAppDesignPanel"
v-if="showAppDesignPanel"
:visible="showAppDesignPanel"
:design="currentAppDesign as any"
@update:visible="showAppDesignPanel = $event"
@@ -1064,7 +1039,7 @@ onUnmounted(() => {
<!-- 应用设置面板 -->
<AppSettingsPanel
v-if="ENABLE_ONLINE_DEV_DESIGN && showAppSettingsPanel"
v-if="showAppSettingsPanel"
:visible="showAppSettingsPanel"
:settings="currentAppSettings as any"
@update:visible="showAppSettingsPanel = $event"
@@ -1074,7 +1049,7 @@ onUnmounted(() => {
<!-- 仪表盘基础信息面板 -->
<DashboardBasicInfoConfirmPanel
v-if="ENABLE_ONLINE_DEV_DESIGN && showDashboardBasicInfoPanel"
v-if="showDashboardBasicInfoPanel"
:visible="showDashboardBasicInfoPanel"
:basic-info="currentDashboardBasicInfo as any"
@update:visible="showDashboardBasicInfoPanel = $event"
@@ -1084,7 +1059,7 @@ onUnmounted(() => {
<!-- 仪表盘设计面板 -->
<DashboardDesignConfirmPanel
v-if="ENABLE_ONLINE_DEV_DESIGN && showDashboardDesignPanel"
v-if="showDashboardDesignPanel"
:visible="showDashboardDesignPanel"
:design="currentDashboardDesign as any"
@update:visible="showDashboardDesignPanel = $event"
@@ -1094,7 +1069,7 @@ onUnmounted(() => {
<!-- 仪表盘发布面板 -->
<DashboardPublishConfirmPanel
v-if="ENABLE_ONLINE_DEV_DESIGN && showDashboardPublishPanel"
v-if="showDashboardPublishPanel"
:visible="showDashboardPublishPanel"
:publish-data="currentDashboardPublish as any"
@update:visible="showDashboardPublishPanel = $event"
@@ -1104,7 +1079,7 @@ onUnmounted(() => {
<!-- 系统总结面板 -->
<SystemSummaryConfirmPanel
v-if="ENABLE_ONLINE_DEV_DESIGN && showSystemSummaryPanel"
v-if="showSystemSummaryPanel"
:visible="showSystemSummaryPanel"
:data="currentSystemSummary as any"
@update:visible="showSystemSummaryPanel = $event"
@@ -1114,7 +1089,7 @@ onUnmounted(() => {
</div>
<!-- 侧边栏布局时的设计面板(保持原有行为) -->
<template v-if="ENABLE_ONLINE_DEV_DESIGN && layout === 'sidebar'">
<template v-if="layout === 'sidebar'">
<DesignEditorPanel
v-if="showDesignPanel"
:visible="showDesignPanel"
@@ -103,18 +103,11 @@ export function useEventHandler(config: EventHandlerConfig) {
themeOptions: configData.themeOptions,
};
const hasDesignPanel = Boolean(onDesignPreview);
// 调用设计预览回调
onDesignPreview?.(previewData);
// 显示提示消息
const waitingContent = `**${configData.title}**\n\n${
configData.message ||
(hasDesignPanel
? '请在右侧面板中确认或编辑设计'
: '轻量版未启用在线开发设计面板,请直接在对话中确认或补充修改要求')
}`;
const waitingContent = `**${configData.title}**\n\n${configData.message || '请在右侧面板中确认或编辑设计'}`;
const initialMsg = messages.value.find((m) => m.id === msgId);
if (initialMsg && !initialMsg.content?.trim()) {
updateAssistantMessage(msgId, {
@@ -785,6 +778,7 @@ export function useEventHandler(config: EventHandlerConfig) {
// ========== 通用事件 ==========
case 'start': {
currentRunId.value = event.run_id || '';
appendObservedStep(event, msgId, 'running');
break;
}
@@ -34,34 +34,6 @@ const isEntered = ref(false);
// 数据更新动画状态
const isUpdating = ref(false);
const ENABLE_DASHBOARD_ADVANCED_WIDGETS =
import.meta.env.VITE_ENABLE_DASHBOARD_ADVANCED_WIDGETS === 'true';
const ADVANCED_WIDGET_TYPES = new Set([
'chart-area',
'chart-bar',
'chart-funnel',
'chart-gauge',
'chart-heatmap',
'chart-kline',
'chart-line',
'chart-pie',
'chart-radar',
'chart-ring',
'chart-sankey',
'chart-scatter',
'data-table',
'filter-date',
'filter-date-range',
'filter-input',
'filter-select',
'form-render',
'iframe',
'image',
'image-carousel',
'video-player',
]);
// 组件映射
const widgetComponents: Record<
string,
@@ -71,6 +43,26 @@ const widgetComponents: Record<
'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(
@@ -89,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'),
),
@@ -96,72 +100,18 @@ const widgetComponents: Record<
'server-monitor': defineAsyncComponent(
() => import('./widgets/ServerMonitor.vue'),
),
...(ENABLE_DASHBOARD_ADVANCED_WIDGETS
? {
'chart-area': defineAsyncComponent(
() => import('./widgets/ChartArea.vue'),
),
'chart-bar': defineAsyncComponent(
() => import('./widgets/ChartBar.vue'),
),
'chart-funnel': defineAsyncComponent(
() => import('./widgets/ChartFunnel.vue'),
),
'chart-gauge': defineAsyncComponent(
() => import('./widgets/ChartGauge.vue'),
),
'chart-heatmap': defineAsyncComponent(
() => import('./widgets/ChartHeatmap.vue'),
),
'chart-kline': defineAsyncComponent(
() => import('./widgets/ChartKline.vue'),
),
'chart-line': defineAsyncComponent(
() => import('./widgets/ChartLine.vue'),
),
'chart-pie': defineAsyncComponent(
() => import('./widgets/ChartPie.vue'),
),
'chart-radar': defineAsyncComponent(
() => import('./widgets/ChartRadar.vue'),
),
'chart-ring': defineAsyncComponent(
() => import('./widgets/ChartRing.vue'),
),
'chart-sankey': defineAsyncComponent(
() => import('./widgets/ChartSankey.vue'),
),
'chart-scatter': defineAsyncComponent(
() => import('./widgets/ChartScatter.vue'),
),
'data-table': defineAsyncComponent(
() => import('./widgets/DataTable.vue'),
),
'filter-date': defineAsyncComponent(
() => import('./widgets/FilterDate.vue'),
),
'filter-date-range': defineAsyncComponent(
() => import('./widgets/FilterDateRange.vue'),
),
'filter-input': defineAsyncComponent(
() => import('./widgets/FilterInput.vue'),
),
'filter-select': defineAsyncComponent(
() => import('./widgets/FilterSelect.vue'),
),
'form-render': defineAsyncComponent(
() => import('./widgets/FormRenderWidget.vue'),
),
iframe: defineAsyncComponent(() => import('./widgets/IframeWidget.vue')),
image: defineAsyncComponent(() => import('./widgets/ImageWidget.vue')),
'image-carousel': defineAsyncComponent(
() => import('./widgets/ImageCarousel.vue'),
),
'video-player': defineAsyncComponent(
() => import('./widgets/VideoPlayer.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();
@@ -170,12 +120,6 @@ const currentComponent = computed(() => {
return widgetComponents[props.widget.type];
});
const isDisabledAdvancedWidget = computed(
() =>
!ENABLE_DASHBOARD_ADVANCED_WIDGETS &&
ADVANCED_WIDGET_TYPES.has(props.widget.type),
);
// 从 paramBindings 解析出实际参数值
function resolveParams(): Record<string, any> | undefined {
const bindings = props.widget.dataSource?.paramBindings;
@@ -380,11 +324,7 @@ const animationClass = computed(() => {
v-else
class="flex h-full w-full items-center justify-center text-gray-400"
>
{{
isDisabledAdvancedWidget
? '轻量版未启用高级组件'
: $t('dashboard-design.unknownWidget')
}}: {{ widget.type }}
{{ $t('dashboard-design.unknownWidget') }}: {{ widget.type }}
</div>
</div>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,475 @@
<script setup lang="ts">
import type { DesignerElement } from '../store/documentDesignStore';
import { computed, ref } from 'vue';
import {
Code,
Download,
Eye,
FileCode,
FileText,
PanelLeft,
PanelRight,
RotateCcw,
RotateCw,
Trash2,
Upload,
ZoomIn,
ZoomOut,
} from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElEmpty,
ElIcon,
ElRadioButton,
ElRadioGroup,
ElScrollbar,
ElSlider,
ElTooltip,
} from 'element-plus';
import { storeToRefs } from 'pinia';
import draggable from 'vuedraggable';
import { useDocumentDesignStore } from '../store/documentDesignStore';
import {
formatPageNumberPreview,
getPageNumberPreviewStyle,
} from '../utils/pageNumber';
import {
formatPageSizeLabel,
resolvePageSizePx,
} from '../utils/pageSize';
import ElementWrapper from './ElementWrapper.vue';
import FloatElementWrapper from './FloatElementWrapper.vue';
import WysiwygCanvas from './WysiwygCanvas.vue';
const emit = defineEmits<{
(e: 'preview'): void;
(e: 'view-code'): void;
(e: 'view-html'): void;
(e: 'clear'): void;
(e: 'import'): void;
(e: 'export'): void;
(e: 'save'): void;
}>();
const store = useDocumentDesignStore();
const { templateConfig, canUndo, canRedo, isDragging, showAttributePanel, zoomLevel } = storeToRefs(store);
const canvasPaperRef = ref<HTMLDivElement | null>(null);
const isWysiwyg = computed(() => templateConfig.value.editorMode === 'wysiwyg');
const handleModeChange = (mode: 'component' | 'wysiwyg') => {
templateConfig.value.editorMode = mode;
store.recordSnapshot();
};
const zoomScale = computed(() => zoomLevel.value / 100);
const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 200];
const zoomIn = () => { zoomLevel.value = ZOOM_STEPS.find((s) => s > zoomLevel.value) ?? 200; };
const zoomOut = () => { zoomLevel.value = [...ZOOM_STEPS].reverse().find((s) => s < zoomLevel.value) ?? 25; };
const resetZoom = () => { zoomLevel.value = 100; };
const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
zoomLevel.value = e.deltaY < 0
? Math.min(200, zoomLevel.value + 10)
: Math.max(25, zoomLevel.value - 10);
}
};
// 悬浮元素(脱离文档流的元素)
const floatElements = computed(() =>
templateConfig.value.elements.filter(
(el: DesignerElement) => el.positionMode === 'float',
),
);
const pageSizeLabel = computed(() => formatPageSizeLabel(templateConfig.value));
const showPageNumberPreview = computed(
() => templateConfig.value.showPageNumber !== false,
);
const pageNumberPreviewText = computed(() =>
formatPageNumberPreview(templateConfig.value),
);
const pageNumberPreviewStyle = computed(() =>
getPageNumberPreviewStyle(templateConfig.value),
);
// 画布样式
const canvasStyle = computed(() => {
const size = resolvePageSizePx(templateConfig.value);
const margin = templateConfig.value.pageMargin;
return {
width: `${size.width}px`,
minHeight: `${size.height}px`,
padding: `${margin.top}px ${margin.right}px ${margin.bottom}px ${margin.left}px`,
};
});
// 点击画布空白处取消选中
const handleCanvasClick = () => {
store.setActive(null);
};
// 拖拽结束后记录快照
const handleDragEnd = () => {
store.setDragging(false);
store.recordSnapshot();
};
// 画布内拖拽开始,记录元素类型供行容器 put 判断
const handleCanvasDragStart = (evt: { oldIndex: number }) => {
const el = templateConfig.value.elements[evt.oldIndex];
store.setDragging(true, el?.type);
};
// 判断元素是否已在行容器列中
function isElementInAnyRow(elementId: string) {
for (const el of templateConfig.value.elements) {
if (el.type === 'row' && el.children) {
for (const child of el.children) {
if (child?.id === elementId) return true;
}
}
}
return false;
}
// 处理拖拽添加到画布:元素面板拖入行容器时可能重复落入画布,需去重
const handleDragAdd = (evt: { from?: { el?: HTMLElement }; newIndex: number }) => {
const added = templateConfig.value.elements[evt.newIndex];
const fromRowColumn = evt.from?.el?.closest?.('.preview-row-column-drop');
if (added && isElementInAnyRow(added.id) && !fromRowColumn) {
templateConfig.value.elements.splice(evt.newIndex, 1);
}
store.recordSnapshot();
};
</script>
<template>
<div
class="design-canvas flex h-full flex-col rounded border border-[var(--el-border-color)] bg-[var(--el-bg-color)]"
>
<!-- 工具栏 -->
<div
class="canvas-toolbar flex flex-shrink-0 items-center justify-between border-b border-[var(--el-border-color)] px-4 py-2"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<ElIcon :size="18" class="text-[var(--el-color-primary)]">
<FileText />
</ElIcon>
<span class="text-sm font-bold">
{{
templateConfig.name ||
$t('document-generator.documentGenerator.documentTemplate')
}}
</span>
</div>
<!-- <ElRadioGroup
:model-value="templateConfig.editorMode || 'component'"
size="small"
@update:model-value="handleModeChange($event as 'component' | 'wysiwyg')"
>
<ElRadioButton value="component">
{{ $t('document-generator.documentGenerator.editorModeComponent') }}
</ElRadioButton>
<ElRadioButton value="wysiwyg">
{{ $t('document-generator.documentGenerator.editorModeWysiwyg') }}
</ElRadioButton>
</ElRadioGroup> -->
</div>
<div class="flex items-center gap-1">
<!-- 撤销/重做仅组件模式 -->
<template v-if="!isWysiwyg">
<ElTooltip
:content="`${$t('common.undo')} (Ctrl+Z)`"
placement="bottom"
>
<ElButton text :disabled="!canUndo" @click="store.undo()">
<ElIcon :size="16"><RotateCcw /></ElIcon>
</ElButton>
</ElTooltip>
<ElTooltip
:content="`${$t('common.redo')} (Ctrl+Y)`"
placement="bottom"
>
<ElButton text :disabled="!canRedo" @click="store.redo()">
<ElIcon :size="16"><RotateCw /></ElIcon>
</ElButton>
</ElTooltip>
<div class="mx-2 h-4 w-px bg-[var(--el-border-color)]"></div>
</template>
<!-- 预览 -->
<ElTooltip :content="$t('common.preview')" placement="bottom">
<ElButton text @click="emit('preview')">
<ElIcon :size="16"><Eye /></ElIcon>
</ElButton>
</ElTooltip>
<!-- 查看代码 -->
<ElTooltip
:content="$t('document-generator.documentGenerator.viewJSON')"
placement="bottom"
>
<ElButton text @click="emit('view-code')">
<ElIcon :size="16"><Code /></ElIcon>
</ElButton>
</ElTooltip>
<!-- 查看HTML -->
<ElTooltip
:content="$t('document-generator.documentGenerator.viewHTML')"
placement="bottom"
>
<ElButton text @click="emit('view-html')">
<ElIcon :size="16"><FileCode /></ElIcon>
</ElButton>
</ElTooltip>
<div class="mx-2 h-4 w-px bg-[var(--el-border-color)]"></div>
<!-- 导入 -->
<ElTooltip :content="$t('common.import')" placement="bottom">
<ElButton text @click="emit('import')">
<ElIcon :size="16"><Upload /></ElIcon>
</ElButton>
</ElTooltip>
<!-- 导出 -->
<ElTooltip :content="$t('common.export')" placement="bottom">
<ElButton text @click="emit('export')">
<ElIcon :size="16"><Download /></ElIcon>
</ElButton>
</ElTooltip>
<!-- 清空 -->
<ElTooltip :content="$t('common.clear')" placement="bottom">
<ElButton text @click="emit('clear')">
<ElIcon :size="16"><Trash2 /></ElIcon>
</ElButton>
</ElTooltip>
<div class="mx-2 h-4 w-px bg-[var(--el-border-color)]"></div>
<!-- 属性面板折叠/展开 -->
<ElTooltip
:content="showAttributePanel ? $t('document-generator.documentGenerator.hideAttributePanel') : $t('document-generator.documentGenerator.showAttributePanel')"
placement="bottom"
>
<ElButton text @click="showAttributePanel = !showAttributePanel">
<ElIcon :size="16">
<PanelRight v-if="showAttributePanel" />
<PanelLeft v-else />
</ElIcon>
</ElButton>
</ElTooltip>
</div>
</div>
<!-- 组件模式画布 -->
<ElScrollbar v-if="!isWysiwyg" class="flex-1" @wheel.native="handleWheel">
<div
class="canvas-container flex justify-center bg-[var(--el-fill-color-light)] p-6"
>
<div
class="canvas-paper-wrapper relative"
:style="{
transform: `scale(${zoomScale})`,
transformOrigin: 'top center',
}"
>
<div
ref="canvasPaperRef"
class="canvas-paper relative bg-[var(--el-bg-color)] shadow-lg"
:style="canvasStyle"
:class="{ 'is-dragging': isDragging }"
@click.self="handleCanvasClick"
>
<ElEmpty
v-if="templateConfig.elements.length === 0 && !isDragging"
:description="
$t('document-generator.documentGenerator.dragElementHere')
"
:image-size="80"
/>
<draggable
v-model="templateConfig.elements"
group="document-design"
item-key="id"
handle=".drag-handle"
ghost-class="ghost-element"
chosen-class="chosen-element"
class="sortable-content space-y-2"
:animation="200"
@start="handleCanvasDragStart"
@end="handleDragEnd"
@add="handleDragAdd"
>
<template #item="{ element, index }">
<ElementWrapper
v-show="element.positionMode !== 'float'"
:element="element"
:index="index"
:total="templateConfig.elements.length"
/>
</template>
</draggable>
<div v-if="floatElements.length > 0" class="float-layer">
<FloatElementWrapper
v-for="el in floatElements"
:key="el.id"
:element="el"
:canvas-ref="canvasPaperRef"
/>
</div>
<div
v-if="isDragging && templateConfig.elements.length === 0"
class="drop-hint flex h-20 items-center justify-center rounded border-2 border-dashed border-[var(--el-color-primary)] bg-[var(--el-color-primary-light-9)]"
>
<span class="text-sm text-[var(--el-color-primary)]">
{{ $t('document-generator.documentGenerator.releaseToAdd') }}
</span>
</div>
<div
v-if="showPageNumberPreview"
class="canvas-page-number"
:style="pageNumberPreviewStyle"
>
{{ pageNumberPreviewText }}
</div>
</div>
</div>
</div>
</ElScrollbar>
<!-- WYSIWYG 模式画布 -->
<WysiwygCanvas v-else class="flex-1" />
<!-- 底部状态栏仅组件模式 -->
<div
v-if="!isWysiwyg"
class="canvas-footer flex flex-shrink-0 items-center justify-between border-t border-[var(--el-border-color)] px-4 py-1 text-xs text-[var(--el-text-color-secondary)]"
>
<div>
{{ pageSizeLabel }}
{{ templateConfig.pageOrientation === 'portrait' ? '↕' : '↔' }}
· {{ templateConfig.pageMargin.top }}/{{ templateConfig.pageMargin.right }}/{{ templateConfig.pageMargin.bottom }}/{{ templateConfig.pageMargin.left }}px
</div>
<div class="flex items-center gap-1.5">
<button class="zoom-btn" :disabled="zoomLevel <= 25" @click="zoomOut">
<ElIcon :size="14"><ZoomOut /></ElIcon>
</button>
<ElSlider
v-model="zoomLevel"
class="zoom-slider"
:min="25"
:max="200"
:step="5"
:show-tooltip="false"
/>
<button class="zoom-btn" :disabled="zoomLevel >= 200" @click="zoomIn">
<ElIcon :size="14"><ZoomIn /></ElIcon>
</button>
<button class="zoom-label" @click="resetZoom">{{ zoomLevel }}%</button>
</div>
</div>
</div>
</template>
<style scoped>
.canvas-paper {
transition: box-shadow 0.2s;
}
.canvas-page-number {
user-select: none;
}
.canvas-paper.is-dragging {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.ghost-element {
background: var(--el-color-primary-light-9);
border: 1px dashed var(--el-color-primary);
opacity: 0.5;
}
.chosen-element {
opacity: 0.8;
}
.float-layer {
position: absolute;
inset: 0;
pointer-events: none;
}
.float-layer > * {
pointer-events: auto;
}
.zoom-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 3px;
background: transparent;
color: var(--el-text-color-regular);
cursor: pointer;
transition: background 0.15s;
}
.zoom-btn:hover:not(:disabled) {
background: var(--el-fill-color);
}
.zoom-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.zoom-slider {
width: 100px;
}
.zoom-label {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 22px;
border: none;
border-radius: 3px;
background: transparent;
color: var(--el-text-color-regular);
cursor: pointer;
font-size: 11px;
transition: background 0.15s;
}
.zoom-label:hover {
background: var(--el-fill-color);
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,309 @@
<script setup lang="ts">
import type { DesignerElement } from '../store/documentDesignStore';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { Image, Stamp, Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElIcon, ElTooltip } from 'element-plus';
import { storeToRefs } from 'pinia';
import { getFileUrl } from '#/composables/useFileUrl';
import { useDocumentDesignStore } from '../store/documentDesignStore';
const props = defineProps<{
element: DesignerElement;
canvasRef: HTMLDivElement | null;
}>();
const store = useDocumentDesignStore();
const { activeId } = storeToRefs(store);
const isActive = computed(() => activeId.value === props.element.id);
const positionStyle = computed(() => ({
left: `${props.element.floatX || 0}px`,
top: `${props.element.floatY || 0}px`,
zIndex: props.element.floatZIndex ?? 100,
}));
// ===== 签章图片URL缓存 =====
const sealImageUrls = ref<Record<string, string>>({});
const getSealImageUrl = (imageId: string): string => {
if (!imageId) return '';
if (sealImageUrls.value[imageId]) {
return sealImageUrls.value[imageId];
}
getFileUrl(imageId)
.then((url) => {
sealImageUrls.value[imageId] = url;
})
.catch(() => {
// ignore
});
return '';
};
watch(
() => props.element.sealImageId,
(imageId) => {
if (imageId && !sealImageUrls.value[imageId]) {
getSealImageUrl(imageId);
}
},
{ immediate: true },
);
// ===== 选中 =====
const handleSelect = (e: MouseEvent) => {
e.stopPropagation();
store.setActive(props.element.id);
};
// ===== 删除 =====
const handleDelete = () => {
store.deleteElement(props.element.id);
};
// ===== 拖拽实现 =====
const isDragging = ref(false);
const dragStart = ref({ mouseX: 0, mouseY: 0, elementX: 0, elementY: 0 });
const handleDragStart = (e: MouseEvent) => {
// 忽略右键 / 中键
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
store.setActive(props.element.id);
isDragging.value = true;
dragStart.value = {
mouseX: e.clientX,
mouseY: e.clientY,
elementX: props.element.floatX || 0,
elementY: props.element.floatY || 0,
};
document.addEventListener('mousemove', handleDragMove);
document.addEventListener('mouseup', handleDragEnd);
};
const handleDragMove = (e: MouseEvent) => {
if (!isDragging.value) return;
const dx = e.clientX - dragStart.value.mouseX;
const dy = e.clientY - dragStart.value.mouseY;
let newX = dragStart.value.elementX + dx;
let newY = dragStart.value.elementY + dy;
// 边界限制(不超出画布)
const canvas = props.canvasRef;
if (canvas) {
const canvasW = canvas.clientWidth;
const canvasH = canvas.clientHeight;
const elW = props.element.width || 120;
const elH = props.element.height || 120;
newX = Math.max(0, Math.min(newX, canvasW - elW));
newY = Math.max(0, Math.min(newY, canvasH - elH));
} else {
newX = Math.max(0, newX);
newY = Math.max(0, newY);
}
store.updateElementProps(props.element.id, {
floatX: Math.round(newX),
floatY: Math.round(newY),
});
};
const handleDragEnd = () => {
if (!isDragging.value) return;
isDragging.value = false;
document.removeEventListener('mousemove', handleDragMove);
document.removeEventListener('mouseup', handleDragEnd);
store.recordSnapshot();
};
onBeforeUnmount(() => {
document.removeEventListener('mousemove', handleDragMove);
document.removeEventListener('mouseup', handleDragEnd);
});
</script>
<template>
<div
class="float-element"
:class="{ 'is-active': isActive, 'is-dragging': isDragging }"
:style="positionStyle"
@mousedown="handleDragStart"
@click="handleSelect"
>
<!-- 选中态的操作按钮 -->
<div v-if="isActive" class="float-toolbar" @mousedown.stop>
<ElTooltip
:content="$t('document-generator.documentGenerator.delete')"
placement="top"
>
<button class="float-toolbar-btn float-toolbar-btn--danger" @click="handleDelete">
<ElIcon :size="14"><Trash2 /></ElIcon>
</button>
</ElTooltip>
</div>
<!-- 内容区域 -->
<div class="float-element-body">
<!-- 签章 -->
<template v-if="element.type === 'seal'">
<div
v-if="
element.sealId && (element.sealImageData || element.sealImageId)
"
:style="{
width: `${element.width || 120}px`,
height: `${element.height || 120}px`,
}"
>
<img
:src="
element.sealImageData ||
getSealImageUrl(element.sealImageId || '')
"
:style="{
width: '100%',
height: '100%',
objectFit: 'contain',
pointerEvents: 'none',
}"
draggable="false"
/>
</div>
<div
v-else
:style="{
width: `${element.width || 120}px`,
height: `${element.height || 120}px`,
border: '2px dashed var(--el-border-color)',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: '4px',
backgroundColor: 'var(--el-fill-color-light)',
}"
>
<Stamp class="h-8 w-8 text-[var(--el-text-color-placeholder)]" />
<span class="text-xs text-[var(--el-text-color-placeholder)]">
{{ $t('document-generator.documentGenerator.selectSeal') }}
</span>
</div>
</template>
<!-- 图片 -->
<template v-else-if="element.type === 'image'">
<img
v-if="element.src || element.imageData"
:src="element.src || element.imageData"
:style="{
width: `${element.width || 100}px`,
height: `${element.height || 100}px`,
objectFit: 'contain',
pointerEvents: 'none',
}"
draggable="false"
/>
<div
v-else
:style="{
width: `${element.width || 100}px`,
height: `${element.height || 100}px`,
border: '2px dashed var(--el-border-color)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: '8px',
backgroundColor: 'var(--el-fill-color-light)',
}"
>
<ElIcon :size="24"><Image /></ElIcon>
<span class="text-xs">
{{ element.width || 100 }} × {{ element.height || 100 }}
</span>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.float-element {
position: absolute;
cursor: move;
user-select: none;
transition: outline 0.15s;
outline: 1px dashed transparent;
outline-offset: 2px;
}
.float-element:hover {
outline-color: var(--el-color-primary-light-5);
}
.float-element.is-active {
outline: 2px solid var(--el-color-primary);
outline-offset: 2px;
}
.float-element.is-dragging {
opacity: 0.85;
cursor: grabbing;
}
.float-toolbar {
position: absolute;
top: -32px;
right: 0;
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
z-index: 10;
}
.float-toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
border-radius: 3px;
color: var(--el-text-color-regular);
cursor: pointer;
transition: background 0.15s;
}
.float-toolbar-btn:hover {
background: var(--el-fill-color-light);
}
.float-toolbar-btn--danger:hover {
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
.float-element-body {
pointer-events: none;
}
</style>
@@ -0,0 +1,287 @@
<script setup lang="ts">
import type {
DocumentMaterial,
MaterialCategory,
} from '../store/documentDesignStore';
import { computed, ref } from 'vue';
import {
AlignLeft,
CheckSquare,
Circle,
Columns2,
DollarSign,
FileDown,
FileText,
Grid,
GripHorizontal,
Heading1,
Image,
Info,
Minus,
Pencil,
Pilcrow,
QrCode,
Rows3,
Search,
Stamp,
Table,
Type,
} from '@vben/icons';
import { $t } from '@vben/locales';
import { ElIcon, ElInput, ElScrollbar } from 'element-plus';
import draggable from 'vuedraggable';
import {
documentMaterials,
useDocumentDesignStore,
} from '../store/documentDesignStore';
const store = useDocumentDesignStore();
const searchKeyword = ref('');
const activeGroups = ref<MaterialCategory[]>([
'layout',
'header',
'info',
'table',
'content',
'approval',
'other',
'footer',
]);
// 图标映射 - 使用已存在的图标
const iconMap: Record<string, any> = {
Heading1,
AlignLeft,
FileText,
Table,
Table2: Rows3,
Pencil,
Circle,
Minus,
Image,
QrCode,
Barcode: QrCode,
Info,
LayoutList: GripHorizontal,
FormInput: Type,
DollarSign,
CheckSquare,
FileDown,
Space: Minus,
Columns: Columns2,
Stamp,
Grid,
Pilcrow,
};
const getIcon = (iconName: string) => {
return iconMap[iconName] || FileText;
};
// 分组材料
const groupedMaterials = computed(() => {
const groups: Record<MaterialCategory, DocumentMaterial[]> = {
layout: [],
header: [],
info: [],
table: [],
content: [],
approval: [],
other: [],
footer: [],
};
documentMaterials.forEach((material) => {
const category = groups[material.category];
if (category) {
category.push(material);
}
});
return groups;
});
// 过滤后的材料
const filteredMaterials = computed(() => {
if (!searchKeyword.value) return groupedMaterials.value;
const keyword = searchKeyword.value.toLowerCase();
const result: Record<MaterialCategory, DocumentMaterial[]> = {
layout: [],
header: [],
info: [],
table: [],
content: [],
approval: [],
other: [],
footer: [],
};
Object.entries(groupedMaterials.value).forEach(([category, materials]) => {
result[category as MaterialCategory] = materials.filter(
(m) =>
getMaterialTitle(m.title).toLowerCase().includes(keyword) ||
m.type.toLowerCase().includes(keyword),
);
});
return result;
});
const getGroupLabel = (group: MaterialCategory) => {
const labels: Record<MaterialCategory, string> = {
layout: $t('document-generator.documentGenerator.layoutElements'),
header: $t('document-generator.documentGenerator.headerElements'),
info: $t('document-generator.documentGenerator.infoElements'),
table: $t('document-generator.documentGenerator.tableElements'),
content: $t('document-generator.documentGenerator.contentElements'),
approval: $t('document-generator.documentGenerator.approvalElements'),
other: $t('document-generator.documentGenerator.otherElements'),
footer: $t('document-generator.documentGenerator.footerElements'),
};
return labels[group] || group;
};
const toggleGroup = (group: MaterialCategory) => {
const index = activeGroups.value.indexOf(group);
if (index === -1) {
activeGroups.value.push(group);
} else {
activeGroups.value.splice(index, 1);
}
};
const onDragStart = (evt: any) => {
// 获取拖拽的 material 类型
const materialType = evt.item?.getAttribute('data-material-type') || null;
store.setDragging(true, materialType);
};
const onDragEnd = () => store.setDragging(false);
// 点击添加元素
const handleClickAdd = (material: DocumentMaterial) => {
store.addElement(material);
};
// 克隆函数
const cloneMaterial = (material: DocumentMaterial) => {
return store.cloneElement(material);
};
// 获取元素标题的国际化文本
const getMaterialTitle = (titleKey: string): string => {
const titleMap: Record<string, string> = {
rowContainer: $t('document-generator.documentGenerator.rowContainer'),
documentHeader: $t('document-generator.documentGenerator.documentHeader'),
documentTitle: $t('document-generator.documentGenerator.documentTitle'),
documentInfo: $t('document-generator.documentGenerator.documentInfo'),
infoRow: $t('document-generator.documentGenerator.infoRow'),
infoTable: $t('document-generator.documentGenerator.infoTable'),
smartTable: $t('document-generator.documentGenerator.smartTable'),
smartText: $t('document-generator.documentGenerator.smartText'),
labelField: $t('document-generator.documentGenerator.labelField'),
detailTable: $t('document-generator.documentGenerator.detailTable'),
amountField: $t('document-generator.documentGenerator.amountField'),
paragraph: $t('document-generator.documentGenerator.paragraph'),
richText: $t('document-generator.documentGenerator.richText'),
approvalArea: $t('document-generator.documentGenerator.approvalArea'),
signature: $t('document-generator.documentGenerator.elementSignature'),
seal: $t('document-generator.documentGenerator.elementSeal'),
qrcode: $t('document-generator.documentGenerator.elementQrcode'),
barcode: $t('document-generator.documentGenerator.barcode'),
image: $t('document-generator.documentGenerator.elementImage'),
divider: $t('document-generator.documentGenerator.elementDivider'),
spacer: $t('document-generator.documentGenerator.spacer'),
documentFooter: $t('document-generator.documentGenerator.documentFooter'),
};
return titleMap[titleKey] || titleKey;
};
</script>
<template>
<div
class="material-panel flex h-full w-56 flex-col rounded border border-[var(--el-border-color)] bg-[var(--el-bg-color)]"
>
<!-- 标题 -->
<div
class="flex flex-shrink-0 items-center gap-2 border-b border-[var(--el-border-color)] px-4 py-3"
>
<span class="text-sm font-bold">{{
$t('document-generator.documentGenerator.elementLibrary')
}}</span>
</div>
<!-- 搜索框 -->
<div class="flex-shrink-0 p-3">
<ElInput
v-model="searchKeyword"
:placeholder="$t('document-generator.documentGenerator.searchElements')"
:prefix-icon="Search"
clearable
size="small"
/>
</div>
<!-- 元素列表 -->
<ElScrollbar class="flex-1">
<div class="px-3 pb-3">
<template v-for="(materials, group) in filteredMaterials" :key="group">
<div v-if="materials.length > 0" class="mb-4">
<!-- 分组标题 -->
<div
class="mb-2 flex cursor-pointer items-center justify-between text-xs font-medium text-[var(--el-text-color-secondary)]"
@click="toggleGroup(group)"
>
<span>{{ getGroupLabel(group) }}</span>
<span class="text-[var(--el-text-color-placeholder)]">
{{ materials.length }}
</span>
</div>
<!-- 元素网格 -->
<draggable
v-show="activeGroups.includes(group)"
:list="materials"
:group="{ name: 'document-design', pull: 'clone', put: false }"
:sort="false"
:clone="cloneMaterial"
item-key="type"
class="grid grid-cols-2 gap-2"
@start="onDragStart"
@end="onDragEnd"
>
<template #item="{ element }">
<div
class="material-item flex cursor-pointer flex-col items-center gap-1 rounded border border-[var(--el-border-color)] p-2 transition-all hover:border-[var(--el-color-primary)] hover:bg-[var(--el-color-primary-light-9)]"
:data-material-type="element.type"
@click="handleClickAdd(element)"
>
<ElIcon
:size="18"
class="text-[var(--el-text-color-secondary)]"
>
<component :is="getIcon(element.icon)" />
</ElIcon>
<span class="text-center text-xs">{{
getMaterialTitle(element.title)
}}</span>
</div>
</template>
</draggable>
</div>
</template>
</div>
</ElScrollbar>
</div>
</template>
<style scoped>
.material-item:active {
transform: scale(0.95);
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Variable } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElIcon, ElPopover, ElScrollbar, ElTag } from 'element-plus';
import { storeToRefs } from 'pinia';
import { useDocumentDesignStore } from '../store/documentDesignStore';
const props = defineProps<{
modelValue?: string;
placeholder?: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'select', varName: string): void;
}>();
const store = useDocumentDesignStore();
const { allVariables } = storeToRefs(store);
// 变量项类型
interface VariableItemWithGroup {
name: string;
label: string;
group: string;
}
// 分组顺序
const groupOrder = [
'表单字段',
'计算字段',
'流程信息',
'审批信息',
'审批信息(按顺序)',
'审批信息(按节点)',
];
function getGroupSortIndex(group: string): number {
const idx = groupOrder.indexOf(group);
if (idx !== -1) return idx;
const subTablePrefix = $t('document-generator.documentGenerator.groupSubTablePrefix');
if (group.startsWith(subTablePrefix)) return 1;
return 999;
}
// 按分组组织变量(保持顺序)
const groupedVariables = computed(() => {
const vars = allVariables.value;
const groups: Record<string, VariableItemWithGroup[]> = {};
for (const v of vars) {
const groupName = (v as any).group || '表单字段';
const item: VariableItemWithGroup = {
name: v.name,
label: v.label,
group: groupName,
};
if (!groups[groupName]) {
groups[groupName] = [];
}
groups[groupName]!.push(item);
}
return groups;
});
// 排序后的分组列表
const sortedGroups = computed(() => {
const groups = Object.keys(groupedVariables.value);
return groups.sort((a, b) => {
return getGroupSortIndex(a) - getGroupSortIndex(b);
});
});
// 选择变量
const selectVariable = (varName: string) => {
// 如果有 modelValue,则追加变量
if (props.modelValue !== undefined) {
const newValue = `${props.modelValue || ''}{{${varName}}}`;
emit('update:modelValue', newValue);
}
// 同时触发 select 事件
emit('select', varName);
};
</script>
<template>
<ElPopover
trigger="click"
:width="360"
popper-class="variable-selector-popover"
>
<template #reference>
<ElButton
size="small"
type="primary"
text
:title="$t('document-generator.documentGenerator.insertVariable')"
>
<ElIcon :size="12"><Variable /></ElIcon>
</ElButton>
</template>
<div class="variable-selector-content">
<div
class="sticky top-0 z-10 bg-[var(--el-bg-color)] pb-2 text-sm font-medium"
>
{{ $t('document-generator.documentGenerator.insertVariable') }}
</div>
<ElScrollbar height="350px">
<div class="space-y-3 pr-2">
<template v-for="group in sortedGroups" :key="group">
<div v-if="groupedVariables[group]">
<div
class="mb-1.5 text-xs font-medium text-[var(--el-color-primary)]"
>
{{ group }}
</div>
<div class="flex flex-wrap gap-1">
<ElTag
v-for="v in groupedVariables[group]"
:key="v.name"
size="small"
class="cursor-pointer hover:bg-[var(--el-color-primary-light-7)]"
@click="selectVariable(v.name)"
>
{{ v.label }}
</ElTag>
</div>
</div>
</template>
</div>
</ElScrollbar>
</div>
</ElPopover>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
export { default as DocumentDesigner } from './index.vue';
export { useDocumentDesignStore } from './store/documentDesignStore';
export type {
DesignerElement,
DocumentMaterial,
DocumentTemplateConfig,
} from './store/documentDesignStore';
@@ -0,0 +1,568 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { Code, Eye, RotateCw, Upload } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElInput, ElMessage, ElMessageBox } from 'element-plus';
import {
previewDocumentApi,
previewHtmlApi,
} from '#/api/online-dev/document-generator';
import { ZqDialog } from '#/components/zq-dialog';
import AttributePanel from './components/AttributePanel.vue';
import DesignCanvas from './components/DesignCanvas.vue';
import MaterialPanel from './components/MaterialPanel.vue';
import { useDocumentDesignStore } from './store/documentDesignStore';
const props = defineProps<{
calculationRules?: any; // 计算规则配置,用于预览
initialConfig?: string;
readonly?: boolean;
templateId?: string; // 模板 ID,用于预览
}>();
const emit = defineEmits<{
(e: 'save', config: string): void;
}>();
const store = useDocumentDesignStore();
// 代码弹窗
const codeVisible = ref(false);
const codeContent = ref('');
// 导入弹窗
const importVisible = ref(false);
const importContent = ref('');
// 初始化配置
onMounted(() => {
if (props.initialConfig) {
store.importConfig(props.initialConfig);
} else {
store.reset();
}
});
// 监听 initialConfig 变化(编辑模式下异步加载数据后更新)
watch(
() => props.initialConfig,
(newConfig) => {
if (newConfig) {
store.importConfig(newConfig);
}
},
);
// 预览弹窗
const previewVisible = ref(false);
const previewLoading = ref(false);
const previewPdfUrl = ref('');
// 预览
const handlePreview = async () => {
if (store.templateConfig.elements.length === 0) {
ElMessage.warning(
$t('document-generator.documentGenerator.pleaseAddElements'),
);
return;
}
previewLoading.value = true;
previewVisible.value = true;
try {
// 直接发送当前 JSON 配置和计算规则,无需保存
const response = await previewDocumentApi({
template_content: store.exportConfig(),
calculation_rules: props.calculationRules,
});
// 创建 Blob URL
const blob = new Blob([response as BlobPart], { type: 'application/pdf' });
previewPdfUrl.value = URL.createObjectURL(blob);
} catch (error) {
console.error('Preview failed:', error);
ElMessage.error($t('document-generator.documentGenerator.previewFailed'));
previewVisible.value = false;
} finally {
previewLoading.value = false;
}
};
// 关闭预览弹窗时清理 URL
const handlePreviewClose = () => {
if (previewPdfUrl.value) {
URL.revokeObjectURL(previewPdfUrl.value);
previewPdfUrl.value = '';
}
previewVisible.value = false;
};
// 查看代码
const handleViewCode = () => {
codeContent.value = store.exportConfig();
codeVisible.value = true;
};
// HTML 预览弹窗
const htmlVisible = ref(false);
const htmlLoading = ref(false);
const htmlContent = ref('');
const htmlPreviewMode = ref<'preview' | 'source'>('source'); // 源码/页面切换
const htmlPreviewUrl = ref(''); // iframe 预览 URL
// 查看 HTML
const handleViewHtml = async () => {
if (store.templateConfig.elements.length === 0) {
ElMessage.warning(
$t('document-generator.documentGenerator.pleaseAddElements'),
);
return;
}
htmlLoading.value = true;
htmlVisible.value = true;
htmlPreviewMode.value = 'source'; // 默认显示源码
try {
// 直接发送当前 JSON 配置,无需保存
const response = await previewHtmlApi({
template_content: store.exportConfig(),
});
htmlContent.value = response.html;
// 创建 Blob URL 用于 iframe 预览
const blob = new Blob([response.html], { type: 'text/html;charset=utf-8' });
if (htmlPreviewUrl.value) {
URL.revokeObjectURL(htmlPreviewUrl.value);
}
htmlPreviewUrl.value = URL.createObjectURL(blob);
} catch (error) {
console.error('HTML preview failed:', error);
ElMessage.error(
$t('document-generator.documentGenerator.htmlPreviewFailed'),
);
htmlVisible.value = false;
} finally {
htmlLoading.value = false;
}
};
// 关闭 HTML 预览弹窗时清理 URL
const handleHtmlPreviewClose = () => {
if (htmlPreviewUrl.value) {
URL.revokeObjectURL(htmlPreviewUrl.value);
htmlPreviewUrl.value = '';
}
htmlVisible.value = false;
};
// 复制 HTML
const handleCopyHtml = async () => {
try {
await navigator.clipboard.writeText(htmlContent.value);
ElMessage.success(
$t('document-generator.documentGenerator.copiedToClipboard'),
);
} catch {
ElMessage.error($t('document-generator.documentGenerator.copyFailed'));
}
};
// 保存
const handleSave = () => {
const config = store.exportConfig();
emit('save', config);
ElMessage.success($t('document-generator.documentGenerator.saveSuccess'));
};
// 清空画布
const handleClear = async () => {
if (store.templateConfig.elements.length === 0) {
return;
}
try {
await ElMessageBox.confirm(
$t('document-generator.documentGenerator.clearCanvasConfirm'),
$t('common.tip'),
{
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'warning',
},
);
store.clearCanvas();
ElMessage.success($t('document-generator.documentGenerator.cleared'));
} catch {
// 取消操作
}
};
// 导出配置
const handleExport = () => {
const config = store.exportConfig();
const blob = new Blob([config], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `document-template-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
ElMessage.success($t('document-generator.documentGenerator.exportSuccess'));
};
// 打开导入弹窗
const handleOpenImport = () => {
importContent.value = '';
importVisible.value = true;
};
// 确认导入
const handleImport = () => {
if (!importContent.value.trim()) {
ElMessage.warning(
$t('document-generator.documentGenerator.pleaseEnterConfig'),
);
return;
}
const success = store.importConfig(importContent.value);
if (success) {
importVisible.value = false;
ElMessage.success($t('document-generator.documentGenerator.importSuccess'));
} else {
ElMessage.error(
$t('document-generator.documentGenerator.configFormatError'),
);
}
};
// 复制代码
const handleCopyCode = async () => {
try {
await navigator.clipboard.writeText(codeContent.value);
ElMessage.success(
$t('document-generator.documentGenerator.copiedToClipboard'),
);
} catch {
ElMessage.error($t('document-generator.documentGenerator.copyFailed'));
}
};
// 键盘快捷键处理
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}
if (codeVisible.value || importVisible.value) {
return;
}
const isMac = navigator.platform.toUpperCase().includes('MAC');
const ctrlKey = isMac ? e.metaKey : e.ctrlKey;
// Ctrl+Z 撤销
if (ctrlKey && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
if (store.canUndo) {
store.undo();
ElMessage.success($t('common.undone'));
}
return;
}
// Ctrl+Y 或 Ctrl+Shift+Z 重做
if ((ctrlKey && e.key === 'y') || (ctrlKey && e.shiftKey && e.key === 'z')) {
e.preventDefault();
if (store.canRedo) {
store.redo();
ElMessage.success($t('common.redone'));
}
return;
}
// Ctrl+C 复制
if (ctrlKey && e.key === 'c') {
if (store.activeId) {
e.preventDefault();
store.copyToClipboard(store.activeId);
ElMessage.success($t('common.copied'));
}
return;
}
// Ctrl+V 粘贴
if (ctrlKey && e.key === 'v') {
if (store.hasClipboard) {
e.preventDefault();
store.pasteFromClipboard();
ElMessage.success($t('common.pasted'));
}
return;
}
// Delete 或 Backspace 删除
if (e.key === 'Delete' || e.key === 'Backspace') {
if (store.activeId) {
e.preventDefault();
store.deleteElement(store.activeId);
ElMessage.success($t('common.deleted'));
}
return;
}
// Ctrl+S 保存
if (ctrlKey && e.key === 's') {
e.preventDefault();
handleSave();
}
};
// 注册/注销键盘事件
onMounted(() => {
window.addEventListener('keydown', handleKeyDown);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown);
});
</script>
<template>
<div class="document-designer flex h-full w-full overflow-hidden">
<!-- 主体区域 -->
<div class="bg-background-deep flex flex-1 gap-3 overflow-hidden p-3">
<!-- 左侧元素面板仅组件模式显示 -->
<div v-show="store.templateConfig.editorMode !== 'wysiwyg'" class="h-full flex-shrink-0">
<MaterialPanel />
</div>
<!-- 中间设计画布 -->
<div class="relative h-full min-w-0 flex-1 overflow-hidden">
<DesignCanvas
@preview="handlePreview"
@view-code="handleViewCode"
@view-html="handleViewHtml"
@clear="handleClear"
@import="handleOpenImport"
@export="handleExport"
@save="handleSave"
/>
</div>
<!-- 右侧属性面板 -->
<transition name="panel-slide">
<div v-show="store.showAttributePanel" class="h-full flex-shrink-0">
<AttributePanel />
</div>
</transition>
</div>
<!-- JSON预览弹窗 -->
<ZqDialog
v-model="codeVisible"
:title="$t('document-generator.documentGenerator.jsonPreview')"
width="800px"
:close-on-click-modal="false"
>
<template #header>
<div class="flex items-center gap-2">
<Code class="h-5 w-5" />
<span>{{
$t('document-generator.documentGenerator.jsonPreview')
}}</span>
</div>
</template>
<div class="code-container">
<pre
class="bg-background-deep overflow-auto rounded-lg p-4 text-sm"
><code>{{ codeContent }}</code></pre>
</div>
<template #footer>
<ElButton @click="handleCopyCode">
{{ $t('document-generator.documentGenerator.copyCode') }}
</ElButton>
<ElButton type="primary" @click="codeVisible = false">
{{ $t('common.close') }}
</ElButton>
</template>
</ZqDialog>
<!-- 导入弹窗 -->
<ZqDialog
v-model="importVisible"
:title="$t('document-generator.documentGenerator.importConfig')"
width="600px"
:close-on-click-modal="false"
>
<template #header>
<div class="flex items-center gap-2">
<Upload class="h-5 w-5" />
<span>{{
$t('document-generator.documentGenerator.importConfig')
}}</span>
</div>
</template>
<ElInput
v-model="importContent"
type="textarea"
:rows="15"
:placeholder="
$t('document-generator.documentGenerator.pasteJsonConfig')
"
/>
<template #footer>
<ElButton @click="importVisible = false">
{{ $t('common.cancel') }}
</ElButton>
<ElButton type="primary" @click="handleImport">
<RotateCw class="mr-1 h-4 w-4" />
{{ $t('common.import') }}
</ElButton>
</template>
</ZqDialog>
<!-- PDF 预览弹窗 -->
<ZqDialog
v-model="previewVisible"
:title="$t('document-generator.documentGenerator.pdfPreview')"
width="900px"
:close-on-click-modal="false"
@close="handlePreviewClose"
>
<template #header>
<div class="flex items-center gap-2">
<Eye class="h-5 w-5" />
<span>{{
$t('document-generator.documentGenerator.pdfPreview')
}}</span>
</div>
</template>
<div class="pdf-preview-container">
<div
v-if="previewLoading"
class="flex h-[600px] items-center justify-center"
>
<span class="text-[var(--el-text-color-secondary)]">
{{ $t('document-generator.documentGenerator.generatingPreview') }}
</span>
</div>
<iframe
v-else-if="previewPdfUrl"
:src="previewPdfUrl"
class="h-[600px] w-full border-0"
></iframe>
<div v-else class="flex h-[600px] items-center justify-center">
<span class="text-[var(--el-text-color-placeholder)]">
{{ $t('document-generator.documentGenerator.noPreviewContent') }}
</span>
</div>
</div>
<template #footer>
<ElButton type="primary" @click="handlePreviewClose">
{{ $t('common.close') }}
</ElButton>
</template>
</ZqDialog>
<!-- HTML 预览弹窗 -->
<ZqDialog
v-model="htmlVisible"
:title="$t('document-generator.documentGenerator.htmlPreview')"
width="900px"
:close-on-click-modal="false"
@close="handleHtmlPreviewClose"
>
<!-- 源码/页面切换按钮 -->
<template #header-extra>
<div class="flex gap-1 rounded-md p-1">
<ElButton
:type="htmlPreviewMode === 'source' ? 'primary' : 'default'"
size="small"
text
@click="htmlPreviewMode = 'source'"
>
{{ $t('document-generator.documentGenerator.sourceCode') }}
</ElButton>
<ElButton
:type="htmlPreviewMode === 'preview' ? 'primary' : 'default'"
size="small"
text
@click="htmlPreviewMode = 'preview'"
>
{{ $t('document-generator.documentGenerator.pagePreview') }}
</ElButton>
</div>
</template>
<div class="html-preview-container">
<div
v-if="htmlLoading"
class="flex h-[500px] items-center justify-center"
>
<span class="text-[var(--el-text-color-secondary)]">
{{ $t('document-generator.documentGenerator.generatingPreview') }}
</span>
</div>
<!-- 源码模式 -->
<pre
v-else-if="htmlPreviewMode === 'source'"
class="bg-background-deep h-[500px] overflow-auto rounded-lg p-4 text-sm"
><code>{{ htmlContent }}</code></pre>
<!-- 页面预览模式 -->
<iframe
v-else
:src="htmlPreviewUrl"
class="h-[500px] w-full rounded-lg border"
></iframe>
</div>
<template #footer>
<ElButton v-if="htmlPreviewMode === 'source'" @click="handleCopyHtml">
{{ $t('document-generator.documentGenerator.copyCode') }}
</ElButton>
<ElButton type="primary" @click="handleHtmlPreviewClose">
{{ $t('common.close') }}
</ElButton>
</template>
</ZqDialog>
</div>
</template>
<style scoped>
.document-designer {
height: 100%;
}
.code-container {
max-height: 500px;
overflow: auto;
}
.code-container pre {
margin: 0;
word-break: break-all;
white-space: pre-wrap;
}
.panel-slide-enter-active,
.panel-slide-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.panel-slide-enter-from,
.panel-slide-leave-to {
opacity: 0;
max-width: 0;
margin-left: 0;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,151 @@
export type PageNumberPosition = 'footer' | 'header';
export type PageNumberAlign = 'center' | 'left' | 'right';
export type PageNumberFormat = 'chinese' | 'english' | 'fraction';
export interface PageNumberOptions {
showPageNumber?: boolean;
pageNumberPosition?: PageNumberPosition;
pageNumberAlign?: PageNumberAlign;
pageNumberFormat?: PageNumberFormat;
pageNumberFontSize?: number;
pageNumberColor?: string;
}
const DEFAULT_OPTIONS: Required<
Pick<
PageNumberOptions,
| 'pageNumberAlign'
| 'pageNumberColor'
| 'pageNumberFontSize'
| 'pageNumberFormat'
| 'pageNumberPosition'
| 'showPageNumber'
>
> = {
showPageNumber: true,
pageNumberPosition: 'footer',
pageNumberAlign: 'center',
pageNumberFormat: 'chinese',
pageNumberFontSize: 10,
pageNumberColor: '#666666',
};
export function resolvePageNumberOptions(
options: PageNumberOptions,
): Required<
Pick<
PageNumberOptions,
| 'pageNumberAlign'
| 'pageNumberColor'
| 'pageNumberFontSize'
| 'pageNumberFormat'
| 'pageNumberPosition'
| 'showPageNumber'
>
> {
return {
showPageNumber: options.showPageNumber ?? DEFAULT_OPTIONS.showPageNumber,
pageNumberPosition:
options.pageNumberPosition ?? DEFAULT_OPTIONS.pageNumberPosition,
pageNumberAlign: options.pageNumberAlign ?? DEFAULT_OPTIONS.pageNumberAlign,
pageNumberFormat:
options.pageNumberFormat ?? DEFAULT_OPTIONS.pageNumberFormat,
pageNumberFontSize:
options.pageNumberFontSize ?? DEFAULT_OPTIONS.pageNumberFontSize,
pageNumberColor: options.pageNumberColor ?? DEFAULT_OPTIONS.pageNumberColor,
};
}
/** 画布预览文案(示例:第 1 页 / 共 1 页) */
export function formatPageNumberPreview(
options: PageNumberOptions,
page = 1,
total = 1,
): string {
const cfg = resolvePageNumberOptions(options);
switch (cfg.pageNumberFormat) {
case 'fraction': {
return `${page} / ${total}`;
}
case 'english': {
return `Page ${page} of ${total}`;
}
default: {
return `${page} 页 / 共 ${total}`;
}
}
}
/** 画布预览定位样式 */
export function getPageNumberPreviewStyle(options: PageNumberOptions): Record<string, string> {
const cfg = resolvePageNumberOptions(options);
const style: Record<string, string> = {
position: 'absolute',
left: '0',
right: '0',
fontSize: `${cfg.pageNumberFontSize}px`,
color: cfg.pageNumberColor,
pointerEvents: 'none',
zIndex: '5',
padding: '0 8px',
};
if (cfg.pageNumberPosition === 'header') {
style.top = '4px';
style.bottom = 'auto';
} else {
style.bottom = '4px';
style.top = 'auto';
}
switch (cfg.pageNumberAlign) {
case 'left': {
style.textAlign = 'left';
break;
}
case 'right': {
style.textAlign = 'right';
break;
}
default: {
style.textAlign = 'center';
break;
}
}
return style;
}
/** 生成 WeasyPrint @page 页码 margin 规则 */
export function buildPageNumberCss(options: PageNumberOptions): string {
const cfg = resolvePageNumberOptions(options);
if (!cfg.showPageNumber) {
return '';
}
const edge = cfg.pageNumberPosition === 'header' ? 'top' : 'bottom';
const marginBox = `${edge}-${cfg.pageNumberAlign}`;
let content: string;
switch (cfg.pageNumberFormat) {
case 'fraction': {
content = 'counter(page) " / " counter(pages)';
break;
}
case 'english': {
content = '"Page " counter(page) " of " counter(pages)';
break;
}
default: {
content = '"第 " counter(page) " 页 / 共 " counter(pages) " 页"';
break;
}
}
return `
@${marginBox} {
content: ${content};
font-size: ${cfg.pageNumberFontSize}pt;
color: ${cfg.pageNumberColor};
}`;
}
@@ -0,0 +1,65 @@
/** 页面尺寸预设(单位:mm) */
export const PAGE_SIZE_PRESETS = {
A4: { widthMm: 210, heightMm: 297 },
A5: { widthMm: 148, heightMm: 210 },
Letter: { widthMm: 216, heightMm: 279 },
} as const;
export type PageSizePreset = keyof typeof PAGE_SIZE_PRESETS;
export type PageSizeValue = PageSizePreset | 'custom';
export interface PageSizeOptions {
pageSize: PageSizeValue | string;
pageOrientation: 'landscape' | 'portrait';
customPageWidth?: number;
customPageHeight?: number;
}
const MM_TO_PX = 96 / 25.4;
export function mmToPx(mm: number): number {
return Math.round(mm * MM_TO_PX);
}
/** 解析页面尺寸(px,已考虑方向) */
export function resolvePageSizePx(options: PageSizeOptions): {
height: number;
width: number;
} {
let widthMm: number;
let heightMm: number;
if (options.pageSize === 'custom') {
widthMm = options.customPageWidth ?? 210;
heightMm = options.customPageHeight ?? 297;
} else {
const preset =
PAGE_SIZE_PRESETS[options.pageSize as PageSizePreset] ??
PAGE_SIZE_PRESETS.A4;
widthMm = preset.widthMm;
heightMm = preset.heightMm;
}
let width = mmToPx(widthMm);
let height = mmToPx(heightMm);
if (options.pageOrientation === 'landscape') {
[width, height] = [height, width];
}
return { width, height };
}
/** 画布底部状态栏显示的页面尺寸文案 */
export function formatPageSizeLabel(options: PageSizeOptions): string {
if (options.pageSize === 'custom') {
const w = options.customPageWidth ?? 210;
const h = options.customPageHeight ?? 297;
if (options.pageOrientation === 'landscape') {
return `${h}×${w}mm`;
}
return `${w}×${h}mm`;
}
return String(options.pageSize);
}
@@ -0,0 +1,826 @@
<script setup lang="ts">
import type {
ImportExportManagerEmits,
ImportExportManagerProps,
TaskRecord,
} from './types';
import type { ImportResult } from '#/api/online-dev/form-data-api';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import {
AlertCircle,
ArrowDownToLine,
ArrowUpFromLine,
CheckCircle2,
Download,
FileSpreadsheet,
Loader,
RefreshCw,
Square,
Trash2,
Upload,
XCircle,
} from '@vben/icons';
import { UploadFilled } from '@element-plus/icons-vue';
import { useLocalStorage } from '@vueuse/core';
import {
ElButton,
ElCheckbox,
ElCollapse,
ElCollapseItem,
ElIcon,
ElMessage,
ElPopconfirm,
ElPopover,
ElScrollbar,
ElTabPane,
ElTabs,
ElTag,
ElUpload,
} from 'element-plus';
import {
cancelExportTaskApi,
createExportTaskApi,
downloadFormDataExcel,
downloadImportTemplate,
importFormDataExcelApi,
pollExportTask,
} from '#/api/online-dev/form-data-api';
defineOptions({ name: 'ImportExportManager' });
const props = withDefaults(defineProps<ImportExportManagerProps>(), {
formName: '',
showImport: true,
showExport: true,
showTemplate: true,
});
const emit = defineEmits<ImportExportManagerEmits>();
// 状态
const activeTab = ref('import');
const popoverVisible = ref(false);
const importLoading = ref(false);
const exportLoading = ref(false);
const updateExisting = ref(false);
const includeSubTables = ref(true);
const useAsyncExport = ref(true); // 默认使用异步导出
const exportProgress = ref(0);
// 任务历史
const taskHistory = useLocalStorage<TaskRecord[]>(
`import-export-history-${props.formCode}`,
[],
);
// 最近导入结果
const lastImportResult = ref<ImportResult | null>(null);
const showErrorDetails = ref<string[]>([]);
// 存储活跃的轮询停止函数
const activePollers = new Map<string, () => void>();
// 启动轮询任务
function startPolling(taskId: string, filename: string, isLatest: boolean) {
const { promise, stop } = pollExportTask(props.formCode, taskId, {
filename,
onProgress: (progress, status, details) => {
updateTaskRecord(taskId, {
progress,
totalCount: details?.total_count,
processedCount: details?.processed_count,
status:
status === 'processing'
? 'running'
: (status === 'completed'
? 'success'
: 'failed'),
});
if (isLatest) {
exportProgress.value = progress;
}
},
});
// 存储 stop 函数
activePollers.set(taskId, stop);
promise
.then(() => {
updateTaskRecord(taskId, { status: 'success' });
ElMessage.success(`任务 ${filename} 导出成功`);
})
.catch((error) => {
updateTaskRecord(taskId, {
status: 'failed',
error: error.message,
});
if (isLatest) {
ElMessage.error(`任务 ${filename} 失败: ${error.message}`);
}
})
.finally(() => {
activePollers.delete(taskId);
if (isLatest) {
exportLoading.value = false;
exportProgress.value = 0;
}
});
}
// 恢复未完成的任务
function recoverRunningTasks() {
taskHistory.value.forEach((task) => {
if (task.type === 'export' && task.status === 'running') {
exportLoading.value = true;
useAsyncExport.value = true;
const isLatest = taskHistory.value[0]?.id === task.id;
startPolling(task.id, task.filename, isLatest);
}
});
}
onMounted(() => {
recoverRunningTasks();
});
// 停止所有轮询
function stopAllPollers() {
activePollers.forEach((stop) => stop());
activePollers.clear();
}
// 页面卸载时停止所有轮询
onUnmounted(() => {
stopAllPollers();
});
// 路由离开时停止所有轮询
onBeforeRouteLeave(() => {
stopAllPollers();
});
// 计算属性
const hasErrors = computed(
() => lastImportResult.value && lastImportResult.value.failed > 0,
);
// 生成任务ID
function generateTaskId() {
return `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
// 添加任务记录
function addTaskRecord(
type: 'export' | 'import',
filename: string,
options?: any,
): TaskRecord {
const record: TaskRecord = {
id: generateTaskId(),
type,
filename,
status: 'running',
startTime: new Date(),
options,
};
taskHistory.value.unshift(record);
return record;
}
// 更新任务记录
function updateTaskRecord(id: string, updates: Partial<TaskRecord>) {
const record = taskHistory.value.find((t) => t.id === id);
if (record) {
Object.assign(record, updates, {
endTime: ['failed', 'success'].includes(updates.status || '')
? new Date()
: undefined,
});
}
}
// 下载模板
async function handleDownloadTemplate() {
try {
await downloadImportTemplate(
props.formCode,
`${props.formName || props.formCode}_导入模板.xlsx`,
);
ElMessage.success('模板下载成功');
} catch {
ElMessage.error('模板下载失败');
}
}
// 导出数据
async function handleExport() {
exportLoading.value = true;
exportProgress.value = 0;
const filename = `${props.formName || props.formCode}_数据.xlsx`;
const options = {
includeSubTables: includeSubTables.value,
filename,
};
const record = addTaskRecord('export', filename, options);
try {
if (useAsyncExport.value) {
// 异步导出(适用于大数据量)
// 1. 创建后端任务
const { task_id } = await createExportTaskApi(props.formCode, {
includeSubTables: includeSubTables.value,
});
// 2. 更新前端记录 ID 为后端 UUID
const index = taskHistory.value.findIndex((t) => t.id === record.id);
if (index !== -1 && taskHistory.value[index]) {
taskHistory.value[index]!.id = task_id;
record.id = task_id;
}
// 3. 跳转到进行中 Tab
activeTab.value = 'running';
// 4. 启动轮询(会自动处理成功/失败/停止)
startPolling(task_id, filename, true);
return; // 轮询会处理后续逻辑
} else {
// 同步导出(适用于小数据量)
await downloadFormDataExcel(props.formCode, {
includeSubTables: includeSubTables.value,
filename,
});
}
updateTaskRecord(record.id, {
status: 'success',
result: { count: 0 },
});
ElMessage.success('导出成功');
emit('export-success');
} catch (error: any) {
updateTaskRecord(record.id, {
status: 'failed',
error: error?.message || '导出失败',
});
ElMessage.error(error?.message || '导出失败');
} finally {
exportLoading.value = false;
exportProgress.value = 0;
}
}
// 上传导入文件
async function handleUploadRequest(options: any) {
const { file } = options;
importLoading.value = true;
lastImportResult.value = null;
showErrorDetails.value = [];
const record = addTaskRecord('import', file.name);
try {
const result = await importFormDataExcelApi(
props.formCode,
file,
updateExisting.value,
);
lastImportResult.value = result;
updateTaskRecord(record.id, {
status: result.failed > 0 ? 'failed' : 'success',
result,
});
if (result.failed > 0) {
ElMessage.warning(
`导入完成:成功 ${result.success} 条,失败 ${result.failed}`,
);
showErrorDetails.value = ['errors'];
} else {
ElMessage.success(`成功导入 ${result.success} 条数据`);
emit('import-success', result);
}
} catch (error: any) {
updateTaskRecord(record.id, {
status: 'failed',
error: error?.message || '导入失败',
});
ElMessage.error(error?.message || '导入失败');
} finally {
importLoading.value = false;
}
}
// 重试任务
function handleRetry(task: TaskRecord) {
if (task.type === 'export' && task.options) {
activeTab.value = 'export';
includeSubTables.value = task.options.includeSubTables ?? true;
useAsyncExport.value = true;
handleExport();
}
}
// 取消任务
async function handleCancelTask(task: TaskRecord) {
if (task.type !== 'export' || task.status !== 'running') {
return;
}
try {
// 1. 停止前端轮询
const stopFn = activePollers.get(task.id);
if (stopFn) {
stopFn();
activePollers.delete(task.id);
}
// 2. 调用后端取消 API
await cancelExportTaskApi(props.formCode, task.id);
// 3. 更新任务状态为已取消
updateTaskRecord(task.id, { status: 'cancelled' });
// 4. 重置导出状态
exportLoading.value = false;
exportProgress.value = 0;
ElMessage.info('导出任务已取消');
} catch (error: any) {
ElMessage.error(error?.message || '取消失败');
}
}
// 清除历史
function handleClearHistory() {
taskHistory.value = runningTasks.value;
ElMessage.success('历史记录已清除');
}
// 计算属性
const runningTasks = computed(() =>
taskHistory.value.filter(
(t) => t.status === 'running' || t.status === 'pending',
),
);
const completedTasks = computed(() =>
taskHistory.value.filter(
(t) => t.status !== 'running' && t.status !== 'pending',
),
);
// 判断是否过期
function isExpired(task: TaskRecord) {
if (task.status === 'success' && task.expiresAt) {
return new Date(task.expiresAt) < new Date();
}
return false;
}
// 格式化时间
function formatTime(date: Date | string) {
const d = new Date(date);
return d.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
// 获取状态标签类型
function getStatusType(
status: string,
): 'danger' | 'info' | 'success' | 'warning' {
const map: Record<string, 'danger' | 'info' | 'success' | 'warning'> = {
pending: 'info',
running: 'warning',
success: 'success',
failed: 'danger',
cancelled: 'info',
};
return map[status] || 'info';
}
// 获取状态文本
function getStatusText(status: string) {
const map: Record<string, string> = {
pending: '等待中',
running: '进行中',
success: '成功',
failed: '失败',
cancelled: '已取消',
};
return map[status] || status;
}
</script>
<template>
<ElPopover
v-model:visible="popoverVisible"
placement="bottom-end"
:width="420"
trigger="click"
>
<template #reference>
<ElButton circle :icon="FileSpreadsheet" title="导入导出" />
</template>
<div class="import-export-manager">
<ElTabs v-model="activeTab" class="!-mt-2">
<!-- 导入 Tab -->
<ElTabPane v-if="showImport" label="导入" name="import">
<div class="flex flex-col gap-4">
<!-- 下载模板 -->
<div
v-if="showTemplate"
class="flex items-center justify-between rounded-lg bg-[var(--el-fill-color-lighter)] p-3"
>
<div class="flex items-center gap-2">
<ArrowDownToLine
class="h-4 w-4 text-[var(--el-color-primary)]"
/>
<span class="text-sm">下载导入模板</span>
</div>
<ElButton
size="small"
type="primary"
link
@click="handleDownloadTemplate"
>
下载
</ElButton>
</div>
<!-- 上传区域 -->
<ElUpload
drag
action=""
:http-request="handleUploadRequest"
:show-file-list="false"
:disabled="importLoading"
accept=".xlsx,.xls"
class="!w-full"
>
<div
v-loading="importLoading"
element-loading-text="正在导入..."
class="flex flex-col items-center py-4"
>
<ElIcon
class="mb-2 text-3xl text-[var(--el-text-color-secondary)]"
>
<UploadFilled />
</ElIcon>
<div class="text-sm text-[var(--el-text-color-secondary)]">
拖拽文件到此处
<em class="not-italic text-[var(--el-color-primary)]">点击上传</em>
</div>
<div
class="mt-1 text-xs text-[var(--el-text-color-placeholder)]"
>
支持 .xlsx, .xls 格式
</div>
</div>
</ElUpload>
<!-- 选项 -->
<div class="flex items-center">
<ElCheckbox v-model="updateExisting" size="small">
更新已存在的数据根据ID匹配
</ElCheckbox>
</div>
<!-- 导入结果 -->
<div
v-if="lastImportResult"
class="rounded-lg border border-[var(--el-border-color-lighter)] p-3"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-sm font-medium">导入结果</span>
<div class="flex items-center gap-3 text-sm">
<span
class="flex items-center gap-1 text-[var(--el-color-success)]"
>
<CheckCircle2 class="h-4 w-4" />
{{ lastImportResult.success }}
</span>
<span
v-if="lastImportResult.failed > 0"
class="flex items-center gap-1 text-[var(--el-color-danger)]"
>
<XCircle class="h-4 w-4" />
{{ lastImportResult.failed }}
</span>
</div>
</div>
<!-- 错误详情 -->
<ElCollapse v-if="hasErrors" v-model="showErrorDetails">
<ElCollapseItem title="查看错误详情" name="errors">
<ElScrollbar max-height="150px">
<div class="space-y-1">
<div
v-for="(err, idx) in lastImportResult.errors"
:key="idx"
class="flex items-start gap-2 rounded bg-[var(--el-color-danger-light-9)] p-2 text-xs"
>
<AlertCircle
class="mt-0.5 h-3 w-3 flex-shrink-0 text-[var(--el-color-danger)]"
/>
<div>
<span class="font-medium"> {{ err.row }} </span>
<span v-if="err.field">字段 {{ err.field }}</span>
<span>{{ err.error }}</span>
</div>
</div>
</div>
</ElScrollbar>
</ElCollapseItem>
</ElCollapse>
</div>
</div>
</ElTabPane>
<!-- 导出 Tab -->
<ElTabPane v-if="showExport" label="导出" name="export">
<div class="flex flex-col gap-4">
<div class="rounded-lg bg-[var(--el-fill-color-lighter)] p-4">
<div class="mb-3 flex items-center gap-2">
<Download class="h-4 w-4 text-[var(--el-color-primary)]" />
<span class="text-sm font-medium">导出数据</span>
</div>
<div class="mb-3 space-y-2">
<ElCheckbox v-model="includeSubTables" size="small">
包含子表数据多Sheet
</ElCheckbox>
<ElCheckbox v-model="useAsyncExport" size="small">
异步导出推荐大数据量使用
</ElCheckbox>
</div>
<!-- 导出进度条 -->
<div
v-if="
exportLoading && useAsyncExport && runningTasks.length > 0
"
class="mb-3"
>
<div class="mb-1 flex justify-between text-xs">
<span>导出进度</span>
<span class="flex items-center gap-2">
<span>{{ runningTasks[0]?.progress || 0 }}%</span>
<span
v-if="runningTasks[0]?.totalCount"
class="text-[var(--el-text-color-secondary)]"
>
({{ runningTasks[0]?.processedCount || 0 }}/{{
runningTasks[0]?.totalCount
}}
条)
</span>
</span>
</div>
<div
class="h-2 overflow-hidden rounded-full bg-[var(--el-fill-color)]"
>
<div
class="h-full rounded-full bg-[var(--el-color-primary)] transition-all duration-300"
:style="{ width: `${runningTasks[0]?.progress || 0}%` }"
></div>
</div>
</div>
<ElButton
type="primary"
:loading="exportLoading"
:disabled="runningTasks.length > 0"
class="w-full"
@click="handleExport"
>
<template #icon>
<ArrowUpFromLine class="h-4 w-4" />
</template>
{{
exportLoading || runningTasks.length > 0
? '导出中...'
: '导出 Excel'
}}
</ElButton>
</div>
</div>
</ElTabPane>
<!-- 进行中 Tab -->
<ElTabPane label="进行中" name="running">
<template #label>
<span class="flex items-center gap-1">
进行中
<ElTag
v-if="runningTasks.length > 0"
type="primary"
size="small"
round
>
{{ runningTasks.length }}
</ElTag>
</span>
</template>
<div
v-if="runningTasks.length === 0"
class="py-8 text-center text-sm text-[var(--el-text-color-placeholder)]"
>
暂无进行中的任务
</div>
<ElScrollbar v-else max-height="250px">
<div class="space-y-2">
<div
v-for="task in runningTasks"
:key="task.id"
class="flex items-center justify-between rounded-lg border border-[var(--el-border-color-lighter)] p-3"
>
<div class="flex items-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--el-color-primary-light-9)]"
>
<Loader
class="h-4 w-4 animate-spin text-[var(--el-color-primary)]"
/>
</div>
<div>
<div class="text-sm font-medium">{{ task.filename }}</div>
<div
class="text-xs text-[var(--el-text-color-placeholder)]"
>
开始于 {{ formatTime(task.startTime) }}
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex flex-col items-end gap-1">
<div class="flex items-center gap-2 text-xs">
<span class="text-[var(--el-color-primary)]">
{{ task.progress || 0 }}%
</span>
<span
v-if="task.totalCount"
class="text-[var(--el-text-color-secondary)]"
>
{{ task.processedCount || 0 }}/{{ task.totalCount }}
</span>
</div>
<div
class="h-1.5 w-24 overflow-hidden rounded-full bg-[var(--el-fill-color)]"
>
<div
class="h-full rounded-full bg-[var(--el-color-primary)] transition-all duration-300"
:style="{ width: `${task.progress || 0}%` }"
></div>
</div>
</div>
<ElPopconfirm
title="确定要取消此导出任务吗?"
confirm-button-text="确定"
cancel-button-text="取消"
@confirm="handleCancelTask(task)"
>
<template #reference>
<ElButton
type="danger"
size="small"
circle
title="取消导出"
>
<Square class="h-3 w-3" />
</ElButton>
</template>
</ElPopconfirm>
</div>
</div>
</div>
</ElScrollbar>
</ElTabPane>
<!-- 历史记录 Tab -->
<ElTabPane label="历史" name="history">
<div class="mb-2 flex justify-end">
<ElButton
v-if="completedTasks.length > 0"
type="danger"
link
size="small"
@click="handleClearHistory"
>
<template #icon>
<Trash2 class="h-3 w-3" />
</template>
清除历史
</ElButton>
</div>
<div
v-if="completedTasks.length === 0"
class="py-8 text-center text-sm text-[var(--el-text-color-placeholder)]"
>
暂无历史记录
</div>
<ElScrollbar v-else max-height="250px">
<div class="space-y-2">
<div
v-for="task in completedTasks"
:key="task.id"
class="flex items-center justify-between rounded-lg border border-[var(--el-border-color-lighter)] p-3"
>
<div class="flex items-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full"
:class="
task.type === 'import'
? 'bg-[var(--el-color-success-light-9)]'
: 'bg-[var(--el-color-primary-light-9)]'
"
>
<Upload
v-if="task.type === 'import'"
class="h-4 w-4 text-[var(--el-color-success)]"
/>
<Download
v-else
class="h-4 w-4 text-[var(--el-color-primary)]"
/>
</div>
<div>
<div class="flex items-center gap-2">
<div class="text-sm font-medium">{{ task.filename }}</div>
<ElTag
v-if="isExpired(task)"
type="info"
size="small"
effect="plain"
>
已过期
</ElTag>
</div>
<div
class="text-xs text-[var(--el-text-color-placeholder)]"
>
{{ formatTime(task.endTime || task.startTime) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<ElButton
v-if="task.status === 'failed' && task.type === 'export'"
type="primary"
link
size="small"
@click="handleRetry(task)"
>
<template #icon>
<RefreshCw class="h-3 w-3" />
</template>
重试
</ElButton>
<ElTag :type="getStatusType(task.status)" size="small">
{{ getStatusText(task.status) }}
</ElTag>
</div>
</div>
</div>
</ElScrollbar>
</ElTabPane>
</ElTabs>
</div>
</ElPopover>
</template>
<style scoped>
.import-export-manager :deep(.el-tabs__header) {
margin-bottom: 12px;
}
.import-export-manager :deep(.el-upload-dragger) {
padding: 0;
border-color: var(--el-border-color);
}
.import-export-manager :deep(.el-upload-dragger:hover) {
border-color: var(--el-color-primary);
}
.import-export-manager :deep(.el-collapse-item__header) {
height: 32px;
font-size: 12px;
line-height: 32px;
color: var(--el-color-danger);
}
.import-export-manager :deep(.el-collapse-item__content) {
padding-bottom: 0;
}
</style>
@@ -0,0 +1,2 @@
export { default as ImportExportManager } from './import-export-manager.vue';
export * from './types';
@@ -0,0 +1,67 @@
/** 导入结果 */
export interface ImportResult {
success: number;
failed: number;
errors: ImportError[];
warnings?: string[];
}
/** 导入错误详情 */
export interface ImportError {
row: number;
field: null | string;
error: string;
type: 'header' | 'option' | 'required' | 'system' | 'type';
}
/** 导出选项 */
export interface ExportOptions {
ids?: string[];
includeSubTables?: boolean;
filename?: string;
}
/** 任务状态 */
export type TaskStatus =
| 'cancelled'
| 'failed'
| 'pending'
| 'running'
| 'success';
/** 任务记录 */
export interface TaskRecord {
id: string;
type: 'export' | 'import';
filename: string;
status: TaskStatus;
startTime: Date;
endTime?: Date;
progress?: number;
totalCount?: number; // 总条数
processedCount?: number; // 已处理条数
result?: ImportResult | { count: number };
error?: string;
expiresAt?: string; // 任务过期时间
options?: ExportOptions; // 导出选项,用于重试
}
/** 组件 Props */
export interface ImportExportManagerProps {
/** 表单编码 */
formCode: string;
/** 表单名称 */
formName?: string;
/** 是否显示导入功能 */
showImport?: boolean;
/** 是否显示导出功能 */
showExport?: boolean;
/** 是否显示下载模板 */
showTemplate?: boolean;
}
/** 组件 Emits */
export interface ImportExportManagerEmits {
(e: 'import-success', result: ImportResult): void;
(e: 'export-success'): void;
}
@@ -0,0 +1,147 @@
/**
* 预览时合并后端 chartData 到 float/cell 图表配置(对齐 JNPF useReport.getRealEchart
*/
export interface ChartDataItem {
drawingId: string;
field: {
classifyNameField?: string[];
seriesNameField?: string[];
seriesDataField?: string[][];
maxField?: string[];
};
}
type EchartStore = Record<
string,
{ drawingId?: string; echartType: string; option: Record<string, any> }
> | null;
function getColor(colorList: any[], index: number) {
const item = colorList?.[index];
if (!item) return undefined;
return item.color1 || item.color2 || undefined;
}
function getPieData(pieOpt: Record<string, any>, list: { name: string; value: string }[]) {
let data = [...list];
if (pieOpt?.showZero) {
data = data.filter((item) => String(item.value) !== '0');
}
if (pieOpt?.sortable) {
data = [...data].sort((a, b) => Number(a.value) - Number(b.value));
}
return data;
}
export function applyChartDataToEcharts(
echarts: EchartStore,
chartData: ChartDataItem[] | null | undefined,
): EchartStore {
if (!echarts || !chartData?.length) return echarts;
const next = JSON.parse(JSON.stringify(echarts)) as NonNullable<EchartStore>;
Object.keys(next).forEach((key) => {
const chart = next[key];
if (!chart?.option) return;
const option = chart.option;
const styleType = option.styleType;
const colorList = option.color?.list || [];
const dataList = chartData.filter((o) => o.drawingId === key);
if (!dataList.length) return;
const data = dataList[0].field;
const {
classifyNameField = [],
seriesDataField = [],
seriesNameField = [],
maxField = [],
} = data;
if (['bar', 'line'].includes(chart.echartType)) {
const series = seriesDataField.map((o, index) => ({
name: seriesNameField[index],
data: o,
type: chart.echartType,
itemStyle: { color: getColor(colorList, index) },
...(chart.echartType === 'line'
? {
smooth: styleType === 2,
step: styleType === 3,
stack: styleType === 4 ? 'total' : '',
lineStyle: { width: option.line?.width },
symbolSize: option.line?.symbolSize,
}
: {}),
...(chart.echartType === 'line' && option.areaStyle
? { areaStyle: option.areaStyle }
: {}),
...(chart.echartType === 'bar'
? {
showBackground: styleType === 4,
stack:
styleType === 5
? seriesNameField[index]
: styleType === 2 || styleType === 6
? 'total'
: '',
}
: {}),
}));
option.series = series;
option.legend = { ...option.legend, data: seriesNameField };
option.xAxis = { ...option.xAxis, data: classifyNameField };
}
if (chart.echartType === 'pie') {
option.series = seriesDataField.map((o, index) => {
const pieData = o.map((item, sIndex) => ({
value: item,
name: classifyNameField[sIndex],
}));
return {
name: seriesNameField[index],
type: 'pie',
radius: styleType === 2 ? ['30%', '60%'] : '50%',
center: [
`${option.seriesCenter?.seriesCenterLeft ?? 50}%`,
`${option.seriesCenter?.seriesCenterTop ?? 50}%`,
],
roseType: option.pie?.roseType ? 'area' : '',
data: getPieData(option.pie || {}, pieData),
};
});
}
if (chart.echartType === 'radar' && maxField.length) {
const indicator = maxField.map((o, sIndex) => ({
max: o,
name: classifyNameField[sIndex],
}));
option.radar = {
...option.radar,
indicator,
shape: styleType === 1 ? 'polygon' : 'circle',
};
option.series = [
{
type: 'radar',
data: seriesDataField.map((element, index) => ({
value: element,
name: seriesNameField[index],
})),
},
];
}
});
return next;
}
export function parseCellsMeta(cells: Record<string, any> | string | undefined) {
if (!cells) return {};
if (typeof cells === 'string') {
try {
return JSON.parse(cells);
} catch {
return {};
}
}
return cells;
}
@@ -0,0 +1,137 @@
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
import { computed, watch, type MaybeRefOrGetter, toValue } from 'vue';
import {
buildFieldOptions,
buildFieldTree,
datasetFieldsCache,
datasetFieldsLoadingMap,
fetchDatasetFields,
normalizeDataset,
type FieldOption,
type FieldTreeNode,
} from '../utils/dataset-fields';
export function useReportDatasetFields(
datasetsSource: MaybeRefOrGetter<ReportDatasetItem[]>,
) {
const fieldsCache = datasetFieldsCache;
const loadingMap = datasetFieldsLoadingMap;
const normalizedDatasets = computed(() =>
(toValue(datasetsSource) || [])
.map(normalizeDataset)
.filter((item) => item.data_source_id),
);
watch(
normalizedDatasets,
(list) => {
const idSet = new Set(list.map((d) => d.data_source_id));
for (const key of Object.keys(fieldsCache.value)) {
if (!idSet.has(key)) {
delete fieldsCache.value[key];
delete loadingMap.value[key];
}
}
},
{ deep: true },
);
async function loadFields(ds: ReportDatasetItem, force = false) {
const normalized = normalizeDataset(ds);
const id = normalized.data_source_id;
if (!id) return [];
if (!force && fieldsCache.value[id] !== undefined) {
return fieldsCache.value[id];
}
if (loadingMap.value[id]) {
return fieldsCache.value[id] || [];
}
loadingMap.value[id] = true;
try {
const fields = await fetchDatasetFields(normalized);
fieldsCache.value[id] = fields;
return fields;
} catch {
fieldsCache.value[id] = [];
return [];
} finally {
loadingMap.value[id] = false;
}
}
async function ensureAll(force = false) {
await Promise.all(
normalizedDatasets.value.map((ds) => loadFields(ds, force)),
);
}
async function ensureByAlias(alias: string, force = false) {
const ds = normalizedDatasets.value.find((item) => item.alias === alias);
if (!ds) return [];
return loadFields(ds, force);
}
async function ensureByDataSourceId(dataSourceId: string, force = false) {
const ds = normalizedDatasets.value.find(
(item) => item.data_source_id === dataSourceId,
);
if (!ds) return [];
return loadFields(ds, force);
}
const allFieldOptions = computed<FieldOption[]>(() =>
buildFieldOptions(normalizedDatasets.value, fieldsCache.value, {
withAlias: true,
}),
);
function getFieldOptions(
alias?: string,
withAlias = true,
currentValue?: string,
) {
const options = buildFieldOptions(
normalizedDatasets.value,
fieldsCache.value,
{ alias, withAlias },
);
return appendIfMissing(options, currentValue);
}
function getFieldTree(
alias?: string,
withAlias = true,
currentValue?: string,
): FieldTreeNode[] {
return buildFieldTree(normalizedDatasets.value, fieldsCache.value, {
alias,
withAlias,
currentValue,
});
}
function appendIfMissing(options: FieldOption[], value?: string) {
const trimmed = (value || '').trim();
if (!trimmed || options.some((opt) => opt.value === trimmed)) {
return options;
}
return [{ label: trimmed, value: trimmed }, ...options];
}
return {
fieldsCache,
loadingMap,
normalizedDatasets,
allFieldOptions,
loadFields,
ensureAll,
ensureByAlias,
ensureByDataSourceId,
getFieldOptions,
getFieldTree,
};
}
@@ -0,0 +1,216 @@
import { computed, reactive, ref } from 'vue';
import { $t } from '@vben/locales';
/** 报表查询条件项(与 JNPF queryList 子集兼容) */
export interface ReportQueryField {
field: string;
label?: string;
component?: string;
defaultValue?: any;
required?: boolean;
placeholder?: string;
showTime?: boolean;
options?: Array<{ label: string; value: any }>;
}
export interface SheetQueryBlock {
sheet: string;
queryList: any[];
}
function isSheetWrappedQueryList(queryList: any[]): queryList is SheetQueryBlock[] {
if (!queryList?.length) return false;
const first = queryList[0];
return (
typeof first === 'object' &&
first !== null &&
'queryList' in first &&
Array.isArray(first.queryList)
);
}
function flattenQueryList(queryList: any[]): any[] {
if (!queryList?.length) return [];
if (isSheetWrappedQueryList(queryList)) {
const flat: any[] = [];
for (const block of queryList) {
for (const item of block.queryList || []) {
if (item && typeof item === 'object') flat.push(item);
}
}
return flat;
}
return queryList.filter((item) => item && typeof item === 'object');
}
function queryListForSheet(queryList: any[], sheetId: string): any[] {
if (!queryList?.length) return [];
if (isSheetWrappedQueryList(queryList)) {
const block = queryList.find((b) => String(b.sheet || '') === String(sheetId));
if (block) return block.queryList || [];
return flattenQueryList(queryList);
}
return queryList;
}
function normalizeComponent(raw: string) {
const c = (raw || 'input').toLowerCase();
if (c.includes('select')) return 'select';
if (c.includes('range')) return 'dateRange';
if (c.includes('date') || c.includes('time')) return 'date';
return 'input';
}
function parseOptions(raw: any): Array<{ label: string; value: any }> | undefined {
if (Array.isArray(raw)) {
return raw.map((item) => {
if (typeof item === 'object' && item !== null) {
return {
label: String(item.label ?? item.fullName ?? item.text ?? item.id ?? ''),
value: item.value ?? item.id,
};
}
return { label: String(item), value: item };
});
}
if (typeof raw === 'string' && raw.trim()) {
return raw.split(',').map((part) => {
const [label, value] = part.split(':');
const v = (value ?? label).trim();
return { label: (label || v).trim(), value: v };
});
}
return undefined;
}
function parseQueryFields(queryList: any[]) {
const fields: ReportQueryField[] = [];
const defaults: Record<string, any> = {};
for (const raw of queryList || []) {
if (!raw || typeof raw !== 'object') continue;
const field = raw.field || raw.vModel || raw.prop;
if (!field) continue;
fields.push({
field,
label: raw.label || raw.__config__?.label || field,
component: raw.component || raw.__config__?.tag || 'input',
defaultValue: raw.defaultValue ?? raw.value,
required: raw.required,
placeholder: raw.placeholder,
showTime: raw.showTime === true || raw.__config__?.showTime === true,
options: parseOptions(raw.options ?? raw.__config__?.options),
});
if (raw.defaultValue !== undefined && raw.defaultValue !== null) {
defaults[field] = raw.defaultValue;
} else if (raw.value !== undefined) {
defaults[field] = raw.value;
}
}
return { fields, defaults };
}
export function useReportQuery() {
const activeSheetId = ref('');
const rawQueryList = ref<any[]>([]);
const isSheetWrapped = ref(false);
const state = reactive({
queryFields: [] as ReportQueryField[],
formValues: {} as Record<string, any>,
});
const searchSchemas = computed(() =>
state.queryFields.map((item) => {
const component = normalizeComponent(item.component || 'input');
const schema: Record<string, any> = {
fieldName: item.field,
label: item.label || item.field,
component,
componentProps: {
placeholder: item.placeholder || item.label || item.field,
showTime: item.showTime === true,
},
rules: item.required
? [{ required: true, message: $t('report-manager.query.required') }]
: undefined,
};
if (component === 'select' && item.options?.length) {
schema.componentProps.options = item.options;
}
return schema;
}),
);
function applyQueryFields(queryList: any[]) {
const { fields, defaults } = parseQueryFields(queryList);
state.queryFields = fields;
state.formValues = { ...defaults };
}
function setQueryList(queryList: any[]) {
rawQueryList.value = Array.isArray(queryList) ? [...queryList] : [];
isSheetWrapped.value = isSheetWrappedQueryList(rawQueryList.value);
if (isSheetWrapped.value && !activeSheetId.value) {
activeSheetId.value = String(rawQueryList.value[0]?.sheet || '');
}
const effective = isSheetWrapped.value
? queryListForSheet(rawQueryList.value, activeSheetId.value)
: rawQueryList.value;
applyQueryFields(effective);
}
function setQueryListForSheet(queryList: any[], sheetId?: string) {
if (sheetId !== undefined) {
activeSheetId.value = sheetId;
}
if (isSheetWrapped.value && activeSheetId.value) {
applyQueryFields(queryListForSheet(rawQueryList.value, activeSheetId.value));
return;
}
setQueryList(queryList);
}
function onActiveSheetChange(sheetId: string) {
if (!sheetId || sheetId === activeSheetId.value) return;
activeSheetId.value = sheetId;
if (isSheetWrapped.value) {
applyQueryFields(queryListForSheet(rawQueryList.value, sheetId));
}
}
function getDefaultParams() {
const params: Record<string, any> = {};
for (const [key, val] of Object.entries(state.formValues)) {
if (val !== undefined && val !== null && val !== '') {
params[key] = val;
}
}
return params;
}
function setFormValues(values: Record<string, any>) {
state.formValues = { ...state.formValues, ...values };
}
function clear() {
rawQueryList.value = [];
isSheetWrapped.value = false;
activeSheetId.value = '';
state.queryFields = [];
state.formValues = {};
}
return {
activeSheetId,
isSheetWrapped,
searchSchemas,
formValues: computed(() => state.formValues),
setQueryList,
setQueryListForSheet,
onActiveSheetChange,
getDefaultParams,
setFormValues,
clear,
};
}
@@ -0,0 +1,389 @@
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import '@zq/univer/style';
import UniverHost from './univer-host.vue';
import type { ZqTabItem } from '#/components/zq-tabs';
import { ZqTabs } from '#/components/zq-tabs';
import { getAllDataSourceApi, type DataSourceSimple } from '#/api/core/data-source';
import { type ReportDatasetItem } from '#/api/online-dev/report-manager';
import { useAppContextStore } from '#/store/app-context';
import CellChartPanel from './modules/cell-chart-panel.vue';
import type { CellChartSelection } from './modules/cell-chart-panel.vue';
import CellPropertyPanel from './modules/cell-property-panel.vue';
import type { CellSelection } from './modules/cell-property-panel.vue';
import FloatEchartPanel from './modules/float-echart-panel.vue';
import type { FloatEchartSelection } from './modules/float-echart-panel.vue';
import FloatImagePanel from './modules/float-image-panel.vue';
import type { FloatImageSelection } from './modules/float-image-panel.vue';
import DatasetPanel from './modules/dataset-panel.vue';
import QueryConfigDialog from './modules/query-config-dialog.vue';
import PreviewDialog from './modules/preview-dialog.vue';
import SortConfigDialog from './modules/sort-config-dialog.vue';
import ColumnConfigDialog from './modules/column-config-dialog.vue';
import ConvertConfigDialog from './modules/convert-config-dialog.vue';
import ReportConfigFeaturesPanel from './modules/report-config-features-panel.vue';
import ReportSettingsPanel from './modules/report-settings-panel.vue';
import { useReportQuery } from './hooks/useReportQuery';
const props = defineProps<{
templateId: string;
versionId: string;
reportName: string;
reportCode?: string;
snapshot: Record<string, any>;
cells: Record<string, any>;
queryList: any[];
sortList?: any[];
columnList?: any[];
convertConfig?: any[];
datasets: ReportDatasetItem[];
allowExport?: boolean;
allowPrint?: boolean;
allowWatermark?: boolean;
watermarkConfig?: Record<string, any>;
}>();
const emit = defineEmits<{
'settings-saved': [
{
allow_export: boolean;
allow_print: boolean;
allow_watermark: boolean;
watermark_text: string;
watermark_show_time: boolean;
watermark_time_format: string;
},
];
}>();
const appContextStore = useAppContextStore();
const univerRef = ref<InstanceType<typeof UniverHost> | null>(null);
const dataSources = ref<DataSourceSimple[]>([]);
const localDatasets = ref<ReportDatasetItem[]>([]);
const localQueryList = ref<any[]>([]);
const localSortList = ref<any[]>([]);
const localColumnList = ref<any[]>([]);
const localConvertConfig = ref<any[]>([]);
const showQueryDialog = ref(false);
const showSortDialog = ref(false);
const showColumnDialog = ref(false);
const showConvertDialog = ref(false);
const showPreviewDialog = ref(false);
const leftTab = ref('dataSource');
const leftTabItems = computed<ZqTabItem[]>(() => [
{
key: 'dataSource',
label: $t('report-manager.leftPanel.dataSource'),
},
{
key: 'reportProperties',
label: $t('report-manager.leftPanel.reportProperties'),
},
]);
const rightTab = ref('cellProperties');
const rightTabItems = computed<ZqTabItem[]>(() => [
{
key: 'cellProperties',
label: $t('report-manager.properties.title'),
},
]);
const cellSelection = ref<CellSelection | null>(null);
const floatImageSelection = ref<FloatImageSelection | null>(null);
const floatEchartSelection = ref<FloatEchartSelection | null>(null);
const cellChartSelection = ref<CellChartSelection | null>(null);
const propertyMode = ref<'cell' | 'image' | 'chart' | 'cellChart'>('cell');
const { setQueryList } = useReportQuery();
function normalizeConvertConfig(raw: any): any[] {
if (Array.isArray(raw)) return [...raw];
if (raw && Array.isArray(raw.list)) return [...raw.list];
return [];
}
function syncLocalState() {
localDatasets.value = [...(props.datasets || [])];
localQueryList.value = [...(props.queryList || [])];
localSortList.value = [...(props.sortList || [])];
localColumnList.value = [...(props.columnList || [])];
localConvertConfig.value = normalizeConvertConfig(props.convertConfig);
setQueryList(localQueryList.value);
}
onMounted(async () => {
syncLocalState();
try {
dataSources.value = await getAllDataSourceApi(
appContextStore.currentApp?.id,
);
} catch {
dataSources.value = [];
}
});
watch(
() => [props.datasets, props.queryList, props.sortList, props.columnList, props.convertConfig, props.versionId],
() => syncLocalState(),
{ deep: true },
);
function getSavePayload() {
const data = univerRef.value?.getData() || {};
return {
id: props.templateId,
versionId: props.versionId,
snapshot: data.snapshot,
cells: data.cells,
queryList: localQueryList.value,
sortList: localSortList.value,
columnList: localColumnList.value,
fenceList: localColumnList.value,
convertConfig: localConvertConfig.value,
dataSetList: localDatasets.value.map((d) => ({
dataSourceId: d.data_source_id,
alias: d.alias,
fieldMapping: d.field_mapping || {},
convertConfig: d.convert_config || {},
sort: d.sort ?? 0,
})),
};
}
function onCellChange(payload: {
startRow: number;
startColumn: number;
cellData: Record<string, any>;
sheetId?: string;
}) {
floatImageSelection.value = null;
floatEchartSelection.value = null;
const custom = payload.cellData?.custom || {};
if (custom.type === 'chart') {
cellSelection.value = null;
cellChartSelection.value = {
startRow: payload.startRow,
startColumn: payload.startColumn,
sheetId: payload.sheetId,
cellData: payload.cellData,
};
propertyMode.value = 'cellChart';
return;
}
cellChartSelection.value = null;
propertyMode.value = 'cell';
cellSelection.value = {
startRow: payload.startRow,
startColumn: payload.startColumn,
sheetId: payload.sheetId,
cellData: payload.cellData,
};
}
function onFocusFloatImage(payload: {
drawingId: string;
imageType: 'BASE64' | 'URL';
option: Record<string, any>;
}) {
cellSelection.value = null;
floatEchartSelection.value = null;
cellChartSelection.value = null;
propertyMode.value = 'image';
floatImageSelection.value = {
drawingId: payload.drawingId,
imageType: payload.imageType,
option: payload.option || {},
};
}
function onFocusFloatEchart(payload: {
drawingId: string;
echartType: string;
option: Record<string, any>;
}) {
cellSelection.value = null;
floatImageSelection.value = null;
cellChartSelection.value = null;
propertyMode.value = 'chart';
floatEchartSelection.value = {
drawingId: payload.drawingId,
echartType: payload.echartType,
option: payload.option || {},
};
}
function onApplyFloatImage(config: FloatImageSelection) {
univerRef.value?.updateFloatImageConfig?.(config);
}
function onApplyFloatEchart(config: FloatEchartSelection) {
univerRef.value?.updateFloatEchartConfig?.(config);
}
function onApplyCellChart(payload: {
startRow: number;
startColumn: number;
echartType: string;
option: Record<string, any>;
preserveCustom: Record<string, any>;
}) {
univerRef.value?.applyCellChart?.(payload);
}
function onApplyCellBinding(binding: {
startRow: number;
startColumn: number;
type: string;
v: string;
custom: Record<string, any>;
}) {
univerRef.value?.applyCellBinding?.(binding);
}
function onSettingsSaved(form: {
allow_export: boolean;
allow_print: boolean;
allow_watermark: boolean;
watermark_text: string;
watermark_show_time: boolean;
watermark_time_format: string;
}) {
emit('settings-saved', form);
}
defineExpose({ getSavePayload });
</script>
<template>
<div class="bg-background-deep flex h-full min-h-0 w-full">
<aside
class="bg-background my-3 ml-3 flex w-56 shrink-0 flex-col rounded-[8px]"
>
<div class="shrink-0 p-2">
<ZqTabs v-model="leftTab" :items="leftTabItems" />
</div>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
<DatasetPanel
v-if="leftTab === 'dataSource'"
class="min-h-0 flex-1"
:datasets="localDatasets"
:data-sources="dataSources"
@update="localDatasets = $event"
/>
<div
v-else-if="leftTab === 'reportProperties'"
class="flex min-h-0 flex-1 flex-col overflow-auto"
>
<ReportConfigFeaturesPanel
:query-list="localQueryList"
:sort-list="localSortList"
:column-list="localColumnList"
:convert-config="localConvertConfig"
@open-query="showQueryDialog = true"
@open-sort="showSortDialog = true"
@open-column="showColumnDialog = true"
@open-convert="showConvertDialog = true"
/>
<!-- <div class="border-t border-[var(--el-border-color)]"> -->
<ReportSettingsPanel
:template-id="templateId"
:report-name="reportName"
:allow-export="allowExport"
:allow-print="allowPrint"
:allow-watermark="allowWatermark"
:watermark-config="watermarkConfig"
@saved="onSettingsSaved"
/>
<!-- </div> -->
</div>
</div>
</aside>
<section
class="bg-background m-3 flex min-w-0 flex-1 flex-col overflow-hidden rounded-[8px]"
>
<UniverHost
ref="univerRef"
mode="design"
class="h-full w-full"
:snapshot="snapshot"
:cells="cells"
@change-cell="onCellChange"
@focus-float-image="onFocusFloatImage"
@focus-float-echart="onFocusFloatEchart"
@preview="showPreviewDialog = true"
/>
</section>
<aside
class="bg-background my-3 mr-3 flex w-52 shrink-0 flex-col overflow-hidden rounded-[8px]"
>
<div class="shrink-0 p-2">
<ZqTabs v-model="rightTab" :items="rightTabItems" />
</div>
<div class="min-h-0 flex-1 overflow-auto p-3 pt-0">
<FloatImagePanel
v-if="propertyMode === 'image'"
:selection="floatImageSelection"
@apply="onApplyFloatImage"
/>
<FloatEchartPanel
v-else-if="propertyMode === 'chart'"
:selection="floatEchartSelection"
:datasets="localDatasets"
@apply="onApplyFloatEchart"
/>
<CellChartPanel
v-else-if="propertyMode === 'cellChart'"
:selection="cellChartSelection"
:datasets="localDatasets"
@apply="onApplyCellChart"
/>
<CellPropertyPanel
v-else
:selection="cellSelection"
:datasets="localDatasets"
@apply="onApplyCellBinding"
/>
</div>
</aside>
<QueryConfigDialog
v-model:visible="showQueryDialog"
:query-list="localQueryList"
:datasets="localDatasets"
@confirm="localQueryList = $event"
/>
<SortConfigDialog
v-model:visible="showSortDialog"
:sort-list="localSortList"
:datasets="localDatasets"
@confirm="localSortList = $event"
/>
<ColumnConfigDialog
v-model:visible="showColumnDialog"
:column-list="localColumnList"
@confirm="localColumnList = $event"
/>
<ConvertConfigDialog
v-model:visible="showConvertDialog"
:convert-config="localConvertConfig"
:datasets="localDatasets"
@confirm="localConvertConfig = $event"
/>
</div>
<PreviewDialog
v-model:visible="showPreviewDialog"
:version-id="versionId"
:report-code="reportCode"
:report-name="reportName"
:query-list="localQueryList"
:get-draft-payload="getSavePayload"
/>
</template>
@@ -0,0 +1,111 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
import ChartBindForm, {
type ChartBindFormState,
} from './chart-bind-form.vue';
import { parseChartOptionToFormState } from './chart-bind-utils';
export interface CellChartSelection {
startRow: number;
startColumn: number;
sheetId?: string;
cellData?: Record<string, any>;
}
const props = defineProps<{
selection: CellChartSelection | null;
datasets: ReportDatasetItem[];
}>();
const emit = defineEmits<{
apply: [payload: {
startRow: number;
startColumn: number;
echartType: string;
option: Record<string, any>;
preserveCustom: Record<string, any>;
}];
}>();
const bindFormRef = ref<InstanceType<typeof ChartBindForm> | null>(null);
const formState = ref<ChartBindFormState | null>(null);
const positionLabel = computed(() => {
if (!props.selection) return '';
return $t('report-manager.properties.position', {
row: props.selection.startRow + 1,
col: props.selection.startColumn + 1,
});
});
function customToFormState(
custom: Record<string, any>,
datasets: ReportDatasetItem[],
): ChartBindFormState {
const { chartType, drawingId, height, width, type, ...restOption } = custom;
return parseChartOptionToFormState(
custom.chartType || 'bar',
restOption,
datasets,
);
}
watch(
() => props.selection,
(sel) => {
if (!sel) {
formState.value = null;
return;
}
formState.value = customToFormState(
sel.cellData?.custom || {},
props.datasets || [],
);
},
{ deep: true },
);
function onFormChange() {
if (!props.selection) return;
const custom = props.selection.cellData?.custom || {};
const { chartType, drawingId, height, width, type, ...restOption } = custom;
const option = bindFormRef.value?.buildOption(restOption) || {};
emit('apply', {
startRow: props.selection.startRow,
startColumn: props.selection.startColumn,
echartType: formState.value?.echartType || custom.chartType || 'bar',
option,
preserveCustom: {
drawingId,
height,
width,
type: 'chart',
},
});
}
</script>
<template>
<div v-if="!selection" class="text-xs text-[var(--el-text-color-secondary)]">
{{ $t('report-manager.chart.cellEmpty') }}
</div>
<div v-else>
<p class="mb-2 text-xs text-[var(--el-text-color-secondary)]">
{{ positionLabel }}
</p>
<p class="mb-2 text-xs font-medium">
{{ $t('report-manager.chart.cellTitle') }}
</p>
<ChartBindForm
ref="bindFormRef"
:datasets="datasets"
:model-value="formState"
@change="onFormChange"
/>
</div>
</template>
@@ -0,0 +1,675 @@
<script lang="ts" setup>
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
import { reactive, watch } from 'vue';
import { CircleHelp } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
ElSwitch,
ElTooltip,
} from 'element-plus';
import { useReportDatasetFields } from '../hooks/useReportDatasetFields';
import ReportFieldSelect from './report-field-select.vue';
export interface CellSelection {
startRow: number;
startColumn: number;
sheetId?: string;
cellData?: Record<string, any>;
}
const DEFAULT_QR_OPTION = {
type: 'static',
color: { dark: '#000000', light: '#f4f5f6' },
errorCorrectionLevel: 'M',
};
const DEFAULT_BARCODE_OPTION = {
type: 'static',
format: 'code128',
displayValue: false,
lineColor: '#000000',
background: '#f4f5f6',
width: 4,
margin: 15,
};
const QR_LEVELS = ['L', 'M', 'Q', 'H'] as const;
const PARENT_CELL_TYPES = ['none', 'default', 'custom'] as const;
function colIndexToLetter(col: number): string {
let n = col;
let s = '';
while (n >= 0) {
s = String.fromCharCode(65 + (n % 26)) + s;
n = Math.floor(n / 26) - 1;
}
return s || 'A';
}
const BARCODE_FORMATS = [
'code128',
'ean13',
'ean8',
'upc',
'code39',
] as const;
const props = defineProps<{
selection: CellSelection | null;
datasets: ReportDatasetItem[];
}>();
const emit = defineEmits<{
apply: [payload: {
startRow: number;
startColumn: number;
type: string;
v: string;
custom: Record<string, any>;
}];
}>();
const { ensureByAlias } = useReportDatasetFields(
() => props.datasets,
);
const form = reactive({
type: 'text',
dataSetName: '',
field: '',
expand: 'none',
fillEmptyRows: false,
fillEmptyNum: 1,
paramField: '',
expressionFormula: '',
codeValue: '',
qrLevel: 'M' as string,
barcodeFormat: 'code128' as string,
polymerizationType: '1' as string,
summaryType: 'sum' as string,
groupType: 'default' as string,
mergeCell: true,
displayType: 'default' as string,
leftParentCellType: 'default' as string,
topParentCellType: 'default' as string,
leftParentCellCustomRowName: 'A',
leftParentCellCustomColName: 1,
topParentCellCustomRowName: 'A',
topParentCellCustomColName: 1,
});
watch(
() => form.dataSetName,
(alias) => {
if (alias) {
ensureByAlias(alias);
}
},
);
watch(
() => props.selection,
(sel) => {
if (!sel) return;
const custom = sel.cellData?.custom || {};
const cellV = sel.cellData?.v;
form.type = custom.type || 'text';
form.dataSetName = custom.dataSetName || custom.alias || '';
form.field = custom.field || custom.bindField || '';
form.expand = custom.expand || custom.expandDirection || 'none';
const poly = custom.polymerizationType;
form.polymerizationType =
poly !== undefined && poly !== null ? String(poly) : '1';
form.summaryType = custom.summaryType || 'sum';
form.groupType = custom.groupType || 'default';
const mergeRaw = custom.mergeCell;
form.mergeCell =
mergeRaw === undefined || mergeRaw === null
? true
: mergeRaw !== false && mergeRaw !== '0' && mergeRaw !== 0;
const fd = (custom.fillDirection || '').toLowerCase();
if (!custom.expand && !custom.expandDirection) {
if (fd === 'portrait' || fd === 'vertical') form.expand = 'down';
else if (fd === 'landscape' || fd === 'horizontal') form.expand = 'right';
else if (form.polymerizationType === '1' || form.polymerizationType === '2') {
form.expand = 'down';
}
}
form.fillEmptyRows = !!custom.fillEmptyRows;
form.fillEmptyNum = Number(custom.fillEmptyNum) > 0 ? Number(custom.fillEmptyNum) : 1;
form.displayType = custom.displayType || 'default';
form.qrLevel =
custom.qrCodeOption?.errorCorrectionLevel ||
DEFAULT_QR_OPTION.errorCorrectionLevel;
form.barcodeFormat =
custom.jsbarcodeOption?.format || DEFAULT_BARCODE_OPTION.format;
form.leftParentCellType = custom.leftParentCellType || 'default';
form.topParentCellType = custom.topParentCellType || 'default';
form.leftParentCellCustomRowName =
custom.leftParentCellCustomRowName || colIndexToLetter(sel.startColumn);
form.leftParentCellCustomColName =
Number(custom.leftParentCellCustomColName) || sel.startRow + 1;
form.topParentCellCustomRowName =
custom.topParentCellCustomRowName || colIndexToLetter(sel.startColumn);
form.topParentCellCustomColName =
Number(custom.topParentCellCustomColName) || sel.startRow;
form.paramField =
custom.value?.replace(/^#\{|\}$/g, '') || custom.field || '';
form.expressionFormula =
custom.formula ||
custom.field ||
custom.value ||
sel.cellData?.f ||
(typeof cellV === 'string' ? cellV : '') ||
'';
if (form.type === 'qrCode' || form.type === 'jsbarcode') {
form.codeValue =
custom.field ||
(typeof cellV === 'string' ? cellV.replace(/^#\{|\}$/g, '') : '') ||
'';
form.qrLevel =
custom.qrCodeOption?.errorCorrectionLevel ||
DEFAULT_QR_OPTION.errorCorrectionLevel;
form.barcodeFormat =
custom.jsbarcodeOption?.format || DEFAULT_BARCODE_OPTION.format;
}
if (form.dataSetName) {
ensureByAlias(form.dataSetName);
}
},
{ deep: true },
);
watch(
() => form.polymerizationType,
(poly) => {
if (poly === '3') {
form.leftParentCellType = 'none';
form.topParentCellType = 'none';
} else if (poly === '2' || poly === '1') {
if (form.expand === 'none') {
form.expand = 'down';
}
}
},
);
function handleApply() {
if (!props.selection) return;
const { startRow, startColumn } = props.selection;
let type = form.type;
let v = '';
const custom: Record<string, any> = { type };
if (type === 'dataSource') {
if (!form.dataSetName || !form.field) return;
v = form.field;
custom.dataSetName = form.dataSetName;
custom.field = form.field;
custom.displayType = form.displayType || 'default';
if (form.displayType === 'qrCode') {
custom.qrCodeOption = {
...DEFAULT_QR_OPTION,
...(props.selection.cellData?.custom?.qrCodeOption || {}),
type: 'static',
errorCorrectionLevel: form.qrLevel,
};
delete custom.jsbarcodeOption;
} else if (form.displayType === 'jsbarcode') {
custom.jsbarcodeOption = {
...DEFAULT_BARCODE_OPTION,
...(props.selection.cellData?.custom?.jsbarcodeOption || {}),
type: 'static',
format: form.barcodeFormat,
};
delete custom.qrCodeOption;
} else {
delete custom.qrCodeOption;
delete custom.jsbarcodeOption;
}
custom.polymerizationType = form.polymerizationType;
if (form.polymerizationType === '3') {
custom.summaryType = form.summaryType;
custom.expand = 'none';
custom.leftParentCellType = form.leftParentCellType;
custom.topParentCellType = form.topParentCellType;
delete custom.fillDirection;
} else {
delete custom.summaryType;
custom.expand = form.expand;
custom.fillDirection =
form.expand === 'right' ? 'landscape' : form.expand === 'down' ? 'portrait' : undefined;
if (form.polymerizationType === '2') {
custom.groupType = form.groupType;
custom.mergeCell = form.mergeCell;
} else {
delete custom.groupType;
delete custom.mergeCell;
}
custom.leftParentCellType = form.leftParentCellType;
custom.topParentCellType = form.topParentCellType;
}
if (form.polymerizationType !== '3' && (form.expand === 'down' || form.expand === 'right')) {
custom.fillEmptyRows = form.fillEmptyRows;
if (form.fillEmptyRows) {
custom.fillEmptyNum = Math.max(1, form.fillEmptyNum || 1);
}
} else if (form.polymerizationType !== '3') {
delete custom.fillEmptyRows;
delete custom.fillEmptyNum;
}
if (form.leftParentCellType === 'custom') {
custom.leftParentCellCustomRowName = form.leftParentCellCustomRowName;
custom.leftParentCellCustomColName = form.leftParentCellCustomColName;
}
if (form.topParentCellType === 'custom') {
custom.topParentCellCustomRowName = form.topParentCellCustomRowName;
custom.topParentCellCustomColName = form.topParentCellCustomColName;
}
} else if (type === 'parameter') {
if (!form.paramField) return;
v = `#{${form.paramField}}`;
custom.value = v;
custom.field = form.paramField;
} else if (type === 'qrCode') {
if (!form.codeValue.trim()) return;
v = form.codeValue;
custom.field = form.codeValue;
custom.displayType = 'qrCode';
custom.qrCodeOption = {
...DEFAULT_QR_OPTION,
...(props.selection.cellData?.custom?.qrCodeOption || {}),
type: 'static',
errorCorrectionLevel: form.qrLevel,
};
} else if (type === 'expression') {
if (!form.expressionFormula.trim()) return;
const raw = form.expressionFormula.trim();
const formula = raw.startsWith('=') ? raw : `=${raw}`;
v = formula;
custom.field = formula.startsWith('=') ? formula.slice(1) : formula;
custom.formula = formula;
} else if (type === 'jsbarcode') {
if (!form.codeValue.trim()) return;
v = form.codeValue;
custom.field = form.codeValue;
custom.displayType = 'jsbarcode';
custom.jsbarcodeOption = {
...DEFAULT_BARCODE_OPTION,
...(props.selection.cellData?.custom?.jsbarcodeOption || {}),
type: 'static',
format: form.barcodeFormat,
};
} else {
type = 'text';
custom.type = 'text';
}
custom.type = type;
emit('apply', { startRow, startColumn, type, v, custom });
}
</script>
<template>
<div v-if="!selection" class="text-xs text-[var(--el-text-color-secondary)]">
{{ $t('report-manager.properties.empty') }}
</div>
<ElForm v-else label-position="top" size="small" class="cell-property-form">
<p class="mb-2 text-xs text-[var(--el-text-color-secondary)]">
{{
$t('report-manager.properties.position', {
row: selection.startRow + 1,
col: selection.startColumn + 1,
})
}}
</p>
<ElFormItem :label="$t('report-manager.properties.cellType')">
<ElSelect v-model="form.type" class="w-full">
<ElOption
:label="$t('report-manager.properties.typeText')"
value="text"
/>
<ElOption
:label="$t('report-manager.properties.typeDataSource')"
value="dataSource"
/>
<ElOption
:label="$t('report-manager.properties.typeParameter')"
value="parameter"
/>
<ElOption
:label="$t('report-manager.properties.typeExpression')"
value="expression"
/>
<ElOption
:label="$t('report-manager.properties.typeQrCode')"
value="qrCode"
/>
<ElOption
:label="$t('report-manager.properties.typeBarcode')"
value="jsbarcode"
/>
</ElSelect>
</ElFormItem>
<template v-if="form.type === 'dataSource'">
<ElFormItem :label="$t('report-manager.properties.dataset')">
<ElSelect v-model="form.dataSetName" class="w-full" filterable>
<ElOption
v-for="ds in datasets"
:key="ds.alias"
:label="`${ds.alias} (${ds.data_source_name || ds.data_source_id})`"
:value="ds.alias"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('report-manager.properties.field')">
<ReportFieldSelect
v-model="form.field"
:datasets="datasets"
:alias="form.dataSetName"
:with-alias="true"
:placeholder="$t('report-manager.properties.fieldPlaceholder')"
/>
</ElFormItem>
<ElFormItem :label="$t('report-manager.properties.displayType')">
<ElSelect v-model="form.displayType" class="w-full">
<ElOption
:label="$t('report-manager.properties.displayDefault')"
value="default"
/>
<ElOption
:label="$t('report-manager.properties.typeQrCode')"
value="qrCode"
/>
<ElOption
:label="$t('report-manager.properties.typeBarcode')"
value="jsbarcode"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="form.displayType === 'qrCode'"
:label="$t('report-manager.code.qrLevel')"
>
<ElSelect v-model="form.qrLevel" class="w-full">
<ElOption v-for="lv in QR_LEVELS" :key="lv" :label="lv" :value="lv" />
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="form.displayType === 'jsbarcode'"
:label="$t('report-manager.code.barcodeFormat')"
>
<ElSelect v-model="form.barcodeFormat" class="w-full">
<ElOption
v-for="fmt in BARCODE_FORMATS"
:key="fmt"
:label="fmt"
:value="fmt"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('report-manager.properties.polymerizationType')">
<ElSelect v-model="form.polymerizationType" class="w-full">
<ElOption
:label="$t('report-manager.properties.polyList')"
value="1"
/>
<ElOption
:label="$t('report-manager.properties.polyGroup')"
value="2"
/>
<ElOption
:label="$t('report-manager.properties.polySummary')"
value="3"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="form.polymerizationType === '3'"
:label="$t('report-manager.properties.summaryType')"
>
<ElSelect v-model="form.summaryType" class="w-full">
<ElOption
v-for="s in ['sum', 'avg', 'max', 'min', 'count']"
:key="s"
:label="$t(`report-manager.chart.summary.${s}`)"
:value="s"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="form.polymerizationType === '2'"
:label="$t('report-manager.properties.groupType')"
>
<ElSelect v-model="form.groupType" class="w-full">
<ElOption
:label="$t('report-manager.properties.groupDefault')"
value="default"
/>
<ElOption
:label="$t('report-manager.properties.groupAdjacent')"
value="adjacent"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="form.polymerizationType === '2' && form.expand === 'down'"
:label="$t('report-manager.properties.mergeCell')"
>
<ElSwitch v-model="form.mergeCell" />
</ElFormItem>
<ElFormItem
v-if="form.polymerizationType !== '3'"
:label="$t('report-manager.properties.expand')"
>
<ElSelect v-model="form.expand" class="w-full">
<ElOption
:label="$t('report-manager.properties.expandNone')"
value="none"
/>
<ElOption
:label="$t('report-manager.properties.expandDown')"
value="down"
/>
<ElOption
:label="$t('report-manager.properties.expandRight')"
value="right"
/>
</ElSelect>
</ElFormItem>
<template
v-if="
form.polymerizationType !== '3' &&
(form.expand === 'down' || form.expand === 'right')
"
>
<ElFormItem :label="$t('report-manager.properties.fillEmptyRows')">
<ElSwitch v-model="form.fillEmptyRows" />
</ElFormItem>
<ElFormItem
v-if="form.fillEmptyRows"
:label="$t('report-manager.properties.fillEmptyNum')"
>
<ElInputNumber
v-model="form.fillEmptyNum"
:min="1"
:max="99"
class="w-full"
/>
</ElFormItem>
</template>
<template
v-if="
form.polymerizationType === '3' ||
(form.polymerizationType !== '3' &&
(form.expand === 'down' || form.expand === 'right'))
"
>
<ElFormItem>
<template #label>
<div class="flex items-center gap-1">
<span>{{ $t('report-manager.properties.leftParent') }}</span>
<ElTooltip
placement="top"
:content="$t('report-manager.properties.leftParentHint')"
>
<CircleHelp
class="h-3.5 w-3.5 cursor-help text-[var(--el-text-color-secondary)]"
/>
</ElTooltip>
</div>
</template>
<ElSelect v-model="form.leftParentCellType" class="w-full">
<ElOption
v-for="t in PARENT_CELL_TYPES"
:key="t"
:label="$t(`report-manager.properties.parentType.${t}`)"
:value="t"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="form.leftParentCellType === 'custom'"
:label="$t('report-manager.properties.leftParentCustom')"
>
<div class="flex gap-2">
<ElInput
v-model="form.leftParentCellCustomRowName"
class="w-1/3"
placeholder="A"
/>
<ElInputNumber
v-model="form.leftParentCellCustomColName"
:min="1"
class="flex-1"
/>
</div>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-1">
<span>{{ $t('report-manager.properties.topParent') }}</span>
<ElTooltip
placement="top"
:content="$t('report-manager.properties.topParentHint')"
>
<CircleHelp
class="h-3.5 w-3.5 cursor-help text-[var(--el-text-color-secondary)]"
/>
</ElTooltip>
</div>
</template>
<ElSelect v-model="form.topParentCellType" class="w-full">
<ElOption
v-for="t in PARENT_CELL_TYPES"
:key="t"
:label="$t(`report-manager.properties.parentType.${t}`)"
:value="t"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="form.topParentCellType === 'custom'"
:label="$t('report-manager.properties.topParentCustom')"
>
<div class="flex gap-2">
<ElInput
v-model="form.topParentCellCustomRowName"
class="w-1/3"
placeholder="A"
/>
<ElInputNumber
v-model="form.topParentCellCustomColName"
:min="1"
class="flex-1"
/>
</div>
</ElFormItem>
</template>
</template>
<template v-else-if="form.type === 'text'">
<p class="mb-2 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('report-manager.properties.textParamHint') }}
</p>
</template>
<template v-else-if="form.type === 'expression'">
<ElFormItem :label="$t('report-manager.properties.expressionFormula')">
<ElInput
v-model="form.expressionFormula"
type="textarea"
:rows="3"
:placeholder="$t('report-manager.properties.expressionPlaceholder')"
/>
</ElFormItem>
<p class="mb-2 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('report-manager.properties.expressionHint') }}
</p>
</template>
<template v-else-if="form.type === 'parameter'">
<ElFormItem :label="$t('report-manager.properties.paramField')">
<ElInput v-model="form.paramField" placeholder="userName" />
</ElFormItem>
</template>
<template v-else-if="form.type === 'qrCode'">
<ElFormItem :label="$t('report-manager.code.content')">
<ElInput
v-model="form.codeValue"
:placeholder="$t('report-manager.code.contentPlaceholder')"
maxlength="256"
clearable
/>
</ElFormItem>
<p class="mb-2 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('report-manager.code.paramHint') }}
</p>
<ElFormItem :label="$t('report-manager.code.qrLevel')">
<ElSelect v-model="form.qrLevel" class="w-full">
<ElOption v-for="lv in QR_LEVELS" :key="lv" :label="lv" :value="lv" />
</ElSelect>
</ElFormItem>
</template>
<template v-else-if="form.type === 'jsbarcode'">
<ElFormItem :label="$t('report-manager.code.content')">
<ElInput
v-model="form.codeValue"
:placeholder="$t('report-manager.code.contentPlaceholder')"
maxlength="128"
clearable
/>
</ElFormItem>
<p class="mb-2 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('report-manager.code.paramHint') }}
</p>
<ElFormItem :label="$t('report-manager.code.barcodeFormat')">
<ElSelect v-model="form.barcodeFormat" class="w-full">
<ElOption
v-for="fmt in BARCODE_FORMATS"
:key="fmt"
:label="fmt"
:value="fmt"
/>
</ElSelect>
</ElFormItem>
</template>
<ElButton type="primary" size="small" class="w-full" @click="handleApply">
{{ $t('report-manager.properties.apply') }}
</ElButton>
</ElForm>
</template>
@@ -0,0 +1,545 @@
<script lang="ts" setup>
import { computed, reactive, watch } from 'vue';
import { $t } from '@vben/locales';
import { Plus, Trash2 } from '@vben/icons';
import {
ElButton,
ElColorPicker,
ElDivider,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
import { useReportDatasetFields } from '../hooks/useReportDatasetFields';
import ReportFieldSelect from './report-field-select.vue';
export interface ChartBindFormState {
echartType: string;
title: string;
dataSetAlias: string;
classifyNameField: string;
seriesNameField: string;
seriesDataField: string;
maxField: string;
summaryType: string;
legendShow: boolean;
legendOrient: string;
legendFontSize: number;
styleType: number;
lineAreaStyle: boolean;
pieRoseType: boolean;
pieShowZero: boolean;
gridTop: number;
gridLeft: number;
gridRight: number;
gridBottom: number;
legendLeft: number;
legendTop: number;
colorListText: string;
chartColors: string[];
seriesCenterLeft: number;
seriesCenterTop: number;
}
const props = defineProps<{
datasets: ReportDatasetItem[];
modelValue: ChartBindFormState | null;
}>();
const emit = defineEmits<{
'update:modelValue': [ChartBindFormState];
change: [ChartBindFormState];
}>();
const CHART_TYPES = ['bar', 'line', 'pie', 'radar'] as const;
const SUMMARY_TYPES = ['none', 'sum', 'avg', 'max', 'min', 'count'] as const;
const form = reactive<ChartBindFormState>({
echartType: 'bar',
title: '',
dataSetAlias: '',
classifyNameField: '',
seriesNameField: '',
seriesDataField: '',
maxField: '',
summaryType: 'sum',
legendShow: true,
legendOrient: 'horizontal',
legendFontSize: 12,
styleType: 1,
lineAreaStyle: false,
pieRoseType: false,
pieShowZero: false,
gridTop: 60,
gridLeft: 30,
gridRight: 10,
gridBottom: 50,
legendLeft: 40,
legendTop: 90,
colorListText: '',
chartColors: [] as string[],
seriesCenterLeft: 50,
seriesCenterTop: 50,
});
const datasetOptions = computed(() =>
(props.datasets || []).map((d) => ({
label: `${d.alias}${d.data_source_name ? ` (${d.data_source_name})` : ''}`,
value: d.alias,
})),
);
const { ensureByAlias } = useReportDatasetFields(
() => props.datasets,
);
watch(
() => form.dataSetAlias,
(alias) => {
if (alias) {
ensureByAlias(alias);
}
},
{ immediate: true },
);
const styleTypeOptions = computed(() => {
const t = form.echartType;
if (t === 'line') {
return [
{ value: 1, labelKey: 'lineDefault' },
{ value: 2, labelKey: 'lineSmooth' },
{ value: 4, labelKey: 'lineStack' },
];
}
if (t === 'bar') {
return [
{ value: 1, labelKey: 'barDefault' },
{ value: 2, labelKey: 'barStack' },
];
}
if (t === 'pie') {
return [
{ value: 1, labelKey: 'pieDefault' },
{ value: 2, labelKey: 'pieRing' },
];
}
if (t === 'radar') {
return [
{ value: 1, labelKey: 'radarPolygon' },
{ value: 2, labelKey: 'radarCircle' },
];
}
return [{ value: 1, labelKey: 'barDefault' }];
});
const showMaxField = computed(() => form.echartType === 'radar');
const showGridLayout = computed(() =>
['bar', 'line', 'radar'].includes(form.echartType),
);
function parseColorList(text: string): string[] {
return (text || '')
.split(/[,\s]+/)
.map((s) => s.trim())
.filter((s) => /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(s));
}
function colorsFromState(state: ChartBindFormState): string[] {
if (state.chartColors?.length) {
return state.chartColors.filter((c) =>
/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(c),
);
}
return parseColorList(state.colorListText);
}
function buildColorList(colors: string[]) {
if (!colors.length) return undefined;
return colors.map((color1) => ({ color1 }));
}
function syncColorListText() {
form.colorListText = colorsFromState(form).join(', ');
}
function addChartColor() {
form.chartColors = [...(form.chartColors || []), '#5470c6'];
syncColorListText();
emitChange();
}
function removeChartColor(index: number) {
form.chartColors = (form.chartColors || []).filter((_, i) => i !== index);
syncColorListText();
emitChange();
}
function onColorChange() {
syncColorListText();
emitChange();
}
watch(
() => props.modelValue,
(val) => {
if (!val) return;
Object.assign(form, val);
if (!form.chartColors?.length && form.colorListText) {
form.chartColors = parseColorList(form.colorListText);
}
if (!form.chartColors) {
form.chartColors = [];
}
if (!form.dataSetAlias && datasetOptions.value.length) {
form.dataSetAlias = datasetOptions.value[0].value;
}
},
{ deep: true, immediate: true },
);
watch(
datasetOptions,
(opts) => {
if (!form.dataSetAlias && opts.length) {
form.dataSetAlias = opts[0].value;
}
},
{ immediate: true },
);
function fieldWithAlias(field: string) {
if (!field) return field;
if (field.includes('.')) return field;
const alias = form.dataSetAlias || datasetOptions.value[0]?.value || '';
return alias ? `${alias}.${field}` : field;
}
function buildOption(baseOption: Record<string, any> = {}) {
const styleType = form.styleType || 1;
const option: Record<string, any> = {
...baseOption,
chartType: form.echartType,
classifyNameField: fieldWithAlias(form.classifyNameField),
seriesNameField: fieldWithAlias(form.seriesNameField),
seriesDataField: fieldWithAlias(form.seriesDataField),
maxField: fieldWithAlias(form.maxField),
summaryType: form.summaryType,
styleType,
title: {
...(baseOption.title || {}),
text: form.title,
show: true,
},
legend: {
...(baseOption.legend || {}),
show: form.legendShow,
orient: form.legendOrient,
left: `${form.legendLeft}%`,
top: `${form.legendTop}%`,
textStyle: {
...(baseOption.legend?.textStyle || {}),
fontSize: form.legendFontSize,
},
},
legendLeft: form.legendLeft,
legendTop: form.legendTop,
};
if (showGridLayout.value) {
option.grid = {
...(baseOption.grid || {}),
top: form.gridTop,
left: form.gridLeft,
right: form.gridRight,
bottom: form.gridBottom,
};
}
const colorList = buildColorList(colorsFromState(form));
if (colorList) {
option.color = {
...(baseOption.color || {}),
list: colorList,
};
}
if (form.echartType === 'line') {
option.areaStyle = form.lineAreaStyle ? { opacity: 0.3 } : false;
option.line = {
...(baseOption.line || {}),
smooth: styleType === 2,
};
}
if (form.echartType === 'pie') {
option.pie = {
...(baseOption.pie || {}),
roseType: form.pieRoseType,
showZero: form.pieShowZero,
};
option.seriesCenter = {
...(baseOption.seriesCenter || {}),
seriesCenterLeft: form.seriesCenterLeft,
seriesCenterTop: form.seriesCenterTop,
};
}
return option;
}
function emitChange() {
const state = { ...form };
emit('update:modelValue', state);
emit('change', state);
}
defineExpose({ buildOption, form });
</script>
<template>
<ElForm label-position="top" size="small" @submit.prevent="emitChange">
<ElFormItem :label="$t('report-manager.chart.type')">
<ElSelect v-model="form.echartType" class="w-full" @change="emitChange">
<ElOption
v-for="t in CHART_TYPES"
:key="t"
:label="$t(`report-manager.chart.types.${t}`)"
:value="t"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('report-manager.chart.title')">
<ElInput v-model="form.title" clearable @change="emitChange" />
</ElFormItem>
<ElFormItem
v-if="datasetOptions.length"
:label="$t('report-manager.chart.dataSet')"
>
<ElSelect
v-model="form.dataSetAlias"
class="w-full"
filterable
@change="emitChange"
>
<ElOption
v-for="d in datasetOptions"
:key="d.value"
:label="d.label"
:value="d.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('report-manager.chart.classifyField')">
<ReportFieldSelect
v-model="form.classifyNameField"
:datasets="datasets"
:alias="form.dataSetAlias"
:with-alias="false"
:placeholder="$t('report-manager.chart.fieldPlaceholder')"
@change="emitChange"
/>
</ElFormItem>
<ElFormItem :label="$t('report-manager.chart.seriesNameField')">
<ReportFieldSelect
v-model="form.seriesNameField"
:datasets="datasets"
:alias="form.dataSetAlias"
:with-alias="false"
:placeholder="$t('report-manager.chart.fieldPlaceholder')"
@change="emitChange"
/>
</ElFormItem>
<ElFormItem :label="$t('report-manager.chart.seriesDataField')">
<ReportFieldSelect
v-model="form.seriesDataField"
:datasets="datasets"
:alias="form.dataSetAlias"
:with-alias="false"
:placeholder="$t('report-manager.chart.fieldPlaceholder')"
@change="emitChange"
/>
</ElFormItem>
<ElFormItem
v-if="showMaxField"
:label="$t('report-manager.chart.maxField')"
>
<ReportFieldSelect
v-model="form.maxField"
:datasets="datasets"
:alias="form.dataSetAlias"
:with-alias="false"
:placeholder="$t('report-manager.chart.fieldPlaceholder')"
@change="emitChange"
/>
</ElFormItem>
<ElFormItem :label="$t('report-manager.chart.summaryType')">
<ElSelect v-model="form.summaryType" class="w-full" @change="emitChange">
<ElOption
v-for="s in SUMMARY_TYPES"
:key="s"
:label="$t(`report-manager.chart.summary.${s}`)"
:value="s"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('report-manager.chart.legendShow')">
<ElSwitch v-model="form.legendShow" @change="emitChange" />
</ElFormItem>
<ElFormItem :label="$t('report-manager.chart.legendOrient')">
<ElSelect v-model="form.legendOrient" class="w-full" @change="emitChange">
<ElOption
:label="$t('report-manager.chart.legendOrientHorizontal')"
value="horizontal"
/>
<ElOption
:label="$t('report-manager.chart.legendOrientVertical')"
value="vertical"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('report-manager.chart.legendFontSize')">
<ElInputNumber
v-model="form.legendFontSize"
:min="12"
:max="25"
class="w-full"
@change="emitChange"
/>
</ElFormItem>
<ElFormItem :label="$t('report-manager.chart.styleType')">
<ElSelect v-model="form.styleType" class="w-full" @change="emitChange">
<ElOption
v-for="opt in styleTypeOptions"
:key="opt.value"
:label="$t(`report-manager.chart.styleTypes.${opt.labelKey}`)"
:value="opt.value"
/>
</ElSelect>
</ElFormItem>
<template v-if="form.echartType === 'line'">
<ElFormItem :label="$t('report-manager.chart.lineArea')">
<ElSwitch v-model="form.lineAreaStyle" @change="emitChange" />
</ElFormItem>
</template>
<template v-if="form.echartType === 'pie'">
<ElFormItem :label="$t('report-manager.chart.pieRose')">
<ElSwitch v-model="form.pieRoseType" @change="emitChange" />
</ElFormItem>
<ElFormItem :label="$t('report-manager.chart.pieShowZero')">
<ElSwitch v-model="form.pieShowZero" @change="emitChange" />
</ElFormItem>
<ElFormItem :label="$t('report-manager.chart.seriesCenterLeft')">
<ElInputNumber
v-model="form.seriesCenterLeft"
:min="0"
:max="100"
class="w-full"
@change="emitChange"
/>
</ElFormItem>
<ElFormItem :label="$t('report-manager.chart.seriesCenterTop')">
<ElInputNumber
v-model="form.seriesCenterTop"
:min="0"
:max="100"
class="w-full"
@change="emitChange"
/>
</ElFormItem>
</template>
<ElDivider content-position="left">
{{ $t('report-manager.chart.layoutSection') }}
</ElDivider>
<ElFormItem :label="$t('report-manager.chart.legendLeft')">
<ElInputNumber
v-model="form.legendLeft"
:min="0"
:max="100"
class="w-full"
@change="emitChange"
/>
</ElFormItem>
<ElFormItem :label="$t('report-manager.chart.legendTop')">
<ElInputNumber
v-model="form.legendTop"
:min="0"
:max="100"
class="w-full"
@change="emitChange"
/>
</ElFormItem>
<template v-if="showGridLayout">
<ElFormItem :label="$t('report-manager.chart.gridTop')">
<ElInputNumber
v-model="form.gridTop"
:min="0"
:max="200"
class="w-full"
@change="emitChange"
/>
</ElFormItem>
<ElFormItem :label="$t('report-manager.chart.gridLeft')">
<ElInputNumber
v-model="form.gridLeft"
:min="0"
:max="200"
class="w-full"
@change="emitChange"
/>
</ElFormItem>
<ElFormItem :label="$t('report-manager.chart.gridRight')">
<ElInputNumber
v-model="form.gridRight"
:min="0"
:max="200"
class="w-full"
@change="emitChange"
/>
</ElFormItem>
<ElFormItem :label="$t('report-manager.chart.gridBottom')">
<ElInputNumber
v-model="form.gridBottom"
:min="0"
:max="200"
class="w-full"
@change="emitChange"
/>
</ElFormItem>
</template>
<ElFormItem :label="$t('report-manager.chart.colorList')">
<div class="flex w-full flex-col gap-2">
<div
v-for="(color, idx) in form.chartColors"
:key="idx"
class="flex items-center gap-2"
>
<ElColorPicker
v-model="form.chartColors[idx]"
color-format="hex"
@change="onColorChange"
/>
<ElButton
type="danger"
link
:icon="Trash2"
@click="removeChartColor(idx)"
/>
</div>
<ElButton type="primary" link :icon="Plus" @click="addChartColor">
{{ $t('report-manager.chart.addColor') }}
</ElButton>
</div>
</ElFormItem>
</ElForm>
</template>
@@ -0,0 +1,70 @@
import type { ChartBindFormState } from './chart-bind-form.vue';
function stripAlias(field: string) {
if (!field || !field.includes('.')) return field;
const parts = field.split('.');
return parts.length > 1 ? parts.slice(1).join('.') : field;
}
function colorListToText(list: unknown): string {
if (!Array.isArray(list)) return '';
return list
.map((item: any) => item?.color1 || item?.color2 || '')
.filter(Boolean)
.join(', ');
}
/** 从图表 option 解析为属性面板表单(悬浮/单元格图表共用) */
export function parseChartOptionToFormState(
echartType: string,
option: Record<string, any>,
datasets: { alias: string }[],
): ChartBindFormState {
const opt = option || {};
const guessAlias = () => {
for (const key of [
'classifyNameField',
'seriesNameField',
'seriesDataField',
'maxField',
] as const) {
const raw = opt[key];
if (typeof raw === 'string' && raw.includes('.')) {
return raw.split('.')[0];
}
}
return datasets[0]?.alias || '';
};
const grid = opt.grid || {};
return {
echartType: echartType || opt.chartType || 'bar',
title: opt.title?.text || '',
dataSetAlias: guessAlias(),
classifyNameField: stripAlias(opt.classifyNameField || ''),
seriesNameField: stripAlias(opt.seriesNameField || ''),
seriesDataField: stripAlias(opt.seriesDataField || ''),
maxField: stripAlias(opt.maxField || ''),
summaryType: opt.summaryType || 'sum',
legendShow: opt.legend?.show !== false,
legendOrient: opt.legend?.orient || 'horizontal',
legendFontSize: Number(opt.legend?.textStyle?.fontSize ?? 12),
styleType: Number(opt.styleType) || 1,
lineAreaStyle: !!opt.areaStyle,
pieRoseType: !!opt.pie?.roseType,
pieShowZero: !!opt.pie?.showZero,
gridTop: Number(grid.top ?? 60),
gridLeft: Number(grid.left ?? 30),
gridRight: Number(grid.right ?? 10),
gridBottom: Number(grid.bottom ?? 50),
legendLeft: Number(opt.legendLeft ?? 40),
legendTop: Number(opt.legendTop ?? 90),
colorListText: colorListToText(opt.color?.list),
chartColors: (opt.color?.list || [])
.map((item: any) => item?.color1 || item?.color2 || '')
.filter((c: string) => /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(c)),
seriesCenterLeft: Number(
opt.seriesCenter?.seriesCenterLeft ?? 50,
),
seriesCenterTop: Number(opt.seriesCenter?.seriesCenterTop ?? 50),
};
}
@@ -0,0 +1,193 @@
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElRadio,
ElRadioGroup,
ElSwitch,
} from 'element-plus';
import { ZqDialog } from '#/components/zq-dialog';
export interface ColumnListConfig {
columnState: boolean;
columnStyle: 'col' | 'row';
columnType: '1' | '2';
maxCol: number;
rowCount: number;
maxRow: number;
colCount: number;
columnData: string;
copyCol: string;
copyRow: string;
fillEmptyRows: boolean;
}
function defaultColumnConfig(): ColumnListConfig {
return {
columnState: false,
columnStyle: 'col',
columnType: '1',
maxCol: 0,
rowCount: 0,
maxRow: 0,
colCount: 0,
columnData: '',
copyCol: '',
copyRow: '',
fillEmptyRows: false,
};
}
const visible = defineModel<boolean>('visible', { default: false });
const props = defineProps<{
columnList: any[];
defaultSheetId?: string;
}>();
const emit = defineEmits<{
confirm: [any[]];
}>();
const sheetId = ref('sheet1');
const form = reactive<ColumnListConfig>(defaultColumnConfig());
watch(
() => props.columnList,
(list) => {
const first = list?.[0];
sheetId.value = first?.sheet || props.defaultSheetId || 'sheet1';
const cfg = first?.columnList;
Object.assign(form, defaultColumnConfig(), cfg || {});
},
{ immediate: true, deep: true },
);
function handleConfirm() {
emit('confirm', [
{
sheet: sheetId.value,
sheetName: sheetId.value,
columnList: { ...form },
},
]);
visible.value = false;
}
</script>
<template>
<ZqDialog
v-model="visible"
:title="$t('report-manager.column.title')"
width="720px"
@confirm="handleConfirm"
>
<p class="mb-3 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('report-manager.column.previewNote') }}
</p>
<ElForm label-position="top" size="small">
<ElFormItem :label="$t('report-manager.column.enable')">
<ElSwitch v-model="form.columnState" />
</ElFormItem>
<template v-if="form.columnState">
<ElFormItem :label="$t('report-manager.column.style')">
<ElRadioGroup v-model="form.columnStyle">
<ElRadio value="col">
{{ $t('report-manager.column.styleCol') }}
</ElRadio>
<ElRadio value="row">
{{ $t('report-manager.column.styleRow') }}
</ElRadio>
</ElRadioGroup>
</ElFormItem>
<template v-if="form.columnStyle === 'col'">
<ElFormItem :label="$t('report-manager.column.type')">
<ElRadioGroup v-model="form.columnType">
<ElRadio value="1">
{{ $t('report-manager.column.overRows') }}
<ElInputNumber
v-model="form.maxCol"
:min="0"
size="small"
class="mx-2 w-24"
/>
{{ $t('report-manager.column.splitCols') }}
</ElRadio>
<ElRadio value="2" class="mt-2 block">
{{ $t('report-manager.column.splitInto') }}
<ElInputNumber
v-model="form.rowCount"
:min="0"
size="small"
class="mx-2 w-24"
/>
{{ $t('report-manager.column.colsUnit') }}
</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem :label="$t('report-manager.column.copyColNo')">
<ElInput
v-model="form.copyCol"
:placeholder="$t('report-manager.column.rangeHint')"
clearable
/>
</ElFormItem>
</template>
<template v-else>
<ElFormItem :label="$t('report-manager.column.type')">
<ElRadioGroup v-model="form.columnType">
<ElRadio value="1">
{{ $t('report-manager.column.overCols') }}
<ElInputNumber
v-model="form.maxRow"
:min="0"
size="small"
class="mx-2 w-24"
/>
{{ $t('report-manager.column.splitRows') }}
</ElRadio>
<ElRadio value="2" class="mt-2 block">
{{ $t('report-manager.column.splitInto') }}
<ElInputNumber
v-model="form.colCount"
:min="0"
size="small"
class="mx-2 w-24"
/>
{{ $t('report-manager.column.rowsUnit') }}
</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem :label="$t('report-manager.column.copyRowNo')">
<ElInput
v-model="form.copyRow"
:placeholder="$t('report-manager.column.rangeHint')"
clearable
/>
</ElFormItem>
</template>
<ElFormItem :label="$t('report-manager.column.dataRange')">
<ElInput
v-model="form.columnData"
:placeholder="$t('report-manager.column.dataRangePlaceholder')"
clearable
/>
</ElFormItem>
<ElFormItem :label="$t('report-manager.column.fillEmpty')">
<ElSwitch v-model="form.fillEmptyRows" />
</ElFormItem>
</template>
</ElForm>
</ZqDialog>
</template>
@@ -0,0 +1,325 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { Plus, Settings2, Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
ElSwitch,
ElTable,
ElTableColumn,
} from 'element-plus';
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
import { ZqDialog } from '#/components/zq-dialog';
import { useReportDatasetFields } from '../hooks/useReportDatasetFields';
import ReportFieldSelect from './report-field-select.vue';
const visible = defineModel<boolean>('visible', { default: false });
const props = defineProps<{
convertConfig: any[];
datasets: ReportDatasetItem[];
}>();
const emit = defineEmits<{
confirm: [any[]];
}>();
const rows = ref<any[]>([]);
const extraVisible = ref(false);
const extraIndex = ref(-1);
const extraForm = ref<any>({ type: '', config: {} });
const { ensureAll } = useReportDatasetFields(() => props.datasets);
watch(visible, (open) => {
if (open) {
ensureAll();
}
});
const typeOptions = computed(() => [
{ label: $t('report-manager.convert.types.select'), value: 'select' },
{ label: $t('report-manager.convert.types.date'), value: 'date' },
{ label: $t('report-manager.convert.types.number'), value: 'number' },
{ label: $t('report-manager.convert.types.user'), value: 'user' },
{ label: $t('report-manager.convert.types.department'), value: 'department' },
{ label: $t('report-manager.convert.types.organize'), value: 'organize' },
{ label: $t('report-manager.convert.types.role'), value: 'role' },
{ label: $t('report-manager.convert.types.dictionary'), value: 'dictionary' },
]);
const dateFormatOptions = [
'yyyy',
'yyyy-MM',
'yyyy-MM-dd',
'yyyy-MM-dd HH:mm',
'yyyy-MM-dd HH:mm:ss',
];
const fieldHints = computed(() =>
(props.datasets || []).map((d) => d.alias).filter(Boolean),
);
watch(
() => props.convertConfig,
(list) => {
rows.value = (list || []).map((item) => ({
field: item.field || '',
type: item.type || '',
config: { ...(item.config || {}) },
}));
},
{ immediate: true, deep: true },
);
function addRow() {
rows.value.push({
field: '',
type: 'select',
config: {
options: [],
format: 'yyyy-MM-dd',
precision: 0,
thousands: false,
names: {},
dictionaryType: '',
},
});
}
function removeRow(index: number) {
rows.value.splice(index, 1);
}
function openExtra(row: any, index: number) {
extraIndex.value = index;
extraForm.value = {
type: row.type,
config: {
options: [],
format: 'yyyy-MM-dd',
precision: 0,
thousands: false,
names: {},
dictionaryType: '',
...(row.config || {}),
},
};
if (!Array.isArray(extraForm.value.config.options)) {
extraForm.value.config.options = [];
}
extraVisible.value = true;
}
function addSelectOption() {
extraForm.value.config.options.push({
id: String(extraForm.value.config.options.length + 1),
fullName: `${$t('report-manager.convert.option')} ${extraForm.value.config.options.length + 1}`,
});
}
function removeSelectOption(index: number) {
extraForm.value.config.options.splice(index, 1);
}
function saveExtra() {
if (extraIndex.value >= 0) {
rows.value[extraIndex.value] = {
...rows.value[extraIndex.value],
config: { ...extraForm.value.config },
};
}
extraVisible.value = false;
}
function handleConfirm() {
emit(
'confirm',
rows.value
.filter((r) => r.field && r.type)
.map((r) => ({
field: r.field,
type: r.type,
config: r.config || {},
})),
);
visible.value = false;
}
function canConfigure(type: string) {
return [
'select',
'date',
'number',
'user',
'department',
'organize',
'role',
'dictionary',
].includes(type);
}
const lookupTypes = ['user', 'department', 'organize', 'role'];
const namesText = computed({
get: () => {
const names = extraForm.value.config?.names;
if (!names || typeof names !== 'object') return '';
return Object.entries(names)
.map(([k, v]) => `${k}:${v}`)
.join(',');
},
set: (text: string) => {
const names: Record<string, string> = {};
for (const part of (text || '').split(',')) {
const trimmed = part.trim();
if (!trimmed) continue;
const [k, v] = trimmed.split(':');
const key = (k || v || '').trim();
if (key) names[key] = (v ?? k).trim();
}
extraForm.value.config.names = names;
},
});
</script>
<template>
<ZqDialog
v-model="visible"
:title="$t('report-manager.convert.title')"
width="760px"
@confirm="handleConfirm"
>
<div class="mb-3 flex items-center justify-between">
<span class="text-muted-foreground text-xs">
{{ $t('report-manager.convert.hint') }}
<template v-if="fieldHints.length">
{{ fieldHints.join(', ') }}
</template>
</span>
<ElButton type="primary" :icon="Plus" @click="addRow">
{{ $t('report-manager.convert.add') }}
</ElButton>
</div>
<ElTable :data="rows" border max-height="360">
<ElTableColumn :label="$t('report-manager.convert.field')" min-width="220">
<template #default="{ row }">
<ReportFieldSelect
v-model="row.field"
size="small"
:datasets="datasets"
:with-alias="true"
:placeholder="$t('report-manager.convert.fieldPlaceholder')"
/>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('report-manager.convert.type')" width="160">
<template #default="{ row }">
<ElSelect v-model="row.type" size="small" class="w-full" filterable>
<ElOption
v-for="opt in typeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('report-manager.convert.configCol')" width="100" align="center">
<template #default="{ row, $index }">
<ElButton
v-if="canConfigure(row.type)"
link
type="primary"
:icon="Settings2"
@click="openExtra(row, $index)"
/>
</template>
</ElTableColumn>
<ElTableColumn width="60" align="center">
<template #default="{ $index }">
<ElButton link type="danger" :icon="Trash2" @click="removeRow($index)" />
</template>
</ElTableColumn>
</ElTable>
</ZqDialog>
<ZqDialog
v-model="extraVisible"
:title="$t('report-manager.convert.extraTitle')"
width="520px"
@confirm="saveExtra"
>
<template v-if="extraForm.type === 'select'">
<div class="mb-2 flex justify-end">
<ElButton type="primary" size="small" :icon="Plus" @click="addSelectOption">
{{ $t('report-manager.convert.addOption') }}
</ElButton>
</div>
<ElTable :data="extraForm.config.options" border size="small" max-height="280">
<ElTableColumn :label="$t('report-manager.convert.optionId')" min-width="100">
<template #default="{ row }">
<ElInput v-model="row.id" size="small" />
</template>
</ElTableColumn>
<ElTableColumn :label="$t('report-manager.convert.optionLabel')" min-width="140">
<template #default="{ row }">
<ElInput v-model="row.fullName" size="small" />
</template>
</ElTableColumn>
<ElTableColumn width="50" align="center">
<template #default="{ $index }">
<ElButton link type="danger" :icon="Trash2" @click="removeSelectOption($index)" />
</template>
</ElTableColumn>
</ElTable>
</template>
<ElForm v-else-if="extraForm.type === 'date'" label-width="120px">
<ElFormItem :label="$t('report-manager.convert.dateFormat')">
<ElSelect v-model="extraForm.config.format" class="w-full">
<ElOption v-for="fmt in dateFormatOptions" :key="fmt" :label="fmt" :value="fmt" />
</ElSelect>
</ElFormItem>
</ElForm>
<ElForm v-else-if="extraForm.type === 'number'" label-width="120px">
<ElFormItem :label="$t('report-manager.convert.precision')">
<ElInputNumber v-model="extraForm.config.precision" :min="0" :max="8" class="w-full" />
</ElFormItem>
<ElFormItem :label="$t('report-manager.convert.thousands')">
<ElSwitch v-model="extraForm.config.thousands" />
</ElFormItem>
</ElForm>
<ElForm v-else-if="lookupTypes.includes(extraForm.type)" label-width="120px">
<ElFormItem :label="$t('report-manager.convert.namesMap')">
<ElInput
v-model="namesText"
type="textarea"
:rows="4"
:placeholder="$t('report-manager.convert.namesPlaceholder')"
/>
</ElFormItem>
</ElForm>
<ElForm v-else-if="extraForm.type === 'dictionary'" label-width="120px">
<ElFormItem :label="$t('report-manager.convert.dictionaryType')">
<ElInput
v-model="extraForm.config.dictionaryType"
:placeholder="$t('report-manager.convert.dictionaryTypePlaceholder')"
/>
</ElFormItem>
</ElForm>
</ZqDialog>
</template>
@@ -0,0 +1,117 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { Plus, Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElTable, ElTableColumn } from 'element-plus';
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
import { ZqDialog } from '#/components/zq-dialog';
import { useReportDatasetFields } from '../hooks/useReportDatasetFields';
import ReportFieldSelect from './report-field-select.vue';
const visible = defineModel<boolean>('visible', { default: false });
const props = defineProps<{
alias?: string;
dataSourceId?: string;
datasets?: ReportDatasetItem[];
fieldMapping?: Record<string, any>;
}>();
const emit = defineEmits<{
confirm: [Record<string, string>];
}>();
const rows = ref<Array<{ key: string; value: string }>>([]);
const { ensureByDataSourceId } = useReportDatasetFields(
() => props.datasets || [],
);
watch(
() => [props.fieldMapping, visible.value, props.dataSourceId] as const,
([mapping, open, dataSourceId]) => {
if (!open) return;
if (dataSourceId) {
ensureByDataSourceId(dataSourceId);
}
const entries = Object.entries(mapping || {});
rows.value = entries.length
? entries.map(([key, value]) => ({ key, value: String(value ?? '') }))
: [{ key: '', value: '' }];
},
{ immediate: true },
);
function addRow() {
rows.value.push({ key: '', value: '' });
}
function removeRow(index: number) {
rows.value.splice(index, 1);
}
function handleConfirm() {
const mapping: Record<string, string> = {};
for (const row of rows.value) {
const key = row.key.trim();
if (!key) continue;
mapping[key] = row.value;
}
emit('confirm', mapping);
visible.value = false;
}
</script>
<template>
<ZqDialog
v-model="visible"
:title="$t('report-manager.fieldMapping.title')"
width="640px"
@confirm="handleConfirm"
>
<p class="text-muted-foreground mb-3 text-xs">
{{ $t('report-manager.fieldMapping.hint') }}
<template v-if="alias">{{ alias }}</template>
</p>
<div class="mb-3 flex justify-end">
<ElButton type="primary" :icon="Plus" @click="addRow">
{{ $t('report-manager.fieldMapping.add') }}
</ElButton>
</div>
<ElTable :data="rows" border max-height="360">
<ElTableColumn :label="$t('report-manager.fieldMapping.sourceField')" min-width="200">
<template #default="{ row }">
<ReportFieldSelect
v-model="row.key"
size="small"
:datasets="datasets || []"
:alias="alias"
:with-alias="false"
:placeholder="$t('report-manager.fieldMapping.sourcePlaceholder')"
/>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('report-manager.fieldMapping.targetField')" min-width="200">
<template #default="{ row }">
<ReportFieldSelect
v-model="row.value"
size="small"
:datasets="datasets || []"
:alias="alias"
:with-alias="false"
:placeholder="$t('report-manager.fieldMapping.targetPlaceholder')"
/>
</template>
</ElTableColumn>
<ElTableColumn width="60" align="center">
<template #default="{ $index }">
<ElButton link type="danger" :icon="Trash2" @click="removeRow($index)" />
</template>
</ElTableColumn>
</ElTable>
</ZqDialog>
</template>
@@ -0,0 +1,369 @@
<script lang="ts" setup>
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
import type { DataSourceSimple } from '#/api/core/data-source';
import { ref, watch } from 'vue';
import { Plus, Settings2, Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElEmpty,
ElMessage,
ElOption,
ElSelect,
ElTree,
} from 'element-plus';
import {
getDataSourceDetailApi,
previewDataSourceApi,
} from '#/api/core/data-source';
import DatasetFieldMappingDialog from './dataset-field-mapping-dialog.vue';
interface DatasetTreeNode {
id: string;
label: string;
nodeType: 'dataset' | 'empty' | 'field' | 'loading';
data?: ReportDatasetItem;
fieldName?: string;
isLeaf?: boolean;
}
const props = defineProps<{
datasets: ReportDatasetItem[];
dataSources: DataSourceSimple[];
}>();
const emit = defineEmits<{
update: [ReportDatasetItem[]];
}>();
const selectedSourceId = ref('');
const mappingVisible = ref(false);
const mappingTarget = ref<ReportDatasetItem | null>(null);
const fieldsCache = ref<Record<string, string[]>>({});
const fieldsLoading = ref<Record<string, boolean>>({});
const treeKey = ref(0);
const treeProps = {
label: 'label',
children: 'children',
isLeaf: 'isLeaf',
};
function normalizeDataset(raw: ReportDatasetItem): ReportDatasetItem {
const dataSourceId =
raw.data_source_id || (raw as any).dataSourceId || '';
return {
...raw,
data_source_id: dataSourceId,
data_source_code:
raw.data_source_code || (raw as any).dataSourceCode || '',
data_source_name:
raw.data_source_name || (raw as any).dataSourceName || '',
alias: raw.alias || raw.data_source_code || (raw as any).dataSourceCode || '',
field_mapping: raw.field_mapping || (raw as any).fieldMapping || {},
convert_config: raw.convert_config || (raw as any).convertConfig || {},
};
}
const normalizedDatasets = ref<ReportDatasetItem[]>([]);
watch(
() => props.datasets,
(list) => {
normalizedDatasets.value = (list || [])
.map(normalizeDataset)
.filter((item) => item.data_source_id);
const idSet = new Set(normalizedDatasets.value.map((d) => d.data_source_id));
for (const key of Object.keys(fieldsCache.value)) {
if (!idSet.has(key)) {
delete fieldsCache.value[key];
delete fieldsLoading.value[key];
}
}
treeKey.value += 1;
},
{ deep: true, immediate: true },
);
function normalizePreviewRows(data: any): any[] {
if (Array.isArray(data)) return data;
if (data && typeof data === 'object') return [data];
return [];
}
function extractFieldsFromRows(rows: any[]): string[] {
const names = new Set<string>();
for (const row of rows || []) {
if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
for (const key of Object.keys(row)) {
if (key) names.add(key);
}
}
return [...names].sort();
}
function mergeDatasetFields(ds: ReportDatasetItem, rows: any[]) {
const names = new Set(extractFieldsFromRows(rows));
const mapping = ds.field_mapping || {};
for (const key of Object.keys(mapping)) {
if (key) names.add(key);
}
for (const value of Object.values(mapping)) {
if (value) names.add(String(value));
}
return [...names].sort();
}
async function loadStaticFields(dataSourceId: string) {
try {
const detail = await getDataSourceDetailApi(dataSourceId);
return extractFieldsFromRows(normalizePreviewRows(detail?.static_data));
} catch {
return [];
}
}
async function loadFieldsForDataset(ds: ReportDatasetItem) {
const id = ds.data_source_id;
if (fieldsCache.value[id] !== undefined || fieldsLoading.value[id]) {
return fieldsCache.value[id] || [];
}
fieldsLoading.value[id] = true;
try {
const result = await previewDataSourceApi(id, { params: {}, limit: 5 });
let fields = mergeDatasetFields(ds, normalizePreviewRows(result?.data));
if (!fields.length) {
fields = mergeDatasetFields(
ds,
normalizePreviewRows(await loadStaticFields(id)),
);
}
fieldsCache.value[id] = fields;
return fields;
} catch (error: any) {
const fallback = mergeDatasetFields(
ds,
normalizePreviewRows(await loadStaticFields(id)),
);
if (fallback.length) {
fieldsCache.value[id] = fallback;
return fallback;
}
ElMessage.error(
error?.message || $t('report-manager.dataset.loadFieldsFailed'),
);
fieldsCache.value[id] = [];
return [];
} finally {
fieldsLoading.value[id] = false;
}
}
function buildFieldNodes(ds: ReportDatasetItem, fields: string[]): DatasetTreeNode[] {
if (!fields.length) {
return [
{
id: `${ds.data_source_id}::__empty`,
label: $t('report-manager.dataset.noFields'),
nodeType: 'empty',
isLeaf: true,
},
];
}
return fields.map((field) => ({
id: `${ds.data_source_id}::${field}`,
label: `${ds.alias}.${field}`,
nodeType: 'field',
fieldName: field,
isLeaf: true,
}));
}
function buildDatasetNodes(): DatasetTreeNode[] {
return normalizedDatasets.value.map((ds) => ({
id: ds.data_source_id,
label: `${ds.alias} (${ds.data_source_code || ds.data_source_id})`,
nodeType: 'dataset',
data: ds,
}));
}
async function loadTreeNode(node: any, resolve: (data: DatasetTreeNode[]) => void) {
if (node.level === 0) {
resolve(buildDatasetNodes());
return;
}
const data = node.data as DatasetTreeNode;
if (data.nodeType !== 'dataset' || !data.data) {
resolve([]);
return;
}
const ds = data.data;
const cached = fieldsCache.value[ds.data_source_id];
if (cached !== undefined) {
resolve(buildFieldNodes(ds, cached));
return;
}
if (fieldsLoading.value[ds.data_source_id]) {
resolve([
{
id: `${ds.data_source_id}::__loading`,
label: $t('report-manager.dataset.loadingFields'),
nodeType: 'loading',
isLeaf: true,
},
]);
return;
}
const fields = await loadFieldsForDataset(ds);
resolve(buildFieldNodes(ds, fields));
}
function handleAdd() {
if (!selectedSourceId.value) return;
const source = props.dataSources.find((s) => s.id === selectedSourceId.value);
if (!source) return;
const exists = normalizedDatasets.value.some(
(d) => d.data_source_id === source.id,
);
if (exists) return;
const next: ReportDatasetItem[] = [
...props.datasets,
{
data_source_id: source.id,
data_source_code: source.code,
data_source_name: source.name,
alias: source.code,
field_mapping: {},
convert_config: {},
sort: props.datasets.length,
},
];
emit('update', next);
selectedSourceId.value = '';
}
function handleRemove(dataSourceId: string) {
emit(
'update',
props.datasets.filter(
(d) => (d.data_source_id || (d as any).dataSourceId) !== dataSourceId,
),
);
}
function openFieldMapping(ds: ReportDatasetItem) {
mappingTarget.value = ds;
mappingVisible.value = true;
}
function onMappingConfirm(mapping: Record<string, string>) {
if (!mappingTarget.value) return;
const targetId = mappingTarget.value.data_source_id;
const next = props.datasets.map((d) =>
(d.data_source_id || (d as any).dataSourceId) === targetId
? { ...d, field_mapping: mapping }
: d,
);
emit('update', next);
mappingVisible.value = false;
delete fieldsCache.value[targetId];
treeKey.value += 1;
}
</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-3 p-3">
<div class="flex shrink-0 gap-2">
<ElSelect
v-model="selectedSourceId"
class="min-w-0 flex-1"
size="small"
filterable
:placeholder="$t('report-manager.dataset.selectSource')"
>
<ElOption
v-for="s in dataSources"
:key="s.id"
:label="`${s.name} (${s.code})`"
:value="s.id"
/>
</ElSelect>
<ElButton type="primary" size="small" :icon="Plus" @click="handleAdd" />
</div>
<div
v-if="normalizedDatasets.length"
class="min-h-0 flex-1 overflow-auto"
>
<ElTree
:key="treeKey"
:props="treeProps"
node-key="id"
lazy
:load="loadTreeNode"
:empty-text="$t('report-manager.dataset.empty')"
class="w-full p-1"
>
<template #default="{ data }">
<div
class="flex w-full min-w-0 items-center justify-between gap-1 pr-2"
:class="data.nodeType === 'field' ? 'pl-1' : ''"
>
<span
class="truncate"
:class="
data.nodeType === 'field'
? 'text-muted-foreground text-xs'
: data.nodeType === 'dataset'
? 'text-sm'
: 'text-muted-foreground text-xs italic'
"
>
{{ data.label }}
</span>
<div
v-if="data.nodeType === 'dataset'"
class="flex shrink-0 items-center gap-1"
>
<ElButton
link
:icon="Settings2"
:title="$t('report-manager.dataset.fieldMapping')"
@click.stop="openFieldMapping(data.data!)"
/>
<ElButton
link
type="danger"
:icon="Trash2"
@click.stop="handleRemove(data.id)"
/>
</div>
</div>
</template>
</ElTree>
</div>
<ElEmpty
v-else
class="min-h-0 flex-1"
:description="$t('report-manager.dataset.empty')"
/>
<DatasetFieldMappingDialog
v-model:visible="mappingVisible"
:alias="mappingTarget?.alias"
:data-source-id="mappingTarget?.data_source_id"
:datasets="datasets"
:field-mapping="mappingTarget?.field_mapping"
@confirm="onMappingConfirm"
/>
</div>
</template>
@@ -0,0 +1,70 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
import ChartBindForm, {
type ChartBindFormState,
} from './chart-bind-form.vue';
import { parseChartOptionToFormState } from './chart-bind-utils';
export interface FloatEchartSelection {
drawingId: string;
echartType: string;
option: Record<string, any>;
}
const props = defineProps<{
selection: FloatEchartSelection | null;
datasets: ReportDatasetItem[];
}>();
const emit = defineEmits<{
apply: [config: FloatEchartSelection];
}>();
const bindFormRef = ref<InstanceType<typeof ChartBindForm> | null>(null);
const formState = ref<ChartBindFormState | null>(null);
watch(
() => props.selection,
(sel) => {
if (!sel) {
formState.value = null;
return;
}
formState.value = parseChartOptionToFormState(
sel.echartType,
sel.option || {},
props.datasets || [],
);
},
{ deep: true },
);
function onFormChange() {
if (!props.selection) return;
const option =
bindFormRef.value?.buildOption(props.selection.option || {}) || {};
emit('apply', {
drawingId: props.selection.drawingId,
echartType: formState.value?.echartType || 'bar',
option,
});
}
</script>
<template>
<div v-if="!selection" class="text-xs text-[var(--el-text-color-secondary)]">
{{ $t('report-manager.chart.empty') }}
</div>
<ChartBindForm
v-else
ref="bindFormRef"
:datasets="datasets"
:model-value="formState"
@change="onFormChange"
/>
</template>
@@ -0,0 +1,151 @@
<script lang="ts" setup>
import { computed, reactive, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElRadioButton,
ElRadioGroup,
ElUpload,
} from 'element-plus';
import {
downloadReportImageApi,
uploadReportFileApi,
} from '#/api/online-dev/report-manager';
export interface FloatImageSelection {
drawingId: string;
imageType: 'BASE64' | 'URL';
option: {
source?: number;
src?: string;
alt?: string;
};
}
const props = defineProps<{
selection: FloatImageSelection | null;
}>();
const emit = defineEmits<{
apply: [config: FloatImageSelection];
}>();
const form = reactive({
source: 1,
src: '',
imageType: 'URL' as 'BASE64' | 'URL',
});
watch(
() => props.selection,
(sel) => {
if (!sel) return;
form.source = sel.option?.source ?? (sel.imageType === 'URL' ? 2 : 1);
form.src = sel.option?.src || '';
form.imageType = sel.imageType || 'URL';
},
{ deep: true },
);
const isUrlSource = computed(() => form.source === 2);
function emitApply() {
if (!props.selection) return;
emit('apply', {
drawingId: props.selection.drawingId,
imageType: form.imageType,
option: {
...props.selection.option,
source: form.source,
src: form.src,
},
});
}
async function handleUpload(file: File) {
try {
const res = await uploadReportFileApi(file);
form.src = res.url;
form.imageType = 'URL';
emitApply();
ElMessage.success($t('report-manager.image.uploadSuccess'));
} catch (error: any) {
ElMessage.error(error?.message || $t('report-manager.image.uploadFailed'));
}
return false;
}
async function handleUrlBlur() {
if (!form.src?.trim() || !props.selection) return;
const val = form.src.trim();
if (!/^https?:\/\//i.test(val)) {
emitApply();
return;
}
try {
const res = await downloadReportImageApi(val, 'URL');
form.src = res.url;
form.imageType = 'URL';
emitApply();
} catch {
form.imageType = 'URL';
emitApply();
}
}
function onSourceChange() {
form.src = '';
form.imageType = form.source === 2 ? 'URL' : 'BASE64';
}
</script>
<template>
<div v-if="!selection" class="text-xs text-[var(--el-text-color-secondary)]">
{{ $t('report-manager.image.empty') }}
</div>
<ElForm v-else label-position="top" size="small">
<ElFormItem :label="$t('report-manager.image.source')">
<ElRadioGroup v-model="form.source" size="small" @change="onSourceChange">
<ElRadioButton :value="1">
{{ $t('report-manager.image.sourceLocal') }}
</ElRadioButton>
<ElRadioButton :value="2">
{{ $t('report-manager.image.sourceUrl') }}
</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
<ElFormItem v-if="isUrlSource" :label="$t('report-manager.image.url')">
<ElInput
v-model="form.src"
:placeholder="$t('report-manager.image.urlPlaceholder')"
@blur="handleUrlBlur"
/>
</ElFormItem>
<ElFormItem v-else :label="$t('report-manager.image.upload')">
<ElUpload
:show-file-list="false"
accept="image/*"
:before-upload="handleUpload as any"
>
<ElButton type="primary" link>
{{ $t('report-manager.image.selectFile') }}
</ElButton>
</ElUpload>
<p
v-if="form.src"
class="text-muted-foreground mt-1 truncate text-xs"
:title="form.src"
>
{{ form.src }}
</p>
</ElFormItem>
</ElForm>
</template>
@@ -0,0 +1,289 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { Download, Printer } from '@vben/icons';
import { $t } from '@vben/locales';
import { ReportDialogHeader, ZqUniverPrint } from '@zq/univer';
import '@zq/univer/style';
import UniverHost from '../univer-host.vue';
import { ElButton, ElDialog, ElMessage, ElMessageBox } from 'element-plus';
import {
exportReportDesignExcelApi,
previewReportDesignApi,
} from '#/api/online-dev/report-manager';
import { useReportQuery } from '#/components/report-design/hooks/useReportQuery';
import ReportQueryForm from '#/components/report-design/modules/report-query-form.vue';
import { countSnapshotImages } from '#/components/report-design/utils/report-media-url';
import { showPreviewWarnings } from '#/components/report-design/utils/preview-warnings';
const visible = defineModel<boolean>('visible', { default: false });
const props = defineProps<{
versionId: string;
reportCode?: string;
reportName?: string;
queryList: any[];
getDraftPayload?: () => Record<string, any> | null | undefined;
}>();
const loading = ref(false);
const exportLoading = ref(false);
const allowExport = ref(true);
const allowPrint = ref(true);
const printRef = ref<InstanceType<typeof ZqUniverPrint> | null>(null);
const previewReady = ref(false);
const previewSessionKey = ref(0);
const previewSnapshot = ref<Record<string, any>>({});
const previewCells = ref<Record<string, any>>({});
const previewChartData = ref<any[]>([]);
const previewWatermark = ref<{ show?: boolean; config?: Record<string, any> }>({
show: false,
config: {},
});
const { searchSchemas, formValues, setQueryList, getDefaultParams, setFormValues } =
useReportQuery();
watch(
() => props.queryList,
(list) => setQueryList(list || []),
{ immediate: true },
);
function resetPreviewState() {
previewReady.value = false;
allowExport.value = true;
allowPrint.value = true;
previewSnapshot.value = {};
previewCells.value = {};
previewChartData.value = [];
previewWatermark.value = { show: false, config: {} };
}
watch(visible, async (v) => {
if (!v) {
resetPreviewState();
return;
}
if (!props.versionId) return;
resetPreviewState();
await runPreview();
previewSessionKey.value += 1;
previewReady.value = true;
});
function buildDraftPayload() {
const draft = props.getDraftPayload?.();
if (!draft) return undefined;
return {
snapshot: draft.snapshot,
cells: draft.cells,
queryList: draft.queryList,
sortList: draft.sortList,
columnList: draft.columnList,
fenceList: draft.fenceList,
convertConfig: draft.convertConfig,
};
}
async function runPreview() {
if (!props.versionId) return;
loading.value = true;
try {
const res = await previewReportDesignApi(
props.versionId,
getDefaultParams(),
buildDraftPayload(),
);
previewSnapshot.value = res.snapshot || {};
previewCells.value = res.cells || {};
previewChartData.value = Array.isArray(res.chartData)
? res.chartData
: [];
previewWatermark.value =
res.watermark && typeof res.watermark === 'object'
? res.watermark
: {
show: !!res.allowWatermark,
config: res.watermarkConfig || {},
};
allowExport.value = res.allowExport !== false;
allowPrint.value = res.allowPrint !== false;
showPreviewWarnings(Array.isArray(res.warnings) ? res.warnings : []);
} finally {
loading.value = false;
}
}
async function handleExportExcel() {
if (exportLoading.value || !props.versionId) return;
if (!allowExport.value) {
ElMessage.warning($t('report-manager.export.notAllowed'));
return;
}
exportLoading.value = true;
try {
const blob = await exportReportDesignExcelApi(
props.versionId,
getDefaultParams(),
buildDraftPayload(),
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${props.reportCode || props.reportName || 'report'}.xlsx`;
a.click();
URL.revokeObjectURL(url);
ElMessage.success($t('report-manager.export.success'));
} catch (error: any) {
ElMessage.error(error?.message || $t('report-manager.export.failed'));
} finally {
exportLoading.value = false;
}
}
async function handlePrint() {
if (!allowPrint.value) {
ElMessage.warning($t('report-manager.print.notAllowed'));
return;
}
if (!previewSnapshot.value || !Object.keys(previewSnapshot.value).length) {
ElMessage.warning($t('report-manager.render.empty'));
return;
}
const imageCount = countSnapshotImages(previewSnapshot.value, previewCells.value);
if (imageCount > 100) {
try {
await ElMessageBox.confirm(
$t('report-manager.print.imageWarn', { count: imageCount }),
$t('report-manager.print.title'),
{ type: 'warning' },
);
} catch {
return;
}
}
printRef.value?.handleCreatePrintUnit?.({
snapshot: previewSnapshot.value as any,
watermarkConfig: previewWatermark.value,
reportName: props.reportName,
reportCode: props.reportCode,
});
}
function handleClose() {
visible.value = false;
}
</script>
<template>
<ElDialog
v-model="visible"
class="report-preview-dialog"
fullscreen
append-to-body
destroy-on-close
:show-close="false"
:close-on-click-modal="false"
>
<div class="bg-background flex h-full min-h-0 flex-col">
<ReportDialogHeader
:report-name="reportName"
:report-code="reportCode"
:fallback-title="$t('report-manager.preview.title')"
>
<template #actions>
<ElButton
v-if="allowExport"
:icon="Download"
:loading="exportLoading"
@click="handleExportExcel"
>
{{ $t('report-manager.export.title') }}
</ElButton>
<ElButton v-if="allowPrint" :icon="Printer" @click="handlePrint">
{{ $t('report-manager.print.title') }}
</ElButton>
<ElButton @click="handleClose">
{{ $t('common.close') }}
</ElButton>
</template>
</ReportDialogHeader>
<main
v-loading="loading"
class="report-preview-dialog__content m-3 min-h-0 min-w-0 flex-1 overflow-hidden rounded-lg"
>
<ReportQueryForm
:schemas="searchSchemas"
:model-value="formValues"
:loading="loading"
@update:model-value="setFormValues"
@search="runPreview"
/>
<div class="report-preview-dialog__sheet">
<UniverHost
v-if="previewReady"
:key="previewSessionKey"
mode="preview"
readonly
class="h-full w-full"
:snapshot="previewSnapshot"
:cells="previewCells"
:chart-data="previewChartData"
:watermark="previewWatermark"
/>
</div>
</main>
</div>
</ElDialog>
<Teleport to="body">
<ZqUniverPrint ref="printRef" />
</Teleport>
</template>
<style lang="scss">
.report-preview-dialog.el-dialog {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
.el-dialog__header {
display: none;
}
.el-dialog__body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
padding: 0;
}
}
.report-preview-dialog__content {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
min-width: 0;
}
.report-preview-dialog__sheet {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
border-radius: 8px;
}
</style>
@@ -0,0 +1,224 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { Plus, Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElInput,
ElOption,
ElSelect,
ElSwitch,
ElTable,
ElTableColumn,
} from 'element-plus';
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
import { ZqDialog } from '#/components/zq-dialog';
import { useReportDatasetFields } from '../hooks/useReportDatasetFields';
import ReportFieldSelect from './report-field-select.vue';
const visible = defineModel<boolean>('visible', { default: false });
const props = defineProps<{
queryList: any[];
datasets?: ReportDatasetItem[];
}>();
const emit = defineEmits<{
confirm: [any[]];
}>();
const rows = ref<any[]>([]);
const { ensureAll } = useReportDatasetFields(() => props.datasets || []);
const paramFieldOptions = computed(() => {
const names = new Set<string>();
for (const row of rows.value) {
const field = String(row.field || '').trim();
if (field) names.add(field);
}
for (const item of props.queryList || []) {
const field = String(item.field || item.vModel || '').trim();
if (field) names.add(field);
}
return [...names].sort();
});
watch(
() => props.queryList,
(list) => {
rows.value = (list || []).map((item) => ({
...item,
optionsText: Array.isArray(item.options)
? item.options
.map((o: any) => `${o.label ?? o.fullName ?? o.id}:${o.value ?? o.id}`)
.join(',')
: typeof item.options === 'string'
? item.options
: '',
}));
},
{ immediate: true, deep: true },
);
watch(visible, (open) => {
if (open) {
ensureAll();
}
});
function getParamFieldOptions(currentValue?: string) {
const options = [...paramFieldOptions.value];
const trimmed = (currentValue || '').trim();
if (trimmed && !options.includes(trimmed)) {
options.unshift(trimmed);
}
return options;
}
function addRow() {
rows.value.push({
field: `param_${rows.value.length + 1}`,
label: '',
component: 'input',
defaultValue: '',
required: false,
});
}
function removeRow(index: number) {
rows.value.splice(index, 1);
}
function handleConfirm() {
emit(
'confirm',
rows.value
.filter((r) => r.field)
.map(({ optionsText, ...rest }) => ({
...rest,
options: rest.component === 'select' ? optionsText || '' : undefined,
})),
);
visible.value = false;
}
</script>
<template>
<ZqDialog
v-model="visible"
:title="$t('report-manager.query.title')"
width="720px"
@confirm="handleConfirm"
>
<div class="mb-3 flex justify-end">
<ElButton type="primary" :icon="Plus" @click="addRow">
{{ $t('report-manager.query.add') }}
</ElButton>
</div>
<ElTable :data="rows" border max-height="360">
<ElTableColumn :label="$t('report-manager.query.field')" min-width="160">
<template #default="{ row }">
<ElSelect
v-if="!(datasets || []).length"
v-model="row.field"
size="small"
class="w-full"
filterable
clearable
allow-create
default-first-option
:placeholder="$t('report-manager.query.fieldPlaceholder')"
>
<ElOption
v-for="name in getParamFieldOptions(row.field)"
:key="name"
:label="name"
:value="name"
/>
</ElSelect>
<ReportFieldSelect
v-else
v-model="row.field"
size="small"
:datasets="datasets || []"
:with-alias="true"
:placeholder="$t('report-manager.query.fieldPlaceholder')"
/>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('report-manager.query.label')" min-width="120">
<template #default="{ row }">
<ElInput v-model="row.label" size="small" />
</template>
</ElTableColumn>
<ElTableColumn :label="$t('report-manager.query.component')" width="120">
<template #default="{ row }">
<ElSelect v-model="row.component" size="small" filterable>
<ElOption :label="$t('report-manager.query.input')" value="input" />
<ElOption :label="$t('report-manager.query.select')" value="select" />
<ElOption
:label="$t('report-manager.query.date')"
value="date"
/>
<ElOption
:label="$t('report-manager.query.dateRange')"
value="dateRange"
/>
</ElSelect>
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('report-manager.query.showTime')"
width="90"
>
<template #default="{ row }">
<ElSwitch
v-if="row.component === 'date' || row.component === 'dateRange'"
v-model="row.showTime"
/>
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('report-manager.query.options')"
min-width="140"
>
<template #default="{ row }">
<ElInput
v-if="row.component === 'select'"
v-model="row.optionsText"
size="small"
:placeholder="$t('report-manager.query.optionsPlaceholder')"
/>
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('report-manager.query.defaultValue')"
min-width="100"
>
<template #default="{ row }">
<ElInput v-model="row.defaultValue" size="small" />
</template>
</ElTableColumn>
<ElTableColumn :label="$t('report-manager.query.requiredLabel')" width="80">
<template #default="{ row }">
<ElSwitch v-model="row.required" />
</template>
</ElTableColumn>
<ElTableColumn width="60" align="center">
<template #default="{ $index }">
<ElButton
link
type="danger"
:icon="Trash2"
@click="removeRow($index)"
/>
</template>
</ElTableColumn>
</ElTable>
</ZqDialog>
</template>
@@ -0,0 +1,174 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import ReportConfigToggleItem from './report-config-toggle-item.vue';
const props = defineProps<{
queryList: any[];
sortList: any[];
columnList: any[];
convertConfig: any[];
}>();
const emit = defineEmits<{
'open-query': [];
'open-sort': [];
'open-column': [];
'open-convert': [];
}>();
const queryEnabled = ref(false);
const sortEnabled = ref(false);
const columnEnabled = ref(false);
const convertEnabled = ref(false);
function getSortRows() {
const first = props.sortList?.[0];
return first?.sortList || [];
}
function getColumnConfig() {
return props.columnList?.[0]?.columnList || null;
}
function syncEnabledFromData() {
if ((props.queryList?.length || 0) > 0) {
queryEnabled.value = true;
}
if (getSortRows().length > 0) {
sortEnabled.value = true;
}
if (getColumnConfig()?.columnState) {
columnEnabled.value = true;
}
if ((props.convertConfig?.length || 0) > 0) {
convertEnabled.value = true;
}
}
watch(
() => [props.queryList, props.sortList, props.columnList, props.convertConfig],
() => syncEnabledFromData(),
{ immediate: true, deep: true },
);
const querySummary = computed(() => {
const count = props.queryList?.length || 0;
if (!count) return '';
return $t('report-manager.leftPanel.configuredCount', { count });
});
const queryDetail = computed(() => {
const labels = (props.queryList || [])
.map((item) => item.label || item.field || item.vModel)
.filter(Boolean)
.slice(0, 3);
if (!labels.length) return '';
const suffix =
(props.queryList?.length || 0) > 3
? ` · +${(props.queryList?.length || 0) - 3}`
: '';
return `${labels.join('、')}${suffix}`;
});
const sortSummary = computed(() => {
const rows = getSortRows();
if (!rows.length) return '';
return $t('report-manager.leftPanel.configuredCount', { count: rows.length });
});
const sortDetail = computed(() => {
return getSortRows()
.map((item: any) => {
const field = item.vModel || item.field || '';
const order =
item.type === 'desc'
? $t('report-manager.sort.desc')
: $t('report-manager.sort.asc');
return field ? `${field} (${order})` : '';
})
.filter(Boolean)
.slice(0, 2)
.join('、');
});
const columnSummary = computed(() => {
const cfg = getColumnConfig();
if (!cfg?.columnState) return '';
return $t('report-manager.leftPanel.columnEnabled');
});
const columnDetail = computed(() => {
const cfg = getColumnConfig();
if (!cfg?.columnState) return '';
const styleLabel =
cfg.columnStyle === 'row'
? $t('report-manager.column.styleRow')
: $t('report-manager.column.styleCol');
const splitCount =
cfg.columnStyle === 'row' ? cfg.rowCount : cfg.colCount;
const range = cfg.columnData ? ` · ${cfg.columnData}` : '';
return `${styleLabel} · ${splitCount || 0}${range}`;
});
const convertSummary = computed(() => {
const count = props.convertConfig?.length || 0;
if (!count) return '';
return $t('report-manager.leftPanel.configuredCount', { count });
});
const convertDetail = computed(() => {
return (props.convertConfig || [])
.map((item) => item.field)
.filter(Boolean)
.slice(0, 2)
.join('、');
});
</script>
<template>
<div class="flex flex-col gap-3 px-2 pb-2">
<ReportConfigToggleItem
:label="$t('report-manager.query.title')"
:enabled="queryEnabled"
:summary="querySummary"
:detail="queryDetail"
:empty-text="$t('report-manager.leftPanel.queryEmpty')"
:action-text="$t('report-manager.leftPanel.clickToConfigure')"
@update:enabled="queryEnabled = $event"
@open="emit('open-query')"
/>
<ReportConfigToggleItem
:label="$t('report-manager.sort.config')"
:enabled="sortEnabled"
:summary="sortSummary"
:detail="sortDetail"
:empty-text="$t('report-manager.leftPanel.sortEmpty')"
:action-text="$t('report-manager.leftPanel.clickToConfigure')"
@update:enabled="sortEnabled = $event"
@open="emit('open-sort')"
/>
<ReportConfigToggleItem
:label="$t('report-manager.column.config')"
:enabled="columnEnabled"
:summary="columnSummary"
:detail="columnDetail"
:empty-text="$t('report-manager.leftPanel.columnEmpty')"
:action-text="$t('report-manager.leftPanel.clickToConfigure')"
@update:enabled="columnEnabled = $event"
@open="emit('open-column')"
/>
<ReportConfigToggleItem
:label="$t('report-manager.convert.config')"
:enabled="convertEnabled"
:summary="convertSummary"
:detail="convertDetail"
:empty-text="$t('report-manager.leftPanel.convertEmpty')"
:action-text="$t('report-manager.leftPanel.clickToConfigure')"
@update:enabled="convertEnabled = $event"
@open="emit('open-convert')"
/>
</div>
</template>
@@ -0,0 +1,98 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { ChevronRight } from '@vben/icons';
import { ElSwitch } from 'element-plus';
const props = defineProps<{
label: string;
enabled: boolean;
summary?: string;
detail?: string;
emptyText: string;
actionText: string;
}>();
const emit = defineEmits<{
'update:enabled': [value: boolean];
open: [];
}>();
const displayText = computed(() => {
if (props.summary && props.detail) {
return `${props.summary} · ${props.detail}`;
}
return props.summary || props.detail || props.emptyText;
});
function handleToggle(value: boolean | string | number) {
emit('update:enabled', !!value);
}
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-2">
<span class="text-foreground truncate text-[13px]">{{ label }}</span>
<ElSwitch :model-value="enabled" size="small" @change="handleToggle" />
</div>
<Transition name="report-config-expand">
<div v-if="enabled" class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs">{{ actionText }}</span>
<button
type="button"
class="report-side-field group"
@click="emit('open')"
>
<span
class="min-w-0 flex-1 truncate text-xs leading-relaxed"
:class="
summary || detail
? 'text-[var(--el-text-color-regular)]'
: 'text-[var(--el-text-color-placeholder)]'
"
>
{{ displayText }}
</span>
<ChevronRight
class="h-3.5 w-3.5 shrink-0 text-[var(--el-text-color-placeholder)] transition-transform group-hover:translate-x-0.5 group-hover:text-[var(--el-color-primary)]"
/>
</button>
</div>
</Transition>
</div>
</template>
<style scoped>
.report-side-field {
display: flex;
width: 100%;
align-items: center;
gap: 8px;
padding: 0 11px;
min-height: 24px;
text-align: left;
cursor: pointer;
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
background: var(--el-bg-color);
transition: border-color 0.2s;
}
.report-side-field:hover {
border-color: var(--el-color-primary-light-5);
}
.report-config-expand-enter-active,
.report-config-expand-leave-active {
transition: all 0.2s ease;
}
.report-config-expand-enter-from,
.report-config-expand-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
@@ -0,0 +1,76 @@
<script lang="ts" setup>
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
import { computed, watch } from 'vue';
import { ElTreeSelect } from 'element-plus';
import { useReportDatasetFields } from '../hooks/useReportDatasetFields';
const modelValue = defineModel<string>({ default: '' });
const props = withDefaults(
defineProps<{
datasets: ReportDatasetItem[];
alias?: string;
withAlias?: boolean;
placeholder?: string;
size?: 'default' | 'large' | 'small';
}>(),
{
withAlias: true,
},
);
const emit = defineEmits<{
change: [string];
}>();
const { ensureAll, ensureByAlias, getFieldTree, loadingMap, normalizedDatasets } =
useReportDatasetFields(() => props.datasets);
const treeData = computed(() =>
getFieldTree(props.alias, props.withAlias, modelValue.value),
);
const loading = computed(() => {
if (props.alias) {
const ds = normalizedDatasets.value.find((item) => item.alias === props.alias);
return !!loadingMap.value[ds?.data_source_id || ''];
}
return Object.values(loadingMap.value).some(Boolean);
});
async function loadTreeFields() {
if (props.alias) {
await ensureByAlias(props.alias);
return;
}
await ensureAll();
}
watch(
() => [props.alias, props.datasets] as const,
() => {
loadTreeFields();
},
{ deep: true, immediate: true },
);
</script>
<template>
<ElTreeSelect
v-model="modelValue"
:data="treeData"
node-key="value"
class="w-full"
filterable
clearable
default-expand-all
:render-after-expand="false"
:loading="loading"
:placeholder="placeholder"
:size="size"
@change="$emit('change', $event)"
/>
</template>
@@ -0,0 +1,155 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { $t } from '@vben/locales';
import {
ElButton,
ElDatePicker,
ElForm,
ElFormItem,
ElInput,
ElOption,
ElSelect,
} from 'element-plus';
export interface ReportQuerySchema {
fieldName: string;
label: string;
component: string;
componentProps?: Record<string, any>;
rules?: any[];
}
const props = withDefaults(
defineProps<{
schemas: ReportQuerySchema[];
modelValue: Record<string, any>;
inline?: boolean;
showSearch?: boolean;
showReset?: boolean;
loading?: boolean;
}>(),
{
inline: true,
showSearch: true,
showReset: false,
loading: false,
},
);
const emit = defineEmits<{
'update:modelValue': [value: Record<string, any>];
search: [];
reset: [];
}>();
const formModel = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
function updateField(field: string, value: any) {
emit('update:modelValue', { ...props.modelValue, [field]: value });
}
function normalizeComponent(component: string) {
const c = (component || 'input').toLowerCase();
if (c.includes('select')) return 'select';
if (c.includes('range')) return 'dateRange';
if (c.includes('date') || c.includes('time')) return 'date';
return 'input';
}
function isDateTime(schema: ReportQuerySchema) {
return schema.componentProps?.showTime === true;
}
function handleReset() {
const cleared: Record<string, any> = {};
for (const schema of props.schemas) {
cleared[schema.fieldName] = undefined;
}
emit('update:modelValue', cleared);
emit('reset');
}
</script>
<template>
<ElForm
v-if="schemas.length"
:inline="inline"
class="bg-background-deep rounded-lg p-4"
@submit.prevent="emit('search')"
>
<ElFormItem
v-for="schema in schemas"
:key="schema.fieldName"
:label="schema.label"
:required="!!schema.rules?.length"
>
<ElSelect
v-if="normalizeComponent(schema.component) === 'select'"
:model-value="formModel[schema.fieldName]"
clearable
filterable
class="min-w-[160px]"
:placeholder="schema.componentProps?.placeholder"
@update:model-value="updateField(schema.fieldName, $event)"
>
<ElOption
v-for="opt in schema.componentProps?.options || []"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<ElDatePicker
v-else-if="normalizeComponent(schema.component) === 'dateRange'"
:model-value="formModel[schema.fieldName]"
clearable
class="!w-[280px]"
:type="isDateTime(schema) ? 'datetimerange' : 'daterange'"
:value-format="isDateTime(schema) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD'"
:start-placeholder="$t('report-manager.query.startDate')"
:end-placeholder="$t('report-manager.query.endDate')"
@update:model-value="updateField(schema.fieldName, $event)"
/>
<ElDatePicker
v-else-if="normalizeComponent(schema.component) === 'date'"
:model-value="formModel[schema.fieldName]"
clearable
class="!w-[200px]"
:type="isDateTime(schema) ? 'datetime' : 'date'"
:value-format="isDateTime(schema) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD'"
:placeholder="schema.componentProps?.placeholder"
@update:model-value="updateField(schema.fieldName, $event)"
/>
<ElInput
v-else
:model-value="formModel[schema.fieldName]"
clearable
class="min-w-[160px]"
:placeholder="schema.componentProps?.placeholder"
@update:model-value="updateField(schema.fieldName, $event)"
/>
</ElFormItem>
<ElFormItem v-if="showSearch || showReset">
<ElButton
v-if="showSearch"
type="primary"
:loading="loading"
@click="emit('search')"
>
{{ $t('report-manager.query.search') }}
</ElButton>
<ElButton v-if="showReset" @click="handleReset">
{{ $t('report-manager.query.reset') }}
</ElButton>
</ElFormItem>
</ElForm>
</template>
@@ -0,0 +1,206 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { CircleHelp } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElInput,
ElMessage,
ElOption,
ElSelect,
ElSwitch,
ElTooltip,
} from 'element-plus';
import { updateReportApi } from '#/api/online-dev/report-manager';
import {
buildReportSettingsPayload,
parseReportSettings,
type ReportSettingsForm,
} from '../utils/report-settings';
const props = defineProps<{
templateId: string;
reportName: string;
allowExport?: boolean;
allowPrint?: boolean;
allowWatermark?: boolean;
watermarkConfig?: Record<string, any>;
}>();
const emit = defineEmits<{
saved: [ReportSettingsForm];
}>();
const timeFormatOptions = [
'yyyy-MM-dd',
'yyyy-MM-dd HH:mm',
'yyyy-MM-dd HH:mm:ss',
];
const saving = ref(false);
const form = ref<ReportSettingsForm>(
parseReportSettings(props.reportName, props),
);
watch(
() => [
props.templateId,
props.reportName,
props.allowExport,
props.allowPrint,
props.allowWatermark,
props.watermarkConfig,
],
() => {
form.value = parseReportSettings(props.reportName, props);
},
);
async function persistSettings() {
if (!props.templateId || saving.value) return;
saving.value = true;
try {
await updateReportApi(
props.templateId,
buildReportSettingsPayload(form.value, props.reportName),
);
emit('saved', { ...form.value });
} catch (error: any) {
ElMessage.error(error?.message || $t('common.saveFailed'));
form.value = parseReportSettings(props.reportName, props);
} finally {
saving.value = false;
}
}
function handleSwitchChange() {
void persistSettings();
}
let textSaveTimer: ReturnType<typeof setTimeout> | undefined;
function handleTextChange() {
if (textSaveTimer) clearTimeout(textSaveTimer);
textSaveTimer = setTimeout(() => {
void persistSettings();
}, 500);
}
</script>
<template>
<div class="flex flex-col gap-3 px-2 pb-2">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-1">
<span class="text-foreground truncate text-[13px]">
{{ $t('report-manager.settings.allowExport') }}
</span>
<ElTooltip
placement="top"
:content="$t('report-manager.settings.allowExportTip')"
>
<CircleHelp
class="h-3.5 w-3.5 shrink-0 cursor-help text-[var(--el-text-color-secondary)]"
/>
</ElTooltip>
</div>
<ElSwitch
v-model="form.allow_export"
size="small"
:disabled="saving"
@change="handleSwitchChange"
/>
</div>
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-1">
<span class="text-foreground truncate text-[13px]">
{{ $t('report-manager.settings.allowPrint') }}
</span>
<ElTooltip
placement="top"
:content="$t('report-manager.settings.allowPrintTip')"
>
<CircleHelp
class="h-3.5 w-3.5 shrink-0 cursor-help text-[var(--el-text-color-secondary)]"
/>
</ElTooltip>
</div>
<ElSwitch
v-model="form.allow_print"
size="small"
:disabled="saving"
@change="handleSwitchChange"
/>
</div>
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-1">
<span class="text-foreground truncate text-[13px]">
{{ $t('report-manager.settings.allowWatermark') }}
</span>
<ElTooltip
placement="top"
:content="$t('report-manager.settings.allowWatermarkTip')"
>
<CircleHelp
class="h-3.5 w-3.5 shrink-0 cursor-help text-[var(--el-text-color-secondary)]"
/>
</ElTooltip>
</div>
<ElSwitch
v-model="form.allow_watermark"
size="small"
:disabled="saving"
@change="handleSwitchChange"
/>
</div>
<template v-if="form.allow_watermark">
<div class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs">
{{ $t('report-manager.settings.watermarkText') }}
</span>
<ElInput
v-model="form.watermark_text"
size="small"
:disabled="saving"
:placeholder="$t('report-manager.settings.watermarkPlaceholder')"
@input="handleTextChange"
/>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted-foreground text-xs">
{{ $t('report-manager.settings.watermarkShowTime') }}
</span>
<ElSwitch
v-model="form.watermark_show_time"
size="small"
:disabled="saving"
@change="handleSwitchChange"
/>
</div>
<div v-if="form.watermark_show_time" class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs">
{{ $t('report-manager.settings.watermarkTimeFormat') }}
</span>
<ElSelect
v-model="form.watermark_time_format"
size="small"
class="w-full"
:disabled="saving"
@change="handleSwitchChange"
>
<ElOption
v-for="fmt in timeFormatOptions"
:key="fmt"
:label="fmt"
:value="fmt"
/>
</ElSelect>
</div>
</template>
</div>
</template>
@@ -0,0 +1,147 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { Plus, Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElFormItem,
ElOption,
ElSelect,
ElTable,
ElTableColumn,
} from 'element-plus';
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
import { ZqDialog } from '#/components/zq-dialog';
import { useReportDatasetFields } from '../hooks/useReportDatasetFields';
import ReportFieldSelect from './report-field-select.vue';
const visible = defineModel<boolean>('visible', { default: false });
const props = defineProps<{
sortList: any[];
datasets: ReportDatasetItem[];
defaultSheetId?: string;
}>();
const emit = defineEmits<{
confirm: [any[]];
}>();
const rows = ref<any[]>([]);
const sheetId = ref('sheet1');
const { ensureAll } = useReportDatasetFields(() => props.datasets);
watch(
() => props.sortList,
(list) => {
const first = list?.[0];
if (first?.sortList?.length) {
sheetId.value = first.sheet || props.defaultSheetId || 'sheet1';
rows.value = first.sortList.map((r: any) => ({ ...r }));
} else {
sheetId.value = props.defaultSheetId || 'sheet1';
rows.value = [];
}
},
{ immediate: true, deep: true },
);
watch(visible, (open) => {
if (open) {
ensureAll();
}
});
function addRow() {
rows.value.push({
id: `sort_${Date.now()}_${rows.value.length}`,
vModel: '',
type: 'asc',
});
}
function removeRow(index: number) {
rows.value.splice(index, 1);
}
function handleConfirm() {
const valid = rows.value.filter((r) => r.vModel?.trim());
emit('confirm', [
{
sheet: sheetId.value,
sheetName: sheetId.value,
sortList: valid,
},
]);
visible.value = false;
}
</script>
<template>
<ZqDialog
v-model="visible"
:title="$t('report-manager.sort.title')"
width="640px"
@confirm="handleConfirm"
>
<p class="mb-3 text-xs text-[var(--el-text-color-secondary)]">
{{ $t('report-manager.sort.hint') }}
</p>
<div class="mb-3 flex justify-end">
<ElButton type="primary" :icon="Plus" @click="addRow">
{{ $t('report-manager.sort.add') }}
</ElButton>
</div>
<ElTable :data="rows" border max-height="320">
<ElTableColumn :label="$t('report-manager.sort.field')" min-width="220">
<template #default="{ row }">
<ReportFieldSelect
v-model="row.vModel"
size="small"
:datasets="datasets"
:with-alias="true"
:placeholder="$t('report-manager.sort.fieldPlaceholder')"
/>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('report-manager.sort.order')" width="140">
<template #default="{ row }">
<ElSelect v-model="row.type" size="small" class="w-full" filterable>
<ElOption
:label="$t('report-manager.sort.asc')"
value="asc"
/>
<ElOption
:label="$t('report-manager.sort.desc')"
value="desc"
/>
</ElSelect>
</template>
</ElTableColumn>
<ElTableColumn width="56" align="center">
<template #default="{ $index }">
<ElButton
link
type="danger"
:icon="Trash2"
@click="removeRow($index)"
/>
</template>
</ElTableColumn>
</ElTable>
<ElFormItem
v-if="(props.datasets || []).length"
class="mt-3"
:label="$t('report-manager.sort.datasetHint')"
>
<span class="text-xs text-[var(--el-text-color-secondary)]">
{{ (props.datasets || []).map((d) => d.alias).join(', ') }}
</span>
</ElFormItem>
</ZqDialog>
</template>
@@ -0,0 +1,321 @@
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { usePreferences } from '@vben/preferences';
import { ZqUniver } from '@zq/univer';
import {
applyChartDataToEcharts,
parseCellsMeta,
type ChartDataItem,
} from './hooks/useReportChart';
const props = withDefaults(
defineProps<{
mode?: 'design' | 'preview' | 'print';
readonly?: boolean;
snapshot?: Record<string, any>;
cells?: Record<string, any>;
chartData?: ChartDataItem[];
watermark?: { show?: boolean; config?: Record<string, any> };
}>(),
{
mode: 'design',
readonly: false,
snapshot: () => ({}),
cells: () => ({}),
chartData: () => [],
watermark: () => ({}),
},
);
const { locale } = usePreferences();
const appLocale = computed(() => locale.value || 'zh-CN');
const univerRef = ref<InstanceType<typeof ZqUniver> | null>(null);
const ready = ref(false);
const activeSheetId = ref('sheet1');
let sheetPollTimer: ReturnType<typeof setInterval> | null = null;
let initToken = 0;
function getSnapshotFingerprint(snap?: Record<string, any>) {
if (!snap || !Object.keys(snap).length) return '';
const sheetOrder = Array.isArray(snap.sheetOrder) ? snap.sheetOrder.join(',') : '';
return `${snap.id || ''}:${sheetOrder}:${Object.keys(snap.sheets || {}).length}`;
}
function syncActiveSheetId() {
const id = univerRef.value?.getActiveWorksheetId?.();
if (id && id !== activeSheetId.value) {
activeSheetId.value = id;
emit('sheetChange', id);
} else if (id) {
activeSheetId.value = id;
}
}
function startSheetPolling() {
stopSheetPolling();
if (props.mode !== 'preview') return;
sheetPollTimer = setInterval(syncActiveSheetId, 500);
}
function stopSheetPolling() {
if (sheetPollTimer) {
clearInterval(sheetPollTimer);
sheetPollTimer = null;
}
}
const emit = defineEmits<{
changeCell: [payload: {
startRow: number;
startColumn: number;
cellData: Record<string, any>;
sheetId?: string;
}];
focusFloatImage: [payload: {
drawingId: string;
imageType: 'BASE64' | 'URL';
option: Record<string, any>;
}];
focusFloatEchart: [payload: {
drawingId: string;
echartType: string;
option: Record<string, any>;
}];
sheetChange: [sheetId: string];
preview: [];
}>();
function resolveEchartStores() {
const meta = parseCellsMeta(props.cells);
let floatEcharts = meta.floatEcharts || {};
let cellEcharts = meta.cellEcharts || {};
const floatImages = meta.floatImages || {};
if (props.mode === 'preview' && props.chartData?.length) {
floatEcharts =
applyChartDataToEcharts(floatEcharts, props.chartData) || floatEcharts;
cellEcharts =
applyChartDataToEcharts(cellEcharts, props.chartData) || cellEcharts;
}
return { floatEcharts, cellEcharts, floatImages };
}
function onChangeCell(payload: {
startRow: number;
startColumn: number;
cellData: Record<string, any>;
}) {
activeSheetId.value =
univerRef.value?.getActiveWorksheetId?.() || activeSheetId.value;
emit('changeCell', {
...payload,
sheetId: activeSheetId.value,
});
}
async function initUniver() {
const token = ++initToken;
await nextTick();
if (token !== initToken) return;
const snap = props.snapshot;
if (!snap || !Object.keys(snap).length) {
ready.value = false;
return;
}
stopSheetPolling();
univerRef.value?.handleDisposeUnit?.();
if (token !== initToken) return;
const { floatEcharts, cellEcharts, floatImages } = resolveEchartStores();
await univerRef.value?.handleCreateDesignUnit?.({
mode: props.mode,
readonly: props.readonly,
snapshot: snap as any,
floatEcharts,
cellEcharts,
floatImages,
uiHeader: props.mode === 'design',
uiFooter: props.mode === 'design',
uiContextMenu: props.mode === 'design' && !props.readonly,
watermark: props.watermark,
appLocale: appLocale.value,
});
if (token !== initToken) {
univerRef.value?.handleDisposeUnit?.();
return;
}
ready.value = true;
syncActiveSheetId();
startSheetPolling();
}
watch(
() => [
getSnapshotFingerprint(props.snapshot),
props.mode,
props.readonly,
appLocale.value,
props.mode === 'preview' ? props.chartData : null,
],
() => {
void initUniver();
},
{ immediate: true },
);
onBeforeUnmount(() => {
initToken += 1;
stopSheetPolling();
univerRef.value?.handleDisposeUnit?.();
});
function getData() {
const pack = univerRef.value?.getDesignWorkbookData?.() || {};
const snapshotStr = pack.snapshot ? JSON.stringify(pack.snapshot) : '{}';
const floatEcharts = pack.floatEcharts || {};
const floatImages = pack.floatImages || {};
const cellEcharts = pack.cellEcharts || {};
const customs = pack.customs || [];
const customCells = customs.length
? customs.map((o: any) => ({
col: o.col,
row: o.row,
sheet: o.sheetId,
type: o.cellData?.custom?.type,
custom: o.cellData?.custom || {},
}))
: [];
return {
snapshot: snapshotStr,
cells: JSON.stringify({
cells: customCells,
floatEcharts,
cellEcharts,
floatImages,
}),
queryList: '[]',
sortList: '[]',
columnList: '[]',
fenceList: '[]',
convertConfig: '{}',
dataSetList: [],
};
}
function onFocusFloatImage(payload: {
drawingId: string;
imageType: 'BASE64' | 'URL';
option: Record<string, any>;
}) {
emit('focusFloatImage', payload);
}
function onFocusFloatEchart(payload: {
drawingId: string;
echartType: string;
option: Record<string, any>;
}) {
emit('focusFloatEchart', payload);
}
function onPreview() {
emit('preview');
}
function applyCellBinding(binding: {
startRow: number;
startColumn: number;
type: string;
v: string;
custom: Record<string, any>;
}) {
univerRef.value?.updateCellsData?.([
{
startRow: binding.startRow,
startColumn: binding.startColumn,
cellData: {
v: binding.v,
custom: binding.custom,
},
},
]);
}
function applyCellChart(payload: {
startRow: number;
startColumn: number;
echartType: string;
option: Record<string, any>;
preserveCustom: Record<string, any>;
}) {
const { startRow, startColumn, echartType, option, preserveCustom } = payload;
univerRef.value?.updateCellsData?.([
{
startRow,
startColumn,
cellData: {
custom: {
...preserveCustom,
type: 'chart',
chartType: echartType,
...option,
},
},
},
]);
}
function updateFloatImageConfig(config: {
drawingId: string;
imageType: 'BASE64' | 'URL';
option: Record<string, any>;
}) {
univerRef.value?.updateFloatImageConfig?.(config);
}
function updateFloatEchartConfig(config: {
drawingId: string;
echartType: string;
option: Record<string, any>;
}) {
univerRef.value?.updateFloatEchartConfig?.(config);
}
defineExpose({
getData,
initUniver,
applyCellBinding,
applyCellChart,
updateFloatImageConfig,
updateFloatEchartConfig,
getActiveWorksheetId: () =>
univerRef.value?.getActiveWorksheetId?.() || activeSheetId.value,
});
</script>
<template>
<div class="zq-univer-host h-full w-full min-h-[400px]">
<ZqUniver
ref="univerRef"
class="h-full w-full"
@change-cell="onChangeCell"
@focus-float-image="onFocusFloatImage"
@focus-float-echart="onFocusFloatEchart"
@preview="onPreview"
/>
</div>
</template>
<style scoped>
.zq-univer-host :deep(.zq-univer-design-content),
.zq-univer-host :deep(.univer-design-container) {
height: 100%;
width: 100%;
}
</style>
@@ -0,0 +1,199 @@
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
import { ref } from 'vue';
import {
getDataSourceDetailApi,
previewDataSourceApi,
} from '#/api/core/data-source';
export interface FieldOption {
label: string;
value: string;
}
export interface FieldTreeNode {
label: string;
value: string;
disabled?: boolean;
children?: FieldTreeNode[];
}
/** 跨组件共享字段缓存,避免重复请求预览接口 */
export const datasetFieldsCache = ref<Record<string, string[]>>({});
export const datasetFieldsLoadingMap = ref<Record<string, boolean>>({});
export function normalizeDataset(raw: ReportDatasetItem): ReportDatasetItem {
const dataSourceId =
raw.data_source_id || (raw as any).dataSourceId || '';
return {
...raw,
data_source_id: dataSourceId,
data_source_code:
raw.data_source_code || (raw as any).dataSourceCode || '',
data_source_name:
raw.data_source_name || (raw as any).dataSourceName || '',
alias:
raw.alias || raw.data_source_code || (raw as any).dataSourceCode || '',
field_mapping: raw.field_mapping || (raw as any).fieldMapping || {},
convert_config: raw.convert_config || (raw as any).convertConfig || {},
};
}
export function normalizePreviewRows(data: any): any[] {
if (Array.isArray(data)) return data;
if (data && typeof data === 'object') return [data];
return [];
}
export function extractFieldsFromRows(rows: any[]): string[] {
const names = new Set<string>();
for (const row of rows || []) {
if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
for (const key of Object.keys(row)) {
if (key) names.add(key);
}
}
return [...names].sort();
}
export function mergeDatasetFields(
ds: ReportDatasetItem,
rows: any[],
): string[] {
const names = new Set(extractFieldsFromRows(rows));
const mapping = ds.field_mapping || {};
for (const key of Object.keys(mapping)) {
if (key) names.add(key);
}
for (const value of Object.values(mapping)) {
if (value) names.add(String(value));
}
return [...names].sort();
}
async function loadStaticFields(dataSourceId: string) {
try {
const detail = await getDataSourceDetailApi(dataSourceId);
return extractFieldsFromRows(normalizePreviewRows(detail?.static_data));
} catch {
return [];
}
}
export async function fetchDatasetFields(
ds: ReportDatasetItem,
): Promise<string[]> {
const normalized = normalizeDataset(ds);
const id = normalized.data_source_id;
if (!id) return mergeDatasetFields(normalized, []);
try {
const result = await previewDataSourceApi(id, { params: {}, limit: 5 });
let fields = mergeDatasetFields(
normalized,
normalizePreviewRows(result?.data),
);
if (!fields.length) {
fields = mergeDatasetFields(
normalized,
normalizePreviewRows(await loadStaticFields(id)),
);
}
return fields;
} catch {
return mergeDatasetFields(
normalized,
normalizePreviewRows(await loadStaticFields(id)),
);
}
}
export function buildFieldOptions(
datasets: ReportDatasetItem[],
fieldsCache: Record<string, string[]>,
options?: {
alias?: string;
withAlias?: boolean;
},
): FieldOption[] {
const list = (datasets || []).map(normalizeDataset).filter((d) => d.alias);
const filtered = options?.alias
? list.filter((d) => d.alias === options.alias)
: list;
const opts: FieldOption[] = [];
const seen = new Set<string>();
for (const ds of filtered) {
const fields = fieldsCache[ds.data_source_id] || [];
for (const field of fields) {
const value = options?.withAlias === false ? field : `${ds.alias}.${field}`;
if (seen.has(value)) continue;
seen.add(value);
opts.push({ label: value, value });
}
}
return opts.sort((a, b) => a.label.localeCompare(b.label));
}
export function buildFieldTree(
datasets: ReportDatasetItem[],
fieldsCache: Record<string, string[]>,
options?: {
alias?: string;
withAlias?: boolean;
currentValue?: string;
},
): FieldTreeNode[] {
const withAlias = options?.withAlias !== false;
const list = (datasets || []).map(normalizeDataset).filter((d) => d.alias);
const filtered = options?.alias
? list.filter((d) => d.alias === options.alias)
: list;
const tree: FieldTreeNode[] = [];
const valueSet = new Set<string>();
for (const ds of filtered) {
const fields = fieldsCache[ds.data_source_id] || [];
const children: FieldTreeNode[] = fields.map((field) => {
const value = withAlias ? `${ds.alias}.${field}` : field;
valueSet.add(value);
return {
label: withAlias ? `${ds.alias}.${field}` : field,
value,
};
});
tree.push({
label: ds.data_source_name
? `${ds.alias} (${ds.data_source_name})`
: ds.alias,
value: `__dataset__:${ds.alias}`,
disabled: true,
children,
});
}
const trimmed = (options?.currentValue || '').trim();
if (trimmed && !valueSet.has(trimmed)) {
tree.unshift({
label: trimmed,
value: trimmed,
});
}
return tree;
}
export function appendCustomFieldOption(
options: FieldOption[],
value?: string,
): FieldOption[] {
const trimmed = (value || '').trim();
if (!trimmed) return options;
if (options.some((opt) => opt.value === trimmed)) return options;
return [{ label: trimmed, value: trimmed }, ...options];
}
@@ -0,0 +1,20 @@
import { $t } from '@vben/locales';
import { ElMessage } from 'element-plus';
/** 展示预览/导出返回的 warning 码(与后端 preview_guard 对齐) */
export function showPreviewWarnings(warnings: string[]) {
for (const code of warnings) {
if (code === 'expression_cycle') {
ElMessage.warning($t('report-manager.preview.expressionCycle'));
} else if (code === 'snapshot_large') {
ElMessage.warning($t('report-manager.preview.snapshotLarge'));
} else if (code.startsWith('dataset_row_warn:')) {
const alias = code.slice('dataset_row_warn:'.length);
ElMessage.warning($t('report-manager.preview.datasetRowWarn', { alias }));
} else if (code.startsWith('dataset_row_limit:')) {
const alias = code.slice('dataset_row_limit:'.length);
ElMessage.warning($t('report-manager.preview.datasetRowLimit', { alias }));
}
}
}
@@ -0,0 +1,144 @@
/** 解析报表媒体路径为可访问 URL(悬浮图片等) */
export function resolveReportMediaUrl(path: string, baseUrl?: string): string {
if (!path) return '';
const trimmed = path.trim();
if (/^data:image\//i.test(trimmed)) return trimmed;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
const apiPrefix = (baseUrl || import.meta.env.VITE_GLOB_API_URL || '/basic-api').replace(
/\/$/,
'',
);
if (trimmed.startsWith('/basic-api')) return trimmed;
if (trimmed.startsWith('/api/')) return `${apiPrefix}${trimmed}`;
const normalized = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed;
if (normalized.startsWith('api/')) return `${apiPrefix}/${normalized}`;
return `${apiPrefix}/api/file_manager/file/download?path=${encodeURIComponent(trimmed)}`;
}
function resolveMediaInObject(obj: Record<string, any>, baseUrl?: string) {
if (typeof obj.src === 'string' && obj.src) {
obj.src = resolveReportMediaUrl(obj.src, baseUrl);
}
if (typeof obj.source === 'string' && obj.source && !obj.source.startsWith('data:')) {
obj.source = resolveReportMediaUrl(obj.source, baseUrl);
}
if (typeof obj.url === 'string' && obj.url) {
obj.url = resolveReportMediaUrl(obj.url, baseUrl);
}
}
/** 解析 cells 中 floatImages 的媒体 URL */
export function resolveReportMediaUrlsInCells(
cells: Record<string, any>,
baseUrl?: string,
): Record<string, any> {
if (!cells || typeof cells !== 'object') return cells;
const next = JSON.parse(JSON.stringify(cells)) as Record<string, any>;
const floatImages = next.floatImages;
if (floatImages && typeof floatImages === 'object') {
for (const item of Object.values(floatImages) as any[]) {
if (item?.option) resolveMediaInObject(item.option, baseUrl);
}
}
return next;
}
/** 解析 snapshot 内 SHEET_DRAWING_PLUGIN 等资源中的图片 URL */
export function resolveReportMediaUrlsInSnapshot(
snapshot: Record<string, any>,
baseUrl?: string,
): Record<string, any> {
if (!snapshot || typeof snapshot !== 'object') return snapshot;
const next = JSON.parse(JSON.stringify(snapshot)) as Record<string, any>;
const resources = next.resources;
if (!Array.isArray(resources)) return next;
for (const resource of resources) {
if (resource?.name !== 'SHEET_DRAWING_PLUGIN' || !resource.data) continue;
try {
const parsed = JSON.parse(resource.data);
for (const sheetBlock of Object.values(parsed) as any[]) {
const data = sheetBlock?.data;
if (!data || typeof data !== 'object') continue;
for (const drawing of Object.values(data) as any[]) {
if (typeof drawing?.source === 'string' && drawing.source) {
drawing.source = resolveReportMediaUrl(drawing.source, baseUrl);
}
}
}
resource.data = JSON.stringify(parsed);
} catch {
/* keep original */
}
}
return next;
}
/** 合并解析 snapshot 与 cells 中的媒体 URL */
export function resolveReportMediaUrls(
payload: { snapshot?: Record<string, any>; cells?: Record<string, any> },
baseUrl?: string,
) {
return {
snapshot: payload.snapshot
? resolveReportMediaUrlsInSnapshot(payload.snapshot, baseUrl)
: payload.snapshot,
cells: payload.cells
? resolveReportMediaUrlsInCells(payload.cells, baseUrl)
: payload.cells,
};
}
/** 统计 snapshot / cells 中的图片数量(用于打印前告警) */
export function countSnapshotImages(
snapshot?: Record<string, any>,
cells?: Record<string, any>,
): number {
let count = 0;
const floatImages = cells?.floatImages;
if (floatImages && typeof floatImages === 'object') {
count += Object.keys(floatImages).length;
}
const resources = snapshot?.resources;
if (Array.isArray(resources)) {
for (const resource of resources) {
if (resource?.name !== 'SHEET_DRAWING_PLUGIN' || !resource.data) continue;
try {
const parsed = JSON.parse(resource.data);
for (const sheetBlock of Object.values(parsed) as any[]) {
const data = sheetBlock?.data;
if (!data || typeof data !== 'object') continue;
for (const drawing of Object.values(data) as any[]) {
if (drawing?.source || drawing?.imageSourceType) count += 1;
}
}
} catch {
/* ignore */
}
}
}
const sheets = snapshot?.sheets;
if (sheets && typeof sheets === 'object') {
for (const sheet of Object.values(sheets) as any[]) {
const cellData = sheet?.cellData;
if (!cellData || typeof cellData !== 'object') continue;
for (const row of Object.values(cellData) as any[]) {
if (!row || typeof row !== 'object') continue;
for (const cell of Object.values(row) as any[]) {
if (cell?.p?.drawings) {
count += Object.keys(cell.p.drawings).length;
}
}
}
}
}
return count;
}
@@ -0,0 +1,49 @@
export interface ReportSettingsForm {
allow_export: boolean;
allow_print: boolean;
allow_watermark: boolean;
watermark_text: string;
watermark_show_time: boolean;
watermark_time_format: string;
}
export function parseReportSettings(
reportName: string,
options: {
allowExport?: boolean;
allowPrint?: boolean;
allowWatermark?: boolean;
watermarkConfig?: Record<string, any>;
},
): ReportSettingsForm {
const wc = options.watermarkConfig || {};
return {
allow_export: options.allowExport !== false,
allow_print: options.allowPrint !== false,
allow_watermark: !!options.allowWatermark,
watermark_text: (wc.content as string) || reportName,
watermark_show_time: !!wc.showTime,
watermark_time_format: (wc.timeFormat as string) || 'yyyy-MM-dd',
};
}
export function buildReportSettingsPayload(
form: ReportSettingsForm,
reportName: string,
) {
return {
allow_export: form.allow_export,
allow_print: form.allow_print,
allow_watermark: form.allow_watermark,
watermark_config: form.allow_watermark
? {
content: form.watermark_text || reportName,
opacity: 0.12,
rotate: -30,
repeat: true,
showTime: form.watermark_show_time,
timeFormat: form.watermark_time_format,
}
: {},
};
}
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from '@vben/locales'
import {
ElForm, ElFormItem, ElInput, ElSelect, ElOption, ElMessage,
} from 'element-plus'
import { ZqDialog } from '#/components/zq-dialog'
import { useWikiStore } from '#/store/wiki'
import ImageSelector from '#/components/zq-form/image-selector/image-selector.vue'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
created: [spaceId: string]
}>()
const { t } = useI18n()
const wikiStore = useWikiStore()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const saving = ref(false)
const form = ref({
name: '',
description: '',
category: 'default',
visibility: 'private',
avatar: undefined as string | undefined,
})
const categories = computed(() => [
{ value: 'default', label: t('wiki.categoryDefault') },
{ value: 'tech', label: t('wiki.categoryTech') },
{ value: 'product', label: t('wiki.categoryProduct') },
{ value: 'design', label: t('wiki.categoryDesign') },
{ value: 'business', label: t('wiki.categoryBusiness') },
{ value: 'other', label: t('wiki.categoryOther') },
])
const visibilityOptions = computed(() => [
{ value: 'private', label: t('wiki.private') },
{ value: 'team', label: t('wiki.team') },
{ value: 'public', label: t('wiki.public') },
])
watch(() => props.modelValue, (v) => {
if (v) {
form.value = { name: '', description: '', category: 'default', visibility: 'private', avatar: undefined }
}
})
async function handleCreate() {
const name = form.value.name.trim()
if (!name) {
ElMessage.warning(t('wiki.spaceNamePlaceholder'))
return
}
saving.value = true
try {
const id = await wikiStore.createSpace({
name,
description: form.value.description.trim() || undefined,
category: form.value.category,
visibility: form.value.visibility,
avatar: form.value.avatar || undefined,
})
if (id) {
visible.value = false
emit('created', id)
}
} catch {
ElMessage.error('Failed')
} finally {
saving.value = false
}
}
</script>
<template>
<ZqDialog
v-model="visible"
:title="t('wiki.createSpace')"
width="480px"
:confirm-loading="saving"
@confirm="handleCreate"
>
<ElForm label-position="top">
<ElFormItem :label="t('wiki.avatar')">
<ImageSelector
v-model="form.avatar"
:size="72"
enable-crop
:crop-aspect-ratio="1"
crop-shape="circle"
source="wiki"
/>
</ElFormItem>
<ElFormItem :label="t('wiki.spaceName')" required>
<ElInput
v-model="form.name"
:placeholder="t('wiki.spaceNamePlaceholder')"
maxlength="200"
show-word-limit
@keyup.enter="handleCreate"
/>
</ElFormItem>
<ElFormItem :label="t('wiki.spaceDescription')">
<ElInput
v-model="form.description"
type="textarea"
:rows="3"
:placeholder="t('wiki.spaceDescriptionPlaceholder')"
maxlength="500"
/>
</ElFormItem>
<ElFormItem :label="t('wiki.category')">
<ElSelect v-model="form.category" class="w-full">
<ElOption
v-for="c in categories"
:key="c.value"
:label="c.label"
:value="c.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="t('wiki.visibility')">
<ElSelect v-model="form.visibility" class="w-full">
<ElOption
v-for="v in visibilityOptions"
:key="v.value"
:label="v.label"
:value="v.value"
/>
</ElSelect>
</ElFormItem>
</ElForm>
</ZqDialog>
</template>
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from '@vben/locales'
import {
ElForm, ElFormItem, ElInput, ElSelect, ElOption, ElMessage,
} from 'element-plus'
import { ZqDialog } from '#/components/zq-dialog'
import { useWikiStore } from '#/store/wiki'
import type { WikiSpace } from '#/types/zq-smart-table/table'
import ImageSelector from '#/components/zq-form/image-selector/image-selector.vue'
const props = defineProps<{
modelValue: boolean
space: WikiSpace | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
updated: [spaceId: string]
}>()
const { t } = useI18n()
const wikiStore = useWikiStore()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const saving = ref(false)
const form = ref({
name: '',
description: '',
category: 'default',
visibility: 'private',
avatar: undefined as string | undefined,
})
const categories = computed(() => [
{ value: 'default', label: t('wiki.categoryDefault') },
{ value: 'tech', label: t('wiki.categoryTech') },
{ value: 'product', label: t('wiki.categoryProduct') },
{ value: 'design', label: t('wiki.categoryDesign') },
{ value: 'business', label: t('wiki.categoryBusiness') },
{ value: 'other', label: t('wiki.categoryOther') },
])
const visibilityOptions = computed(() => [
{ value: 'private', label: t('wiki.private') },
{ value: 'team', label: t('wiki.team') },
{ value: 'public', label: t('wiki.public') },
])
watch(() => props.modelValue, (v) => {
if (v && props.space) {
form.value = {
name: props.space.name,
description: props.space.description || '',
category: props.space.category || 'default',
visibility: props.space.visibility || 'private',
avatar: props.space.avatar || undefined,
}
}
})
async function handleSave() {
if (!props.space) return
const name = form.value.name.trim()
if (!name) {
ElMessage.warning(t('wiki.spaceNamePlaceholder'))
return
}
saving.value = true
try {
await wikiStore.updateSpace(props.space.id, {
name,
description: form.value.description.trim() || null,
category: form.value.category,
visibility: form.value.visibility,
avatar: form.value.avatar || null,
})
ElMessage.success(t('wiki.editSpaceSuccess'))
visible.value = false
emit('updated', props.space.id)
} catch {
ElMessage.error('Failed')
} finally {
saving.value = false
}
}
</script>
<template>
<ZqDialog
v-model="visible"
:title="t('wiki.editSpace')"
width="480px"
:confirm-loading="saving"
@confirm="handleSave"
>
<ElForm label-position="top">
<ElFormItem :label="t('wiki.avatar')">
<ImageSelector
v-model="form.avatar"
:size="72"
enable-crop
:crop-aspect-ratio="1"
crop-shape="circle"
source="wiki"
/>
</ElFormItem>
<ElFormItem :label="t('wiki.spaceName')" required>
<ElInput
v-model="form.name"
:placeholder="t('wiki.spaceNamePlaceholder')"
maxlength="200"
show-word-limit
@keyup.enter="handleSave"
/>
</ElFormItem>
<ElFormItem :label="t('wiki.spaceDescription')">
<ElInput
v-model="form.description"
type="textarea"
:rows="3"
:placeholder="t('wiki.spaceDescriptionPlaceholder')"
maxlength="500"
/>
</ElFormItem>
<ElFormItem :label="t('wiki.category')">
<ElSelect v-model="form.category" class="w-full">
<ElOption
v-for="c in categories"
:key="c.value"
:label="c.label"
:value="c.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="t('wiki.visibility')">
<ElSelect v-model="form.visibility" class="w-full">
<ElOption
v-for="v in visibilityOptions"
:key="v.value"
:label="v.label"
:value="v.value"
/>
</ElSelect>
</ElFormItem>
</ElForm>
</ZqDialog>
</template>
@@ -0,0 +1,950 @@
<script setup lang="ts">
import '#/styles/zq-smart-table/theme.css'
import { ref, computed, nextTick, watch } from 'vue'
import { useTableStore } from '#/store/zq-smart-table'
import { SmartItemType, type Table } from '#/types/zq-smart-table/table'
import { useI18n } from '@vben/locales'
import { useRouter, useRoute } from 'vue-router'
import {
Grid,
FileText,
Search,
ChevronDown,
ChevronRight,
Plus,
Ellipsis,
Pencil,
Trash2,
FilePlus2,
BookOpen,
} from '@vben/icons'
import { ElMessageBox } from 'element-plus'
import TemplateDialog from '#/components/zq-smart-table/layout/TemplateDialog.vue'
import TemplateSelectDialog from '#/components/zq-smart-table/document/TemplateSelectDialog.vue'
import type { TableTemplate } from '#/components/zq-smart-table/templates/table-templates'
import type { DocumentTemplate } from '#/components/zq-smart-table/templates/document-templates'
type TreeNode = Table & { children: TreeNode[] }
interface FlatNode {
id: string
name: string
type: SmartItemType
depth: number
hasChildren: boolean
parentId: string | null | undefined
}
const emit = defineEmits<{
selectSpace: [spaceId: string]
selectDoc: [tableId: string]
showSpaces: []
}>()
const { t } = useI18n()
const tableStore = useTableStore()
const router = useRouter()
const route = useRoute()
const SIDEBAR_DEFAULT = 256
const SIDEBAR_MIN = 200
const SIDEBAR_MAX = 480
const sidebarWidth = ref(SIDEBAR_DEFAULT)
const isResizing = ref(false)
const searchText = ref('')
function onResizeStart(e: MouseEvent) {
e.preventDefault()
isResizing.value = true
const startX = e.clientX
const startW = sidebarWidth.value
function onMove(ev: MouseEvent) {
const newW = startW + (ev.clientX - startX)
sidebarWidth.value = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, newW))
}
function onUp() {
isResizing.value = false
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
function onResizeDblClick() {
sidebarWidth.value = SIDEBAR_DEFAULT
}
// Active state
const activeView = computed(() => {
const tableId = route.params.tableId as string | undefined
if (tableId) return { type: 'doc' as const, id: tableId }
return { type: 'spaces' as const, id: null }
})
// ==================== My Documents Tree ====================
const expandedIds = ref<Set<string>>(new Set())
function toggleExpand(id: string) {
const s = new Set(expandedIds.value)
if (s.has(id)) s.delete(id)
else s.add(id)
expandedIds.value = s
}
function buildTree(items: Table[]): TreeNode[] {
const map = new Map<string, TreeNode>()
const roots: TreeNode[] = []
for (const item of items) {
map.set(item.id, { ...item, children: [] })
}
for (const node of map.values()) {
const pid = node.parentId
if (pid && map.has(pid)) {
map.get(pid)!.children.push(node)
} else {
roots.push(node)
}
}
return roots
}
function filterTree(nodes: TreeNode[], q: string): TreeNode[] {
if (!q) return nodes
const result: TreeNode[] = []
for (const node of nodes) {
const childMatch = filterTree(node.children, q)
if (node.name.toLowerCase().includes(q) || childMatch.length > 0) {
result.push({ ...node, children: childMatch })
}
}
return result
}
function flattenTree(nodes: TreeNode[], depth = 0): FlatNode[] {
const result: FlatNode[] = []
for (const node of nodes) {
result.push({
id: node.id,
name: node.name,
type: node.type,
depth,
hasChildren: node.children.length > 0,
parentId: node.parentId,
})
if (node.children.length > 0 && expandedIds.value.has(node.id)) {
result.push(...flattenTree(node.children, depth + 1))
}
}
return result
}
const tree = computed(() => buildTree(tableStore.tables))
const filteredTree = computed(() => {
const q = searchText.value.trim().toLowerCase()
return filterTree(tree.value, q)
})
const flatList = computed(() => flattenTree(filteredTree.value))
const hasSearchResults = computed(() => flatList.value.length > 0)
watch(() => tableStore.tables, () => {
if (expandedIds.value.size === 0 && tableStore.tables.length > 0) {
const ids = new Set<string>()
function walk(nodes: TreeNode[]) {
for (const n of nodes) {
if (n.children.length > 0) {
ids.add(n.id)
walk(n.children)
}
}
}
walk(tree.value as TreeNode[])
expandedIds.value = ids
}
}, { immediate: true })
// Drag state
const dragId = ref<string | null>(null)
const dropTargetId = ref<string | null>(null)
const dropPosition = ref<'inside' | 'before' | 'after' | null>(null)
function onDragStart(e: DragEvent, id: string) {
dragId.value = id
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', id)
}
}
function onDragOver(e: DragEvent, id: string) {
e.preventDefault()
if (dragId.value === id) return
dropTargetId.value = id
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const y = e.clientY - rect.top
const h = rect.height
if (y < h * 0.25) dropPosition.value = 'before'
else if (y > h * 0.75) dropPosition.value = 'after'
else dropPosition.value = 'inside'
}
function onDragLeave() {
dropTargetId.value = null
dropPosition.value = null
}
function onDrop(e: DragEvent) {
e.preventDefault()
if (!dragId.value || !dropTargetId.value || dragId.value === dropTargetId.value) {
resetDrag()
return
}
const targetItem = tableStore.tables.find(t => t.id === dropTargetId.value)
if (!targetItem) { resetDrag(); return }
const draggedId = dragId.value
if (dropPosition.value === 'inside') {
const children = tableStore.tables.filter(
(t) => (t.parentId ?? null) === dropTargetId.value && t.id !== draggedId,
)
const lastChild = children.length > 0 ? children[children.length - 1] : null
tableStore.moveTable(draggedId, dropTargetId.value, lastChild?.id ?? null)
expandedIds.value = new Set([...expandedIds.value, dropTargetId.value])
} else {
const newParentId = targetItem.parentId ?? null
const siblings = tableStore.tables.filter(
(t) => (t.parentId ?? null) === newParentId && t.id !== draggedId,
)
const targetIdx = siblings.findIndex((s) => s.id === dropTargetId.value)
let afterId: string | null = null
if (dropPosition.value === 'before') {
afterId = targetIdx > 0 ? (siblings[targetIdx - 1]?.id ?? null) : null
} else {
afterId = dropTargetId.value
}
tableStore.moveTable(draggedId, newParentId, afterId)
}
resetDrag()
}
function resetDrag() {
dragId.value = null
dropTargetId.value = null
dropPosition.value = null
}
function getItemDropClass(nodeId: string) {
if (dropTargetId.value !== nodeId) return ''
if (dropPosition.value === 'inside') return 'drop-inside'
if (dropPosition.value === 'before') return 'drop-before'
if (dropPosition.value === 'after') return 'drop-after'
return ''
}
// Rename
const renamingId = ref<string | null>(null)
const renameValue = ref('')
const renameInputEl = ref<HTMLInputElement | null>(null)
function setRenameRef(el: any) { renameInputEl.value = el }
function startRename(tableId: string, currentName: string) {
renamingId.value = tableId
renameValue.value = currentName
nextTick(() => {
renameInputEl.value?.focus()
renameInputEl.value?.select()
})
}
async function confirmRename(tableId: string) {
if (renameValue.value.trim()) {
const table = tableStore.tables.find((item) => item.id === tableId)
if (table) {
table.name = renameValue.value.trim()
const { updateTableApi } = await import('#/api/smart-table')
updateTableApi(tableId, { name: table.name }).catch(() => {})
}
}
renamingId.value = null
}
function handleDeleteTable(tableId: string, tableName: string) {
ElMessageBox.confirm(
t('sidebar.deleteTableConfirm', { name: tableName }),
t('sidebar.deleteTable'),
{
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning',
},
).then(async () => {
await tableStore.deleteTable(tableId)
if (activeView.value.type === 'doc' && activeView.value.id === tableId) {
router.push('/wiki')
}
}).catch(() => {})
}
function handleContextCommand(command: string, tableId: string, tableName: string) {
if (command === 'rename') startRename(tableId, tableName)
else if (command === 'delete') handleDeleteTable(tableId, tableName)
else if (command === 'addSubPage') handleAddSubPage(tableId)
}
// Actions
const showTemplateDialog = ref(false)
const showDocTemplateDialog = ref(false)
async function handleSelectTemplate(template: TableTemplate) {
const newId = await tableStore.addTableFromTemplate(template)
if (newId) {
tableStore.creatingFromTemplate = true
try {
await tableStore.loadTableFull(newId)
} finally {
tableStore.creatingFromTemplate = false
}
router.push(`/wiki/doc/${newId}`)
emit('selectDoc', newId)
}
}
async function handleAddDocument() {
const name = t('document.untitled')
const newId = await tableStore.addDocument(name)
if (newId) {
await tableStore.loadTableFull(newId)
router.push(`/wiki/doc/${newId}`)
emit('selectDoc', newId)
}
}
async function handleSelectDocTemplate(template: DocumentTemplate) {
const name = template.name || t('document.untitled')
const newId = await tableStore.addDocumentFromTemplate(name, template.content)
if (newId) {
await tableStore.loadTableFull(newId)
router.push(`/wiki/doc/${newId}`)
emit('selectDoc', newId)
}
}
async function handleBlankDocFromDialog() {
await handleAddDocument()
}
async function handleAddSubPage(parentId: string) {
const name = t('document.untitled')
const newId = await tableStore.addSubPage(parentId, name)
if (newId) {
expandedIds.value = new Set([...expandedIds.value, parentId])
await tableStore.loadTableFull(newId)
router.push(`/wiki/doc/${newId}`)
emit('selectDoc', newId)
}
}
function handleSelectDoc(tableId: string) {
if (renamingId.value === tableId) return
tableStore.activeTableId = tableId
router.push(`/wiki/doc/${tableId}`)
emit('selectDoc', tableId)
}
function handleShowSpaces() {
router.push('/wiki')
emit('showSpaces')
}
function getNodeIcon(type: SmartItemType) {
return type === SmartItemType.Document ? FileText : Grid
}
</script>
<template>
<div
class="zq-sidebar"
:class="{ 'is-resizing': isResizing }"
:style="{ width: `${sidebarWidth}px` }"
>
<div class="zq-sidebar__inner" :style="{ width: `${sidebarWidth}px` }">
<!-- Header -->
<div class="zq-sidebar__header">
<div class="zq-sidebar__brand">
<div class="zq-sidebar__logo">
<BookOpen class="w-4 h-4" />
</div>
<span class="zq-sidebar__brand-title">{{ t('wiki.title') }}</span>
</div>
</div>
<!-- Search -->
<div class="zq-sidebar__search">
<Search class="zq-sidebar__search-icon" />
<input
v-model="searchText"
class="zq-sidebar__search-input"
:placeholder="t('wiki.searchDocPlaceholder')"
/>
</div>
<!-- Scrollable content -->
<div class="zq-sidebar__content">
<!-- Wiki Spaces entry -->
<div class="zq-sidebar__section">
<div
class="zq-sidebar__section-header"
:class="{ 'is-active': activeView.type === 'spaces' }"
@click="handleShowSpaces"
>
<BookOpen class="zq-sidebar__section-icon" />
<span class="zq-sidebar__section-title">{{ t('wiki.wikiSpaces') }}</span>
</div>
</div>
<!-- Divider -->
<div class="zq-sidebar__divider" />
<!-- My Documents Section -->
<div class="zq-sidebar__section">
<div class="zq-sidebar__section-header zq-sidebar__section-header--docs">
<FileText class="zq-sidebar__section-icon" />
<span class="zq-sidebar__section-title">{{ t('wiki.myDocuments') }}</span>
</div>
<!-- Document tree -->
<template v-if="flatList.length > 0">
<div
v-for="node in flatList"
:key="node.id"
class="zq-sidebar__item"
:style="{ '--depth': node.depth }"
:class="[
{
'is-active': activeView.type === 'doc' && activeView.id === node.id,
'is-dragging': dragId === node.id,
},
getItemDropClass(node.id),
]"
draggable="true"
@dragstart="onDragStart($event, node.id)"
@dragover="onDragOver($event, node.id)"
@dragleave="onDragLeave"
@drop="onDrop($event)"
@dragend="resetDrag"
@click="handleSelectDoc(node.id)"
>
<button
v-if="node.hasChildren"
class="zq-sidebar__expand-toggle"
@click.stop="toggleExpand(node.id)"
>
<component
:is="expandedIds.has(node.id) ? ChevronDown : ChevronRight"
class="w-3.5 h-3.5"
/>
</button>
<span v-else class="zq-sidebar__expand-spacer" />
<component :is="getNodeIcon(node.type)" class="zq-sidebar__item-icon" />
<input
v-if="renamingId === node.id"
:ref="setRenameRef"
v-model="renameValue"
class="zq-sidebar__rename-input"
@blur="confirmRename(node.id)"
@keyup.enter="confirmRename(node.id)"
@keyup.escape="renamingId = null"
@click.stop
/>
<span v-else class="zq-sidebar__item-name">{{ node.name }}</span>
<el-dropdown
v-if="renamingId !== node.id"
trigger="click"
class="zq-sidebar__item-actions"
@command="(cmd: string) => handleContextCommand(cmd, node.id, node.name)"
>
<button class="zq-sidebar__more-btn" @click.stop>
<Ellipsis class="w-3.5 h-3.5" />
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="addSubPage">
<div class="flex items-center gap-2">
<FilePlus2 class="w-3.5 h-3.5" />
<span>{{ t('sidebar.newSubPage') }}</span>
</div>
</el-dropdown-item>
<el-dropdown-item command="rename">
<div class="flex items-center gap-2">
<Pencil class="w-3.5 h-3.5" />
<span>{{ t('common.rename') }}</span>
</div>
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<div class="flex items-center gap-2" style="color: var(--zq-danger-color)">
<Trash2 class="w-3.5 h-3.5" />
<span>{{ t('common.delete') }}</span>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<!-- Empty my docs -->
<div v-if="!searchText && flatList.length === 0 && !tableStore.loading" class="zq-sidebar__empty">
{{ t('wiki.emptyMyDocs') }}
</div>
<!-- Search no results -->
<div v-if="searchText && !hasSearchResults" class="zq-sidebar__no-results">
<Search class="w-6 h-6" style="color: var(--zq-text-placeholder)" />
<span>{{ t('wiki.noResults') }}</span>
</div>
</div>
</div>
<!-- Bottom actions -->
<div class="zq-sidebar__footer">
<button class="zq-sidebar__footer-btn" @click="showTemplateDialog = true">
<Plus class="w-4 h-4" />
<span>{{ t('sidebar.newTable') }}</span>
</button>
<button class="zq-sidebar__footer-btn" @click="showDocTemplateDialog = true">
<Plus class="w-4 h-4" />
<span>{{ t('sidebar.newDocument') }}</span>
</button>
</div>
</div>
<!-- Resize handle -->
<div
class="zq-sidebar__resize-handle"
@mousedown="onResizeStart"
@dblclick="onResizeDblClick"
/>
<TemplateDialog v-model="showTemplateDialog" @select="handleSelectTemplate" />
<TemplateSelectDialog
v-model="showDocTemplateDialog"
@select="handleSelectDocTemplate"
@blank="handleBlankDocFromDialog"
/>
</div>
</template>
<style scoped>
.zq-sidebar {
position: relative;
flex-shrink: 0;
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
overflow: visible;
z-index: 20;
}
.zq-sidebar.is-resizing {
transition: none;
}
.zq-sidebar__inner {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--zq-bg-sidebar);
border-right: 1px solid var(--zq-border-color-light);
}
/* Resize handle */
.zq-sidebar__resize-handle {
position: absolute;
top: 0;
right: -3px;
width: 3px;
height: 100%;
cursor: col-resize;
z-index: 30;
transition: background-color 0.15s;
}
.zq-sidebar__resize-handle:hover,
.zq-sidebar.is-resizing .zq-sidebar__resize-handle {
background-color: var(--zq-brand-color);
}
/* Header */
.zq-sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 12px 8px;
flex-shrink: 0;
}
.zq-sidebar__brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.zq-sidebar__logo {
width: 30px;
height: 30px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: var(--zq-brand-color);
flex-shrink: 0;
}
.zq-sidebar__brand-title {
font-size: 14px;
font-weight: 600;
color: var(--zq-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Search */
.zq-sidebar__search {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 12px 8px;
padding: 7px 10px;
border-radius: 8px;
background: var(--zq-bg-primary);
border: 1px solid transparent;
transition: all 0.15s;
}
.zq-sidebar__search:focus-within {
border-color: var(--zq-brand-color);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--zq-brand-color) 15%, transparent);
}
.zq-sidebar__search-icon {
width: 14px;
height: 14px;
color: var(--zq-text-placeholder);
flex-shrink: 0;
}
.zq-sidebar__search-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 13px;
color: var(--zq-text-primary);
line-height: 1;
}
.zq-sidebar__search-input::placeholder {
color: var(--zq-text-placeholder);
}
/* Scrollable content */
.zq-sidebar__content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 0 16px;
}
.zq-sidebar__content::-webkit-scrollbar {
width: 4px;
}
.zq-sidebar__content::-webkit-scrollbar-thumb {
background: var(--zq-border-color);
border-radius: 4px;
}
.zq-sidebar__content::-webkit-scrollbar-thumb:hover {
background: var(--zq-text-placeholder);
}
/* Section */
.zq-sidebar__section {
padding: 0 8px;
}
.zq-sidebar__section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
margin: 2px 0;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--zq-text-secondary);
transition: all 0.15s;
}
.zq-sidebar__section-header:hover {
background: var(--zq-bg-cell-hover);
color: var(--zq-text-primary);
}
.zq-sidebar__section-header.is-active {
background: var(--zq-brand-color-light);
color: var(--zq-brand-color);
}
.zq-sidebar__section-header--docs {
cursor: default;
}
.zq-sidebar__section-header--docs:hover {
background: transparent;
color: var(--zq-text-secondary);
}
.zq-sidebar__section-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
}
.zq-sidebar__section-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.zq-sidebar__section-add {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
border: none;
background: transparent;
color: var(--zq-text-placeholder);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.zq-sidebar__section-add:hover {
background: var(--zq-bg-cell-hover);
color: var(--zq-brand-color);
}
/* Divider */
.zq-sidebar__divider {
height: 1px;
margin: 6px 12px;
background: var(--zq-border-color-light);
}
/* Tree items (same as ZqTableSidebar) */
.zq-sidebar__item {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 8px 5px calc(8px + var(--depth, 0) * 16px);
margin: 1px 0;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: var(--zq-text-primary);
transition: background-color 0.15s, color 0.15s;
position: relative;
user-select: none;
}
.zq-sidebar__item:hover {
background: var(--zq-bg-cell-hover);
}
.zq-sidebar__item.is-active {
background: var(--zq-brand-color-light);
color: var(--zq-brand-color);
}
.zq-sidebar__item.is-active::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 3px;
border-radius: 0 2px 2px 0;
background: var(--zq-brand-color);
}
.zq-sidebar__item.is-dragging {
opacity: 0.4;
}
.zq-sidebar__item.drop-inside {
background: color-mix(in srgb, var(--zq-brand-color) 12%, transparent);
outline: 2px solid var(--zq-brand-color);
outline-offset: -2px;
border-radius: 6px;
}
.zq-sidebar__item.drop-before {
box-shadow: inset 0 2px 0 0 var(--zq-brand-color);
}
.zq-sidebar__item.drop-after {
box-shadow: inset 0 -2px 0 0 var(--zq-brand-color);
}
.zq-sidebar__expand-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
border: none;
background: transparent;
color: var(--zq-text-placeholder);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.zq-sidebar__expand-toggle:hover {
background: var(--zq-bg-cell-hover);
color: var(--zq-text-secondary);
}
.zq-sidebar__expand-spacer {
width: 18px;
flex-shrink: 0;
}
.zq-sidebar__item-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
opacity: 0.7;
}
.zq-sidebar__item.is-active .zq-sidebar__item-icon {
opacity: 1;
}
.zq-sidebar__item-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
.zq-sidebar__rename-input {
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 1.4;
padding: 1px 6px;
border-radius: 4px;
border: 1px solid var(--zq-brand-color);
outline: none;
background: var(--zq-bg-primary);
color: var(--zq-text-primary);
}
.zq-sidebar__item-actions {
flex-shrink: 0;
margin-left: auto;
}
.zq-sidebar__more-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
border: none;
background: transparent;
color: var(--zq-text-placeholder);
cursor: pointer;
opacity: 0;
transition: all 0.15s;
flex-shrink: 0;
}
.zq-sidebar__item:hover .zq-sidebar__more-btn {
opacity: 1;
}
.zq-sidebar__more-btn:hover {
background: var(--zq-bg-secondary);
color: var(--zq-text-secondary);
}
/* Empty & No Results */
.zq-sidebar__empty {
padding: 16px;
font-size: 12px;
color: var(--zq-text-placeholder);
text-align: center;
}
.zq-sidebar__no-results {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px 16px;
font-size: 13px;
color: var(--zq-text-placeholder);
}
/* Footer */
.zq-sidebar__footer {
flex-shrink: 0;
border-top: 1px solid var(--zq-border-color-light);
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.zq-sidebar__footer-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 12px;
border-radius: 6px;
border: none;
background: transparent;
color: var(--zq-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.zq-sidebar__footer-btn:hover {
background: var(--zq-bg-cell-hover);
color: var(--zq-text-primary);
}
</style>
@@ -0,0 +1,256 @@
<script lang="ts" setup>
import type { InstanceDocument } from '#/api/online-dev/workflow';
import { computed, ref, watch } from 'vue';
import { Download, RefreshCw } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElEmpty, ElMessage } from 'element-plus';
import {
generateInstanceDocumentsApi,
getInstanceDocumentsApi,
} from '#/api/online-dev/workflow';
import { ZqDialog } from '#/components/zq-dialog';
import { ZqTabs } from '#/components/zq-tabs/index';
import { getFileUrl } from '#/composables/useFileUrl';
interface Props {
instanceId: string;
}
const props = defineProps<Props>();
const visible = defineModel<boolean>('visible', { default: false });
const documentsLoading = ref(false);
const documentsList = ref<InstanceDocument[]>([]);
const generating = ref(false);
const activeDocId = ref('');
const previewUrls = ref<Record<string, string>>({});
const previewLoading = ref(false);
const currentPreviewDoc = computed(
() =>
documentsList.value.find((doc) => doc.id === activeDocId.value) ||
documentsList.value[0] ||
null,
);
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
async function loadPreviewUrl(doc: InstanceDocument) {
if (previewUrls.value[doc.id]) return;
previewLoading.value = true;
try {
const url = await getFileUrl(doc.file_id);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${$t('common.loadFailed')}: ${response.status}`);
}
const blob = await response.blob();
previewUrls.value[doc.id] = URL.createObjectURL(blob);
} catch (error: any) {
ElMessage.error(error?.message || $t('common.loadFailed'));
} finally {
previewLoading.value = false;
}
}
async function loadDocuments() {
if (!props.instanceId) return;
documentsLoading.value = true;
documentsList.value = [];
activeDocId.value = '';
Object.values(previewUrls.value).forEach((url) => URL.revokeObjectURL(url));
previewUrls.value = {};
try {
let docs = await getInstanceDocumentsApi(props.instanceId);
if (docs.length === 0) {
generating.value = true;
try {
await generateInstanceDocumentsApi(props.instanceId);
docs = await getInstanceDocumentsApi(props.instanceId);
} catch (genError: any) {
ElMessage.error(
genError?.message || $t('workflow.generateDocumentsFailed'),
);
} finally {
generating.value = false;
}
}
documentsList.value = docs;
if (docs.length > 0) {
activeDocId.value = docs[0].id;
await loadPreviewUrl(docs[0]);
}
} catch (error: any) {
ElMessage.error(error?.message || $t('common.loadFailed'));
} finally {
documentsLoading.value = false;
}
}
async function handleTabChange(docId: string) {
activeDocId.value = docId;
const doc = documentsList.value.find((d) => d.id === docId);
if (doc) {
await loadPreviewUrl(doc);
}
}
async function handleDownloadDocument(doc: InstanceDocument) {
try {
const cachedUrl = previewUrls.value[doc.id];
let downloadUrl: string;
let needRevoke = false;
if (cachedUrl) {
downloadUrl = cachedUrl;
} else {
const url = await getFileUrl(doc.file_id);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${$t('common.downloadFailed')}: ${response.status}`);
}
const blob = await response.blob();
downloadUrl = window.URL.createObjectURL(blob);
needRevoke = true;
}
const link = document.createElement('a');
link.href = downloadUrl;
link.download = `${doc.document_name}.pdf`;
document.body.append(link);
link.click();
link.remove();
if (needRevoke) {
window.URL.revokeObjectURL(downloadUrl);
}
} catch (error: any) {
ElMessage.error(error?.message || $t('common.downloadFailed'));
}
}
async function handleRegenerateDocuments() {
if (!props.instanceId) return;
generating.value = true;
Object.values(previewUrls.value).forEach((url) => URL.revokeObjectURL(url));
previewUrls.value = {};
try {
await generateInstanceDocumentsApi(props.instanceId);
const docs = await getInstanceDocumentsApi(props.instanceId);
documentsList.value = docs;
if (docs.length > 0) {
activeDocId.value = docs[0].id;
await loadPreviewUrl(docs[0]);
}
ElMessage.success($t('workflow.regenerateDocumentsSuccess'));
} catch (error: any) {
ElMessage.error(error?.message || $t('workflow.regenerateDocumentsFailed'));
} finally {
generating.value = false;
}
}
function handleDialogClosed() {
activeDocId.value = '';
Object.values(previewUrls.value).forEach((url) => URL.revokeObjectURL(url));
previewUrls.value = {};
}
watch(visible, (val) => {
if (val && props.instanceId) {
loadDocuments();
}
});
</script>
<template>
<ZqDialog
v-model="visible"
:title="$t('workflow.documents')"
width="1000px"
:show-footer="false"
@closed="handleDialogClosed"
>
<div
v-loading="documentsLoading || generating"
class="bg-background min-h-[calc(100vh-160px)]"
>
<template
v-if="!documentsLoading && !generating && documentsList.length === 0"
>
<ElEmpty :description="$t('workflow.noDocuments')" />
</template>
<template v-else-if="documentsList.length > 0">
<template v-if="documentsList.length > 1">
<ZqTabs
:items="
documentsList.map((doc) => ({
key: doc.id,
label: doc.document_name,
}))
"
:model-value="activeDocId"
@update:model-value="handleTabChange"
/>
</template>
<div
v-if="currentPreviewDoc"
class="mb-3 flex items-center justify-between"
>
<div class="text-muted-foreground text-sm">
{{ currentPreviewDoc.template_name }} ·
{{ formatFileSize(currentPreviewDoc.file_size) }} ·
{{ currentPreviewDoc.page_count }} {{ $t('workflow.pages') }}
</div>
<div class="flex gap-2">
<ElButton
size="small"
:loading="generating"
@click="handleRegenerateDocuments"
>
<RefreshCw class="mr-1 h-4 w-4" />
{{ $t('workflow.regenerateDocuments') }}
</ElButton>
<ElButton
type="primary"
size="small"
@click="handleDownloadDocument(currentPreviewDoc)"
>
<Download class="mr-1 h-4 w-4" />
{{ $t('common.download') }}
</ElButton>
</div>
</div>
<div v-loading="previewLoading" class="h-[calc(100vh-200px)]">
<iframe
v-if="currentPreviewDoc && previewUrls[currentPreviewDoc.id]"
:src="previewUrls[currentPreviewDoc.id]"
class="h-full w-full border-none"
></iframe>
</div>
</template>
</div>
</ZqDialog>
</template>
@@ -0,0 +1,497 @@
<script setup lang="ts">
import type { FormField } from './components/ConditionEditor.vue';
// 可用的子流程列表
import type { SubflowOption } from './components/PropertyPanel.vue';
import type {
ConditionBranchConfig,
FlowDefinition,
FlowNode,
NodeType,
ParallelBranchConfig,
} from './types';
import type { FormMeta } from '#/api/online-dev/form-manager';
/**
* 流程设计器组件
* 支持独立页面和嵌入模式
*/
import { computed, onMounted, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
getFormByCodeApi,
getPublishedFormsSimpleApi,
} from '#/api/online-dev/form-manager';
import type { PublishedFormSimple } from '#/api/online-dev/form-manager';
import { getWorkflowListApi } from '#/api/online-dev/workflow';
import { resolveFormListApplicationIdParam } from '#/utils/form-list-application-id';
import FlowCanvas from './components/FlowCanvas.vue';
import PropertyPanel from './components/PropertyPanel.vue';
import Toolbar from './components/Toolbar.vue';
import { useFlowData } from './hooks/useFlowData';
import { useFlowValidation } from './hooks/useFlowValidation';
import { isValidFlowDefinition } from './types';
const props = withDefaults(
defineProps<{
/** 所属应用ID(用于加载同应用下的表单列表) */
applicationId?: string;
/** 是否嵌入模式(隐藏工具栏的保存等按钮) */
embedded?: boolean;
/** 关联的表单编码 */
formCode?: string;
/** 初始流程定义 */
initialDefinition?: FlowDefinition | null;
/** 是否只读模式 */
readonly?: boolean;
}>(),
{
applicationId: '',
formCode: '',
initialDefinition: null,
readonly: false,
embedded: false,
},
);
const emit = defineEmits<{
/** 流程定义变更 */
change: [definition: FlowDefinition];
/** 保存(独立模式) */
save: [definition: FlowDefinition];
}>();
// 流程数据管理
const {
flowDefinition,
selectedNodeId,
selectedNode,
isDirty,
canUndo,
canRedo,
selectNode,
addNode,
deleteNode,
updateNode,
addConditionBranch,
deleteConditionBranch,
undo,
redo,
exportFlow,
importFlow,
resetFlow,
} = useFlowData();
// 流程校验
const { nodeErrors, validateQuick } = useFlowValidation();
// 属性面板状态
const propertyPanelVisible = ref(false);
const selectedBranchId = ref<null | string>(null);
const selectedBranchConfig = ref<ConditionBranchConfig | ParallelBranchConfig | null>(null);
// 表单元数据
const formMeta = ref<FormMeta | null>(null);
const formFields = ref<FormField[]>([]);
const appForms = ref<PublishedFormSimple[]>([]);
const subflowList = ref<SubflowOption[]>([]);
const currentFlowId = ref<string>('');
// 流程名称
const flowName = computed({
get: () => flowDefinition.value.name,
set: (val) => {
flowDefinition.value.name = val;
},
});
// 加载表单配置
async function loadFormMeta() {
if (!props.formCode) return;
try {
formMeta.value = await getFormByCodeApi(props.formCode);
// 解析表单字段
if (formMeta.value?.form_config?.items) {
formFields.value = parseFormFields(formMeta.value.form_config.items);
}
} catch (error: any) {
console.error('加载表单配置失败:', error);
}
}
// 加载当前应用下所有已发布表单(用于字段更新节点的跨表单选择)
async function loadAppForms() {
try {
const applicationId = props.applicationId?.trim()
? props.applicationId
: resolveFormListApplicationIdParam();
appForms.value = await getPublishedFormsSimpleApi(applicationId);
} catch (error: any) {
console.error('加载应用表单列表失败:', error);
}
}
// 加载可用的子流程列表(只加载已发布的流程)
async function loadSubflowList() {
try {
const res = await getWorkflowListApi({
status: 'published',
pageSize: 100,
});
subflowList.value = res.items.map((flow) => ({
id: flow.id,
name: flow.name,
description: flow.description,
formId: flow.form_code,
status: flow.status,
}));
} catch (error: any) {
console.error('加载子流程列表失败:', error);
}
}
// 解析表单字段为条件编辑器需要的格式
function parseFormFields(items: any[]): FormField[] {
const fields: FormField[] = [];
for (const item of items) {
if (
item.type === 'grid' ||
item.type === 'tabs' ||
item.type === 'collapse'
) {
// 容器类型,递归解析
if (item.children) {
fields.push(...parseFormFields(item.children));
}
if (item.columns) {
for (const col of item.columns) {
if (col.children) {
fields.push(...parseFormFields(col.children));
}
}
}
} else if (item.field) {
// 普通字段
const field: FormField = {
name: item.field,
label: item.label || item.field,
type: mapFieldType(item.type),
options: item.options,
multiple: item.props?.multiple ?? false,
};
if (item.type === 'form-selector' && item.formSelectorConfig) {
field.formSelectorConfig = {
formCode: item.formSelectorConfig.formCode || '',
valueField: item.formSelectorConfig.valueField || 'id',
labelField: item.formSelectorConfig.labelField || '',
};
}
fields.push(field);
}
}
return fields;
}
// 映射字段类型
function mapFieldType(
type: string,
): 'date' | 'dept' | 'form-selector' | 'number' | 'select' | 'string' | 'user' {
const typeMap: Record<
string,
'date' | 'dept' | 'form-selector' | 'number' | 'select' | 'string' | 'user'
> = {
input: 'string',
textarea: 'string',
number: 'number',
'input-number': 'number',
date: 'date',
datetime: 'date',
'date-picker': 'date',
select: 'select',
radio: 'select',
checkbox: 'select',
'user-selector': 'user',
'dept-selector': 'dept',
'form-selector': 'form-selector',
};
return typeMap[type] || 'string';
}
// 监听初始定义变化(后端新建流程可能返回 {},需区分有效定义)
watch(
() => props.initialDefinition,
(newDef) => {
if (isValidFlowDefinition(newDef)) {
importFlow(newDef);
} else {
resetFlow(newDef?.name);
}
},
{ immediate: true },
);
// 监听表单编码变化
watch(
() => props.formCode,
() => {
loadFormMeta();
},
{ immediate: true },
);
// 监听应用ID变化,重新加载应用表单列表
watch(
() => props.applicationId,
() => {
loadAppForms();
},
{ immediate: true },
);
// 监听流程定义变化,通知父组件并执行校验
watch(
flowDefinition,
(newDef) => {
emit('change', newDef);
// 实时校验
validateQuick(newDef);
},
{ deep: true },
);
// 选择节点
function handleSelectNode(nodeId: string) {
if (props.readonly) return;
selectNode(nodeId);
selectedBranchId.value = null;
selectedBranchConfig.value = null;
propertyPanelVisible.value = true;
}
// 添加节点
function handleAddNode(type: NodeType, parentId: string, branchId?: string) {
if (props.readonly) return;
addNode(type, { parentId, branchId });
}
// 删除节点
async function handleDeleteNode(nodeId: string) {
if (props.readonly) return;
try {
await ElMessageBox.confirm(
$t('workflow-designer.designer.deleteNodeConfirm'),
$t('workflow-designer.designer.tips'),
{
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'warning',
},
);
deleteNode(nodeId);
if (selectedNodeId.value === nodeId) {
propertyPanelVisible.value = false;
}
} catch {
// 取消删除
}
}
// 添加条件分支
function handleAddBranch(nodeId: string) {
if (props.readonly) return;
addConditionBranch(nodeId);
}
// 删除条件分支
async function handleDeleteBranch(nodeId: string, branchId: string) {
if (props.readonly) return;
try {
await ElMessageBox.confirm(
$t('workflow-designer.designer.deleteBranchConfirm'),
$t('workflow-designer.designer.tips'),
{
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'warning',
},
);
deleteConditionBranch(nodeId, branchId);
} catch {
// 取消删除
}
}
// 点击条件分支
function handleClickBranch(nodeId: string, branchId: string) {
if (props.readonly) return;
selectNode(nodeId);
const node = selectedNode.value;
if (node?.branches) {
const branch = node.branches.find((b) => b.id === branchId);
if (branch) {
selectedBranchId.value = branchId;
selectedBranchConfig.value = { ...branch.config };
propertyPanelVisible.value = true;
}
}
}
// 更新节点
function handleUpdateNode(nodeId: string, updates: Partial<FlowNode>) {
updateNode(nodeId, updates);
}
// 更新分支
function handleUpdateBranch(
_nodeId: string,
branchId: string,
updates: Partial<ConditionBranchConfig> | Partial<ParallelBranchConfig>,
) {
const node = selectedNode.value;
if (node?.branches) {
const branch = node.branches.find((b) => b.id === branchId);
if (branch) {
Object.assign(branch.config, updates);
if (updates.name) {
branch.name = updates.name;
}
if (selectedBranchId.value === branchId) {
selectedBranchConfig.value = { ...branch.config };
}
}
}
}
// 保存流程
function handleSave() {
const flow = exportFlow();
emit('save', flow);
if (!props.embedded) {
ElMessage.success($t('workflow-designer.designer.saveSuccess'));
}
}
// 预览流程
function handlePreview() {
const flow = exportFlow();
console.log($t('workflow-designer.designer.preview'), flow);
ElMessage.info($t('workflow-designer.designer.previewInfo'));
}
// 发布流程
function handlePublish() {
const flow = exportFlow();
console.log($t('workflow-designer.designer.publish'), flow);
ElMessage.success($t('workflow-designer.designer.publishSuccess'));
}
// 键盘快捷键
function handleKeydown(e: KeyboardEvent) {
if (props.readonly) return;
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault();
if (e.shiftKey) {
redo();
} else {
undo();
}
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown);
// 加载子流程列表
loadSubflowList();
});
</script>
<template>
<div
class="flow-designer"
:class="{ 'is-embedded': embedded, 'is-readonly': readonly }"
>
<!-- 工具栏 -->
<Toolbar
v-if="!embedded"
v-model:flow-name="flowName"
:can-undo="canUndo"
:can-redo="canRedo"
:is-dirty="isDirty"
@save="handleSave"
@undo="undo"
@redo="redo"
@preview="handlePreview"
@publish="handlePublish"
/>
<!-- 画布 -->
<FlowCanvas
:flow-definition="flowDefinition"
:selected-node-id="selectedNodeId"
:flow-name="flowName"
:form-name="formMeta?.name"
:can-undo="canUndo"
:can-redo="canRedo"
:node-errors="nodeErrors"
@select-node="handleSelectNode"
@add-node="handleAddNode"
@delete-node="handleDeleteNode"
@add-branch="handleAddBranch"
@delete-branch="handleDeleteBranch"
@click-branch="handleClickBranch"
@undo="undo"
@redo="redo"
/>
<!-- 属性面板 -->
<PropertyPanel
v-if="!readonly"
v-model:visible="propertyPanelVisible"
:node="selectedNode"
:branch-id="selectedBranchId"
:branch-config="selectedBranchConfig"
:form-fields="formFields"
:app-forms="appForms"
:subflow-list="subflowList"
:current-flow-id="currentFlowId"
@update-node="handleUpdateNode"
@update-branch="handleUpdateBranch"
/>
</div>
</template>
<style scoped>
.flow-designer {
display: flex;
flex-direction: column;
height: 100%;
background: var(--el-bg-color);
}
.flow-designer.is-embedded {
overflow: hidden;
/* border: 1px solid var(--el-border-color-lighter); */
border-radius: 8px;
}
.flow-designer.is-readonly {
pointer-events: none;
}
.flow-designer.is-readonly :deep(.add-node-btn-wrapper),
.flow-designer.is-readonly :deep(.node-delete),
.flow-designer.is-readonly :deep(.branch-delete),
.flow-designer.is-readonly :deep(.condition-delete),
.flow-designer.is-readonly :deep(.add-branch-btn) {
display: none;
}
</style>
@@ -0,0 +1,229 @@
<script setup lang="ts">
import type { NodeType } from '../types';
/**
* 添加节点按钮组件
* 钉钉/飞书风格的 + 按钮
*/
import { ref } from 'vue';
import {
Bell,
ClipboardCheck,
Clock,
GitBranch,
GitMerge,
PenLine,
Plus,
Send,
UserCheck,
Webhook,
Workflow,
} from '@vben/icons';
import { $t } from '@vben/locales';
import { ElPopover } from 'element-plus';
import { NODE_TYPE_CONFIGS } from '../types';
const emit = defineEmits<{
add: [type: NodeType];
}>();
const popoverVisible = ref(false);
// 可添加的节点类型
const addableNodes = Object.values(NODE_TYPE_CONFIGS).filter((n) => n.canAdd);
// 获取节点名称(国际化)
function getNodeName(type: NodeType): string {
return $t(`workflow-designer.nodes.${type}.name`);
}
// 获取节点描述(国际化)
function getNodeDesc(type: NodeType): string {
return $t(`workflow-designer.nodes.${type}.desc`);
}
function handleAddNode(type: NodeType) {
popoverVisible.value = false;
emit('add', type);
}
function getNodeIcon(type: NodeType) {
switch (type) {
case 'approval': {
return UserCheck;
}
case 'condition': {
return GitBranch;
}
case 'copy': {
return Send;
}
case 'delay': {
return Clock;
}
case 'handle': {
return ClipboardCheck;
}
case 'notify': {
return Bell;
}
case 'parallel': {
return GitMerge;
}
case 'service': {
return Webhook;
}
case 'subflow': {
return Workflow;
}
case 'data_update': {
return PenLine;
}
default: {
return Plus;
}
}
}
</script>
<template>
<div class="add-node-btn-wrapper">
<!-- 上方连接线 -->
<div class="add-node-line"></div>
<!-- 添加按钮 -->
<ElPopover
v-model:visible="popoverVisible"
placement="right"
:width="200"
trigger="click"
popper-class="add-node-popover"
>
<template #reference>
<div class="add-node-btn">
<Plus class="add-node-icon" />
</div>
</template>
<!-- 节点类型选择 -->
<div class="node-type-list">
<div
v-for="nodeConfig in addableNodes"
:key="nodeConfig.type"
class="node-type-item"
@click="handleAddNode(nodeConfig.type)"
>
<div
class="node-type-icon"
:style="{ background: nodeConfig.bgColor }"
>
<component
:is="getNodeIcon(nodeConfig.type)"
class="h-4 w-4 text-white"
/>
</div>
<div class="node-type-info">
<div class="node-type-name">{{ getNodeName(nodeConfig.type) }}</div>
<div class="node-type-desc">{{ getNodeDesc(nodeConfig.type) }}</div>
</div>
</div>
</div>
</ElPopover>
<!-- 下方连接线 -->
<div class="add-node-line"></div>
</div>
</template>
<style scoped>
.add-node-btn-wrapper {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding: 0;
}
.add-node-line {
width: 2px;
height: 20px;
background-color: var(--el-border-color-darker);
}
.add-node-btn {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
cursor: pointer;
background-color: var(--el-color-primary);
border-radius: 50%;
box-shadow: 0 2px 6px rgb(0 0 0 / 15%);
transition: all 0.2s ease;
}
.add-node-btn:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 20%);
transform: scale(1.1);
}
.add-node-icon {
width: 18px;
height: 18px;
color: white;
}
.node-type-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.node-type-item {
display: flex;
gap: 12px;
align-items: center;
padding: 8px;
cursor: pointer;
border-radius: 8px;
transition: background-color 0.2s ease;
}
.node-type-item:hover {
background-color: var(--el-fill-color-light);
}
.node-type-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
}
.node-type-info {
flex: 1;
min-width: 0;
}
.node-type-name {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.node-type-desc {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,568 @@
<script setup lang="ts">
import type { Condition, ConditionGroup, ConditionOperator } from '../types';
/**
* 条件表达式编辑器
* 支持条件组(OR关系)和组内条件(AND关系)
*/
import { computed } from 'vue';
import { Plus, Trash2, X } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElDatePicker,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
} from 'element-plus';
import { generateNodeId } from '../types';
const props = defineProps<{
// 可用的表单字段列表
fields?: FormField[];
groups: ConditionGroup[];
}>();
const emit = defineEmits<{
'update:groups': [groups: ConditionGroup[]];
}>();
// 表单字段定义
export interface FormField {
name: string;
label: string;
type: 'date' | 'dept' | 'form-selector' | 'number' | 'select' | 'string' | 'user';
options?: { label: string; value: any }[];
multiple?: boolean;
formSelectorConfig?: {
formCode: string;
valueField?: string;
labelField?: string;
};
}
const formFields = computed(() => props.fields || []);
// 操作符配置
const operatorOptions: {
label: string;
types: string[];
value: ConditionOperator;
}[] = [
{
value: 'eq',
label: $t('workflow-designer.condition.operators.eq'),
types: ['string', 'number', 'date', 'select', 'user', 'dept'],
},
{
value: 'ne',
label: $t('workflow-designer.condition.operators.ne'),
types: ['string', 'number', 'date', 'select', 'user', 'dept'],
},
{
value: 'gt',
label: $t('workflow-designer.condition.operators.gt'),
types: ['number', 'date'],
},
{
value: 'gte',
label: $t('workflow-designer.condition.operators.gte'),
types: ['number', 'date'],
},
{
value: 'lt',
label: $t('workflow-designer.condition.operators.lt'),
types: ['number', 'date'],
},
{
value: 'lte',
label: $t('workflow-designer.condition.operators.lte'),
types: ['number', 'date'],
},
{
value: 'contains',
label: $t('workflow-designer.condition.operators.contains'),
types: ['string'],
},
{
value: 'not_contains',
label: $t('workflow-designer.condition.operators.not_contains'),
types: ['string'],
},
{
value: 'in',
label: $t('workflow-designer.condition.operators.in'),
types: ['select'],
},
{
value: 'not_in',
label: $t('workflow-designer.condition.operators.not_in'),
types: ['select'],
},
{
value: 'empty',
label: $t('workflow-designer.condition.operators.empty'),
types: ['string', 'number', 'date', 'select', 'user', 'dept'],
},
{
value: 'not_empty',
label: $t('workflow-designer.condition.operators.not_empty'),
types: ['string', 'number', 'date', 'select', 'user', 'dept'],
},
];
// 根据字段类型获取可用操作符
function getOperatorsForField(fieldName: string) {
const field = formFields.value.find((f) => f.name === fieldName);
if (!field) return operatorOptions;
return operatorOptions.filter((op) => op.types.includes(field.type));
}
// 获取字段信息
function getField(fieldName: string) {
return formFields.value.find((f) => f.name === fieldName);
}
// 判断操作符是否需要值输入
function needsValue(operator: ConditionOperator) {
return !['empty', 'not_empty'].includes(operator);
}
// 创建默认条件
function createDefaultCondition(): Condition {
const firstField = formFields.value[0];
return {
id: generateNodeId('condition'),
field: firstField?.name || '',
operator: 'eq',
value: firstField?.type === 'number' ? 0 : '',
};
}
// 添加条件组
function addGroup() {
const newGroup: ConditionGroup = {
id: generateNodeId('condition'),
conditions: [createDefaultCondition()],
};
emit('update:groups', [...props.groups, newGroup]);
}
// 删除条件组
function removeGroup(groupIndex: number) {
const newGroups = props.groups.filter((_, i) => i !== groupIndex);
emit('update:groups', newGroups);
}
// 添加条件到组
function addCondition(groupIndex: number) {
const group = props.groups[groupIndex];
if (!group) return;
const newGroups = [...props.groups];
newGroups[groupIndex] = {
id: group.id,
conditions: [...group.conditions, createDefaultCondition()],
};
emit('update:groups', newGroups);
}
// 删除条件
function removeCondition(groupIndex: number, conditionIndex: number) {
const group = props.groups[groupIndex];
if (!group) return;
const newConditions = group.conditions.filter((_, i) => i !== conditionIndex);
// 如果组内没有条件了,删除整个组
if (newConditions.length === 0) {
removeGroup(groupIndex);
} else {
const newGroups = [...props.groups];
newGroups[groupIndex] = {
id: group.id,
conditions: newConditions,
};
emit('update:groups', newGroups);
}
}
// 更新条件
function updateCondition(
groupIndex: number,
conditionIndex: number,
updates: Partial<Condition>,
) {
const group = props.groups[groupIndex];
if (!group) return;
const condition = group.conditions[conditionIndex];
if (!condition) return;
const newConditions = [...group.conditions];
const updatedCondition: Condition = {
id: condition.id,
field: updates.field === undefined ? condition.field : updates.field,
operator:
updates.operator === undefined ? condition.operator : updates.operator,
value: updates.value === undefined ? condition.value : updates.value,
};
// 如果字段变了,重置操作符和值
if (updates.field !== undefined && updates.field !== condition.field) {
const field = getField(updates.field);
const operators = getOperatorsForField(updates.field);
updatedCondition.operator = operators[0]?.value || 'eq';
updatedCondition.value = field?.type === 'number' ? 0 : '';
}
newConditions[conditionIndex] = updatedCondition;
const newGroups = [...props.groups];
newGroups[groupIndex] = {
id: group.id,
conditions: newConditions,
};
emit('update:groups', newGroups);
}
</script>
<template>
<div class="condition-editor">
<!-- 条件组列表 -->
<div v-if="groups.length > 0" class="condition-groups">
<div
v-for="(group, groupIndex) in groups"
:key="group.id"
class="condition-group"
>
<!-- 组标题 -->
<div class="group-header">
<span class="group-title"
>{{ $t('workflow-designer.condition.title') }}
{{ groupIndex + 1 }}</span
>
<span class="group-logic">{{
$t('workflow-designer.condition.andHint')
}}</span>
<ElButton
type="danger"
text
size="small"
@click="removeGroup(groupIndex)"
>
<Trash2 class="h-4 w-4" />
</ElButton>
</div>
<!-- 组内条件列表 -->
<div class="conditions-list">
<div
v-for="(condition, conditionIndex) in group.conditions"
:key="condition.id"
class="condition-item"
>
<!-- 条件连接符 -->
<div v-if="conditionIndex > 0" class="condition-connector">
<span class="connector-text">{{
$t('workflow-designer.condition.and')
}}</span>
</div>
<div class="condition-row">
<!-- 字段选择 -->
<ElSelect
:model-value="condition.field"
:placeholder="$t('workflow-designer.condition.selectField')"
class="field-select"
@update:model-value="
updateCondition(groupIndex, conditionIndex, { field: $event })
"
>
<ElOption
v-for="field in formFields"
:key="field.name"
:value="field.name"
:label="field.label"
/>
</ElSelect>
<!-- 操作符选择 -->
<ElSelect
:model-value="condition.operator"
:placeholder="$t('workflow-designer.condition.selectOperator')"
class="operator-select"
@update:model-value="
updateCondition(groupIndex, conditionIndex, {
operator: $event,
})
"
>
<ElOption
v-for="op in getOperatorsForField(condition.field)"
:key="op.value"
:value="op.value"
:label="op.label"
/>
</ElSelect>
<!-- 值输入根据字段类型和操作符显示不同控件 -->
<template v-if="needsValue(condition.operator)">
<!-- 数字类型 -->
<ElInputNumber
v-if="getField(condition.field)?.type === 'number'"
:model-value="condition.value"
:placeholder="$t('workflow-designer.condition.inputNumber')"
class="value-input"
controls-position="right"
@update:model-value="
updateCondition(groupIndex, conditionIndex, {
value: $event,
})
"
/>
<!-- 日期类型 -->
<ElDatePicker
v-else-if="getField(condition.field)?.type === 'date'"
:model-value="condition.value"
type="date"
:placeholder="$t('workflow-designer.condition.selectDate')"
class="value-input"
value-format="YYYY-MM-DD"
@update:model-value="
updateCondition(groupIndex, conditionIndex, {
value: $event,
})
"
/>
<!-- 选择类型 -->
<ElSelect
v-else-if="getField(condition.field)?.type === 'select'"
:model-value="condition.value"
:placeholder="$t('workflow-designer.condition.selectValue')"
class="value-input"
:multiple="
condition.operator === 'in' ||
condition.operator === 'not_in'
"
@update:model-value="
updateCondition(groupIndex, conditionIndex, {
value: $event,
})
"
>
<ElOption
v-for="opt in getField(condition.field)?.options || []"
:key="opt.value"
:value="opt.value"
:label="opt.label"
/>
</ElSelect>
<!-- 字符串类型默认 -->
<ElInput
v-else
:model-value="condition.value"
:placeholder="$t('workflow-designer.condition.inputValue')"
class="value-input"
@update:model-value="
updateCondition(groupIndex, conditionIndex, {
value: $event,
})
"
/>
</template>
<!-- 删除条件按钮 -->
<ElButton
type="danger"
text
size="small"
class="delete-btn"
@click="removeCondition(groupIndex, conditionIndex)"
>
<X class="h-4 w-4" />
</ElButton>
</div>
</div>
</div>
<!-- 添加条件按钮 -->
<ElButton
type="primary"
text
size="small"
class="add-condition-btn"
@click="addCondition(groupIndex)"
>
<Plus class="mr-1 h-4 w-4" />
{{ $t('workflow-designer.condition.addCondition') }}
</ElButton>
<!-- 组间分隔符 -->
<div v-if="groupIndex < groups.length - 1" class="group-separator">
<span class="separator-text">{{
$t('workflow-designer.condition.or')
}}</span>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-state">
<p>{{ $t('workflow-designer.condition.emptyHint') }}</p>
</div>
<!-- 添加条件组按钮 -->
<ElButton type="primary" plain class="add-group-btn" @click="addGroup">
<Plus class="mr-1 h-4 w-4" />
{{ $t('workflow-designer.condition.addGroup') }}
</ElButton>
</div>
</template>
<style scoped>
.condition-editor {
display: flex;
flex-direction: column;
gap: 16px;
}
.condition-groups {
display: flex;
flex-direction: column;
gap: 16px;
}
.condition-group {
position: relative;
padding: 16px;
background: var(--el-fill-color-lighter);
border-radius: 8px;
}
.group-header {
display: flex;
gap: 8px;
align-items: center;
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.group-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.group-logic {
flex: 1;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.conditions-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.condition-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.condition-connector {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 0;
}
.connector-text {
padding: 2px 12px;
font-size: 12px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-radius: 10px;
}
.condition-row {
display: flex;
gap: 8px;
align-items: center;
}
.field-select {
flex-shrink: 0;
width: 120px;
}
.operator-select {
flex-shrink: 0;
width: 110px;
}
.value-input {
flex: 1;
min-width: 100px;
}
.delete-btn {
flex-shrink: 0;
}
.add-condition-btn {
margin-top: 8px;
}
.group-separator {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-top: 16px;
}
.group-separator::before,
.group-separator::after {
flex: 1;
height: 1px;
content: '';
background: var(--el-border-color);
}
.separator-text {
padding: 4px 16px;
margin: 0 12px;
font-size: 12px;
color: var(--el-color-warning);
background: var(--el-color-warning-light-9);
border-radius: 10px;
}
.empty-state {
padding: 24px;
color: var(--el-text-color-secondary);
text-align: center;
background: var(--el-fill-color-lighter);
border-radius: 8px;
}
.empty-state p {
margin: 0;
font-size: 14px;
}
.add-group-btn {
width: 100%;
}
</style>
@@ -0,0 +1,449 @@
<script setup lang="ts">
import type { NodeError } from '../hooks/useFlowValidation';
import type { FlowDefinition, NodeType } from '../types';
/**
* 流程画布组件
* 支持缩放和拖拽
*/
import { computed, ref } from 'vue';
import {
Eye,
Move,
Redo2,
ScanSearch,
Undo2,
ZoomIn,
ZoomOut,
} from '@vben/icons';
import FlowMinimap from './FlowMinimap.vue';
import FlowNode from './FlowNode.vue';
import FlowPreview from './FlowPreview.vue';
const props = defineProps<{
canRedo?: boolean;
canUndo?: boolean;
flowDefinition: FlowDefinition;
flowName?: string;
formName?: string;
nodeErrors?: NodeError[];
selectedNodeId?: null | string;
}>();
const emit = defineEmits<{
addBranch: [nodeId: string];
addNode: [type: NodeType, parentId: string, branchId?: string];
clickBranch: [nodeId: string, branchId: string];
deleteBranch: [nodeId: string, branchId: string];
deleteNode: [nodeId: string];
redo: [];
selectNode: [nodeId: string];
undo: [];
}>();
// 预览弹窗
const showPreview = ref(false);
// 缩略图显示状态
const showMinimap = ref(true);
function handlePreview() {
showPreview.value = true;
}
function handleUndo() {
emit('undo');
}
function handleRedo() {
emit('redo');
}
// 画布容器引用
const canvasContainerRef = ref<HTMLElement | null>(null);
// 缩放比例
const scale = ref(100);
const minScale = 50;
const maxScale = 150;
// 拖拽状态
const isDragging = ref(false);
const startX = ref(0);
const startY = ref(0);
const translateX = ref(0);
const translateY = ref(0);
const canvasStyle = computed(() => ({
transform: `scale(${scale.value / 100}) translate(${translateX.value}px, ${translateY.value}px)`,
transformOrigin: 'top center',
}));
function zoomIn() {
if (scale.value < maxScale) {
scale.value = Math.min(scale.value + 10, maxScale);
}
}
function zoomOut() {
if (scale.value > minScale) {
scale.value = Math.max(scale.value - 10, minScale);
}
}
function resetZoom() {
scale.value = 100;
translateX.value = 0;
translateY.value = 0;
}
function handleMouseDown(e: MouseEvent) {
if (e.button === 1 || (e.button === 0 && e.altKey)) {
isDragging.value = true;
startX.value = e.clientX - translateX.value;
startY.value = e.clientY - translateY.value;
e.preventDefault();
}
}
function handleMouseMove(e: MouseEvent) {
if (isDragging.value) {
translateX.value = e.clientX - startX.value;
translateY.value = e.clientY - startY.value;
}
}
function handleMouseUp() {
isDragging.value = false;
}
function handleWheel(e: WheelEvent) {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
if (e.deltaY < 0) {
zoomIn();
} else {
zoomOut();
}
}
}
// 转发事件
function forwardSelectNode(nodeId: string) {
emit('selectNode', nodeId);
}
function forwardAddNode(type: NodeType, parentId: string, branchId?: string) {
emit('addNode', type, parentId, branchId);
}
function forwardDeleteNode(nodeId: string) {
emit('deleteNode', nodeId);
}
function forwardAddBranch(nodeId: string) {
emit('addBranch', nodeId);
}
function forwardDeleteBranch(nodeId: string, branchId: string) {
emit('deleteBranch', nodeId, branchId);
}
function forwardClickBranch(nodeId: string, branchId: string) {
emit('clickBranch', nodeId, branchId);
}
</script>
<template>
<div
ref="canvasContainerRef"
class="flow-canvas-container bg-background-deep"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
@wheel="handleWheel"
>
<!-- 缩放控制 -->
<div class="zoom-controls">
<div
class="zoom-btn"
@click="zoomOut"
:class="{ disabled: scale <= minScale }"
>
<ZoomOut class="zoom-icon" />
</div>
<div class="zoom-value" @click="resetZoom">{{ scale }}%</div>
<div
class="zoom-btn"
@click="zoomIn"
:class="{ disabled: scale >= maxScale }"
>
<ZoomIn class="zoom-icon" />
</div>
</div>
<!-- 左上角工具栏 -->
<div class="top-toolbar">
<div
class="toolbar-btn"
title="撤销 (Ctrl+Z)"
:class="{ disabled: !props.canUndo }"
@click="handleUndo"
>
<Undo2 class="toolbar-icon" />
</div>
<div
class="toolbar-btn"
title="重做 (Ctrl+Shift+Z)"
:class="{ disabled: !props.canRedo }"
@click="handleRedo"
>
<Redo2 class="toolbar-icon" />
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-btn" title="预览流程" @click="handlePreview">
<Eye class="toolbar-icon" />
</div>
<div
class="toolbar-btn"
:class="{ active: showMinimap }"
title="缩略图"
@click="showMinimap = !showMinimap"
>
<ScanSearch class="toolbar-icon" />
</div>
</div>
<!-- 拖拽提示 -->
<div class="drag-hint">
<Move class="hint-icon" />
<span>按住 Alt + 拖拽 鼠标中键拖拽</span>
</div>
<!-- 缩略图 -->
<FlowMinimap
v-if="showMinimap"
:flow-nodes="flowDefinition.nodes"
:canvas-container="canvasContainerRef"
:scale="scale"
/>
<!-- 预览弹窗 -->
<FlowPreview
v-model="showPreview"
:flow-definition="props.flowDefinition"
:flow-name="props.flowName"
:form-name="props.formName"
/>
<!-- 画布内容 -->
<div
class="flow-canvas"
:style="canvasStyle"
:class="{ 'is-dragging': isDragging }"
>
<FlowNode
:node="flowDefinition.nodes"
:selected-node-id="selectedNodeId"
:node-errors="nodeErrors"
@select-node="forwardSelectNode"
@add-node="forwardAddNode"
@delete-node="forwardDeleteNode"
@add-branch="forwardAddBranch"
@delete-branch="forwardDeleteBranch"
@click-branch="forwardClickBranch"
/>
</div>
</div>
</template>
<style scoped>
.flow-canvas-container {
position: relative;
flex: 1;
overflow: auto;
/* 点阵背景 - 支持浅色/深色模式 */
--dot-color: rgb(0 0 0 / 15%);
--dot-size: 1px;
--dot-space: 24px;
/* background-color: var(--el-color-info-light-9); */
background-image: radial-gradient(
circle,
var(--dot-color) var(--dot-size),
transparent var(--dot-size)
);
background-size: var(--dot-space) var(--dot-space);
/* 自定义滚动条 */
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--el-border-color);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--el-border-color-darker);
}
}
.zoom-controls {
position: fixed;
bottom: 60px;
left: 40px;
z-index: 100;
display: flex;
gap: 4px;
align-items: center;
padding: 4px;
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 12px rgb(0 0 0 / 10%);
}
.zoom-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
cursor: pointer;
border-radius: 6px;
transition: background-color 0.2s ease;
}
.zoom-btn:hover:not(.disabled) {
background-color: var(--el-fill-color);
}
.zoom-btn.disabled {
cursor: not-allowed;
opacity: 0.4;
}
.zoom-icon {
width: 18px;
height: 18px;
color: var(--el-text-color-primary);
}
.zoom-value {
min-width: 50px;
font-size: 13px;
color: var(--el-text-color-primary);
text-align: center;
cursor: pointer;
}
.zoom-value:hover {
color: var(--el-color-primary);
}
.drag-hint {
position: fixed;
right: 40px;
bottom: 60px;
z-index: 100;
display: flex;
gap: 6px;
align-items: center;
padding: 8px 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 12px rgb(0 0 0 / 10%);
}
.hint-icon {
width: 14px;
height: 14px;
}
.flow-canvas {
display: flex;
flex-direction: column;
align-items: center;
min-height: 100%;
padding: 40px 20px 100px;
transition: transform 0.1s ease;
}
.flow-canvas.is-dragging {
cursor: grabbing;
transition: none;
}
/* 左上角工具栏 */
.top-toolbar {
position: fixed;
top: 120px;
left: 40px;
z-index: 100;
display: flex;
gap: 4px;
align-items: center;
padding: 4px;
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 12px rgb(0 0 0 / 10%);
}
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
}
.toolbar-btn:hover:not(.disabled) {
background-color: var(--el-fill-color);
}
.toolbar-btn.disabled {
cursor: not-allowed;
opacity: 0.4;
}
.toolbar-btn.active {
background-color: var(--el-color-primary-light-8);
}
.toolbar-btn.active .toolbar-icon {
color: var(--el-color-primary);
}
.toolbar-icon {
width: 18px;
height: 18px;
color: var(--el-text-color-primary);
}
.toolbar-divider {
width: 1px;
height: 20px;
margin: 0 4px;
background: var(--el-border-color-lighter);
}
/* 深色模式 */
.dark .flow-canvas-container {
--dot-color: rgb(255 255 255 / 15%);
}
</style>
@@ -0,0 +1,423 @@
<script setup lang="ts">
import type { FlowNode } from '../types';
/**
* 流程缩略图组件
* 使用 SVG 简化绘制流程图,支持视口导航
*/
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { $t } from '@vben/locales';
interface Props {
/** 流程节点 */
flowNodes: FlowNode;
/** 画布容器元素 */
canvasContainer?: HTMLElement | null;
/** 当前缩放比例 */
scale?: number;
}
const props = withDefaults(defineProps<Props>(), {
scale: 100,
});
// 缩略图配置
const config = {
width: 160,
height: 200,
collapsedHeight: 36,
nodeWidth: 32,
nodeHeight: 12,
nodeGap: 16,
branchGap: 40,
padding: 12,
};
// 节点颜色映射 - 与 NODE_TYPE_CONFIGS 保持一致
const nodeColors: Record<string, string> = {
start: 'rgb(87, 106, 149)', // 发起人 - 蓝灰色
approval: 'rgb(255, 148, 62)', // 审批人 - 橙色
handle: 'rgb(250, 173, 20)', // 办理人 - 金色
copy: 'rgb(50, 150, 250)', // 抄送人 - 蓝色
delay: 'rgb(156, 163, 175)', // 延时 - 灰色
notify: 'rgb(236, 72, 153)', // 通知 - 粉色
service: 'rgb(14, 165, 233)', // 服务调用 - 天蓝色
subflow: 'rgb(139, 92, 246)', // 子流程 - 紫罗兰色
condition: 'rgb(21, 188, 131)', // 条件分支 - 绿色
parallel: 'rgb(114, 46, 209)', // 并行分支 - 紫色
end: 'rgb(87, 106, 149)', // 结束 - 蓝灰色
};
// 计算节点布局
interface NodeLayout {
id: string;
type: string;
name: string;
x: number;
y: number;
width: number;
height: number;
}
// 简化的布局结果
interface LayoutResult {
nodes: NodeLayout[];
totalHeight: number;
}
// 递归计算节点布局 - 简化版,只计算节点位置
function calculateLayout(
node: FlowNode | undefined,
startX: number,
startY: number,
): LayoutResult {
const nodes: NodeLayout[] = [];
let currentY = startY;
let currentNode: FlowNode | undefined = node;
while (currentNode) {
// 条件分支或并行分支节点
if (
(currentNode.type === 'condition' || currentNode.type === 'parallel') &&
currentNode.branches?.length
) {
nodes.push({
id: currentNode.id,
type: currentNode.type,
name: currentNode.name,
x: startX,
y: currentY,
width: config.nodeWidth,
height: config.nodeHeight,
});
currentY += config.nodeGap;
// 计算所有分支
const branchCount = currentNode.branches.length;
const totalBranchWidth = (branchCount - 1) * config.branchGap;
let branchStartX = startX - totalBranchWidth / 2;
let maxBranchHeight = 0;
for (const branch of currentNode.branches) {
const branchResult = calculateLayout(
branch.children,
branchStartX,
currentY,
);
nodes.push(...branchResult.nodes);
maxBranchHeight = Math.max(maxBranchHeight, branchResult.totalHeight);
branchStartX += config.branchGap;
}
currentY += maxBranchHeight + config.nodeGap;
currentNode = currentNode.children;
} else {
// 普通节点
nodes.push({
id: currentNode.id,
type: currentNode.type,
name: currentNode.name,
x: startX,
y: currentY,
width: config.nodeWidth,
height: config.nodeHeight,
});
currentY += config.nodeGap;
currentNode = currentNode.children;
}
}
return {
nodes,
totalHeight: currentY - startY,
};
}
// 计算布局结果
const layoutResult = computed(() => {
return calculateLayout(props.flowNodes, config.width / 2, config.padding);
});
// SVG viewBox
const viewBox = computed(() => {
const { totalHeight } = layoutResult.value;
const height = Math.max(config.height, totalHeight + config.padding * 2);
return `0 0 ${config.width} ${height}`;
});
// 视口指示器
const viewport = ref({ x: 0, y: 0, width: 100, height: 60 });
const isDraggingViewport = ref(false);
const dragStart = ref({ x: 0, y: 0, viewportX: 0, viewportY: 0 });
// 更新视口位置
function updateViewport() {
if (!props.canvasContainer) return;
const container = props.canvasContainer;
const {
scrollLeft,
scrollTop,
clientWidth,
clientHeight,
scrollWidth,
scrollHeight,
} = container;
// 计算缩略图中的视口大小和位置
const scaleX = (config.width - config.padding * 2) / scrollWidth;
const scaleY =
(layoutResult.value.totalHeight || config.height) / scrollHeight;
const scale = Math.min(scaleX, scaleY, 1);
viewport.value = {
x: config.padding + scrollLeft * scale,
y: config.padding + scrollTop * scale,
width: Math.max(20, clientWidth * scale),
height: Math.max(15, clientHeight * scale),
};
}
// 点击缩略图导航
function handleMinimapClick(e: MouseEvent) {
if (!props.canvasContainer || isDraggingViewport.value) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
navigateToPosition(x, y);
}
// 导航到指定位置
function navigateToPosition(minimapX: number, minimapY: number) {
if (!props.canvasContainer) return;
const container = props.canvasContainer;
const { scrollWidth, scrollHeight, clientWidth, clientHeight } = container;
const scaleX = (config.width - config.padding * 2) / scrollWidth;
const scaleY =
(layoutResult.value.totalHeight || config.height) / scrollHeight;
const scale = Math.min(scaleX, scaleY, 1);
const targetScrollLeft =
(minimapX - config.padding) / scale - clientWidth / 2;
const targetScrollTop =
(minimapY - config.padding) / scale - clientHeight / 2;
container.scrollTo({
left: Math.max(0, targetScrollLeft),
top: Math.max(0, targetScrollTop),
behavior: 'smooth',
});
}
// 视口拖拽
function handleViewportMouseDown(e: MouseEvent) {
e.stopPropagation();
isDraggingViewport.value = true;
dragStart.value = {
x: e.clientX,
y: e.clientY,
viewportX: viewport.value.x,
viewportY: viewport.value.y,
};
document.addEventListener('mousemove', handleViewportMouseMove);
document.addEventListener('mouseup', handleViewportMouseUp);
}
function handleViewportMouseMove(e: MouseEvent) {
if (!isDraggingViewport.value || !props.canvasContainer) return;
const dx = e.clientX - dragStart.value.x;
const dy = e.clientY - dragStart.value.y;
const container = props.canvasContainer;
const { scrollWidth, scrollHeight } = container;
const scaleX = (config.width - config.padding * 2) / scrollWidth;
const scaleY =
(layoutResult.value.totalHeight || config.height) / scrollHeight;
const scale = Math.min(scaleX, scaleY, 1);
const newScrollLeft =
(dragStart.value.viewportX + dx - config.padding) / scale;
const newScrollTop =
(dragStart.value.viewportY + dy - config.padding) / scale;
container.scrollLeft = Math.max(0, newScrollLeft);
container.scrollTop = Math.max(0, newScrollTop);
}
function handleViewportMouseUp() {
isDraggingViewport.value = false;
document.removeEventListener('mousemove', handleViewportMouseMove);
document.removeEventListener('mouseup', handleViewportMouseUp);
}
// 监听画布滚动
let scrollHandler: (() => void) | null = null;
watch(
() => props.canvasContainer,
(container, oldContainer) => {
if (oldContainer && scrollHandler) {
oldContainer.removeEventListener('scroll', scrollHandler);
}
if (container) {
scrollHandler = () => updateViewport();
container.addEventListener('scroll', scrollHandler);
updateViewport();
}
},
{ immediate: true },
);
// 监听流程变化
watch(
() => props.flowNodes,
() => {
updateViewport();
},
{ deep: true },
);
onMounted(() => {
updateViewport();
});
onUnmounted(() => {
if (props.canvasContainer && scrollHandler) {
props.canvasContainer.removeEventListener('scroll', scrollHandler);
}
});
// 获取节点圆点大小
function getDotSize(type: string): number {
switch (type) {
case 'condition': {
return 5;
}
case 'end':
case 'start': {
return 4;
}
default: {
return 4;
}
}
}
</script>
<template>
<div class="flow-minimap">
<!-- 标题栏 -->
<div class="minimap-header">
<span class="minimap-title">{{
$t('workflow-designer.designer.minimap')
}}</span>
</div>
<!-- 缩略图内容 -->
<div class="minimap-content" @click="handleMinimapClick">
<svg
class="minimap-svg"
:viewBox="viewBox"
preserveAspectRatio="xMidYMin meet"
>
<!-- 节点小圆点 -->
<g class="nodes">
<circle
v-for="node in layoutResult.nodes"
:key="node.id"
:cx="node.x"
:cy="node.y"
:r="getDotSize(node.type)"
:fill="nodeColors[node.type] || '#999'"
class="node-dot"
/>
</g>
<!-- 视口指示器 -->
<rect
v-if="canvasContainer"
:x="viewport.x"
:y="viewport.y"
:width="viewport.width"
:height="viewport.height"
class="viewport-indicator"
@mousedown="handleViewportMouseDown"
/>
</svg>
</div>
</div>
</template>
<style scoped>
.flow-minimap {
position: fixed;
top: 168px;
left: 40px;
z-index: 99;
width: 180px;
overflow: hidden;
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 12px rgb(0 0 0 / 10%);
}
.minimap-header {
display: flex;
align-items: center;
padding: 8px 10px;
user-select: none;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.minimap-title {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.minimap-content {
height: 180px;
padding: 8px;
}
.minimap-svg {
width: 100%;
height: 100%;
cursor: pointer;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.node-dot {
opacity: 0.9;
transition: opacity 0.2s;
}
.node-dot:hover {
opacity: 1;
}
.viewport-indicator {
pointer-events: all;
cursor: move;
fill: transparent;
stroke: var(--el-color-primary);
stroke-width: 1.5;
}
.viewport-indicator:hover {
fill: rgb(64 158 255 / 10%);
}
</style>
@@ -0,0 +1,473 @@
<script setup lang="ts">
import type { NodeError } from '../hooks/useFlowValidation';
/**
* 流程节点渲染器(递归组件)
* 根据节点类型渲染对应的节点组件
*/
import type { FlowNode as FlowNodeType, NodeType } from '../types';
import AddNodeButton from './AddNodeButton.vue';
import ApprovalNode from './nodes/ApprovalNode.vue';
import ConditionNode from './nodes/ConditionNode.vue';
import CopyNode from './nodes/CopyNode.vue';
import DataUpdateNode from './nodes/DataUpdateNode.vue';
import DelayNode from './nodes/DelayNode.vue';
import EndNode from './nodes/EndNode.vue';
import HandleNode from './nodes/HandleNode.vue';
import NotifyNode from './nodes/NotifyNode.vue';
import ParallelNode from './nodes/ParallelNode.vue';
import ServiceNode from './nodes/ServiceNode.vue';
import StartNode from './nodes/StartNode.vue';
import SubflowNode from './nodes/SubflowNode.vue';
/** 节点执行状态 */
export type NodeStatus = 'active' | 'completed' | 'pending' | 'rejected';
/** 节点状态映射 */
export interface NodeStatusMap {
[nodeId: string]: NodeStatus;
}
const props = defineProps<{
branchId?: string;
// 是否在条件分支内部
inBranch?: boolean;
node: FlowNodeType;
// 节点错误列表
nodeErrors?: NodeError[];
// 节点状态映射(用于流程详情页面显示执行状态)
nodeStatuses?: NodeStatusMap;
parentId?: string;
selectedNodeId?: null | string;
}>();
const emit = defineEmits<{
addBranch: [nodeId: string];
addNode: [type: NodeType, parentId: string, branchId?: string];
clickBranch: [nodeId: string, branchId: string];
deleteBranch: [nodeId: string, branchId: string];
deleteNode: [nodeId: string];
selectNode: [nodeId: string];
}>();
// 获取节点错误
function getNodeError(nodeId: string): NodeError | undefined {
return props.nodeErrors?.find((e) => e.nodeId === nodeId);
}
function hasNodeError(nodeId: string): boolean {
return (
props.nodeErrors?.some((e) => e.nodeId === nodeId && e.level === 'error') ||
false
);
}
function hasNodeWarning(nodeId: string): boolean {
return (
props.nodeErrors?.some(
(e) => e.nodeId === nodeId && e.level === 'warning',
) || false
);
}
// 获取节点执行状态
function getNodeStatus(nodeId: string): NodeStatus | undefined {
return props.nodeStatuses?.[nodeId];
}
function handleSelectNode(nodeId: string) {
emit('selectNode', nodeId);
}
function handleAddNode(type: NodeType) {
emit('addNode', type, props.node.id, props.branchId);
}
function handleDeleteNode() {
emit('deleteNode', props.node.id);
}
function handleAddBranch() {
emit('addBranch', props.node.id);
}
function handleDeleteBranch(branchId: string) {
emit('deleteBranch', props.node.id, branchId);
}
function handleClickBranch(branchId: string) {
emit('clickBranch', props.node.id, branchId);
}
// 转发子节点事件
function forwardSelectNode(nodeId: string) {
emit('selectNode', nodeId);
}
function forwardAddNode(type: NodeType, parentId: string, branchId?: string) {
emit('addNode', type, parentId, branchId);
}
function forwardDeleteNode(nodeId: string) {
emit('deleteNode', nodeId);
}
function forwardAddBranch(nodeId: string) {
emit('addBranch', nodeId);
}
function forwardDeleteBranch(nodeId: string, branchId: string) {
emit('deleteBranch', nodeId, branchId);
}
function forwardClickBranch(nodeId: string, branchId: string) {
emit('clickBranch', nodeId, branchId);
}
</script>
<template>
<div
class="flow-node-wrapper"
:class="{
'node-status-completed': getNodeStatus(node.id) === 'completed',
'node-status-active': getNodeStatus(node.id) === 'active',
'node-status-rejected': getNodeStatus(node.id) === 'rejected',
'node-status-pending': getNodeStatus(node.id) === 'pending',
}"
>
<!-- 状态标记 -->
<div
v-if="getNodeStatus(node.id)"
class="node-status-badge"
:class="`status-${getNodeStatus(node.id)}`"
>
<span class="status-icon"></span>
</div>
<!-- 根据节点类型渲染对应组件 -->
<div class="node-container">
<!-- 开始节点 -->
<StartNode
v-if="node.type === 'start'"
:node="node"
:selected="selectedNodeId === node.id"
@click="handleSelectNode(node.id)"
/>
<!-- 审批节点 -->
<ApprovalNode
v-else-if="node.type === 'approval'"
:node="node"
:selected="selectedNodeId === node.id"
:has-error="hasNodeError(node.id)"
:has-warning="hasNodeWarning(node.id)"
:error-message="getNodeError(node.id)?.message"
@click="handleSelectNode(node.id)"
@delete="handleDeleteNode"
/>
<!-- 办理节点 -->
<HandleNode
v-else-if="node.type === 'handle'"
:node="node"
:selected="selectedNodeId === node.id"
:has-error="hasNodeError(node.id)"
:has-warning="hasNodeWarning(node.id)"
:error-message="getNodeError(node.id)?.message"
@click="handleSelectNode(node.id)"
@delete="handleDeleteNode"
/>
<!-- 抄送节点 -->
<CopyNode
v-else-if="node.type === 'copy'"
:node="node"
:selected="selectedNodeId === node.id"
:has-error="hasNodeError(node.id)"
:has-warning="hasNodeWarning(node.id)"
:error-message="getNodeError(node.id)?.message"
@click="handleSelectNode(node.id)"
@delete="handleDeleteNode"
/>
<!-- 延时节点 -->
<DelayNode
v-else-if="node.type === 'delay'"
:node="node"
:selected="selectedNodeId === node.id"
@click="handleSelectNode(node.id)"
@delete="handleDeleteNode"
/>
<!-- 通知节点 -->
<NotifyNode
v-else-if="node.type === 'notify'"
:node="node"
:selected="selectedNodeId === node.id"
@click="handleSelectNode(node.id)"
@delete="handleDeleteNode"
/>
<!-- 服务调用节点 -->
<ServiceNode
v-else-if="node.type === 'service'"
:node="node"
:selected="selectedNodeId === node.id"
@click="handleSelectNode(node.id)"
@delete="handleDeleteNode"
/>
<!-- 子流程节点 -->
<SubflowNode
v-else-if="node.type === 'subflow'"
:node="node"
:selected="selectedNodeId === node.id"
@click="handleSelectNode(node.id)"
@delete="handleDeleteNode"
/>
<!-- 字段更新节点 -->
<DataUpdateNode
v-else-if="node.type === 'data_update'"
:node="node"
:selected="selectedNodeId === node.id"
@click="handleSelectNode(node.id)"
@delete="handleDeleteNode"
/>
<!-- 条件分支节点 -->
<ConditionNode
v-else-if="node.type === 'condition'"
:node="node"
:selected="selectedNodeId === node.id"
:is-nested="inBranch"
@click="handleSelectNode(node.id)"
@delete="handleDeleteNode"
@add-branch="handleAddBranch"
@delete-branch="handleDeleteBranch"
@click-branch="handleClickBranch"
>
<!-- 每个分支的子节点 -->
<template
v-for="branch in node.branches"
:key="branch.id"
#[`branch-${branch.id}`]
>
<!-- 分支内的添加按钮 -->
<AddNodeButton
@add="(type) => emit('addNode', type, node.id, branch.id)"
/>
<!-- 分支内的子节点递归 -->
<FlowNode
v-if="branch.children"
:node="branch.children"
:selected-node-id="selectedNodeId"
:parent-id="node.id"
:branch-id="branch.id"
:in-branch="true"
:node-errors="nodeErrors"
:node-statuses="nodeStatuses"
@select-node="forwardSelectNode"
@add-node="forwardAddNode"
@delete-node="forwardDeleteNode"
@add-branch="forwardAddBranch"
@delete-branch="forwardDeleteBranch"
@click-branch="forwardClickBranch"
/>
</template>
</ConditionNode>
<!-- 并行分支节点 -->
<ParallelNode
v-else-if="node.type === 'parallel'"
:node="node"
:selected="selectedNodeId === node.id"
:is-nested="inBranch"
@click="handleSelectNode(node.id)"
@delete="handleDeleteNode"
@add-branch="handleAddBranch"
@delete-branch="handleDeleteBranch"
@click-branch="handleClickBranch"
>
<!-- 每个并行分支的子节点 -->
<template
v-for="branch in node.branches"
:key="branch.id"
#[`branch-${branch.id}`]
>
<!-- 分支内的添加按钮 -->
<AddNodeButton
@add="(type) => emit('addNode', type, node.id, branch.id)"
/>
<!-- 分支内的子节点递归 -->
<FlowNode
v-if="branch.children"
:node="branch.children"
:selected-node-id="selectedNodeId"
:parent-id="node.id"
:branch-id="branch.id"
:in-branch="true"
:node-errors="nodeErrors"
:node-statuses="nodeStatuses"
@select-node="forwardSelectNode"
@add-node="forwardAddNode"
@delete-node="forwardDeleteNode"
@add-branch="forwardAddBranch"
@delete-branch="forwardDeleteBranch"
@click-branch="forwardClickBranch"
/>
</template>
</ParallelNode>
<!-- 结束节点不在分支内时显示 -->
<EndNode
v-else-if="node.type === 'end' && !inBranch"
:node="node"
:selected="selectedNodeId === node.id"
@click="handleSelectNode(node.id)"
/>
</div>
<!-- 添加节点按钮非结束节点时显示 -->
<AddNodeButton v-if="node.type !== 'end'" @add="handleAddNode" />
<!-- 子节点递归渲染 -->
<FlowNode
v-if="node.children && node.type !== 'end'"
:node="node.children"
:selected-node-id="selectedNodeId"
:parent-id="node.id"
:in-branch="inBranch"
:node-errors="nodeErrors"
:node-statuses="nodeStatuses"
@select-node="forwardSelectNode"
@add-node="forwardAddNode"
@delete-node="forwardDeleteNode"
@add-branch="forwardAddBranch"
@delete-branch="forwardDeleteBranch"
@click-branch="forwardClickBranch"
/>
</div>
</template>
<style scoped>
/* 进行中动画 */
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.flow-node-wrapper {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.node-container {
display: flex;
justify-content: center;
}
/* 节点状态标记 */
.node-status-badge {
position: absolute;
top: -6px;
left: 50%;
z-index: 20;
display: flex;
gap: 4px;
align-items: center;
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
border-radius: 10px;
transform: translateX(-50%);
}
.node-status-badge.status-completed {
color: var(--el-color-success);
background: var(--el-color-success-light-9);
border: 1px solid var(--el-color-success-light-5);
}
.node-status-badge.status-completed::before {
content: '已完成';
}
.node-status-badge.status-active {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-5);
animation: pulse 2s infinite;
}
.node-status-badge.status-active::before {
content: '进行中';
}
.node-status-badge.status-rejected {
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
border: 1px solid var(--el-color-danger-light-5);
}
.node-status-badge.status-rejected::before {
content: '已拒绝';
}
.node-status-badge.status-pending {
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color);
}
.node-status-badge.status-pending::before {
content: '待执行';
}
/* 状态图标 */
.status-icon {
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-completed .status-icon {
background: var(--el-color-success);
}
.status-active .status-icon {
background: var(--el-color-primary);
}
.status-rejected .status-icon {
background: var(--el-color-danger);
}
.status-pending .status-icon {
background: var(--el-text-color-placeholder);
}
/* 节点状态边框效果 */
.node-status-completed :deep(.flow-node) {
box-shadow: 0 0 0 2px var(--el-color-success-light-5);
}
.node-status-active :deep(.flow-node) {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.node-status-rejected :deep(.flow-node) {
box-shadow: 0 0 0 2px var(--el-color-danger-light-5);
}
</style>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import type { FlowDefinition } from '../types';
/**
* 流程预览弹窗
* 使用 FlowPreviewContent 组件渲染流程图
*/
import { computed } from 'vue';
import { ZqDialog } from '#/components/zq-dialog';
import FlowPreviewContent from './FlowPreviewContent.vue';
interface Props {
modelValue: boolean;
flowDefinition: FlowDefinition;
flowName?: string;
formName?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
}>();
const dialogVisible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
</script>
<template>
<ZqDialog
v-model="dialogVisible"
title="流程预览"
width="1000px"
:show-footer="false"
:max-height="600"
>
<FlowPreviewContent
:flow-definition="flowDefinition"
:flow-name="flowName"
:form-name="formName"
height="500px"
/>
</ZqDialog>
</template>
<style>
/* 全屏时流程图区域自适应 - 全局样式 */
.zq-dialog.is-fullscreen .preview-content {
display: flex;
flex-direction: column;
height: 100%;
}
.zq-dialog.is-fullscreen .preview-canvas-wrapper {
flex: 1;
height: auto !important;
}
.zq-dialog.is-fullscreen .zq-dialog-body {
height: 100%;
}
.zq-dialog.is-fullscreen .zq-dialog-body .el-scrollbar {
height: 100%;
}
.zq-dialog.is-fullscreen .zq-dialog-body .el-scrollbar__wrap {
height: 100%;
}
</style>
@@ -0,0 +1,407 @@
<script setup lang="ts">
import type { FieldAccess, FormFieldPermission } from '../types';
/**
* 表单字段权限配置组件
* 用于在审批节点中配置每个表单字段的权限(可编辑/只读/隐藏)
*/
import { computed, ref, watch } from 'vue';
import { Edit3, Eye, EyeOff, RotateCcw } from '@vben/icons';
import {
ElButton,
ElEmpty,
ElTable,
ElTableColumn,
ElTag,
ElTooltip,
} from 'element-plus';
export interface FormField {
name: string;
label: string;
type: string;
required?: boolean;
}
interface Props {
/** 表单字段列表 */
fields: FormField[];
/** 当前权限配置 */
permissions: FormFieldPermission[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:permissions': [permissions: FormFieldPermission[]];
}>();
// 权限选项配置
const accessOptions: {
color: string;
icon: any;
label: string;
value: FieldAccess;
}[] = [
{
value: 'editable',
label: '可编辑',
icon: Edit3,
color: 'var(--el-color-success)',
},
{
value: 'readonly',
label: '只读',
icon: Eye,
color: 'var(--el-color-warning)',
},
{
value: 'hidden',
label: '隐藏',
icon: EyeOff,
color: 'var(--el-color-info)',
},
];
// 内部权限映射(field -> access
const permissionMap = ref<Map<string, FieldAccess>>(new Map());
// 初始化权限映射
watch(
() => props.permissions,
(newPermissions) => {
const map = new Map<string, FieldAccess>();
for (const p of newPermissions) {
map.set(p.field, p.access);
}
permissionMap.value = map;
},
{ immediate: true },
);
// 获取字段权限
function getFieldAccess(fieldName: string): FieldAccess {
return permissionMap.value.get(fieldName) || 'editable';
}
// 设置字段权限
function setFieldAccess(fieldName: string, access: FieldAccess) {
const map = new Map(permissionMap.value);
// 如果是默认值(可编辑),则移除配置
if (access === 'editable') {
map.delete(fieldName);
} else {
map.set(fieldName, access);
}
permissionMap.value = map;
// 转换为数组格式并触发更新
const permissions: FormFieldPermission[] = [];
for (const [field, acc] of map.entries()) {
permissions.push({ field, access: acc });
}
emit('update:permissions', permissions);
}
// 循环切换权限
function cycleAccess(fieldName: string) {
const current = getFieldAccess(fieldName);
const currentIndex = accessOptions.findIndex((o) => o.value === current);
const nextIndex = (currentIndex + 1) % accessOptions.length;
setFieldAccess(fieldName, accessOptions[nextIndex]!.value);
}
// 批量设置所有字段权限
function setAllFieldsAccess(access: FieldAccess) {
const map = new Map<string, FieldAccess>();
if (access !== 'editable') {
for (const field of props.fields) {
map.set(field.name, access);
}
}
permissionMap.value = map;
const permissions: FormFieldPermission[] = [];
for (const [field, acc] of map.entries()) {
permissions.push({ field, access: acc });
}
emit('update:permissions', permissions);
}
// 重置所有权限
function resetAll() {
permissionMap.value = new Map();
emit('update:permissions', []);
}
// 获取权限配置
function getAccessConfig(access: FieldAccess) {
return accessOptions.find((o) => o.value === access) || accessOptions[0];
}
// 统计信息
const statistics = computed(() => {
let editable = 0;
let readonly = 0;
let hidden = 0;
for (const field of props.fields) {
const access = getFieldAccess(field.name);
switch (access) {
case 'editable': {
editable++;
break;
}
case 'hidden': {
{
hidden++;
// No default
}
break;
}
case 'readonly': {
readonly++;
break;
}
}
}
return { editable, readonly, hidden, total: props.fields.length };
});
// 字段类型显示
function getFieldTypeLabel(type: string): string {
const typeMap: Record<string, string> = {
input: '文本',
textarea: '多行文本',
number: '数字',
'input-number': '数字',
date: '日期',
datetime: '日期时间',
'date-picker': '日期',
select: '下拉选择',
radio: '单选',
checkbox: '多选',
'user-selector': '人员选择',
'dept-selector': '部门选择',
upload: '附件',
string: '文本',
};
return typeMap[type] || type;
}
</script>
<template>
<div class="form-field-permissions">
<!-- 快捷操作栏 -->
<div class="quick-actions">
<div class="action-buttons">
<ElTooltip content="全部可编辑" placement="top">
<ElButton
size="small"
:icon="Edit3"
@click="setAllFieldsAccess('editable')"
>
可编辑
</ElButton>
</ElTooltip>
<ElTooltip content="全部只读" placement="top">
<ElButton
size="small"
:icon="Eye"
@click="setAllFieldsAccess('readonly')"
>
只读
</ElButton>
</ElTooltip>
<ElTooltip content="全部隐藏" placement="top">
<ElButton
size="small"
:icon="EyeOff"
@click="setAllFieldsAccess('hidden')"
>
隐藏
</ElButton>
</ElTooltip>
<ElTooltip content="重置为默认" placement="top">
<ElButton size="small" :icon="RotateCcw" @click="resetAll">
重置
</ElButton>
</ElTooltip>
</div>
<div class="statistics">
<span class="stat-item editable">{{ statistics.editable }} 可编辑</span>
<span class="stat-item readonly">{{ statistics.readonly }} 只读</span>
<span class="stat-item hidden">{{ statistics.hidden }} 隐藏</span>
</div>
</div>
<!-- 字段列表 -->
<div v-if="fields.length > 0" class="field-list">
<ElTable
:data="fields"
size="small"
:show-header="false"
class="permission-table"
>
<ElTableColumn prop="label" min-width="120">
<template #default="{ row }">
<div class="field-info">
<span class="field-label">{{ row.label }}</span>
<ElTag size="small" type="info" class="field-type">
{{ getFieldTypeLabel(row.type) }}
</ElTag>
</div>
</template>
</ElTableColumn>
<ElTableColumn width="120" align="right">
<template #default="{ row }">
<div
class="access-toggle"
:class="getFieldAccess(row.name)"
@click="cycleAccess(row.name)"
>
<component
:is="getAccessConfig(getFieldAccess(row.name))?.icon"
class="access-icon"
/>
<span class="access-label">
{{ getAccessConfig(getFieldAccess(row.name))?.label }}
</span>
</div>
</template>
</ElTableColumn>
</ElTable>
</div>
<!-- 空状态 -->
<ElEmpty v-else description="暂无表单字段" :image-size="60" />
</div>
</template>
<style scoped>
.form-field-permissions {
display: flex;
flex-direction: column;
gap: 12px;
}
.quick-actions {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: var(--el-fill-color-lighter);
border-radius: 8px;
}
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.statistics {
display: flex;
gap: 12px;
font-size: 12px;
}
.stat-item {
display: flex;
gap: 4px;
align-items: center;
}
.stat-item.editable {
color: var(--el-color-success);
}
.stat-item.readonly {
color: var(--el-color-warning);
}
.stat-item.hidden {
color: var(--el-color-info);
}
.field-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.permission-table {
--el-table-border-color: transparent;
}
.permission-table :deep(.el-table__row) {
cursor: pointer;
}
.permission-table :deep(.el-table__row:hover) {
background-color: var(--el-fill-color-light);
}
.field-info {
display: flex;
gap: 8px;
align-items: center;
}
.field-label {
font-size: 13px;
color: var(--el-text-color-primary);
}
.field-type {
padding: 0 6px;
font-size: 11px;
}
.access-toggle {
display: inline-flex;
gap: 4px;
align-items: center;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s ease;
}
.access-toggle.editable {
color: var(--el-color-success);
background: var(--el-color-success-light-9);
}
.access-toggle.readonly {
color: var(--el-color-warning);
background: var(--el-color-warning-light-9);
}
.access-toggle.hidden {
color: var(--el-color-info);
background: var(--el-color-info-light-9);
}
.access-toggle:hover {
opacity: 0.8;
}
.access-icon {
width: 14px;
height: 14px;
}
.access-label {
font-weight: 500;
}
</style>
@@ -0,0 +1,45 @@
<script lang="ts" setup>
import { $t } from '@vben/locales';
import { ElFormItem, ElInput } from 'element-plus';
import { MAX_NODE_NAME_LENGTH } from '../utils/node-display';
defineOptions({ name: 'NodeNameField' });
defineProps<{
modelValue: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
</script>
<template>
<ElFormItem
:label="$t('workflow-designer.property.base.nodeName')"
class="node-name-form-item"
>
<ElInput
:model-value="modelValue"
:placeholder="$t('workflow-designer.property.base.nodeNamePlaceholder')"
:maxlength="MAX_NODE_NAME_LENGTH"
show-word-limit
clearable
@update:model-value="emit('update:modelValue', $event)"
/>
<p class="node-name-hint">
{{ $t('workflow-designer.property.base.nodeNameHint') }}
</p>
</ElFormItem>
</template>
<style scoped>
.node-name-hint {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.4;
color: var(--el-text-color-secondary);
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,201 @@
<script setup lang="ts">
import { Eye, Play, Redo2, Save, Undo2 } from '@vben/icons';
/**
* 设计器工具栏
*/
import { $t } from '@vben/locales';
import { ElButton, ElInput, ElMessage } from 'element-plus';
const props = defineProps<{
canRedo: boolean;
canUndo: boolean;
flowName: string;
isDirty: boolean;
}>();
const emit = defineEmits<{
preview: [];
publish: [];
redo: [];
save: [];
undo: [];
'update:flowName': [name: string];
}>();
function handleSave() {
emit('save');
}
function handleUndo() {
if (props.canUndo) {
emit('undo');
}
}
function handleRedo() {
if (props.canRedo) {
emit('redo');
}
}
function handlePreview() {
emit('preview');
}
function handlePublish() {
if (props.isDirty) {
ElMessage.warning($t('workflow-designer.designer.saveFirst'));
return;
}
emit('publish');
}
</script>
<template>
<div class="designer-toolbar">
<div class="toolbar-left">
<!-- 流程名称 -->
<ElInput
:model-value="flowName"
class="flow-name-input"
:placeholder="$t('workflow-designer.designer.flowNamePlaceholder')"
@update:model-value="emit('update:flowName', $event)"
/>
<span v-if="isDirty" class="dirty-indicator">*</span>
</div>
<div class="toolbar-center">
<!-- 撤销/重做 -->
<div class="toolbar-group">
<div
class="toolbar-btn"
:class="{ disabled: !canUndo }"
:title="`${$t('workflow-designer.designer.undo')} (Ctrl+Z)`"
@click="handleUndo"
>
<Undo2 class="toolbar-icon" />
</div>
<div
class="toolbar-btn"
:class="{ disabled: !canRedo }"
:title="`${$t('workflow-designer.designer.redo')} (Ctrl+Y)`"
@click="handleRedo"
>
<Redo2 class="toolbar-icon" />
</div>
</div>
</div>
<div class="toolbar-right">
<!-- 预览 -->
<ElButton @click="handlePreview">
<Eye class="btn-icon" />
{{ $t('workflow-designer.designer.preview') }}
</ElButton>
<!-- 保存 -->
<ElButton type="primary" @click="handleSave">
<Save class="btn-icon" />
{{ $t('workflow-designer.designer.save') }}
</ElButton>
<!-- 发布 -->
<ElButton type="success" @click="handlePublish">
<Play class="btn-icon" />
{{ $t('workflow-designer.designer.publish') }}
</ElButton>
</div>
</div>
</template>
<style scoped>
.designer-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
padding: 0 16px;
background: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.toolbar-left {
display: flex;
gap: 8px;
align-items: center;
}
.flow-name-input {
width: 200px;
}
.flow-name-input :deep(.el-input__wrapper) {
background: transparent;
box-shadow: none;
}
.flow-name-input :deep(.el-input__inner) {
font-size: 16px;
font-weight: 500;
}
.dirty-indicator {
font-size: 18px;
font-weight: bold;
color: var(--el-color-danger);
}
.toolbar-center {
display: flex;
gap: 16px;
align-items: center;
}
.toolbar-group {
display: flex;
gap: 4px;
align-items: center;
padding: 4px;
background: var(--el-fill-color-light);
border-radius: 8px;
}
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
}
.toolbar-btn:hover:not(.disabled) {
background: var(--el-fill-color-darker);
}
.toolbar-btn.disabled {
cursor: not-allowed;
opacity: 0.4;
}
.toolbar-icon {
width: 18px;
height: 18px;
color: var(--el-text-color-primary);
}
.toolbar-right {
display: flex;
gap: 12px;
align-items: center;
}
.btn-icon {
width: 16px;
height: 16px;
margin-right: 4px;
}
</style>
@@ -0,0 +1,274 @@
<script setup lang="ts">
import type { ApprovalNodeConfig, FlowNode } from '../../types';
/**
* 审批人节点
*/
import { computed } from 'vue';
import { AlertCircle, UserCheck, X } from '@vben/icons';
import { $t } from '@vben/locales';
import { getNodeDisplayName } from '../../utils/node-display';
import { NODE_TYPE_CONFIGS } from '../../types';
const props = defineProps<{
errorMessage?: string;
hasError?: boolean;
hasWarning?: boolean;
node: FlowNode;
selected?: boolean;
}>();
const emit = defineEmits<{
click: [];
delete: [];
}>();
const config = NODE_TYPE_CONFIGS.approval;
const nodeConfig = computed(() => props.node.config as ApprovalNodeConfig);
const multiApprovalText = computed(() => {
const type = nodeConfig.value.multiApproval;
if (!type) return '';
return $t(`workflow-designer.nodes.approval.multiApproval.${type}`);
});
// 审批人摘要显示
const assigneeSummary = computed(() => {
const config = nodeConfig.value;
const count = config.assignees?.length || 0;
switch (config.assigneeType) {
case 'department': {
return count > 0
? `${count}${$t('workflow-designer.nodes.approval.summary.department')}`
: '';
}
case 'form_field': {
const fields = config.assigneeFields || (config.assigneeField ? [config.assigneeField] : []);
return fields.length > 0
? `${$t('workflow-designer.nodes.approval.summary.formField')}(${fields.length})`
: $t('workflow-designer.nodes.approval.summary.formField');
}
case 'initiator': {
return $t('workflow-designer.nodes.approval.summary.initiator');
}
case 'manager': {
return `${$t('workflow-designer.nodes.approval.summary.prefix')}${config.assigneeLevel || 1}${$t('workflow-designer.nodes.approval.summary.manager')}`;
}
case 'role': {
return count > 0
? `${count}${$t('workflow-designer.nodes.approval.summary.role')}`
: '';
}
case 'superior': {
return `${$t('workflow-designer.nodes.approval.summary.prefix')}${config.assigneeLevel || 1}${$t('workflow-designer.nodes.approval.summary.superior')}`;
}
case 'user': {
return count > 0
? `${count}${$t('workflow-designer.nodes.approval.summary.user')}`
: '';
}
default: {
return '';
}
}
});
</script>
<template>
<div
class="flow-node approval-node"
:class="{
'is-selected': selected,
'has-error': hasError,
'has-warning': hasWarning && !hasError,
}"
@click="emit('click')"
>
<!-- 错误标记 -->
<div
v-if="hasError || hasWarning"
class="node-error-badge"
:class="{ 'is-warning': hasWarning && !hasError }"
>
<AlertCircle class="error-icon" />
</div>
<!-- 删除按钮 -->
<div class="node-delete" @click.stop="emit('delete')">
<X class="delete-icon" />
</div>
<div class="node-header" :style="{ background: config.bgColor }">
<UserCheck class="node-icon" />
<span class="node-title">{{ getNodeDisplayName(node) }}</span>
</div>
<div class="node-content">
<div v-if="assigneeSummary" class="assignee-summary">
<span class="summary-text">{{ assigneeSummary }}</span>
<span v-if="multiApprovalText" class="info-tag">{{
multiApprovalText
}}</span>
</div>
<div v-else class="node-placeholder">
{{ $t('workflow-designer.nodes.approval.placeholder') }}
</div>
<!-- 错误提示 -->
<div v-if="errorMessage" class="error-message">{{ errorMessage }}</div>
</div>
</div>
</template>
<style scoped>
.flow-node {
position: relative;
width: 220px;
overflow: hidden;
cursor: pointer;
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
transition: all 0.2s ease;
}
.flow-node:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
}
.flow-node.is-selected {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.flow-node.has-error {
box-shadow: 0 0 0 2px var(--el-color-danger);
}
.flow-node.has-warning {
box-shadow: 0 0 0 2px var(--el-color-warning);
}
.node-error-badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 25;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: var(--el-color-danger);
border-radius: 50%;
}
.node-error-badge.is-warning {
background: var(--el-color-warning);
}
.error-icon {
width: 14px;
height: 14px;
color: white;
}
.error-message {
padding: 4px 8px;
margin-top: 8px;
font-size: 12px;
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
border-radius: 4px;
}
.flow-node:hover .node-delete {
opacity: 1;
}
.node-delete {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: rgb(0 0 0 / 30%);
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s ease;
}
.node-delete:hover {
background: rgb(0 0 0 / 50%);
}
.delete-icon {
width: 12px;
height: 12px;
color: white;
}
.node-header {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 16px;
color: white;
}
.node-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
}
.node-title {
font-size: 14px;
font-weight: 500;
}
.node-content {
padding: 12px 16px;
}
.node-info {
display: flex;
gap: 8px;
align-items: center;
}
.info-label {
font-size: 13px;
color: var(--el-text-color-primary);
}
.info-tag {
padding: 2px 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color);
border-radius: 4px;
}
.assignee-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.summary-text {
font-size: 13px;
color: var(--el-text-color-primary);
}
.node-placeholder {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
</style>
@@ -0,0 +1,499 @@
<script setup lang="ts">
import type {
ConditionBranchConfig,
ConditionOperator,
FlowNode,
} from '../../types';
import { X } from '@vben/icons';
/**
* 条件分支节点
* 钉钉/飞书风格的条件分支
*/
import { $t } from '@vben/locales';
import {
getBranchDisplayName,
getNodeDisplayName,
} from '../../utils/node-display';
const props = defineProps<{
/** 是否是嵌套在分支内的条件节点 */
isNested?: boolean;
node: FlowNode;
selected?: boolean;
}>();
const emit = defineEmits<{
addBranch: [];
click: [];
clickBranch: [branchId: string];
delete: [];
deleteBranch: [branchId: string];
}>();
// 操作符显示文本
const operatorLabels: Record<ConditionOperator, string> = {
eq: '=',
ne: '≠',
gt: '>',
gte: '≥',
lt: '<',
lte: '≤',
contains: '包含',
not_contains: '不包含',
in: '属于',
not_in: '不属于',
empty: '为空',
not_empty: '不为空',
};
// 生成条件摘要
function getConditionSummary(config: ConditionBranchConfig): string {
if (!config.groups?.length) return '';
const group = config.groups[0];
if (!group?.conditions?.length) return '';
const condition = group.conditions[0];
if (!condition) return '';
const op = operatorLabels[condition.operator] || condition.operator;
// 简化显示:字段 操作符 值
let summary = `${condition.field} ${op}`;
if (!['empty', 'not_empty'].includes(condition.operator)) {
summary += ` ${condition.value}`;
}
// 如果有更多条件,显示省略
const totalConditions = config.groups.reduce(
(sum, g) => sum + (g.conditions?.length || 0),
0,
);
if (totalConditions > 1) {
summary += `${totalConditions}个条件`;
}
return summary;
}
</script>
<template>
<div class="condition-node-wrapper" :class="{ 'is-nested': isNested }">
<!-- 删除整个条件节点按钮 -->
<div class="condition-delete" @click.stop="emit('delete')">
<X class="delete-icon" />
</div>
<div
v-if="!isNested"
class="gateway-title"
:class="{ 'is-selected': selected }"
@click.stop="emit('click')"
>
{{ getNodeDisplayName(props.node) }}
</div>
<!-- 添加分支按钮 -->
<div class="add-branch-btn" @click.stop="emit('addBranch')">
{{ $t('workflow-designer.nodes.condition.addCondition') }}
</div>
<!-- 条件分支容器 - 使用表格布局确保对齐 -->
<div class="condition-box">
<!-- 分支列表 -->
<div class="condition-branches">
<div
v-for="(branch, index) in node.branches"
:key="branch.id"
class="branch-item"
:class="{
'is-first': index === 0,
'is-last': index === (node.branches?.length || 0) - 1,
}"
>
<!-- 分支顶部区域横线 + 垂直线 -->
<div class="branch-top-area">
<!-- 左侧横线从左边界到中心 -->
<div class="h-line left" :class="{ 'is-first': index === 0 }"></div>
<!-- 右侧横线从中心到右边界 -->
<div
class="h-line right"
:class="{ 'is-last': index === (node.branches?.length || 0) - 1 }"
></div>
<!-- 垂直线连接顶部横线和分支卡片 -->
<div class="v-line"></div>
</div>
<!-- 分支条件卡片 -->
<div
class="branch-card"
:class="{ 'is-default': branch.config.isDefault }"
@click.stop="emit('clickBranch', branch.id)"
>
<div class="branch-header">
<span class="branch-priority"
>{{ $t('workflow-designer.nodes.condition.priority')
}}{{ index + 1 }}</span
>
<span class="branch-name">{{
getBranchDisplayName(branch, {
index,
isDefault: branch.config.isDefault,
branchType: 'condition',
})
}}</span>
<!-- 删除分支按钮默认分支不可删除 -->
<div
v-if="
!branch.config.isDefault && (node.branches?.length || 0) > 2
"
class="branch-delete"
@click.stop="emit('deleteBranch', branch.id)"
>
<X class="delete-icon" />
</div>
</div>
<div class="branch-body">
<template v-if="branch.config.isDefault">
<span class="branch-desc">{{
$t('workflow-designer.nodes.condition.defaultBranchDesc')
}}</span>
</template>
<template v-else-if="getConditionSummary(branch.config)">
<span class="branch-condition">{{
getConditionSummary(branch.config)
}}</span>
</template>
<template v-else>
<span class="branch-desc">{{
$t('workflow-designer.nodes.condition.pleaseSetCondition')
}}</span>
</template>
</div>
</div>
<!-- 分支内容插槽(子节点) -->
<div class="branch-content">
<slot :name="`branch-${branch.id}`" :branch="branch"></slot>
</div>
<!-- 分支底部区域(垂直线 + 横线) -->
<div class="branch-bottom-area">
<!-- 垂直线:连接分支内容和底部横线 -->
<div class="v-line"></div>
<!-- 左侧横线 -->
<div class="h-line left" :class="{ 'is-first': index === 0 }"></div>
<!-- 右侧横线 -->
<div
class="h-line right"
:class="{ 'is-last': index === (node.branches?.length || 0) - 1 }"
></div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.condition-node-wrapper {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.condition-delete {
position: absolute;
top: 6px;
right: 6px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
cursor: pointer;
background: var(--el-color-danger);
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s ease;
}
.condition-node-wrapper:hover .condition-delete {
opacity: 1;
}
.delete-icon {
width: 14px;
height: 14px;
color: white;
}
.add-branch-btn {
position: absolute;
top: -10px;
left: 50%;
z-index: 5;
padding: 4px 12px;
font-size: 12px;
color: white;
white-space: nowrap;
cursor: pointer;
background: var(--el-color-primary);
border-radius: 12px;
transform: translateX(-50%);
}
.add-branch-btn:hover {
background: var(--el-color-primary-dark-2);
}
/* 条件分支容器 */
.condition-box {
display: flex;
flex-direction: column;
align-items: center;
}
.condition-branches {
display: flex;
align-items: stretch; /* 关键:让所有分支等高 */
}
.branch-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
min-width: 260px;
padding: 0;
}
/* 分支顶部区域 */
.branch-top-area {
position: relative;
display: flex;
justify-content: center;
width: 100%;
height: 20px;
}
.branch-top-area .v-line {
width: 2px;
height: 100%;
background: var(--el-border-color-darker);
}
.branch-top-area .h-line {
position: absolute;
top: 0;
width: 50%;
height: 2px;
background: var(--el-border-color-darker);
}
.branch-top-area .h-line.left {
left: 0;
}
.branch-top-area .h-line.right {
right: 0;
}
/* 第一个分支隐藏左边横线 */
.h-line.left.is-first {
visibility: hidden;
}
/* 最后一个分支隐藏右边横线 */
.h-line.right.is-last {
visibility: hidden;
}
/* 分支底部区域 */
.branch-bottom-area {
position: relative;
display: flex;
flex: 1;
justify-content: center;
width: 100%;
min-height: 20px;
}
.branch-bottom-area .v-line {
width: 2px;
height: 100%;
min-height: 20px;
background: var(--el-border-color-darker);
}
.branch-bottom-area .h-line {
position: absolute;
bottom: 0;
width: 50%;
height: 2px;
background: var(--el-border-color-darker);
}
.branch-bottom-area .h-line.left {
left: 0;
}
.branch-bottom-area .h-line.right {
right: 0;
}
/* 分支条件卡片 - 和普通节点一样宽 */
.branch-card {
position: relative;
flex-shrink: 0;
width: 220px;
overflow: hidden;
cursor: pointer;
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
transition: all 0.2s ease;
}
.branch-card:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
}
.branch-card:hover .branch-delete {
opacity: 1;
}
.branch-card.is-default {
border: 1px dashed var(--el-border-color);
}
.branch-header {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 16px;
color: white;
background: rgb(21 188 131);
}
.branch-card.is-default .branch-header {
background: var(--el-fill-color-darker);
}
.branch-priority {
padding: 2px 6px;
font-size: 12px;
background: rgb(255 255 255 / 20%);
border-radius: 4px;
}
.branch-name {
flex: 1;
font-size: 14px;
font-weight: 500;
}
.branch-delete {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: rgb(0 0 0 / 20%);
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s ease;
}
.branch-delete:hover {
background: rgb(0 0 0 / 40%);
}
.branch-delete .delete-icon {
width: 10px;
height: 10px;
}
.branch-body {
padding: 12px 16px;
}
.branch-desc {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.branch-condition {
display: inline-block;
max-width: 100%;
padding: 4px 8px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--el-text-color-primary);
white-space: nowrap;
background: var(--el-fill-color-light);
border-radius: 4px;
}
.branch-placeholder {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
/* 分支内容区域 */
.branch-content {
display: flex;
flex-shrink: 0;
flex-direction: column;
align-items: center;
width: 100%;
}
.gateway-title {
margin-bottom: 8px;
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-primary);
text-align: center;
cursor: pointer;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color);
border-radius: 6px;
}
.gateway-title.is-selected {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary-light-7);
}
/* 嵌套条件节点的特殊处理 */
.condition-node-wrapper.is-nested {
margin-top: 0;
}
/* 嵌套条件节点顶部的入口垂直线 - 连接 AddNodeButton 和条件分支 */
.condition-node-wrapper.is-nested .condition-box::before {
display: block;
width: 2px;
height: 20px;
margin: 0 auto;
content: '';
background: var(--el-border-color-darker);
}
/* 嵌套条件节点底部的出口垂直线 - 连接条件分支和下方节点 */
.condition-node-wrapper.is-nested .condition-box::after {
display: block;
width: 2px;
height: 20px;
margin: 0 auto;
content: '';
background: var(--el-border-color-darker);
}
</style>
@@ -0,0 +1,244 @@
<script setup lang="ts">
import type { CopyNodeConfig, FlowNode } from '../../types';
/**
* 抄送人节点
*/
import { computed } from 'vue';
import { AlertCircle, Send, X } from '@vben/icons';
import { $t } from '@vben/locales';
import { getNodeDisplayName } from '../../utils/node-display';
import { NODE_TYPE_CONFIGS } from '../../types';
const props = defineProps<{
errorMessage?: string;
hasError?: boolean;
hasWarning?: boolean;
node: FlowNode;
selected?: boolean;
}>();
const emit = defineEmits<{
click: [];
delete: [];
}>();
const config = NODE_TYPE_CONFIGS.copy;
const nodeConfig = computed(() => props.node.config as CopyNodeConfig);
// 抷送人摘要显示
const assigneeSummary = computed(() => {
const config = nodeConfig.value;
const count = config.assignees?.length || 0;
switch (config.assigneeType) {
case 'department': {
return count > 0
? `${count}${$t('workflow-designer.nodes.copy.summary.department')}`
: '';
}
case 'form_field': {
const fields = config.assigneeFields || (config.assigneeField ? [config.assigneeField] : []);
return fields.length > 0
? `${$t('workflow-designer.nodes.copy.summary.formField')}(${fields.length})`
: $t('workflow-designer.nodes.copy.summary.formField');
}
case 'initiator': {
return $t('workflow-designer.nodes.copy.summary.initiator');
}
case 'role': {
return count > 0
? `${count}${$t('workflow-designer.nodes.copy.summary.role')}`
: '';
}
case 'user': {
return count > 0
? `${count}${$t('workflow-designer.nodes.copy.summary.user')}`
: '';
}
default: {
return '';
}
}
});
</script>
<template>
<div
class="flow-node copy-node"
:class="{
'is-selected': selected,
'has-error': hasError,
'has-warning': hasWarning && !hasError,
}"
@click="emit('click')"
>
<!-- 错误标记 -->
<div
v-if="hasError || hasWarning"
class="node-error-badge"
:class="{ 'is-warning': hasWarning && !hasError }"
>
<AlertCircle class="error-icon" />
</div>
<!-- 删除按钮 -->
<div class="node-delete" @click.stop="emit('delete')">
<X class="delete-icon" />
</div>
<div class="node-header" :style="{ background: config.bgColor }">
<Send class="node-icon" />
<span class="node-title">{{ getNodeDisplayName(node) }}</span>
</div>
<div class="node-content">
<div v-if="assigneeSummary" class="assignee-summary">
<span class="summary-text">{{ assigneeSummary }}</span>
</div>
<div v-else class="node-placeholder">
{{ $t('workflow-designer.nodes.copy.placeholder') }}
</div>
<div v-if="errorMessage" class="error-message">{{ errorMessage }}</div>
</div>
</div>
</template>
<style scoped>
.flow-node {
position: relative;
width: 220px;
overflow: hidden;
cursor: pointer;
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
transition: all 0.2s ease;
}
.flow-node:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
}
.flow-node.is-selected {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.flow-node.has-error {
box-shadow: 0 0 0 2px var(--el-color-danger);
}
.flow-node.has-warning {
box-shadow: 0 0 0 2px var(--el-color-warning);
}
.node-error-badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 25;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: var(--el-color-danger);
border-radius: 50%;
}
.node-error-badge.is-warning {
background: var(--el-color-warning);
}
.error-icon {
width: 14px;
height: 14px;
color: white;
}
.error-message {
padding: 4px 8px;
margin-top: 8px;
font-size: 12px;
color: var(--el-color-warning);
background: var(--el-color-warning-light-9);
border-radius: 4px;
}
.flow-node:hover .node-delete {
opacity: 1;
}
.node-delete {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: rgb(0 0 0 / 30%);
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s ease;
}
.node-delete:hover {
background: rgb(0 0 0 / 50%);
}
.delete-icon {
width: 12px;
height: 12px;
color: white;
}
.node-header {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 16px;
color: white;
}
.node-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
}
.node-title {
font-size: 14px;
font-weight: 500;
}
.node-content {
padding: 12px 16px;
}
.node-info {
display: flex;
gap: 8px;
align-items: center;
}
.assignee-summary {
display: flex;
gap: 8px;
align-items: center;
}
.summary-text {
font-size: 13px;
color: var(--el-text-color-primary);
}
.node-placeholder {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
</style>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import type { DataUpdateNodeConfig, FlowNode } from '../../types';
/**
* 字段更新节点
* 自动更新表单字段值
*/
import { computed } from 'vue';
import { PenLine, X } from '@vben/icons';
import { $t } from '@vben/locales';
import { getNodeDisplayName } from '../../utils/node-display';
import { NODE_TYPE_CONFIGS } from '../../types';
const props = defineProps<{
node: FlowNode;
selected?: boolean;
}>();
const emit = defineEmits<{
click: [];
delete: [];
}>();
const config = NODE_TYPE_CONFIGS.data_update;
const nodeConfig = computed(() => props.node.config as DataUpdateNodeConfig);
// 规则摘要显示
const ruleSummary = computed(() => {
const cfg = nodeConfig.value;
const rules = cfg?.rules || [];
if (rules.length === 0) {
return '';
}
const countText = $t('workflow-designer.nodes.data_update.summary.ruleCount', {
count: rules.length,
});
if (cfg?.targetFormCode) {
return `${$t('workflow-designer.nodes.data_update.summary.crossForm')} · ${countText}`;
}
return countText;
});
</script>
<template>
<div
class="flow-node data-update-node"
:class="{ 'is-selected': selected }"
@click="emit('click')"
>
<!-- 删除按钮 -->
<div class="node-delete" @click.stop="emit('delete')">
<X class="delete-icon" />
</div>
<div class="node-header" :style="{ background: config.bgColor }">
<PenLine class="node-icon" />
<span class="node-title">{{ getNodeDisplayName(node) }}</span>
</div>
<div class="node-content">
<div v-if="ruleSummary" class="update-summary">
<span class="summary-text">{{ ruleSummary }}</span>
</div>
<div v-else class="node-placeholder">
{{ $t('workflow-designer.nodes.data_update.placeholder') }}
</div>
</div>
</div>
</template>
<style scoped>
.flow-node {
position: relative;
width: 220px;
overflow: hidden;
cursor: pointer;
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
transition: all 0.2s ease;
}
.flow-node:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
}
.flow-node.is-selected {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.flow-node:hover .node-delete {
opacity: 1;
}
.node-delete {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: rgb(0 0 0 / 30%);
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s ease;
}
.node-delete:hover {
background: rgb(0 0 0 / 50%);
}
.delete-icon {
width: 12px;
height: 12px;
color: white;
}
.node-header {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 16px;
color: white;
}
.node-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
}
.node-title {
font-size: 14px;
font-weight: 500;
}
.node-content {
padding: 12px 16px;
}
.update-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.summary-text {
font-size: 13px;
color: var(--el-text-color-primary);
}
.node-placeholder {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
</style>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import type { DelayNodeConfig, FlowNode } from '../../types';
/**
* 延时等待节点
* 等待指定时间后继续执行流程
*/
import { computed } from 'vue';
import { Clock, X } from '@vben/icons';
import { $t } from '@vben/locales';
import { getNodeDisplayName } from '../../utils/node-display';
import { NODE_TYPE_CONFIGS } from '../../types';
const props = defineProps<{
node: FlowNode;
selected?: boolean;
}>();
const emit = defineEmits<{
click: [];
delete: [];
}>();
const config = NODE_TYPE_CONFIGS.delay;
const nodeConfig = computed(() => props.node.config as DelayNodeConfig);
// 延时摘要显示
const delaySummary = computed(() => {
const cfg = nodeConfig.value;
if (!cfg.duration || cfg.duration <= 0) {
return '';
}
const unitText = $t(`workflow-designer.nodes.delay.summary.${cfg.unit}`);
return `${$t('workflow-designer.nodes.delay.summary.wait')}${cfg.duration}${unitText}`;
});
</script>
<template>
<div
class="flow-node delay-node"
:class="{ 'is-selected': selected }"
@click="emit('click')"
>
<!-- 删除按钮 -->
<div class="node-delete" @click.stop="emit('delete')">
<X class="delete-icon" />
</div>
<div class="node-header" :style="{ background: config.bgColor }">
<Clock class="node-icon" />
<span class="node-title">{{ getNodeDisplayName(node) }}</span>
</div>
<div class="node-content">
<div v-if="delaySummary" class="delay-summary">
<span class="summary-text">{{ delaySummary }}</span>
</div>
<div v-else class="node-placeholder">
{{ $t('workflow-designer.nodes.delay.placeholder') }}
</div>
</div>
</div>
</template>
<style scoped>
.flow-node {
position: relative;
width: 220px;
overflow: hidden;
cursor: pointer;
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
transition: all 0.2s ease;
}
.flow-node:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
}
.flow-node.is-selected {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.flow-node:hover .node-delete {
opacity: 1;
}
.node-delete {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: rgb(0 0 0 / 30%);
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s ease;
}
.node-delete:hover {
background: rgb(0 0 0 / 50%);
}
.delete-icon {
width: 12px;
height: 12px;
color: white;
}
.node-header {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 16px;
color: white;
}
.node-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
}
.node-title {
font-size: 14px;
font-weight: 500;
}
.node-content {
padding: 12px 16px;
}
.delay-summary {
display: flex;
gap: 8px;
align-items: center;
}
.summary-text {
font-size: 13px;
color: var(--el-text-color-primary);
}
.node-placeholder {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
</style>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { FlowNode } from '../../types';
/**
* 结束节点
*/
import { CircleCheck } from '@vben/icons';
import { getNodeDisplayName } from '../../utils/node-display';
import { NODE_TYPE_CONFIGS } from '../../types';
const props = defineProps<{
node: FlowNode;
selected?: boolean;
}>();
const emit = defineEmits<{
click: [];
}>();
const config = NODE_TYPE_CONFIGS.end;
</script>
<template>
<div class="end-node-wrapper" :class="{ 'is-selected': selected }" @click="emit('click')">
<!-- 上方连接线 -->
<div class="end-line"></div>
<div class="end-node" :style="{ background: config.bgColor }">
<CircleCheck class="end-icon" />
<span class="end-text">{{ getNodeDisplayName(props.node) }}</span>
</div>
</div>
</template>
<style scoped>
.end-node-wrapper {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 0 20px;
}
.end-line {
width: 2px;
height: 20px;
background: var(--el-border-color-darker);
}
.end-node {
display: flex;
gap: 6px;
align-items: center;
padding: 8px 20px;
color: white;
border-radius: 20px;
}
.end-icon {
width: 16px;
height: 16px;
}
.end-text {
font-size: 14px;
font-weight: 500;
}
</style>
@@ -0,0 +1,263 @@
<script setup lang="ts">
import type { FlowNode, HandleNodeConfig } from '../../types';
/**
* 办理人节点
* 用于指定人员完成任务(如填写信息、上传附件),没有通过/拒绝操作
*/
import { computed } from 'vue';
import { AlertCircle, ClipboardCheck, X } from '@vben/icons';
import { $t } from '@vben/locales';
import { getNodeDisplayName } from '../../utils/node-display';
import { NODE_TYPE_CONFIGS } from '../../types';
const props = defineProps<{
errorMessage?: string;
hasError?: boolean;
hasWarning?: boolean;
node: FlowNode;
selected?: boolean;
}>();
const emit = defineEmits<{
click: [];
delete: [];
}>();
const config = NODE_TYPE_CONFIGS.handle;
const nodeConfig = computed(() => props.node.config as HandleNodeConfig);
const multiHandleText = computed(() => {
const type = nodeConfig.value.multiHandle;
if (!type) return '';
return $t(`workflow-designer.nodes.handle.multiHandle.${type}`);
});
// 办理人摘要显示
const assigneeSummary = computed(() => {
const config = nodeConfig.value;
const count = config.assignees?.length || 0;
switch (config.assigneeType) {
case 'department': {
return count > 0
? `${count}${$t('workflow-designer.nodes.handle.summary.department')}`
: '';
}
case 'form_field': {
const fields = config.assigneeFields || (config.assigneeField ? [config.assigneeField] : []);
return fields.length > 0
? `${$t('workflow-designer.nodes.handle.summary.formField')}(${fields.length})`
: $t('workflow-designer.nodes.handle.summary.formField');
}
case 'initiator': {
return $t('workflow-designer.nodes.handle.summary.initiator');
}
case 'manager': {
return `${$t('workflow-designer.nodes.handle.summary.prefix')}${config.assigneeLevel || 1}${$t('workflow-designer.nodes.handle.summary.manager')}`;
}
case 'role': {
return count > 0
? `${count}${$t('workflow-designer.nodes.handle.summary.role')}`
: '';
}
case 'superior': {
return `${$t('workflow-designer.nodes.handle.summary.prefix')}${config.assigneeLevel || 1}${$t('workflow-designer.nodes.handle.summary.superior')}`;
}
case 'user': {
return count > 0
? `${count}${$t('workflow-designer.nodes.handle.summary.user')}`
: '';
}
default: {
return '';
}
}
});
</script>
<template>
<div
class="flow-node handle-node"
:class="{
'is-selected': selected,
'has-error': hasError,
'has-warning': hasWarning && !hasError,
}"
@click="emit('click')"
>
<!-- 错误标记 -->
<div
v-if="hasError || hasWarning"
class="node-error-badge"
:class="{ 'is-warning': hasWarning && !hasError }"
>
<AlertCircle class="error-icon" />
</div>
<!-- 删除按钮 -->
<div class="node-delete" @click.stop="emit('delete')">
<X class="delete-icon" />
</div>
<div class="node-header" :style="{ background: config.bgColor }">
<ClipboardCheck class="node-icon" />
<span class="node-title">{{ getNodeDisplayName(node) }}</span>
</div>
<div class="node-content">
<div v-if="assigneeSummary" class="assignee-summary">
<span class="summary-text">{{ assigneeSummary }}</span>
<span v-if="multiHandleText" class="info-tag">{{
multiHandleText
}}</span>
</div>
<div v-else class="node-placeholder">
{{ $t('workflow-designer.nodes.handle.placeholder') }}
</div>
<div v-if="errorMessage" class="error-message">{{ errorMessage }}</div>
</div>
</div>
</template>
<style scoped>
.flow-node {
position: relative;
width: 220px;
overflow: hidden;
cursor: pointer;
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
transition: all 0.2s ease;
}
.flow-node:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
}
.flow-node.is-selected {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.flow-node.has-error {
box-shadow: 0 0 0 2px var(--el-color-danger);
}
.flow-node.has-warning {
box-shadow: 0 0 0 2px var(--el-color-warning);
}
.node-error-badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 25;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: var(--el-color-danger);
border-radius: 50%;
}
.node-error-badge.is-warning {
background: var(--el-color-warning);
}
.error-icon {
width: 14px;
height: 14px;
color: white;
}
.error-message {
padding: 4px 8px;
margin-top: 8px;
font-size: 12px;
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
border-radius: 4px;
}
.flow-node:hover .node-delete {
opacity: 1;
}
.node-delete {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: rgb(0 0 0 / 30%);
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s ease;
}
.node-delete:hover {
background: rgb(0 0 0 / 50%);
}
.delete-icon {
width: 12px;
height: 12px;
color: white;
}
.node-header {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 16px;
color: white;
}
.node-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
}
.node-title {
font-size: 14px;
font-weight: 500;
}
.node-content {
padding: 12px 16px;
}
.info-tag {
padding: 2px 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color);
border-radius: 4px;
}
.assignee-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.summary-text {
font-size: 13px;
color: var(--el-text-color-primary);
}
.node-placeholder {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
</style>
@@ -0,0 +1,205 @@
<script setup lang="ts">
import type { FlowNode, NotifyNodeConfig } from '../../types';
/**
* 通知节点
* 发送消息通知给指定人员
*/
import { computed } from 'vue';
import { Bell, X } from '@vben/icons';
import { $t } from '@vben/locales';
import { getNodeDisplayName } from '../../utils/node-display';
import { NODE_TYPE_CONFIGS } from '../../types';
const props = defineProps<{
node: FlowNode;
selected?: boolean;
}>();
const emit = defineEmits<{
click: [];
delete: [];
}>();
const config = NODE_TYPE_CONFIGS.notify;
const nodeConfig = computed(() => props.node.config as NotifyNodeConfig);
// 通知摘要显示
const notifySummary = computed(() => {
const cfg = nodeConfig.value;
if (!cfg.channels || cfg.channels.length === 0) {
return '';
}
const channelNames = cfg.channels
.map((c) => $t(`workflow-designer.property.notify.channelTypes.${c}`))
.join('、');
return `${$t('workflow-designer.nodes.notify.summary.channels')}: ${channelNames}`;
});
// 通知对象摘要
const recipientSummary = computed(() => {
const cfg = nodeConfig.value;
const count = cfg.recipients?.length || 0;
switch (cfg.recipientType) {
case 'department': {
return count > 0
? `${count}${$t('workflow-designer.nodes.notify.summary.department')}`
: '';
}
case 'form_field': {
const fields = cfg.recipientFields || (cfg.recipientField ? [cfg.recipientField] : []);
return fields.length > 0
? `${$t('workflow-designer.nodes.notify.summary.formField')}(${fields.length})`
: $t('workflow-designer.nodes.notify.summary.formField');
}
case 'initiator': {
return $t('workflow-designer.nodes.notify.summary.initiator');
}
case 'role': {
return count > 0
? `${count}${$t('workflow-designer.nodes.notify.summary.role')}`
: '';
}
case 'user': {
return count > 0
? `${count}${$t('workflow-designer.nodes.notify.summary.user')}`
: '';
}
default: {
return '';
}
}
});
</script>
<template>
<div
class="flow-node notify-node"
:class="{ 'is-selected': selected }"
@click="emit('click')"
>
<!-- 删除按钮 -->
<div class="node-delete" @click.stop="emit('delete')">
<X class="delete-icon" />
</div>
<div class="node-header" :style="{ background: config.bgColor }">
<Bell class="node-icon" />
<span class="node-title">{{ getNodeDisplayName(node) }}</span>
</div>
<div class="node-content">
<div v-if="notifySummary || recipientSummary" class="notify-summary">
<span v-if="recipientSummary" class="summary-text">{{
recipientSummary
}}</span>
<span v-if="notifySummary" class="info-tag">{{ notifySummary }}</span>
</div>
<div v-else class="node-placeholder">
{{ $t('workflow-designer.nodes.notify.placeholder') }}
</div>
</div>
</div>
</template>
<style scoped>
.flow-node {
position: relative;
width: 220px;
overflow: hidden;
cursor: pointer;
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
transition: all 0.2s ease;
}
.flow-node:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
}
.flow-node.is-selected {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.flow-node:hover .node-delete {
opacity: 1;
}
.node-delete {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: rgb(0 0 0 / 30%);
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s ease;
}
.node-delete:hover {
background: rgb(0 0 0 / 50%);
}
.delete-icon {
width: 12px;
height: 12px;
color: white;
}
.node-header {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 16px;
color: white;
}
.node-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
}
.node-title {
font-size: 14px;
font-weight: 500;
}
.node-content {
padding: 12px 16px;
}
.notify-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.summary-text {
font-size: 13px;
color: var(--el-text-color-primary);
}
.info-tag {
padding: 2px 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color);
border-radius: 4px;
}
.node-placeholder {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
</style>
@@ -0,0 +1,385 @@
<script setup lang="ts">
import type { FlowNode } from '../../types';
/**
* 并行分支节点
* 多条路径同时执行,全部完成后汇合
*/
import { X } from '@vben/icons';
import { $t } from '@vben/locales';
import {
getBranchDisplayName,
getNodeDisplayName,
} from '../../utils/node-display';
const props = defineProps<{
/** 是否是嵌套在分支内的并行节点 */
isNested?: boolean;
node: FlowNode;
selected?: boolean;
}>();
const emit = defineEmits<{
addBranch: [];
click: [];
clickBranch: [branchId: string];
delete: [];
deleteBranch: [branchId: string];
}>();
</script>
<template>
<div class="parallel-node-wrapper" :class="{ 'is-nested': isNested }">
<!-- 删除整个并行节点按钮 -->
<div class="parallel-delete" @click.stop="emit('delete')">
<X class="delete-icon" />
</div>
<div
v-if="!isNested"
class="gateway-title"
:class="{ 'is-selected': selected }"
@click.stop="emit('click')"
>
{{ getNodeDisplayName(props.node) }}
</div>
<!-- 添加分支按钮 -->
<div class="add-branch-btn" @click.stop="emit('addBranch')">
{{ $t('workflow-designer.property.parallelBranch.addBranch') }}
</div>
<!-- 并行分支容器 -->
<div class="parallel-box">
<!-- 分支列表 -->
<div class="parallel-branches">
<div
v-for="(branch, index) in node.branches"
:key="branch.id"
class="branch-item"
:class="{
'is-first': index === 0,
'is-last': index === (node.branches?.length || 0) - 1,
}"
>
<!-- 分支顶部区域横线 + 垂直线 -->
<div class="branch-top-area">
<!-- 左侧横线从左边界到中心 -->
<div class="h-line left" :class="{ 'is-first': index === 0 }"></div>
<!-- 右侧横线从中心到右边界 -->
<div
class="h-line right"
:class="{ 'is-last': index === (node.branches?.length || 0) - 1 }"
></div>
<!-- 垂直线连接顶部横线和分支卡片 -->
<div class="v-line"></div>
</div>
<!-- 分支标题卡片 -->
<div
class="branch-card"
@click.stop="emit('clickBranch', branch.id)"
>
<div class="branch-header">
<span class="branch-name">{{
getBranchDisplayName(branch, {
index,
branchType: 'parallel',
})
}}</span>
<!-- 删除分支按钮至少保留2个分支 -->
<div
v-if="(node.branches?.length || 0) > 2"
class="branch-delete"
@click.stop="emit('deleteBranch', branch.id)"
>
<X class="delete-icon" />
</div>
</div>
</div>
<!-- 分支内容插槽子节点 -->
<div class="branch-content">
<slot :name="`branch-${branch.id}`" :branch="branch"></slot>
</div>
<!-- 分支底部区域垂直线 + 横线 -->
<div class="branch-bottom-area">
<!-- 垂直线连接分支内容和底部横线 -->
<div class="v-line"></div>
<!-- 左侧横线 -->
<div class="h-line left" :class="{ 'is-first': index === 0 }"></div>
<!-- 右侧横线 -->
<div
class="h-line right"
:class="{ 'is-last': index === (node.branches?.length || 0) - 1 }"
></div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.parallel-node-wrapper {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.parallel-delete {
position: absolute;
top: 6px;
right: 6px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
cursor: pointer;
background: var(--el-color-danger);
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s ease;
}
.parallel-node-wrapper:hover .parallel-delete {
opacity: 1;
}
.delete-icon {
width: 14px;
height: 14px;
color: white;
}
.add-branch-btn {
position: absolute;
top: -10px;
left: 50%;
z-index: 5;
padding: 4px 12px;
font-size: 12px;
color: white;
white-space: nowrap;
cursor: pointer;
background: var(--el-color-primary);
border-radius: 12px;
transform: translateX(-50%);
}
.add-branch-btn:hover {
background: var(--el-color-primary-dark-2);
}
/* 并行分支容器 */
.parallel-box {
display: flex;
flex-direction: column;
align-items: center;
}
.parallel-branches {
display: flex;
align-items: stretch;
}
.branch-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
min-width: 260px;
padding: 0;
}
/* 分支顶部区域 */
.branch-top-area {
position: relative;
display: flex;
justify-content: center;
width: 100%;
height: 20px;
}
.branch-top-area .v-line {
width: 2px;
height: 100%;
background: var(--el-border-color-darker);
}
.branch-top-area .h-line {
position: absolute;
top: 0;
width: 50%;
height: 2px;
background: var(--el-border-color-darker);
}
.branch-top-area .h-line.left {
left: 0;
}
.branch-top-area .h-line.right {
right: 0;
}
/* 第一个分支隐藏左边横线 */
.h-line.left.is-first {
visibility: hidden;
}
/* 最后一个分支隐藏右边横线 */
.h-line.right.is-last {
visibility: hidden;
}
/* 分支底部区域 */
.branch-bottom-area {
position: relative;
display: flex;
flex: 1;
justify-content: center;
width: 100%;
min-height: 20px;
}
.branch-bottom-area .v-line {
width: 2px;
height: 100%;
min-height: 20px;
background: var(--el-border-color-darker);
}
.branch-bottom-area .h-line {
position: absolute;
bottom: 0;
width: 50%;
height: 2px;
background: var(--el-border-color-darker);
}
.branch-bottom-area .h-line.left {
left: 0;
}
.branch-bottom-area .h-line.right {
right: 0;
}
/* 分支标题卡片 */
.branch-card {
position: relative;
flex-shrink: 0;
width: 220px;
overflow: hidden;
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
.branch-card:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
}
.branch-card:hover .branch-delete {
opacity: 1;
}
.branch-header {
position: relative;
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
padding: 10px 16px;
color: white;
cursor: pointer;
background: var(--el-color-primary);
}
.branch-name {
font-size: 14px;
font-weight: 500;
}
.branch-delete {
position: absolute;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
cursor: pointer;
background: rgb(0 0 0 / 20%);
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s ease;
}
.branch-delete:hover {
background: rgb(0 0 0 / 40%);
}
.branch-delete .delete-icon {
width: 10px;
height: 10px;
}
/* 分支内容区域 */
.branch-content {
display: flex;
flex-shrink: 0;
flex-direction: column;
align-items: center;
width: 100%;
}
.gateway-title {
margin-bottom: 8px;
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-primary);
text-align: center;
cursor: pointer;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color);
border-radius: 6px;
}
.gateway-title.is-selected {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary-light-7);
}
/* 嵌套并行节点的特殊处理 */
.parallel-node-wrapper.is-nested {
margin-top: 0;
}
/* 嵌套并行节点顶部的入口垂直线 */
.parallel-node-wrapper.is-nested .parallel-box::before {
display: block;
width: 2px;
height: 20px;
margin: 0 auto;
content: '';
background: var(--el-border-color-darker);
}
/* 嵌套并行节点底部的出口垂直线 */
.parallel-node-wrapper.is-nested .parallel-box::after {
display: block;
width: 2px;
height: 20px;
margin: 0 auto;
content: '';
background: var(--el-border-color-darker);
}
</style>
@@ -0,0 +1,192 @@
<script setup lang="ts">
import type { FlowNode, ServiceNodeConfig } from '../../types';
/**
* 服务调用节点
* 调用外部服务或API
*/
import { computed } from 'vue';
import { Webhook, X } from '@vben/icons';
import { $t } from '@vben/locales';
import { getNodeDisplayName } from '../../utils/node-display';
import { NODE_TYPE_CONFIGS } from '../../types';
const props = defineProps<{
node: FlowNode;
selected?: boolean;
}>();
const emit = defineEmits<{
click: [];
delete: [];
}>();
const config = NODE_TYPE_CONFIGS.service;
const nodeConfig = computed(() => props.node.config as ServiceNodeConfig);
// 服务摘要显示
const serviceSummary = computed(() => {
const cfg = nodeConfig.value;
if (cfg.serviceName) {
return cfg.serviceName;
}
if (cfg.url) {
// 显示简短的 URL
try {
const url = new URL(cfg.url);
return `${cfg.method} ${$t('workflow-designer.nodes.service.summary.url')}: ${url.pathname}`;
} catch {
return `${cfg.method} ${$t('workflow-designer.nodes.service.summary.url')}: ${cfg.url.slice(0, 30)}...`;
}
}
return '';
});
// 方法标签颜色
const methodColor = computed(() => {
const colors: Record<string, string> = {
GET: 'var(--el-color-success)',
POST: 'var(--el-color-primary)',
PUT: 'var(--el-color-warning)',
DELETE: 'var(--el-color-danger)',
PATCH: 'var(--el-color-info)',
};
return colors[nodeConfig.value.method] || 'var(--el-color-info)';
});
</script>
<template>
<div
class="flow-node service-node"
:class="{ 'is-selected': selected }"
@click="emit('click')"
>
<!-- 删除按钮 -->
<div class="node-delete" @click.stop="emit('delete')">
<X class="delete-icon" />
</div>
<div class="node-header" :style="{ background: config.bgColor }">
<Webhook class="node-icon" />
<span class="node-title">{{ getNodeDisplayName(node) }}</span>
</div>
<div class="node-content">
<div v-if="serviceSummary" class="service-summary">
<span class="method-tag" :style="{ backgroundColor: methodColor }">
{{ nodeConfig.method }}
</span>
<span class="summary-text">{{ serviceSummary }}</span>
</div>
<div v-else class="node-placeholder">
{{ $t('workflow-designer.nodes.service.placeholder') }}
</div>
</div>
</div>
</template>
<style scoped>
.flow-node {
position: relative;
width: 220px;
overflow: hidden;
cursor: pointer;
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
transition: all 0.2s ease;
}
.flow-node:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
}
.flow-node.is-selected {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.flow-node:hover .node-delete {
opacity: 1;
}
.node-delete {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: rgb(0 0 0 / 30%);
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s ease;
}
.node-delete:hover {
background: rgb(0 0 0 / 50%);
}
.delete-icon {
width: 12px;
height: 12px;
color: white;
}
.node-header {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 16px;
color: white;
}
.node-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
}
.node-title {
font-size: 14px;
font-weight: 500;
}
.node-content {
padding: 12px 16px;
}
.service-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.method-tag {
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
color: white;
text-transform: uppercase;
border-radius: 4px;
}
.summary-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.node-placeholder {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
</style>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { FlowNode } from '../../types';
import { UserRound } from '@vben/icons';
/**
* 发起人节点
*/
import { $t } from '@vben/locales';
import { getNodeDisplayName } from '../../utils/node-display';
import { NODE_TYPE_CONFIGS } from '../../types';
defineProps<{
node: FlowNode;
selected?: boolean;
}>();
const emit = defineEmits<{
click: [];
}>();
const config = NODE_TYPE_CONFIGS.start;
</script>
<template>
<div
class="flow-node start-node"
:class="{ 'is-selected': selected }"
@click="emit('click')"
>
<div class="node-header" :style="{ background: config.bgColor }">
<UserRound class="node-icon" />
<span class="node-title">{{ getNodeDisplayName(node) }}</span>
</div>
<div class="node-content">
<span class="node-desc">{{
$t('workflow-designer.nodes.start.placeholder')
}}</span>
</div>
</div>
</template>
<style scoped>
.flow-node {
width: 220px;
overflow: hidden;
cursor: pointer;
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
transition: all 0.2s ease;
}
.flow-node:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
}
.flow-node.is-selected {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.node-header {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 16px;
color: white;
}
.node-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
}
.node-title {
font-size: 14px;
font-weight: 500;
}
.node-content {
padding: 12px 16px;
}
.node-desc {
font-size: 13px;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,175 @@
<script setup lang="ts">
import type { FlowNode, SubflowNodeConfig } from '../../types';
/**
* 子流程节点
* 调用其他已定义的流程
*/
import { computed } from 'vue';
import { Workflow, X } from '@vben/icons';
import { $t } from '@vben/locales';
import { getNodeDisplayName } from '../../utils/node-display';
import { NODE_TYPE_CONFIGS } from '../../types';
const props = defineProps<{
node: FlowNode;
selected?: boolean;
}>();
const emit = defineEmits<{
click: [];
delete: [];
}>();
const config = NODE_TYPE_CONFIGS.subflow;
const nodeConfig = computed(() => props.node.config as SubflowNodeConfig);
// 子流程摘要显示
const subflowSummary = computed(() => {
const cfg = nodeConfig.value;
if (cfg.subflowName) {
return cfg.subflowName;
}
return '';
});
// 等待模式文本
const waitModeText = computed(() => {
return nodeConfig.value.waitForCompletion
? $t('workflow-designer.nodes.subflow.summary.sync')
: $t('workflow-designer.nodes.subflow.summary.async');
});
</script>
<template>
<div
class="flow-node subflow-node"
:class="{ 'is-selected': selected }"
@click="emit('click')"
>
<!-- 删除按钮 -->
<div class="node-delete" @click.stop="emit('delete')">
<X class="delete-icon" />
</div>
<div class="node-header" :style="{ background: config.bgColor }">
<Workflow class="node-icon" />
<span class="node-title">{{ getNodeDisplayName(node) }}</span>
</div>
<div class="node-content">
<div v-if="subflowSummary" class="subflow-summary">
<span class="summary-text">{{ subflowSummary }}</span>
<span class="mode-tag">{{ waitModeText }}</span>
</div>
<div v-else class="node-placeholder">
{{ $t('workflow-designer.nodes.subflow.placeholder') }}
</div>
</div>
</div>
</template>
<style scoped>
.flow-node {
position: relative;
width: 220px;
overflow: hidden;
cursor: pointer;
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
transition: all 0.2s ease;
}
.flow-node:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
}
.flow-node.is-selected {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.flow-node:hover .node-delete {
opacity: 1;
}
.node-delete {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: rgb(0 0 0 / 30%);
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s ease;
}
.node-delete:hover {
background: rgb(0 0 0 / 50%);
}
.delete-icon {
width: 12px;
height: 12px;
color: white;
}
.node-header {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 16px;
color: white;
}
.node-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
}
.node-title {
font-size: 14px;
font-weight: 500;
}
.node-content {
padding: 12px 16px;
}
.subflow-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.summary-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.mode-tag {
padding: 2px 6px;
font-size: 11px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color);
border-radius: 4px;
}
.node-placeholder {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
</style>
@@ -0,0 +1,394 @@
import type {
AddNodePosition,
ConditionBranchConfig,
FlowBranch,
FlowDefinition,
FlowNode,
NodeType,
ParallelBranchConfig,
} from '../types';
/**
* 流程数据管理 Hook
*/
import { computed, nextTick, ref } from 'vue';
import {
createDefaultFlow,
createDefaultNode,
DEFAULT_CONDITION_BRANCH,
generateNodeId,
normalizeFlowDefinition,
} from '../types';
import { ensureNodeNames } from '../utils/ensure-node-names';
export function useFlowData(initialFlow?: FlowDefinition) {
// 流程定义
const flowDefinition = ref<FlowDefinition>(
initialFlow || createDefaultFlow(),
);
// 当前选中的节点ID
const selectedNodeId = ref<null | string>(null);
// 是否有未保存的更改
const isDirty = ref(false);
// 历史记录(用于撤销/重做)
const history = ref<string[]>([]);
const historyIndex = ref(-1);
const maxHistory = 50;
// 当前选中的节点
const selectedNode = computed(() => {
if (!selectedNodeId.value) return null;
return findNodeById(flowDefinition.value.nodes, selectedNodeId.value);
});
// 保存历史
function saveHistory() {
const snapshot = JSON.stringify(flowDefinition.value);
// 如果不是在历史末尾,删除后面的历史
if (historyIndex.value < history.value.length - 1) {
history.value = history.value.slice(0, historyIndex.value + 1);
}
history.value.push(snapshot);
// 限制历史记录数量
if (history.value.length > maxHistory) {
history.value.shift();
} else {
historyIndex.value++;
}
isDirty.value = true;
}
// 撤销
function undo() {
if (historyIndex.value > 0) {
historyIndex.value--;
flowDefinition.value = JSON.parse(history.value[historyIndex.value]!);
}
}
// 重做
function redo() {
if (historyIndex.value < history.value.length - 1) {
historyIndex.value++;
flowDefinition.value = JSON.parse(history.value[historyIndex.value]!);
}
}
// 查找节点
function findNodeById(
node: FlowNode | undefined,
id: string,
): FlowNode | null {
if (!node) return null;
if (node.id === id) return node;
// 在子节点中查找
if (node.children) {
const found = findNodeById(node.children, id);
if (found) return found;
}
// 在分支中查找
if (node.branches) {
for (const branch of node.branches) {
if (branch.children) {
const found = findNodeById(branch.children, id);
if (found) return found;
}
}
}
return null;
}
// 查找节点的父节点
function findParentNode(
node: FlowNode | undefined,
targetId: string,
parent: FlowNode | null = null,
): null | { branchId?: string; parent: FlowNode | null } {
if (!node) return null;
if (node.id === targetId) return { parent };
// 在子节点中查找
if (node.children) {
if (node.children.id === targetId) {
return { parent: node };
}
const found = findParentNode(node.children, targetId, node);
if (found) return found;
}
// 在分支中查找
if (node.branches) {
for (const branch of node.branches) {
if (branch.children) {
if (branch.children.id === targetId) {
return { parent: node, branchId: branch.id };
}
const found = findParentNode(branch.children, targetId, node);
if (found) return found;
}
}
}
return null;
}
// 选择节点
function selectNode(nodeId: null | string) {
nextTick(() => {
selectedNodeId.value = nodeId;
});
}
// 添加节点
function addNode(type: NodeType, position: AddNodePosition) {
saveHistory();
const newNode = createDefaultNode(type);
const parentNode = findNodeById(
flowDefinition.value.nodes,
position.parentId,
);
if (!parentNode) return null;
if (position.branchId && parentNode.branches) {
// 在分支中添加
const branch = parentNode.branches.find(
(b) => b.id === position.branchId,
);
if (branch) {
newNode.children = branch.children;
branch.children = newNode;
}
} else {
// 在主线中添加
newNode.children = parentNode.children;
parentNode.children = newNode;
}
// 强制触发响应式更新,确保 selectedNode computed 能正确获取新节点
flowDefinition.value = { ...flowDefinition.value };
selectedNodeId.value = newNode.id;
return newNode;
}
// 删除节点
function deleteNode(nodeId: string) {
const node = findNodeById(flowDefinition.value.nodes, nodeId);
if (!node) return false;
// 不能删除开始和结束节点
if (node.type === 'start' || node.type === 'end') return false;
saveHistory();
const result = findParentNode(flowDefinition.value.nodes, nodeId);
if (!result || !result.parent) return false;
const { parent, branchId } = result;
if (branchId && parent.branches) {
// 从分支中删除
const branch = parent.branches.find((b) => b.id === branchId);
if (branch && branch.children?.id === nodeId) {
branch.children = node.children;
}
} else if (parent.children?.id === nodeId) {
// 从主线中删除
parent.children = node.children;
}
if (selectedNodeId.value === nodeId) {
selectedNodeId.value = null;
}
return true;
}
// 更新节点
function updateNode(nodeId: string, updates: Partial<FlowNode>) {
const node = findNodeById(flowDefinition.value.nodes, nodeId);
if (!node) return false;
saveHistory();
Object.assign(node, updates);
return true;
}
// 添加分支(支持条件分支和并行分支)
function addConditionBranch(nodeId: string) {
const node = findNodeById(flowDefinition.value.nodes, nodeId);
if (!node || !node.branches) return false;
if (node.type !== 'condition' && node.type !== 'parallel') return false;
saveHistory();
if (node.type === 'condition') {
// 条件分支:在默认条件之前插入新分支
const defaultIndex = node.branches.findIndex(
(b) => (b.config as ConditionBranchConfig).isDefault,
);
const newBranch: FlowBranch = {
id: generateNodeId('route'),
name: `条件${node.branches.length}`,
config: {
...DEFAULT_CONDITION_BRANCH,
id: generateNodeId('route'),
name: `条件${node.branches.length}`,
priority: node.branches.length,
},
};
if (defaultIndex === -1) {
node.branches.push(newBranch);
} else {
node.branches.splice(defaultIndex, 0, newBranch);
}
} else {
// 并行分支:直接添加新分支
const branchId = generateNodeId('route');
const branchName = `分支${node.branches.length + 1}`;
const newBranch: FlowBranch = {
id: branchId,
name: branchName,
config: {
id: branchId,
name: branchName,
} as ParallelBranchConfig,
};
node.branches.push(newBranch);
}
return true;
}
// 删除分支(支持条件分支和并行分支)
function deleteConditionBranch(nodeId: string, branchId: string) {
const node = findNodeById(flowDefinition.value.nodes, nodeId);
if (!node || !node.branches) return false;
if (node.type !== 'condition' && node.type !== 'parallel') return false;
// 至少保留2个分支
if (node.branches.length <= 2) return false;
const branch = node.branches.find((b) => b.id === branchId);
if (!branch) return false;
// 条件分支不能删除默认分支
if (
node.type === 'condition' &&
(branch.config as ConditionBranchConfig).isDefault
) {
return false;
}
saveHistory();
const index = node.branches.findIndex((b) => b.id === branchId);
if (index !== -1) {
node.branches.splice(index, 1);
}
return true;
}
// 移动分支优先级
function moveBranchPriority(
nodeId: string,
branchId: string,
direction: 'down' | 'up',
) {
const node = findNodeById(flowDefinition.value.nodes, nodeId);
if (!node || node.type !== 'condition' || !node.branches) return false;
const index = node.branches.findIndex((b) => b.id === branchId);
if (index === -1) return false;
const branch = node.branches[index]!;
if ((branch.config as ConditionBranchConfig).isDefault) return false;
saveHistory();
if (direction === 'up' && index > 0) {
[node.branches[index - 1], node.branches[index]] = [
node.branches[index]!,
node.branches[index - 1]!,
];
} else if (direction === 'down' && index < node.branches.length - 2) {
// -2 因为不能和默认分支交换
[node.branches[index], node.branches[index + 1]] = [
node.branches[index + 1]!,
node.branches[index]!,
];
}
return true;
}
// 导出流程定义
function exportFlow(): FlowDefinition {
const cloned = JSON.parse(
JSON.stringify(flowDefinition.value),
) as FlowDefinition;
return ensureNodeNames(cloned);
}
// 导入流程定义
function importFlow(flow: FlowDefinition) {
saveHistory();
flowDefinition.value = ensureNodeNames(normalizeFlowDefinition(flow));
selectedNodeId.value = null;
isDirty.value = false;
}
// 重置流程
function resetFlow(name?: string) {
saveHistory();
flowDefinition.value = createDefaultFlow(name);
selectedNodeId.value = null;
isDirty.value = false;
}
// 初始化历史
saveHistory();
return {
flowDefinition,
selectedNodeId,
selectedNode,
isDirty,
// 操作方法
selectNode,
addNode,
deleteNode,
updateNode,
addConditionBranch,
deleteConditionBranch,
moveBranchPriority,
// 历史操作
undo,
redo,
canUndo: computed(() => historyIndex.value > 0),
canRedo: computed(() => historyIndex.value < history.value.length - 1),
// 导入导出
exportFlow,
importFlow,
resetFlow,
};
}
@@ -0,0 +1,723 @@
import type { FlowDefinition, FlowNode } from '../types';
/**
* 流程校验 Hook
*/
import { computed, ref } from 'vue';
import {
getNodeDisplayName,
MAX_NODE_NAME_LENGTH,
} from '../utils/node-display';
/** 校验结果状态 */
export type ValidationStatus =
| 'error'
| 'pending'
| 'running'
| 'success'
| 'warning';
/** 校验项 */
export interface ValidationItem {
id: string;
name: string;
description: string;
status: ValidationStatus;
message?: string;
}
/** 节点错误 */
export interface NodeError {
nodeId: string;
nodeName: string;
level: 'error' | 'warning';
message: string;
}
/** 校验结果 */
export interface ValidationResult {
valid: boolean;
hasWarnings: boolean;
items: ValidationItem[];
nodeErrors: NodeError[];
}
/**
* 流程校验 Hook
*/
export function useFlowValidation() {
const isValidating = ref(false);
const validationItems = ref<ValidationItem[]>([]);
const currentIndex = ref(-1);
// 校验规则定义
const validationRules: Array<{
description: string;
id: string;
level: 'error' | 'warning';
name: string;
validate: (flow: FlowDefinition) => { message?: string; valid: boolean };
}> = [
{
id: 'has_nodes',
name: '流程结构检查',
description: '检查流程是否包含有效节点',
level: 'error',
validate: (flow) => {
if (!flow.nodes) {
return { valid: false, message: '流程定义为空' };
}
return { valid: true };
},
},
{
id: 'node_name_length',
name: '节点名称长度检查',
description: '检查节点与分支名称是否超过长度限制',
level: 'error',
validate: (flow) => {
const invalid = findNodesWithInvalidNameLength(flow.nodes);
if (invalid.length > 0) {
const labels = [...new Set(invalid.map((v) => v.nodeName))];
return {
valid: false,
message: `以下节点/分支名称超过 ${MAX_NODE_NAME_LENGTH} 字:${labels.join(', ')}`,
};
}
return { valid: true };
},
},
{
id: 'has_approval',
name: '审批节点检查',
description: '检查流程是否包含至少一个审批节点',
level: 'error',
validate: (flow) => {
const hasApproval = findNodesOfType(flow.nodes, 'approval').length > 0;
if (!hasApproval) {
return { valid: false, message: '流程必须包含至少一个审批节点' };
}
return { valid: true };
},
},
{
id: 'approval_assignees',
name: '审批人配置检查',
description: '检查所有审批节点是否配置了审批人',
level: 'error',
validate: (flow) => {
const approvalNodes = findNodesOfType(flow.nodes, 'approval');
const invalidNodes: string[] = [];
for (const node of approvalNodes) {
const config = (node.config || {}) as Record<string, any>;
const assignees = config.assignees || [];
const assigneeType = config.assigneeType || 'user';
const assigneeFields = config.assigneeFields || [];
const assigneeField = config.assigneeField || '';
if (
['department', 'role', 'user'].includes(assigneeType) &&
assignees.length === 0
) {
invalidNodes.push(node.name || node.id);
}
if (assigneeType === 'form_field' && assigneeFields.length === 0 && !assigneeField) {
invalidNodes.push(node.name || node.id);
}
}
if (invalidNodes.length > 0) {
return {
valid: false,
message: `以下审批节点未配置审批人: ${invalidNodes.join(', ')}`,
};
}
return { valid: true };
},
},
{
id: 'condition_config',
name: '条件分支配置检查',
description: '检查条件分支是否配置了条件',
level: 'error',
validate: (flow) => {
const conditionNodes = findNodesOfType(flow.nodes, 'condition');
const invalidBranches: string[] = [];
for (const node of conditionNodes) {
const branches = node.branches || [];
for (const branch of branches) {
const config = (branch.config || {}) as Record<string, any>;
// 非默认分支必须有条件
if (!config.isDefault) {
const groups = config.groups || [];
if (groups.length === 0) {
invalidBranches.push(
`${node.name || node.id} - ${branch.name || branch.id}`,
);
}
}
}
}
if (invalidBranches.length > 0) {
return {
valid: false,
message: `以下条件分支未配置条件: ${invalidBranches.join(', ')}`,
};
}
return { valid: true };
},
},
{
id: 'condition_default',
name: '默认分支检查',
description: '检查条件节点是否有默认分支',
level: 'warning',
validate: (flow) => {
const conditionNodes = findNodesOfType(flow.nodes, 'condition');
const noDefaultNodes: string[] = [];
for (const node of conditionNodes) {
const branches = node.branches || [];
const hasDefault = branches.some(
(b) => (b.config as Record<string, any>)?.isDefault,
);
if (!hasDefault) {
noDefaultNodes.push(node.name || node.id);
}
}
if (noDefaultNodes.length > 0) {
return {
valid: true, // 警告级别,不阻止保存
message: `以下条件节点没有默认分支,可能导致流程无法继续: ${noDefaultNodes.join(', ')}`,
};
}
return { valid: true };
},
},
{
id: 'copy_assignees',
name: '抄送人配置检查',
description: '检查抄送节点是否配置了抄送人',
level: 'warning',
validate: (flow) => {
const copyNodes = findNodesOfType(flow.nodes, 'copy');
const invalidNodes: string[] = [];
for (const node of copyNodes) {
const config = (node.config || {}) as Record<string, any>;
const assignees = config.assignees || [];
const assigneeType = config.assigneeType || 'user';
const assigneeFields = config.assigneeFields || [];
const assigneeField = config.assigneeField || '';
if (
['department', 'role', 'user'].includes(assigneeType) &&
assignees.length === 0
) {
invalidNodes.push(node.name || node.id);
}
if (assigneeType === 'form_field' && assigneeFields.length === 0 && !assigneeField) {
invalidNodes.push(node.name || node.id);
}
}
if (invalidNodes.length > 0) {
return {
valid: true, // 警告级别
message: `以下抄送节点未配置抄送人: ${invalidNodes.join(', ')}`,
};
}
return { valid: true };
},
},
{
id: 'handle_assignees',
name: '办理人配置检查',
description: '检查办理节点是否配置了办理人',
level: 'error',
validate: (flow) => {
const handleNodes = findNodesOfType(flow.nodes, 'handle');
const invalidNodes: string[] = [];
for (const node of handleNodes) {
const config = (node.config || {}) as Record<string, any>;
const assignees = config.assignees || [];
const assigneeType = config.assigneeType || 'user';
const assigneeFields = config.assigneeFields || [];
const assigneeField = config.assigneeField || '';
if (
['department', 'role', 'user'].includes(assigneeType) &&
assignees.length === 0
) {
invalidNodes.push(node.name || node.id);
}
if (assigneeType === 'form_field' && assigneeFields.length === 0 && !assigneeField) {
invalidNodes.push(node.name || node.id);
}
}
if (invalidNodes.length > 0) {
return {
valid: false,
message: `以下办理节点未配置办理人: ${invalidNodes.join(', ')}`,
};
}
return { valid: true };
},
},
{
id: 'parallel_branches',
name: '并行分支检查',
description: '检查并行分支是否有内容',
level: 'warning',
validate: (flow) => {
const parallelNodes = findNodesOfType(flow.nodes, 'parallel');
const emptyBranches: string[] = [];
for (const node of parallelNodes) {
const branches = node.branches || [];
for (const branch of branches) {
if (!branch.children) {
emptyBranches.push(
`${node.name || node.id} - ${branch.name || branch.id}`,
);
}
}
}
if (emptyBranches.length > 0) {
return {
valid: true, // 警告级别
message: `以下并行分支为空: ${emptyBranches.join(', ')}`,
};
}
return { valid: true };
},
},
{
id: 'condition_branches',
name: '条件分支内容检查',
description: '检查条件分支是否有内容',
level: 'warning',
validate: (flow) => {
const conditionNodes = findNodesOfType(flow.nodes, 'condition');
const emptyBranches: string[] = [];
for (const node of conditionNodes) {
const branches = node.branches || [];
for (const branch of branches) {
if (!branch.children) {
emptyBranches.push(
`${node.name || node.id} - ${branch.name || branch.id}`,
);
}
}
}
if (emptyBranches.length > 0) {
return {
valid: true, // 警告级别
message: `以下条件分支为空: ${emptyBranches.join(', ')}`,
};
}
return { valid: true };
},
},
];
interface NameLengthViolation {
nodeId: string;
nodeName: string;
message: string;
}
function findNodesWithInvalidNameLength(
node: FlowNode | null | undefined,
results: NameLengthViolation[] = [],
): NameLengthViolation[] {
if (!node) return results;
const nodeLabel = getNodeDisplayName(node);
if (node.name && node.name.length > MAX_NODE_NAME_LENGTH) {
results.push({
nodeId: node.id,
nodeName: nodeLabel,
message: `名称超过 ${MAX_NODE_NAME_LENGTH}`,
});
}
if (node.branches) {
for (const branch of node.branches) {
const branchLabel = branch.name?.trim() || branch.id;
if (branch.name && branch.name.length > MAX_NODE_NAME_LENGTH) {
results.push({
nodeId: node.id,
nodeName: `${nodeLabel} - ${branchLabel}`,
message: `分支名称超过 ${MAX_NODE_NAME_LENGTH}`,
});
}
const configName = (branch.config as { name?: string })?.name;
if (configName && configName.length > MAX_NODE_NAME_LENGTH) {
results.push({
nodeId: node.id,
nodeName: `${nodeLabel} - ${branchLabel}`,
message: `分支名称超过 ${MAX_NODE_NAME_LENGTH}`,
});
}
if (branch.children) {
findNodesWithInvalidNameLength(branch.children, results);
}
}
}
if (node.children) {
findNodesWithInvalidNameLength(node.children, results);
}
return results;
}
/**
* 递归查找指定类型的节点
*/
function findNodesOfType(
node: FlowNode | null | undefined,
type: string,
): FlowNode[] {
if (!node) return [];
const result: FlowNode[] = [];
if (node.type === type) {
result.push(node);
}
// 递归子节点
if (node.children) {
result.push(...findNodesOfType(node.children, type));
}
// 递归分支
if (node.branches) {
for (const branch of node.branches) {
if (branch.children) {
result.push(...findNodesOfType(branch.children, type));
}
}
}
return result;
}
// 节点错误集合
const nodeErrors = ref<NodeError[]>([]);
/**
* 收集节点级别的错误
*/
function collectNodeErrors(flow: FlowDefinition): NodeError[] {
const errors: NodeError[] = [];
// 检查审批节点
const approvalNodes = findNodesOfType(flow.nodes, 'approval');
for (const node of approvalNodes) {
const config = (node.config || {}) as Record<string, any>;
const assignees = config.assignees || [];
const assigneeType = config.assigneeType || 'user';
const assigneeFields = config.assigneeFields || [];
const assigneeField = config.assigneeField || '';
if (
['department', 'role', 'user'].includes(assigneeType) &&
assignees.length === 0
) {
errors.push({
nodeId: node.id,
nodeName: node.name,
level: 'error',
message: '未配置审批人',
});
}
if (assigneeType === 'form_field' && assigneeFields.length === 0 && !assigneeField) {
errors.push({
nodeId: node.id,
nodeName: node.name,
level: 'error',
message: '未选择表单字段',
});
}
}
// 检查办理节点
const handleNodes = findNodesOfType(flow.nodes, 'handle');
for (const node of handleNodes) {
const config = (node.config || {}) as Record<string, any>;
const assignees = config.assignees || [];
const assigneeType = config.assigneeType || 'user';
const assigneeFields = config.assigneeFields || [];
const assigneeField = config.assigneeField || '';
if (
['department', 'role', 'user'].includes(assigneeType) &&
assignees.length === 0
) {
errors.push({
nodeId: node.id,
nodeName: node.name,
level: 'error',
message: '未配置办理人',
});
}
if (assigneeType === 'form_field' && assigneeFields.length === 0 && !assigneeField) {
errors.push({
nodeId: node.id,
nodeName: node.name,
level: 'error',
message: '未选择表单字段',
});
}
}
// 检查抄送节点
const copyNodes = findNodesOfType(flow.nodes, 'copy');
for (const node of copyNodes) {
const config = (node.config || {}) as Record<string, any>;
const assignees = config.assignees || [];
const assigneeType = config.assigneeType || 'user';
const assigneeFields = config.assigneeFields || [];
const assigneeField = config.assigneeField || '';
if (
['department', 'role', 'user'].includes(assigneeType) &&
assignees.length === 0
) {
errors.push({
nodeId: node.id,
nodeName: node.name,
level: 'warning',
message: '未配置抄送人',
});
}
if (assigneeType === 'form_field' && assigneeFields.length === 0 && !assigneeField) {
errors.push({
nodeId: node.id,
nodeName: node.name,
level: 'warning',
message: '未选择表单字段',
});
}
}
// 检查节点/分支名称长度(供画布定位,规则列表中不重复校验)
for (const violation of findNodesWithInvalidNameLength(flow.nodes)) {
errors.push({
nodeId: violation.nodeId,
nodeName: violation.nodeName,
level: 'error',
message: violation.message,
});
}
// 检查条件分支
const conditionNodes = findNodesOfType(flow.nodes, 'condition');
for (const node of conditionNodes) {
const branches = node.branches || [];
for (const branch of branches) {
const config = branch.config || {};
if (!(config as any).isDefault) {
const groups = (config as any).groups || [];
if (groups.length === 0) {
errors.push({
nodeId: node.id,
nodeName: `${node.name} - ${branch.name}`,
level: 'error',
message: '未配置条件',
});
}
}
}
}
return errors;
}
/**
* 执行校验(带动画效果)
*/
async function validate(flow: FlowDefinition): Promise<ValidationResult> {
isValidating.value = true;
currentIndex.value = -1;
nodeErrors.value = [];
// 初始化校验项
validationItems.value = validationRules.map((rule) => ({
id: rule.id,
name: rule.name,
description: rule.description,
status: 'pending' as ValidationStatus,
}));
let hasError = false;
let hasWarning = false;
// 逐个执行校验
for (const [i, rule] of validationRules.entries()) {
currentIndex.value = i;
const item = validationItems.value[i];
if (!item || !rule) continue;
item.status = 'running';
// 模拟校验延迟,让用户看到过程
await sleep(200);
const result = rule.validate(flow);
if (!result.valid) {
item.status = 'error';
item.message = result.message;
hasError = true;
} else if (result.message) {
// 有消息但 valid=true 表示警告
item.status = 'warning';
item.message = result.message;
hasWarning = true;
} else {
item.status = 'success';
}
}
// 收集节点级别错误
nodeErrors.value = collectNodeErrors(flow);
const hasNodeLevelErrors = nodeErrors.value.some((e) => e.level === 'error');
if (hasNodeLevelErrors) {
hasError = true;
}
isValidating.value = false;
currentIndex.value = -1;
return {
valid: !hasError,
hasWarnings: hasWarning,
items: validationItems.value,
nodeErrors: nodeErrors.value,
};
}
/**
* 快速校验(不带动画)
*/
function validateQuick(flow: FlowDefinition): ValidationResult {
const items: ValidationItem[] = [];
let hasError = false;
let hasWarning = false;
for (const rule of validationRules) {
const result = rule.validate(flow);
const item: ValidationItem = {
id: rule.id,
name: rule.name,
description: rule.description,
status: 'success',
};
if (!result.valid) {
item.status = 'error';
item.message = result.message;
hasError = true;
} else if (result.message) {
item.status = 'warning';
item.message = result.message;
hasWarning = true;
}
items.push(item);
}
const errors = collectNodeErrors(flow);
nodeErrors.value = errors;
validationItems.value = items;
const hasNodeLevelErrors = errors.some((e) => e.level === 'error');
return {
valid: !hasError && !hasNodeLevelErrors,
hasWarnings: hasWarning,
items,
nodeErrors: errors,
};
}
/**
* 获取节点错误
*/
function getNodeError(nodeId: string): NodeError | undefined {
return nodeErrors.value.find((e) => e.nodeId === nodeId);
}
/** 检查节点是否有错误 */
function hasNodeError(nodeId: string): boolean {
return nodeErrors.value.some(
(e) => e.nodeId === nodeId && e.level === 'error',
);
}
/** 检查节点是否有警告 */
function hasNodeWarning(nodeId: string): boolean {
return nodeErrors.value.some(
(e) => e.nodeId === nodeId && e.level === 'warning',
);
}
/**
* 重置校验状态
*/
function reset() {
isValidating.value = false;
validationItems.value = [];
currentIndex.value = -1;
}
// 计算属性
const allPassed = computed(() => {
return (
validationItems.value.length > 0 &&
validationItems.value.every(
(item) => item.status === 'success' || item.status === 'warning',
)
);
});
const hasErrors = computed(() => {
return (
validationItems.value.some((item) => item.status === 'error') ||
nodeErrors.value.some((e) => e.level === 'error')
);
});
return {
isValidating,
validationItems,
nodeErrors,
currentIndex,
allPassed,
hasErrors,
validate,
validateQuick,
getNodeError,
hasNodeError,
hasNodeWarning,
reset,
};
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -0,0 +1,244 @@
<script setup lang="ts">
import type { ConditionBranchConfig, FlowNode, NodeType, ParallelBranchConfig } from './types';
/**
* 审批流程设计器
* 钉钉/飞书风格
*/
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import { ElMessage, ElMessageBox } from 'element-plus';
import FlowCanvas from './components/FlowCanvas.vue';
import PropertyPanel from './components/PropertyPanel.vue';
import Toolbar from './components/Toolbar.vue';
import { useFlowData } from './hooks/useFlowData';
// 流程数据管理
const {
flowDefinition,
selectedNodeId,
selectedNode,
isDirty,
canUndo,
canRedo,
selectNode,
addNode,
deleteNode,
updateNode,
addConditionBranch,
deleteConditionBranch,
undo,
redo,
exportFlow,
} = useFlowData();
// 属性面板状态
const propertyPanelVisible = ref(false);
const selectedBranchId = ref<null | string>(null);
const selectedBranchConfig = ref<ConditionBranchConfig | ParallelBranchConfig | null>(null);
// 流程名称
const flowName = computed({
get: () => flowDefinition.value.name,
set: (val) => {
flowDefinition.value.name = val;
},
});
// 选择节点
function handleSelectNode(nodeId: string) {
selectNode(nodeId);
selectedBranchId.value = null;
selectedBranchConfig.value = null;
propertyPanelVisible.value = true;
}
// 添加节点
function handleAddNode(type: NodeType, parentId: string, branchId?: string) {
addNode(type, { parentId, branchId });
}
// 删除节点
async function handleDeleteNode(nodeId: string) {
try {
await ElMessageBox.confirm(
$t('workflow-designer.designer.deleteNodeConfirm'),
$t('workflow-designer.designer.tips'),
{
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'warning',
},
);
deleteNode(nodeId);
if (selectedNodeId.value === nodeId) {
propertyPanelVisible.value = false;
}
} catch {
// 取消删除
}
}
// 添加条件分支
function handleAddBranch(nodeId: string) {
addConditionBranch(nodeId);
}
// 删除条件分支
async function handleDeleteBranch(nodeId: string, branchId: string) {
try {
await ElMessageBox.confirm(
$t('workflow-designer.designer.deleteBranchConfirm'),
$t('workflow-designer.designer.tips'),
{
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'warning',
},
);
deleteConditionBranch(nodeId, branchId);
} catch {
// 取消删除
}
}
// 点击条件分支
function handleClickBranch(nodeId: string, branchId: string) {
selectNode(nodeId);
// 找到对应的分支配置
const node = selectedNode.value;
if (node?.branches) {
const branch = node.branches.find((b) => b.id === branchId);
if (branch) {
selectedBranchId.value = branchId;
selectedBranchConfig.value = { ...branch.config };
propertyPanelVisible.value = true;
}
}
}
// 更新节点
function handleUpdateNode(nodeId: string, updates: Partial<FlowNode>) {
updateNode(nodeId, updates);
}
// 更新分支
function handleUpdateBranch(
_nodeId: string,
branchId: string,
updates: Partial<ConditionBranchConfig> | Partial<ParallelBranchConfig>,
) {
const node = selectedNode.value;
if (node?.branches) {
const branch = node.branches.find((b) => b.id === branchId);
if (branch) {
// 更新分支配置
Object.assign(branch.config, updates);
// 同步更新分支名称
if (updates.name) {
branch.name = updates.name;
}
// 同步更新 selectedBranchConfig 以便 UI 响应
if (selectedBranchId.value === branchId) {
selectedBranchConfig.value = { ...branch.config };
}
}
}
}
// 保存流程
function handleSave() {
const flow = exportFlow();
console.log($t('workflow-designer.designer.save'), flow);
ElMessage.success($t('workflow-designer.designer.saveSuccess'));
}
// 预览流程
function handlePreview() {
const flow = exportFlow();
console.log($t('workflow-designer.designer.preview'), flow);
ElMessage.info($t('workflow-designer.designer.previewInfo'));
}
// 发布流程
function handlePublish() {
const flow = exportFlow();
console.log($t('workflow-designer.designer.publish'), flow);
ElMessage.success($t('workflow-designer.designer.publishSuccess'));
}
// 键盘快捷键
function handleKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault();
if (e.shiftKey) {
redo();
} else {
undo();
}
} else if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
e.preventDefault();
redo();
} else if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
}
// 注册键盘事件
if (typeof window !== 'undefined') {
window.addEventListener('keydown', handleKeydown);
}
</script>
<template>
<div class="workflow-designer">
<!-- 工具栏 -->
<Toolbar
v-model:flow-name="flowName"
:can-undo="canUndo"
:can-redo="canRedo"
:is-dirty="isDirty"
@save="handleSave"
@undo="undo"
@redo="redo"
@preview="handlePreview"
@publish="handlePublish"
/>
<!-- 画布 -->
<FlowCanvas
:flow-definition="flowDefinition"
:selected-node-id="selectedNodeId"
@select-node="handleSelectNode"
@add-node="handleAddNode"
@delete-node="handleDeleteNode"
@add-branch="handleAddBranch"
@delete-branch="handleDeleteBranch"
@click-branch="handleClickBranch"
/>
<!-- 属性面板 -->
<PropertyPanel
v-model:visible="propertyPanelVisible"
:node="selectedNode"
:branch-id="selectedBranchId"
:branch-config="selectedBranchConfig"
@update-node="handleUpdateNode"
@update-branch="handleUpdateBranch"
/>
</div>
</template>
<style scoped>
.workflow-designer {
display: flex;
flex-direction: column;
height: 100%;
background: var(--el-bg-color);
}
</style>
@@ -0,0 +1,813 @@
/**
* 审批流程设计器类型定义
* 钉钉/飞书风格
*/
// 节点类型
export type NodeType =
| 'approval'
| 'condition'
| 'copy'
| 'data_update'
| 'delay'
| 'end'
| 'handle'
| 'notify'
| 'parallel'
| 'route'
| 'service'
| 'start'
| 'subflow';
// 子流程变量传递方式
export type SubflowVarPassMode = 'all' | 'none' | 'selected';
// 延时单位
export type DelayUnit = 'day' | 'hour' | 'minute' | 'workday';
// 通知方式
export type NotifyChannel =
| 'chat'
| 'dingtalk'
| 'email'
| 'feishu'
| 'site'
| 'sms'
| 'wechat';
// HTTP 请求方法
export type HttpMethod = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT';
// 服务调用失败处理
export type ServiceFailAction = 'continue' | 'retry' | 'stop';
// 审批人类型
export type AssigneeType =
| 'department' // 指定部门
| 'form_field' // 表单字段
| 'initiator' // 发起人自己
| 'manager' // 直属经理
| 'role' // 指定角色
| 'superior' // 上级主管(部门领导)
| 'user'; // 指定用户
// 多人审批方式
export type MultiApprovalType =
| 'any' // 或签(一人同意即可)
| 'parallel' // 会签(所有人同意)
| 'sequential'; // 依次审批
// 审批操作
export type ApprovalAction =
| 'add_sign' // 加签(增加审批人)
| 'approve' // 通过
| 'delegate' // 委派(让他人代为审批,审批后回到自己)
| 'reduce_sign' // 减签(减少审批人,仅会签模式)
| 'reject' // 拒绝(终止流程)
| 'return' // 驳回(退回上一步或发起人)
| 'transfer'; // 转办(转交他人审批,自己不再处理)
// 超时处理方式
export type TimeoutAction = 'auto_approve' | 'auto_reject' | 'none' | 'notify';
// 条件操作符
export type ConditionOperator =
| 'contains' // 包含
| 'empty' // 为空
| 'eq' // 等于
| 'gt' // 大于
| 'gte' // 大于等于
| 'in' // 在...中
| 'lt' // 小于
| 'lte' // 小于等于
| 'ne' // 不等于
| 'not_contains' // 不包含
| 'not_empty' // 不为空
| 'not_in'; // 不在...中
// 表单字段权限
export type FieldAccess = 'editable' | 'hidden' | 'readonly';
// 表单字段权限配置
export interface FormFieldPermission {
field: string;
access: FieldAccess;
}
// 超时时间单位
export type TimeoutUnit = 'day' | 'hour' | 'minute';
// 超时配置
export interface TimeoutConfig {
enabled: boolean;
duration: number; // 时长
unit: TimeoutUnit; // 单位
hours?: number; // 保留兼容(转换后的小时数)
action: TimeoutAction;
// 通知相关配置(当 action 为 notify 时使用)
notifyChannels?: NotifyChannel[]; // 通知渠道
}
// 操作权限配置
export interface ActionPermission {
action: ApprovalAction;
enabled: boolean;
label: string; // 自定义显示名称
}
// 任务通知配置
export interface TaskNotifyConfig {
enabled: boolean; // 是否发送通知
channels: NotifyChannel[]; // 通知渠道
}
// 发起人通知配置
export interface InitiatorNotifyConfig {
onApprove?: boolean; // 通过时通知发起人
onReject?: boolean; // 拒绝时通知发起人(默认true)
onComplete?: boolean; // 流程完成时通知发起人(默认true,仅结束节点有效)
channels?: NotifyChannel[]; // 通知渠道
}
// form-selector 字段到用户字段的映射
export interface AssigneeFieldMapping {
fieldName: string; // form-selector 字段名
formCode: string; // 引用的表单编码
userField: string; // 引用表单中的用户字段名
}
// 审批节点配置
export interface ApprovalNodeConfig {
assigneeType: AssigneeType;
assignees: string[]; // 用户ID/角色ID/部门ID列表
assigneeLevel?: number; // 上级层级(当 assigneeType 为 superior 时)
assigneeField?: string; // 兼容旧数据:单个表单字段名
assigneeFields?: string[]; // 多个表单字段名(当 assigneeType 为 form_field 时)
assigneeFieldMappings?: AssigneeFieldMapping[]; // form-selector 字段的用户字段映射
multiApproval: MultiApprovalType;
actions: ApprovalAction[]; // 保留兼容
actionPermissions?: ActionPermission[]; // 新的操作权限配置
timeout: TimeoutConfig;
formPermissions: FormFieldPermission[];
// 空审批人处理
emptyAssignee?: 'admin' | 'error' | 'skip';
// 任务通知配置
taskNotify?: TaskNotifyConfig;
// 发起人通知配置
initiatorNotify?: InitiatorNotifyConfig;
// 签名配置
requireSignature?: boolean; // 是否需要签名(通过时)
}
// 抄送节点配置
export interface CopyNodeConfig {
assigneeType: AssigneeType;
assignees: string[];
assigneeLevel?: number;
assigneeField?: string;
assigneeFields?: string[];
assigneeFieldMappings?: AssigneeFieldMapping[];
// 任务通知配置
taskNotify?: TaskNotifyConfig;
}
// 办理节点配置
export interface HandleNodeConfig {
assigneeType: AssigneeType;
assignees: string[];
assigneeLevel?: number;
assigneeField?: string;
assigneeFields?: string[];
assigneeFieldMappings?: AssigneeFieldMapping[];
// 多人办理方式
multiHandle: 'all' | 'any' | 'sequential';
// 表单字段权限
formPermissions: FormFieldPermission[];
// 超时配置
timeout: TimeoutConfig;
// 任务通知配置
taskNotify?: TaskNotifyConfig;
// 发起人通知配置
initiatorNotify?: InitiatorNotifyConfig;
}
// 单个条件
export interface Condition {
id: string;
field: string;
operator: ConditionOperator;
value: any;
}
// 条件组(组内条件为 AND 关系)
export interface ConditionGroup {
id: string;
conditions: Condition[];
}
// 条件分支配置
export interface ConditionBranchConfig {
id: string;
name: string;
priority: number;
// 条件组之间为 OR 关系
groups: ConditionGroup[];
isDefault?: boolean;
}
// 条件节点配置
export interface ConditionNodeConfig {
branches: ConditionBranchConfig[];
}
// 并行分支配置
export interface ParallelBranchConfig {
id: string;
name: string;
}
// 并行节点配置(分支实际存储在 FlowNode.branches 中)
export interface ParallelNodeConfig {
[key: string]: any;
}
// 延时节点配置
export interface DelayNodeConfig {
// 延时时长
duration: number;
// 延时单位
unit: DelayUnit;
}
// 通知节点配置
export interface NotifyNodeConfig {
// 通知对象类型
recipientType: AssigneeType;
// 通知对象
recipients: string[];
// 上级层级(当recipientType为superior时)
recipientLevel?: number;
// 表单字段(当recipientType为form_field时)
recipientField?: string;
recipientFields?: string[];
recipientFieldMappings?: AssigneeFieldMapping[];
// 通知渠道
channels: NotifyChannel[];
// 通知标题
title: string;
// 通知内容
content: string;
}
// 服务调用节点配置
export interface ServiceNodeConfig {
// 服务名称
serviceName: string;
// 请求地址
url: string;
// 请求方法
method: HttpMethod;
// 请求头
headers: Array<{ key: string; value: string }>;
// 请求参数(JSON 格式)
params: string;
// 请求体(JSON 格式)
body: string;
// 超时时间(秒)
timeout: number;
// 重试次数
retryCount: number;
// 失败处理
failAction: ServiceFailAction;
// 响应结果存储字段(存入流程变量)
resultVariable?: string;
}
// 子流程节点配置
export interface SubflowNodeConfig {
// 子流程 ID
subflowId: string;
// 子流程名称(显示用)
subflowName: string;
// 变量传递方式
varPassMode: SubflowVarPassMode;
// 选择传递的变量(当 varPassMode 为 selected 时)
selectedVars: string[];
// 是否等待子流程完成
waitForCompletion: boolean;
// 子流程完成后的结果变量
resultVariable?: string;
// 结果回传方式
resultPassMode?: 'all' | 'none' | 'selected';
// 选择回传的变量
resultVars?: string[];
// 是否启用超时
timeoutEnabled?: boolean;
// 超时时间
timeout?: number;
// 超时时间单位
timeoutUnit?: 'day' | 'hour' | 'minute';
// 超时处理方式
timeoutAction?: 'reject' | 'skip';
}
// 字段更新值类型
export type FieldUpdateValueType = 'constant' | 'field' | 'formula' | 'system';
// 字段更新规则
export interface FieldUpdateRule {
id: string;
field: string; // 目标字段名
valueType: FieldUpdateValueType;
value: any; // 具体值
}
// 字段更新匹配条件(跨表单时用于定位目标记录)
export interface FieldUpdateMatchCondition {
sourceField: string; // 当前表单的字段
targetField: string; // 目标表单的字段
}
// 字段更新节点配置
export interface DataUpdateNodeConfig {
targetFormCode?: string; // 目标表单编码,空 = 当前表单
matchCondition?: FieldUpdateMatchCondition; // 匹配条件(跨表单时必填)
updateScope?: 'all' | 'first'; // 更新范围:首条 / 全部(默认 first)
// 更新规则列表(按顺序执行)
rules: FieldUpdateRule[];
}
// 结束节点配置
export interface EndNodeConfig {
result: 'approved' | 'rejected';
}
// 节点配置联合类型
export type NodeConfig =
| ApprovalNodeConfig
| ConditionNodeConfig
| CopyNodeConfig
| DataUpdateNodeConfig
| DelayNodeConfig
| EndNodeConfig
| HandleNodeConfig
| NotifyNodeConfig
| ParallelNodeConfig
| Record<string, never>
| ServiceNodeConfig
| SubflowNodeConfig;
// 基础节点
export interface FlowNode {
id: string;
type: NodeType;
name: string;
config: NodeConfig;
// 子节点(用于条件分支)
children?: FlowNode;
// 条件分支的多个分支
branches?: FlowBranch[];
}
// 条件分支
export interface FlowBranch {
id: string;
name: string;
config: ConditionBranchConfig | ParallelBranchConfig;
children?: FlowNode;
}
// 流程定义
export interface FlowDefinition {
id: string;
name: string;
description?: string;
version: number;
// 根节点(开始节点)
nodes: FlowNode;
}
// 设计器状态
export interface DesignerState {
definition: FlowDefinition;
selectedNodeId: null | string;
zoom: number;
isDirty: boolean;
}
// 节点添加位置
export interface AddNodePosition {
parentId: string;
branchId?: string; // 如果是在分支中添加
}
// 节点操作事件
export interface NodeOperationEvent {
type: 'add' | 'delete' | 'select' | 'update';
nodeId: string;
data?: any;
}
// 默认操作权限配置
export const DEFAULT_ACTION_PERMISSIONS: ActionPermission[] = [
{ action: 'approve', enabled: true, label: '通过' },
{ action: 'reject', enabled: true, label: '拒绝' },
{ action: 'return', enabled: false, label: '驳回' },
{ action: 'delegate', enabled: false, label: '委派' },
{ action: 'transfer', enabled: false, label: '转办' },
{ action: 'add_sign', enabled: false, label: '加签' },
{ action: 'reduce_sign', enabled: false, label: '减签' },
];
// 默认配置
export const DEFAULT_APPROVAL_CONFIG: ApprovalNodeConfig = {
assigneeType: 'user',
assignees: [],
multiApproval: 'any',
actions: ['approve', 'reject'],
actionPermissions: [...DEFAULT_ACTION_PERMISSIONS],
timeout: {
enabled: false,
duration: 24,
unit: 'hour',
action: 'notify',
},
formPermissions: [],
emptyAssignee: 'error',
};
export const DEFAULT_COPY_CONFIG: CopyNodeConfig = {
assigneeType: 'user',
assignees: [],
};
export const DEFAULT_HANDLE_CONFIG: HandleNodeConfig = {
assigneeType: 'user',
assignees: [],
multiHandle: 'any',
formPermissions: [],
timeout: {
enabled: false,
duration: 24,
unit: 'hour',
action: 'notify',
},
};
export const DEFAULT_CONDITION_BRANCH: ConditionBranchConfig = {
id: '',
name: 'Condition',
priority: 1,
groups: [],
isDefault: false,
};
export const DEFAULT_DELAY_CONFIG: DelayNodeConfig = {
duration: 1,
unit: 'hour',
};
export const DEFAULT_NOTIFY_CONFIG: NotifyNodeConfig = {
recipientType: 'user',
recipients: [],
channels: ['site'],
title: '',
content: '',
};
export const DEFAULT_SERVICE_CONFIG: ServiceNodeConfig = {
serviceName: '',
url: '',
method: 'POST',
headers: [],
params: '',
body: '',
timeout: 30,
retryCount: 0,
failAction: 'stop',
resultVariable: '',
};
export const DEFAULT_SUBFLOW_CONFIG: SubflowNodeConfig = {
subflowId: '',
subflowName: '',
varPassMode: 'all',
selectedVars: [],
waitForCompletion: true,
resultVariable: '',
};
export const DEFAULT_DATA_UPDATE_CONFIG: DataUpdateNodeConfig = {
targetFormCode: '',
updateScope: 'first',
rules: [],
};
// 节点类型配置
export interface NodeTypeConfig {
type: NodeType;
name: string;
icon: string;
color: string;
bgColor: string;
description: string;
canAdd: boolean; // 是否可以手动添加
canDelete: boolean; // 是否可以删除
maxCount?: number; // 最大数量限制
}
export const NODE_TYPE_CONFIGS: Record<NodeType, NodeTypeConfig> = {
start: {
type: 'start',
name: '发起人',
icon: 'UserRound',
color: 'rgb(87, 106, 149)',
bgColor:
'linear-gradient(135deg, rgb(99, 102, 241) 0%, rgb(139, 92, 246) 100%)',
description: '流程发起人',
canAdd: false,
canDelete: false,
maxCount: 1,
},
approval: {
type: 'approval',
name: '审批人',
icon: 'UserCheck',
color: 'rgb(50, 150, 250)',
bgColor:
'linear-gradient(135deg, rgb(59, 130, 246) 0%, rgb(37, 99, 235) 100%)',
description: '添加审批人节点',
canAdd: true,
canDelete: true,
},
handle: {
type: 'handle',
name: '办理人',
icon: 'ClipboardCheck',
color: 'rgb(250, 173, 20)',
bgColor:
'linear-gradient(135deg, rgb(250, 204, 21) 0%, rgb(245, 158, 11) 100%)',
description: '添加办理人节点',
canAdd: true,
canDelete: true,
},
copy: {
type: 'copy',
name: '抄送人',
icon: 'Send',
color: 'rgb(255, 148, 62)',
bgColor:
'linear-gradient(135deg, rgb(251, 146, 60) 0%, rgb(249, 115, 22) 100%)',
description: '添加抄送人节点',
canAdd: true,
canDelete: true,
},
delay: {
type: 'delay',
name: '延时等待',
icon: 'Clock',
color: 'rgb(156, 163, 175)',
bgColor:
'linear-gradient(135deg, rgb(148, 163, 184) 0%, rgb(100, 116, 139) 100%)',
description: '等待指定时间后继续',
canAdd: true,
canDelete: true,
},
notify: {
type: 'notify',
name: '发送通知',
icon: 'Bell',
color: 'rgb(236, 72, 153)',
bgColor:
'linear-gradient(135deg, rgb(236, 72, 153) 0%, rgb(219, 39, 119) 100%)',
description: '发送消息通知',
canAdd: true,
canDelete: true,
},
service: {
type: 'service',
name: '服务调用',
icon: 'Webhook',
color: 'rgb(14, 165, 233)',
bgColor:
'linear-gradient(135deg, rgb(6, 182, 212) 0%, rgb(8, 145, 178) 100%)',
description: '调用外部服务或API',
canAdd: true,
canDelete: true,
},
subflow: {
type: 'subflow',
name: '子流程',
icon: 'Workflow',
color: 'rgb(139, 92, 246)',
bgColor:
'linear-gradient(135deg, rgb(139, 92, 246) 0%, rgb(124, 58, 237) 100%)',
description: '调用其他流程',
canAdd: true,
canDelete: true,
},
data_update: {
type: 'data_update',
name: '字段更新',
icon: 'PenLine',
color: 'rgb(16, 185, 129)',
bgColor:
'linear-gradient(135deg, rgb(16, 185, 129) 0%, rgb(5, 150, 105) 100%)',
description: '自动更新表单字段值',
canAdd: true,
canDelete: true,
},
condition: {
type: 'condition',
name: '条件分支',
icon: 'GitBranch',
color: 'rgb(21, 188, 131)',
bgColor:
'linear-gradient(135deg, rgb(52, 211, 153) 0%, rgb(16, 185, 129) 100%)',
description: '添加条件分支',
canAdd: true,
canDelete: true,
},
parallel: {
type: 'parallel',
name: '并行分支',
icon: 'GitMerge',
color: 'rgb(114, 46, 209)',
bgColor:
'linear-gradient(135deg, rgb(147, 51, 234) 0%, rgb(126, 34, 206) 100%)',
description: '添加并行分支,同时执行',
canAdd: true,
canDelete: true,
},
route: {
type: 'route',
name: '路由',
icon: 'Route',
color: 'rgb(132, 94, 247)',
bgColor:
'linear-gradient(135deg, rgb(167, 139, 250) 0%, rgb(139, 92, 246) 100%)',
description: '条件路由节点',
canAdd: false,
canDelete: false,
},
end: {
type: 'end',
name: '结束',
icon: 'CircleCheck',
color: 'rgb(87, 106, 149)',
bgColor:
'linear-gradient(135deg, rgb(100, 116, 139) 0%, rgb(71, 85, 105) 100%)',
description: '流程结束',
canAdd: false,
canDelete: false,
maxCount: 1,
},
};
// 生成唯一ID
export function generateNodeId(type: NodeType): string {
return `${type}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
// 创建默认节点
export function createDefaultNode(type: NodeType, name?: string): FlowNode {
const config = NODE_TYPE_CONFIGS[type];
const node: FlowNode = {
id: generateNodeId(type),
type,
name: name || config.name,
config: {},
};
switch (type) {
case 'approval': {
node.config = { ...DEFAULT_APPROVAL_CONFIG };
break;
}
case 'condition': {
node.config = { branches: [] } as ConditionNodeConfig;
node.branches = [
{
id: generateNodeId('route'),
name: 'Condition 1',
config: {
...DEFAULT_CONDITION_BRANCH,
id: generateNodeId('route'),
name: 'Condition 1',
priority: 1,
},
},
{
id: generateNodeId('route'),
name: 'Default',
config: {
...DEFAULT_CONDITION_BRANCH,
id: generateNodeId('route'),
name: 'Default',
priority: 999,
isDefault: true,
},
},
];
break;
}
case 'copy': {
node.config = { ...DEFAULT_COPY_CONFIG };
break;
}
case 'delay': {
node.config = { ...DEFAULT_DELAY_CONFIG };
break;
}
case 'end': {
node.config = { result: 'approved' } as EndNodeConfig;
break;
}
case 'handle': {
node.config = { ...DEFAULT_HANDLE_CONFIG };
break;
}
case 'notify': {
node.config = { ...DEFAULT_NOTIFY_CONFIG };
break;
}
case 'parallel': {
node.config = {} as ParallelNodeConfig;
const branch1Id = generateNodeId('route');
const branch2Id = generateNodeId('route');
node.branches = [
{
id: branch1Id,
name: 'Branch 1',
config: {
id: branch1Id,
name: 'Branch 1',
} as ParallelBranchConfig,
},
{
id: branch2Id,
name: 'Branch 2',
config: {
id: branch2Id,
name: 'Branch 2',
} as ParallelBranchConfig,
},
];
break;
}
case 'service': {
node.config = { ...DEFAULT_SERVICE_CONFIG };
break;
}
case 'subflow': {
node.config = { ...DEFAULT_SUBFLOW_CONFIG };
break;
}
case 'data_update': {
node.config = { ...DEFAULT_DATA_UPDATE_CONFIG };
break;
}
}
return node;
}
/** 判断流程定义是否包含有效的根节点树 */
export function isValidFlowDefinition(
def: FlowDefinition | null | undefined,
): def is FlowDefinition {
return !!def && !!def.nodes && typeof def.nodes === 'object';
}
/** 将空/不完整的流程定义规范化为可用的默认结构 */
export function normalizeFlowDefinition(
def: FlowDefinition | null | undefined,
defaultName?: string,
): FlowDefinition {
if (isValidFlowDefinition(def)) {
return def;
}
const flow = createDefaultFlow(def?.name || defaultName);
if (def && typeof def === 'object') {
return { ...flow, ...def, nodes: flow.nodes };
}
return flow;
}
// 创建默认流程
export function createDefaultFlow(
name: string = 'New Workflow',
): FlowDefinition {
const startNode = createDefaultNode('start', 'Initiator');
const endNode = createDefaultNode('end', 'End');
startNode.children = endNode;
return {
id: generateNodeId('start').replace('start_', 'flow_'),
name,
version: 1,
nodes: startNode,
};
}
@@ -0,0 +1,77 @@
import type {
ConditionBranchConfig,
FlowBranch,
FlowDefinition,
FlowNode,
} from '../types';
import { normalizeNodeNameForPersist } from './node-display';
function defaultBranchName(
branch: FlowBranch,
index: number,
nodeType: 'condition' | 'parallel',
): string {
const config = branch.config as ConditionBranchConfig | undefined;
if (config?.isDefault) {
return '默认分支';
}
if (nodeType === 'parallel') {
return `分支${index + 1}`;
}
return `条件${index + 1}`;
}
function ensureBranchNames(
branches: FlowBranch[] | undefined,
nodeType: 'condition' | 'parallel',
): void {
if (!branches?.length) return;
branches.forEach((branch, index) => {
const config = branch.config as ConditionBranchConfig & {
name?: string;
};
const fallback = defaultBranchName(branch, index, nodeType);
if (!branch.name?.trim()) {
branch.name = config?.name?.trim() || fallback;
}
if (config && !config.name?.trim()) {
config.name = branch.name;
}
});
}
function ensureNodeName(node: FlowNode): void {
node.name = normalizeNodeNameForPersist(node.type, node.name);
if (node.type === 'condition' || node.type === 'parallel') {
ensureBranchNames(node.branches, node.type);
}
if (node.children) {
walkNode(node.children);
}
if (node.branches) {
for (const branch of node.branches) {
if (branch.children) {
walkNode(branch.children);
}
}
}
}
function walkNode(node: FlowNode | undefined): void {
if (!node) return;
ensureNodeName(node);
}
/** 为流程定义中缺失的节点/分支名称回填默认值 */
export function ensureNodeNames(def: FlowDefinition): FlowDefinition {
if (def.nodes) {
walkNode(def.nodes);
}
return def;
}
@@ -0,0 +1,109 @@
import type { ConditionBranchConfig, FlowBranch, FlowNode, NodeType } from '../types';
import { $t } from '@vben/locales';
import { NODE_TYPE_CONFIGS } from '../types';
const MAX_NODE_NAME_LENGTH = 100;
/** 节点名称最大长度(与 DB workflow_task.node_name 一致) */
export { MAX_NODE_NAME_LENGTH };
type TranslateFn = (key: string, params?: Record<string, unknown>) => string;
/** 节点类型对应的默认持久化名称(与 NODE_TYPE_CONFIGS 一致) */
export function getDefaultNodeName(type: NodeType | string): string {
const config = NODE_TYPE_CONFIGS[type as NodeType];
return config?.name ?? String(type);
}
/**
* 规范化待保存的节点名称:trim 后为空则回填默认名
*/
export function normalizeNodeNameForPersist(
type: NodeType | string,
name?: string,
): string {
const trimmed = name?.trim();
if (trimmed) {
return trimmed;
}
return getDefaultNodeName(type);
}
function resolveTypeTitle(type: string, t?: TranslateFn): string {
const translate = t ?? $t;
const key = `workflow-designer.nodes.${type}.title`;
const translated = translate(key);
if (translated !== key) {
return translated;
}
const config = NODE_TYPE_CONFIGS[type as NodeType];
return config?.name ?? type;
}
/**
* 获取节点在 UI 上的展示名称
* - 有自定义 name 时用 name
* - 否则回退为节点类型的默认标题
*/
export function getNodeDisplayName(
node: Pick<FlowNode, 'name' | 'type'>,
t?: TranslateFn,
): string {
const trimmed = node.name?.trim();
if (trimmed) {
return trimmed;
}
return resolveTypeTitle(node.type, t);
}
export interface BranchDisplayOptions {
index?: number;
isDefault?: boolean;
branchType?: 'condition' | 'parallel';
t?: TranslateFn;
}
/**
* 获取条件/并行分支在 UI 上的展示名称
*/
export function getBranchDisplayName(
branch: FlowBranch,
options: BranchDisplayOptions = {},
): string {
const { index = 0, t } = options;
const translate = t ?? $t;
const config = branch.config as ConditionBranchConfig | undefined;
const isDefault =
options.isDefault ?? config?.isDefault === true;
if (isDefault) {
const key = 'workflow-designer.nodes.condition.defaultBranch';
const translated = translate(key);
return translated !== key ? translated : '默认分支';
}
const fromBranch = branch.name?.trim();
const fromConfig =
config && 'name' in config && typeof config.name === 'string'
? config.name.trim()
: '';
if (fromBranch) {
return fromBranch;
}
if (fromConfig) {
return fromConfig;
}
const branchType = options.branchType ?? 'condition';
if (branchType === 'parallel') {
const key = 'workflow-designer.nodes.parallel.branchFallback';
const translated = translate(key, { num: index + 1 });
return translated !== key ? translated : `分支${index + 1}`;
}
const key = 'workflow-designer.nodes.condition.conditionBranch';
const translated = translate(key, { num: index + 1 });
return translated !== key ? translated : `条件${index + 1}`;
}
@@ -0,0 +1,527 @@
<script lang="ts" setup>
import type { FlowDefinition, FlowNode } from '../designer/types';
/**
* 流程路径预览组件
* 基于流程定义展示审批路径(用于发起流程时预览)
*/
import { computed, ref, watch } from 'vue';
import {
Circle,
Clock,
Forward,
GitBranch,
PenLine,
Play,
Square,
User,
Users,
} from '@vben/icons';
import { $t } from '@vben/locales';
import { ElEmpty, ElSkeleton, ElTag } from 'element-plus';
import { getDeptUsersApi } from '#/api/core/dept';
import { getRoleUsersApi } from '#/api/core/role';
import { getNodeDisplayName } from '#/components/workflow/designer/utils/node-display';
import { UserAvatar } from '#/components/user-avatar/index';
interface Props {
flowDefinition: FlowDefinition | null;
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
});
// 缓存角色和部门下的用户
const roleUsersCache = ref<Record<string, string[]>>({});
const deptUsersCache = ref<Record<string, string[]>>({});
// 节点类型图标配置
const nodeIconConfig: Record<string, any> = {
start: Play,
end: Square,
approval: User,
handle: User,
copy: Forward,
condition: GitBranch,
parallel: Users,
delay: Clock,
notify: Forward,
service: Circle,
subflow: GitBranch,
data_update: PenLine,
};
// 需要显示的节点类型
const visibleNodeTypes = new Set([
'approval',
'copy',
'data_update',
'delay',
'end',
'handle',
'start',
]);
// 扁平化流程节点(递归遍历树形结构)
function flattenNodes(
node: FlowNode | undefined,
result: FlowNode[] = [],
): FlowNode[] {
if (!node) return result;
// 只添加可见的节点类型
if (visibleNodeTypes.has(node.type)) {
result.push(node);
}
// 处理条件分支和并行分支
if (node.branches && node.branches.length > 0) {
// 对于条件分支,只显示第一个分支的节点(简化显示)
if (node.type === 'condition' && node.branches[0]?.children) {
flattenNodes(node.branches[0].children, result);
}
// 对于并行分支,显示所有分支的节点
if (node.type === 'parallel') {
for (const branch of node.branches) {
if (branch.children) {
flattenNodes(branch.children, result);
}
}
}
}
// 递归处理子节点
if (node.children) {
flattenNodes(node.children, result);
}
return result;
}
// 扁平化后的节点列表
const flatNodes = computed(() => {
if (!props.flowDefinition?.nodes) return [];
return flattenNodes(props.flowDefinition.nodes);
});
// 获取节点图标
function getNodeIcon(type: string) {
return nodeIconConfig[type] || Circle;
}
// 获取节点显示名称
function getNodeName(node: FlowNode) {
return getNodeDisplayName(node);
}
// 获取节点状态样式(预览时使用主题色)
function getStatusStyle() {
return {
'--node-color': 'var(--el-color-primary)',
'--node-bg': 'var(--el-color-primary-light-9)',
};
}
// 获取审批人类型文本
function getAssigneeTypeText(type: string): string {
const key = `workflow-designer.preview.assigneeTypes.${type}`;
const translated = $t(key);
return translated === key ? type : translated;
}
// 获取审批人信息
function getAssigneeInfo(node: FlowNode): null | {
assignees: string[];
field?: string;
level?: number;
type: string;
} {
const config = node.config as any;
if (!config) return null;
return {
type: config.assigneeType || 'user',
assignees: config.assignees || [],
level: config.assigneeLevel,
field: config.assigneeField,
};
}
// 加载角色下的用户
async function loadRoleUsers(roleIds: string[]) {
for (const roleId of roleIds) {
if (roleUsersCache.value[roleId]) continue;
try {
const res = await getRoleUsersApi(roleId, { page: 1, pageSize: 50 });
// RoleUser 接口返回 id 字段
roleUsersCache.value[roleId] = res.items.map((u: any) => String(u.id));
} catch {
roleUsersCache.value[roleId] = [];
}
}
}
// 加载部门下的用户
async function loadDeptUsers(deptIds: string[]) {
for (const deptId of deptIds) {
if (deptUsersCache.value[deptId]) continue;
try {
const res = await getDeptUsersApi(deptId, { page: 1, pageSize: 50 });
// DeptUser 接口返回 id 字段
deptUsersCache.value[deptId] = res.items.map((u: any) => String(u.id));
} catch {
deptUsersCache.value[deptId] = [];
}
}
}
// 获取角色下的所有用户ID
function getRoleUserIds(roleIds: string[]): string[] {
const userIds: string[] = [];
for (const roleId of roleIds) {
const users = roleUsersCache.value[roleId] || [];
userIds.push(...users);
}
return [...new Set(userIds)];
}
// 获取部门下的所有用户ID
function getDeptUserIds(deptIds: string[]): string[] {
const userIds: string[] = [];
for (const deptId of deptIds) {
const users = deptUsersCache.value[deptId] || [];
userIds.push(...users);
}
return [...new Set(userIds)];
}
// 监听流程定义变化,预加载角色和部门用户
watch(
() => props.flowDefinition,
async (flowDef) => {
if (!flowDef?.nodes) return;
const roleIds: string[] = [];
const deptIds: string[] = [];
// 收集所有角色和部门ID
function collectIds(node: FlowNode | undefined) {
if (!node) return;
const config = node.config as any;
if (config?.assigneeType === 'role' && config.assignees?.length) {
roleIds.push(...config.assignees);
}
if (config?.assigneeType === 'department' && config.assignees?.length) {
deptIds.push(...config.assignees);
}
if (node.children) collectIds(node.children);
if (node.branches) {
for (const branch of node.branches) {
if (branch.children) collectIds(branch.children);
}
}
}
collectIds(flowDef.nodes);
// 加载用户
if (roleIds.length > 0) await loadRoleUsers([...new Set(roleIds)]);
if (deptIds.length > 0) await loadDeptUsers([...new Set(deptIds)]);
},
{ immediate: true },
);
</script>
<template>
<div class="flow-path-preview">
<ElSkeleton v-if="loading" :rows="6" animated />
<template v-else-if="flatNodes.length > 0">
<!-- 流程节点列表 -->
<div class="progress-timeline">
<div
v-for="(node, index) in flatNodes"
:key="node.id"
class="progress-node"
:class="{ 'is-last': index === flatNodes.length - 1 }"
:style="getStatusStyle()"
>
<!-- 节点图标 -->
<div class="node-icon">
<component :is="getNodeIcon(node.type)" class="icon" />
</div>
<!-- 连接线 -->
<div v-if="index < flatNodes.length - 1" class="node-line"></div>
<!-- 节点内容 -->
<div
class="node-content bg-background min-h-12 rounded-[8px] px-4 py-2"
>
<div class="node-header">
<span class="node-name">{{ getNodeName(node) }}</span>
<ElTag type="info" size="small">
{{
$t('workflow-designer.detail.flowProgress.nodeStatus.pending')
}}
</ElTag>
</div>
<!-- 审批人信息 -->
<div
v-if="
['approval', 'handle', 'copy'].includes(node.type) &&
getAssigneeInfo(node)
"
class="node-handlers mb-2"
>
<div class="assignee-info">
<span class="assignee-type">
{{ getAssigneeTypeText(getAssigneeInfo(node)!.type) }}
</span>
<!-- 指定成员 -->
<div
v-if="
getAssigneeInfo(node)!.type === 'user' &&
getAssigneeInfo(node)!.assignees.length > 0
"
class="assignee-avatars"
>
<UserAvatar
v-for="userId in getAssigneeInfo(node)!.assignees"
:key="userId"
:user-id="userId"
:size="32"
:font-size="12"
:shadow="false"
auto-load
show-info
/>
</div>
<!-- 指定角色 - 显示角色下的用户 -->
<div
v-if="
getAssigneeInfo(node)!.type === 'role' &&
getAssigneeInfo(node)!.assignees.length > 0
"
class="assignee-avatars"
>
<template
v-if="
getRoleUserIds(getAssigneeInfo(node)!.assignees).length >
0
"
>
<UserAvatar
v-for="userId in getRoleUserIds(
getAssigneeInfo(node)!.assignees,
)"
:key="userId"
:user-id="userId"
:size="32"
:font-size="12"
:shadow="false"
auto-load
show-info
/>
</template>
<span v-else class="text-muted-foreground text-xs">{{
$t('workflow.start.loadingUsers')
}}</span>
</div>
<!-- 指定部门 - 显示部门下的用户 -->
<div
v-if="
getAssigneeInfo(node)!.type === 'department' &&
getAssigneeInfo(node)!.assignees.length > 0
"
class="assignee-avatars"
>
<template
v-if="
getDeptUserIds(getAssigneeInfo(node)!.assignees).length >
0
"
>
<UserAvatar
v-for="userId in getDeptUserIds(
getAssigneeInfo(node)!.assignees,
)"
:key="userId"
:user-id="userId"
:size="32"
:font-size="12"
:shadow="false"
auto-load
show-info
/>
</template>
<span v-else class="text-muted-foreground text-xs">{{
$t('workflow.start.loadingUsers')
}}</span>
</div>
<!-- 上级主管/经理 -->
<span
v-if="
getAssigneeInfo(node)!.type === 'superior' ||
getAssigneeInfo(node)!.type === 'manager'
"
class="assignee-level"
>
{{ $t('workflow-designer.preview.labels.level')
}}{{ getAssigneeInfo(node)!.level || 1
}}{{ $t('workflow-designer.preview.labels.levelSuffix')
}}{{
getAssigneeInfo(node)!.type === 'superior'
? $t('workflow-designer.preview.labels.superior')
: $t('workflow-designer.preview.labels.manager')
}}
</span>
<!-- 发起人 -->
<span
v-if="getAssigneeInfo(node)!.type === 'initiator'"
class="assignee-level"
>
{{ $t('workflow-designer.preview.assigneeTypes.initiator') }}
</span>
<!-- 表单字段 -->
<span
v-if="getAssigneeInfo(node)!.type === 'form_field'"
class="assignee-level"
>
{{
$t('workflow-designer.preview.assigneeTypes.form_field')
}}: {{ getAssigneeInfo(node)!.field }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<ElEmpty
v-else
:description="$t('workflow-designer.detail.flowProgress.noData')"
/>
</div>
</template>
<style scoped>
.flow-path-preview {
padding: 16px;
}
/* 流程时间线 */
.progress-timeline {
position: relative;
}
.progress-node {
position: relative;
display: flex;
padding-bottom: 24px;
}
.progress-node.is-last {
padding-bottom: 0;
}
/* 节点图标 */
.node-icon {
z-index: 1;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--node-bg);
border: 2px solid var(--node-color);
border-radius: 50%;
border-style: dashed;
margin-top: 8px;
}
.node-icon .icon {
width: 16px;
height: 16px;
color: var(--node-color);
}
/* 连接线 */
.node-line {
position: absolute;
top: 32px;
bottom: 0;
left: 15px;
width: 2px;
background: var(--el-border-color-light);
margin-top: 16px;
}
/* 节点内容 */
.node-content {
flex: 1;
min-width: 0;
margin-left: 12px;
}
.node-header {
display: flex;
gap: 8px;
align-items: center;
min-height: 32px;
}
.node-name {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
/* 审批人信息 */
.node-handlers {
padding: 12px;
margin-top: 8px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.assignee-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.assignee-type {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.assignee-avatars {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.assignee-selector {
max-width: 100%;
}
.assignee-level {
font-size: 13px;
color: var(--el-text-color-primary);
}
</style>
@@ -0,0 +1,908 @@
<script lang="ts" setup>
import type { FlowProgress, ProgressNode } from '#/api/online-dev/workflow';
import type { NodeType } from '#/components/workflow/designer/types';
/**
* 流程进度 Tab 组件
* 展示完整的审批流程路径和状态
*/
import { computed, ref, watch } from 'vue';
import {
Circle,
Clock,
CornerDownLeft,
Forward,
GitBranch,
PenLine,
Play,
Square,
User,
Users,
} from '@vben/icons';
import { $t } from '@vben/locales';
import { ElEmpty, ElImage, ElMessage, ElSkeleton, ElTag } from 'element-plus';
import { getInstanceProgressApi } from '#/api/online-dev/workflow';
import { getNodeDisplayName } from '#/components/workflow/designer/utils/node-display';
import { UserAvatar } from '#/components/user-avatar/index';
import { getFileUrl } from '#/composables/useFileUrl';
interface Props {
instanceId: string;
}
const props = defineProps<Props>();
const loading = ref(false);
const progress = ref<FlowProgress | null>(null);
// 签名图片URL缓存
const signatureUrls = ref<Record<string, string>>({});
// 节点类型图标配置(静态)
const nodeIconConfig: Record<string, any> = {
start: Play,
end: Square,
approval: User,
handle: User,
copy: Forward,
condition: GitBranch,
parallel: Users,
delay: Clock,
notify: Forward,
service: Circle,
subflow: GitBranch,
data_update: PenLine,
};
// 节点状态配置
const statusConfig = computed<
Record<string, { bgColor: string; color: string; label: string }>
>(() => ({
completed: {
label: $t('workflow-designer.detail.flowProgress.nodeStatus.completed'),
color: 'var(--el-color-success)',
bgColor: 'var(--el-color-success-light-9)',
},
active: {
label: $t('workflow-designer.detail.flowProgress.nodeStatus.active'),
color: 'var(--el-color-primary)',
bgColor: 'var(--el-color-primary-light-9)',
},
pending: {
label: $t('workflow-designer.detail.flowProgress.nodeStatus.pending'),
color: 'var(--el-text-color-secondary)',
bgColor: 'var(--el-fill-color-light)',
},
skipped: {
label: $t('workflow-designer.detail.flowProgress.nodeStatus.skipped'),
color: 'var(--el-text-color-placeholder)',
bgColor: 'var(--el-fill-color-lighter)',
},
rejected: {
label: $t('workflow-designer.detail.flowProgress.nodeStatus.rejected'),
color: 'var(--el-color-danger)',
bgColor: 'var(--el-color-danger-light-9)',
},
}));
// 操作类型配置
const actionConfig = computed<Record<string, { label: string; type: string }>>(
() => ({
approve: {
label: $t('workflow-designer.detail.flowProgress.actions.approve'),
type: 'success',
},
reject: {
label: $t('workflow-designer.detail.flowProgress.actions.reject'),
type: 'danger',
},
return: {
label: $t('workflow-designer.detail.flowProgress.actions.return'),
type: 'warning',
},
transfer: {
label: $t('workflow-designer.detail.flowProgress.actions.transfer'),
type: 'warning',
},
delegate: {
label: $t('workflow-designer.detail.flowProgress.actions.delegate'),
type: 'info',
},
handle: {
label: $t('workflow-designer.detail.flowProgress.actions.handle'),
type: 'success',
},
add_sign: {
label: $t('workflow-designer.detail.flowProgress.actions.add_sign'),
type: 'warning',
},
}),
);
// 加签类型配置
const signTypeConfig = computed<Record<string, string>>(() => ({
before: $t('workflow-designer.detail.flowProgress.signTypes.before'),
after: $t('workflow-designer.detail.flowProgress.signTypes.after'),
parallel: $t('workflow-designer.detail.flowProgress.signTypes.parallel'),
}));
// 额外操作类型配置
const extraActionConfig = computed<
Record<string, { label: string; type: string }>
>(() => ({
add_sign: {
label: $t('workflow-designer.detail.flowProgress.extraActions.add_sign'),
type: 'warning',
},
reduce_sign: {
label: $t('workflow-designer.detail.flowProgress.extraActions.reduce_sign'),
type: 'danger',
},
transfer: {
label: $t('workflow-designer.detail.flowProgress.extraActions.transfer'),
type: 'warning',
},
delegate: {
label: $t('workflow-designer.detail.flowProgress.extraActions.delegate'),
type: 'info',
},
return: {
label: $t('workflow-designer.detail.flowProgress.extraActions.return'),
type: 'danger',
},
}));
// 加载数据
async function loadProgress() {
if (!props.instanceId) return;
loading.value = true;
try {
progress.value = await getInstanceProgressApi(props.instanceId);
// 加载签名图片URL
if (progress.value?.nodes) {
await loadSignatureUrls(progress.value.nodes);
}
} catch (error: any) {
ElMessage.error(
error?.message || $t('workflow-designer.detail.flowProgress.loadFailed'),
);
} finally {
loading.value = false;
}
}
// 加载签名图片URL
async function loadSignatureUrls(nodes: ProgressNode[]) {
const fileIds: string[] = [];
for (const node of nodes) {
for (const handler of node.handlers || []) {
if ((handler as any).signature_file_id) {
fileIds.push((handler as any).signature_file_id);
}
}
}
if (fileIds.length === 0) return;
const results = await Promise.all(
fileIds.map(async (fileId) => {
try {
const url = await getFileUrl(fileId);
return { fileId, url };
} catch {
return { fileId, url: '' };
}
}),
);
for (const { fileId, url } of results) {
if (url) {
signatureUrls.value[fileId] = url;
}
}
}
// 监听 instanceId 变化
watch(
() => props.instanceId,
(newId) => {
if (newId) {
loadProgress();
}
},
{ immediate: true },
);
// 获取节点图标
function getNodeIcon(type: string) {
return nodeIconConfig[type] || Circle;
}
// 获取节点显示名称
function getNodeName(node: ProgressNode) {
return getNodeDisplayName({
name: node.name,
type: node.type as NodeType,
});
}
// 获取节点状态样式
function getStatusStyle(status: string) {
const config = statusConfig.value[status] ?? statusConfig.value.pending!;
return {
'--node-color': config?.color,
'--node-bg': config?.bgColor,
};
}
// 获取节点状态标签
function getStatusLabel(status: string) {
return statusConfig.value[status]?.label || status;
}
// 获取节点状态 ElTag type
function getStatusTagType(status: string): string {
const typeMap: Record<string, string> = {
completed: 'success',
active: 'primary',
rejected: 'danger',
pending: 'info',
skipped: 'info',
};
return typeMap[status] || 'info';
}
// 获取操作标签
function getActionLabel(action: string) {
return actionConfig.value[action]?.label || action;
}
// 获取操作 ElTag type
function getActionTagType(action: string): string {
return actionConfig.value[action]?.type || 'info';
}
// 获取额外操作标签
function getExtraActionLabel(action: { sign_type: string; type: string }) {
if (action.type === 'add_sign') {
return (
signTypeConfig.value[action.sign_type] ||
$t('workflow-designer.detail.flowProgress.extraActions.add_sign')
);
}
return extraActionConfig.value[action.type]?.label || action.type;
}
// 获取额外操作 ElTag type
function getExtraActionTagType(type: string): string {
return extraActionConfig.value[type]?.type || 'info';
}
// 格式化时间
function formatTime(time: null | string | undefined) {
if (!time) return '';
return time;
}
// 是否显示处理人详情
function hasHandlerDetails(node: ProgressNode) {
return node.handlers.length > 0 || node.extra_actions.length > 0;
}
// 需要显示的节点类型(过滤掉条件分支、并行分支、延时、通知、服务调用、子流程等非人工处理节点)
const visibleNodeTypes = new Set([
'approval',
'copy',
'data_update',
'delay',
'end',
'handle',
'start',
]);
const filteredNodes = computed(() => {
if (!progress.value) return [];
return progress.value.nodes.filter((node) => visibleNodeTypes.has(node.type));
});
</script>
<template>
<div class="flow-progress m-4 rounded-[8px]">
<ElSkeleton v-if="loading" :rows="8" animated />
<template v-else-if="progress && filteredNodes.length > 0">
<!-- 驳回记录提示 -->
<div v-if="progress.returns.length > 0" class="return-records">
<div class="return-title">
<CornerDownLeft class="return-icon" />
{{ $t('workflow-designer.detail.flowProgress.returnRecords') }}
</div>
<div
v-for="(ret, index) in progress.returns"
:key="index"
class="return-item"
>
<span class="return-from">{{ ret.from_node_name }}</span>
<CornerDownLeft class="return-arrow" />
<span class="return-to">{{ ret.to_node_name }}</span>
<span class="return-info">
{{ ret.operator_name }} · {{ ret.time }}
</span>
<span v-if="ret.reason" class="return-reason">{{ ret.reason }}</span>
</div>
</div>
<!-- 流程节点列表 -->
<div class="progress-timeline">
<div
v-for="(node, index) in filteredNodes"
:key="node.id"
class="progress-node"
:class="[
`status-${node.status}`,
{ 'is-last': index === filteredNodes.length - 1 },
]"
:style="getStatusStyle(node.status)"
>
<!-- 节点图标 -->
<div class="node-icon">
<component :is="getNodeIcon(node.type)" class="icon" />
</div>
<!-- 连接线 -->
<div v-if="index < filteredNodes.length - 1" class="node-line"></div>
<!-- 节点内容 -->
<div
class="node-content bg-background min-h-12 rounded-[8px] px-4 py-2"
>
<div class="node-header">
<span class="node-name">{{ getNodeName(node) }}</span>
<ElTag :type="getStatusTagType(node.status) as any" size="small">
{{ getStatusLabel(node.status) }}
</ElTag>
<span v-if="node.completed_at" class="node-time">
{{ formatTime(node.completed_at) }}
</span>
</div>
<!-- 延时节点信息 -->
<div
v-if="
node.type === 'delay' &&
node.status === 'active' &&
node.delay_until
"
class="delay-info"
>
<Clock class="delay-icon" />
<span>{{
$t('workflow-designer.detail.flowProgress.delayWaiting')
}}</span>
<span class="delay-time">{{ node.delay_until }}</span>
</div>
<!-- 处理人信息(审批/办理/抄送节点) -->
<div v-if="hasHandlerDetails(node)" class="node-handlers mb-2">
<!-- 处理人列表 -->
<div class="handlers-list">
<div
v-for="handler in node.handlers"
:key="handler.user_id || handler.user_name"
class="handler-item"
>
<div class="handler-avatar-wrapper">
<UserAvatar
v-if="handler.user_id"
:user-id="handler.user_id"
:name="handler.user_name"
:size="36"
:font-size="13"
:show-info="true"
:shadow="false"
auto-load
/>
<template v-else>
<div class="handler-placeholder">
{{ handler.user_name.charAt(0) }}
</div>
<div class="handler-name-text">
{{ handler.user_name }}
</div>
</template>
</div>
<!-- 状态标签 -->
<ElTag
v-if="handler.action"
:type="getActionTagType(handler.action) as any"
size="small"
>
{{ getActionLabel(handler.action) }}
</ElTag>
<ElTag
v-else-if="handler.status === 'pending'"
type="warning"
size="small"
>
{{ $t('workflow-designer.detail.flowProgress.pending') }}
</ElTag>
<ElTag
v-else-if="handler.status === 'waiting'"
type="info"
size="small"
>
{{ $t('workflow-designer.detail.flowProgress.waiting') }}
</ElTag>
<ElTag
v-else-if="handler.status === 'copied'"
type="success"
size="small"
>
{{ $t('workflow-designer.detail.flowProgress.copied') }}
</ElTag>
<ElTag
v-else-if="handler.status === 'skipped'"
type="info"
size="small"
>
{{ $t('workflow-designer.detail.flowProgress.skipped') }}
</ElTag>
<div v-if="handler.handled_at" class="handler-time">
{{ formatTime(handler.handled_at) }}
</div>
</div>
</div>
<!-- 处理意见和签名(如果有) -->
<div
v-for="handler in node.handlers.filter(
(h) => h.comment || (h as any).signature_file_id,
)"
:key="`comment-${handler.user_id}`"
class="handler-comment-item"
>
<div v-if="handler.comment" class="comment-content">
<span class="comment-user">{{ handler.user_name }}</span>
<span class="comment-text">{{ handler.comment }}</span>
</div>
<!-- 签名图片 -->
<div
v-if="
(handler as any).signature_file_id &&
signatureUrls[(handler as any).signature_file_id]
"
class="handler-signature"
>
<span class="signature-label"
>{{ $t('workflow.pending.signature') }}:</span
>
<ElImage
:src="
signatureUrls[(handler as any).signature_file_id] || ''
"
:preview-src-list="[
signatureUrls[(handler as any).signature_file_id] || '',
]"
fit="contain"
class="signature-image"
/>
</div>
</div>
<!-- 额外操作(驳回等,加签/转交/委派已在 handlers 中显示) -->
<div
v-for="(action, actionIndex) in node.extra_actions.filter(
(a) =>
![
'add_sign',
'reduce_sign',
'transfer',
'delegate',
].includes(a.type),
)"
:key="`action-${actionIndex}`"
class="extra-action-item"
>
<div class="action-row">
<!-- 操作发起人 -->
<UserAvatar
v-if="action.from_user_id"
:user-id="action.from_user_id"
:name="action.from_user_name"
:size="24"
:font-size="10"
:show-info="false"
:shadow="false"
auto-load
/>
<span class="action-user-name">{{
action.from_user_name
}}</span>
<!-- 操作类型标签 -->
<ElTag
:type="getExtraActionTagType(action.type) as any"
size="small"
>
{{ getExtraActionLabel(action) }}
</ElTag>
<!-- 目标用户(非驳回操作) -->
<template
v-if="action.type !== 'return' && action.to_user_id"
>
<span class="action-arrow">→</span>
<UserAvatar
:user-id="action.to_user_id"
:name="action.to_user_name"
:size="24"
:font-size="10"
:show-info="false"
:shadow="false"
auto-load
/>
<span class="action-user-name">{{
action.to_user_name
}}</span>
</template>
<!-- 驳回目标节点 -->
<template v-else-if="action.type === 'return'">
<span class="action-arrow">→</span>
<span class="action-target-node">{{
action.to_user_name
}}</span>
</template>
<span class="action-time">{{ action.time }}</span>
</div>
<!-- 操作说明/驳回原因 -->
<div v-if="action.comment" class="action-comment">
{{ action.comment }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<ElEmpty
v-else
:description="$t('workflow-designer.detail.flowProgress.noData')"
/>
</div>
</template>
<style scoped>
.flow-progress {
padding: 16px;
}
/* 驳回记录 */
.return-records {
padding: 12px 16px;
margin-bottom: 20px;
background: var(--el-color-warning-light-9);
/* border-left: 3px solid var(--el-color-warning); */
border-radius: 8px;
}
.return-title {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: var(--el-color-warning-dark-2);
}
.return-icon {
width: 16px;
height: 16px;
}
.return-item {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 6px 0;
font-size: 12px;
color: var(--el-text-color-regular);
}
.return-item + .return-item {
border-top: 1px dashed var(--el-border-color-lighter);
}
.return-from,
.return-to {
font-weight: 500;
color: var(--el-text-color-primary);
}
.return-arrow {
width: 14px;
height: 14px;
color: var(--el-color-warning);
}
.return-info {
color: var(--el-text-color-secondary);
}
.return-reason {
flex-basis: 100%;
padding-left: 20px;
font-style: italic;
color: var(--el-text-color-secondary);
}
/* 流程时间线 */
.progress-timeline {
position: relative;
}
.progress-node {
position: relative;
display: flex;
padding-bottom: 24px;
}
.progress-node.is-last {
padding-bottom: 0;
}
/* 节点图标 */
.node-icon {
z-index: 1;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--node-bg);
border: 2px solid var(--node-color);
border-radius: 50%;
}
.node-icon .icon {
width: 16px;
height: 16px;
color: var(--node-color);
}
/* 连接线 */
.node-line {
position: absolute;
top: 32px;
bottom: 0;
left: 15px;
width: 2px;
background: var(--el-border-color-light);
}
.progress-node.status-completed .node-line {
background: var(--el-color-success-light-5);
}
.progress-node.status-active .node-line {
background: linear-gradient(
to bottom,
var(--el-color-primary-light-5) 0%,
var(--el-border-color-light) 100%
);
}
/* 节点内容 */
.node-content {
flex: 1;
min-width: 0;
margin-left: 12px;
}
.node-header {
display: flex;
gap: 8px;
align-items: center;
min-height: 32px;
}
.node-name {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.node-time {
margin-left: auto;
font-size: 12px;
color: var(--el-text-color-secondary);
}
/* 处理人信息 */
.node-handlers {
padding: 12px;
margin-top: 8px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.handlers-list {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.handler-item {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
min-width: 70px;
}
.handler-avatar-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.handler-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
font-size: 14px;
font-weight: 500;
color: var(--el-color-info);
background: var(--el-color-info-light-7);
border-radius: 50%;
}
.handler-name-text {
max-width: 70px;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--el-text-color-primary);
text-align: center;
white-space: nowrap;
}
.handler-time {
font-size: 10px;
color: var(--el-text-color-secondary);
}
/* 处理意见 */
.handler-comment-item {
padding: 8px 10px;
margin-top: 10px;
font-size: 12px;
background: var(--el-bg-color);
border-radius: 4px;
}
.comment-user {
font-weight: 500;
color: var(--el-text-color-primary);
}
.comment-text {
color: var(--el-text-color-regular);
}
/* 签名图片 */
.handler-signature {
display: flex;
gap: 8px;
align-items: flex-start;
margin-top: 8px;
}
.handler-signature .signature-label {
flex-shrink: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.handler-signature .signature-image {
max-width: 120px;
max-height: 60px;
border-radius: 4px;
background: #fff;
}
/* 额外操作 */
.extra-action-item {
padding: 8px 0;
border-top: 1px dashed var(--el-border-color-lighter);
}
.action-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.action-user-name {
font-size: 13px;
color: var(--el-text-color-primary);
}
.action-arrow {
margin: 0 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.action-target-node {
font-size: 13px;
font-weight: 500;
color: var(--el-color-primary);
}
.action-time {
margin-left: auto;
font-size: 11px;
color: var(--el-text-color-secondary);
}
.action-comment {
padding: 6px 8px;
margin-top: 6px;
margin-left: 30px;
font-size: 12px;
color: var(--el-text-color-regular);
background: var(--el-bg-color);
border-radius: 4px;
}
/* 状态样式 */
.progress-node.status-skipped .node-icon {
opacity: 0.5;
}
.progress-node.status-skipped .node-content {
opacity: 0.6;
}
.progress-node.status-pending .node-icon {
border-style: dashed;
}
/* 延时节点信息 */
.delay-info {
display: flex;
gap: 6px;
align-items: center;
padding: 8px 12px;
margin-top: 8px;
font-size: 12px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-radius: 6px;
}
.delay-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.delay-time {
font-weight: 500;
}
</style>
@@ -0,0 +1,208 @@
<script lang="ts" setup>
import type { Component } from 'vue';
import type {
WorkflowInstance,
WorkflowInstanceListItem,
} from '#/api/online-dev/workflow';
import { computed, ref, watch } from 'vue';
import { FileText, Route, Workflow } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElEmpty } from 'element-plus';
import FlowProgressTab from '#/components/workflow/detial/FlowProgressTab.vue';
import FlowchartTab from '#/components/workflow/detial/tabs/FlowchartTab.vue';
import FormTab from '#/components/workflow/detial/tabs/FormTab.vue';
import ProgressTab from '#/components/workflow/detial/tabs/ProgressTab.vue';
import { ZqTabs } from '#/components/zq-tabs';
interface TabItem {
key: string;
label: string;
icon?: Component;
}
interface InstanceLike {
id: string;
title?: string;
workflow_name?: string;
}
interface Props {
instance: InstanceLike | null | WorkflowInstanceListItem;
defaultTab?: string;
extraTabs?: TabItem[];
emptyText?: string;
}
const props = withDefaults(defineProps<Props>(), {
defaultTab: 'progress',
extraTabs: () => [],
emptyText: '',
});
const emit = defineEmits<{
loaded: [instance: WorkflowInstance];
}>();
// 状态
const activeTab = ref(props.defaultTab);
const instanceDetail = ref<null | WorkflowInstance>(null);
// Tab 加载状态(懒加载)
const loadedTabs = ref<Set<string>>(new Set([props.defaultTab]));
// 默认 Tab 配置
const defaultTabItems = computed(() => [
{
key: 'progress',
label: $t('workflow-designer.detail.tabs.progress'),
icon: Workflow,
},
{
key: 'form',
label: $t('workflow-designer.detail.tabs.form'),
icon: FileText,
},
{
key: 'flowchart',
label: $t('workflow-designer.detail.tabs.flowchart'),
icon: Workflow,
},
{
key: 'flowProgress',
label: $t('workflow-designer.detail.tabs.flowProgress'),
icon: Route,
},
]);
// 合并 Tab 配置
const tabItems = computed(() => {
// 如果有额外的 tabs,插入到最前面
if (props.extraTabs.length > 0) {
return [...props.extraTabs, ...defaultTabItems.value];
}
return defaultTabItems.value;
});
// 空状态文本
const emptyDescription = computed(() => {
return props.emptyText || $t('workflow.initiated.selectInstance');
});
// ProgressTab 加载完成回调
function handleProgressLoaded(inst: WorkflowInstance) {
instanceDetail.value = inst;
emit('loaded', inst);
}
// 监听实例变化
watch(
() => props.instance?.id,
(newId, oldId) => {
if (newId && newId !== oldId) {
// 重置状态
activeTab.value = props.defaultTab;
instanceDetail.value = null;
loadedTabs.value = new Set([props.defaultTab]);
}
},
);
// 监听 tab 切换,记录已加载的 tab
watch(activeTab, (tab) => {
loadedTabs.value.add(tab);
});
// 暴露给父组件
defineExpose({
activeTab,
instanceDetail,
});
</script>
<template>
<div class="instance-detail-panel h-[calc(100vh-120px)] overflow-hidden">
<template v-if="instance">
<div class="detail-content flex h-full flex-col overflow-hidden">
<!-- Tabs 头部 -->
<div class="tabs-header flex-shrink-0 px-4 py-4">
<ZqTabs v-model="activeTab" :items="tabItems" class="w-fit" />
</div>
<!-- Tab 内容区域 -->
<div
:key="instance.id"
class="tabs-content bg-background-deep mx-4 mb-4 min-h-0 flex-1 overflow-hidden rounded-[8px]"
>
<!-- 额外的 Tab 内容插槽 -->
<template v-for="tab in extraTabs" :key="tab.key">
<div v-show="activeTab === tab.key" class="h-full">
<slot
:name="`tab-${tab.key}`"
:instance="instance"
:instance-detail="instanceDetail"
></slot>
</div>
</template>
<!-- 流程进度 -->
<div v-show="activeTab === 'progress'" class="h-full">
<ProgressTab
:instance-id="instance.id"
@loaded="handleProgressLoaded"
/>
</div>
<!-- 表单内容 -->
<div v-show="activeTab === 'form'" class="h-full">
<FormTab
v-if="loadedTabs.has('form')"
:instance-id="instance.id"
:form-code="instanceDetail?.form_code"
/>
</div>
<!-- 流程图 -->
<div v-show="activeTab === 'flowchart'" class="h-full">
<FlowchartTab
v-if="loadedTabs.has('flowchart')"
:instance-id="instance.id"
:workflow-id="instanceDetail?.workflow_id"
:current-node-id="instanceDetail?.current_node_id"
:instance-status="instanceDetail?.status"
/>
</div>
<!-- 审批路径 -->
<div
v-show="activeTab === 'flowProgress'"
class="h-full overflow-auto"
>
<FlowProgressTab
v-if="loadedTabs.has('flowProgress')"
:instance-id="instance.id"
/>
</div>
</div>
</div>
</template>
<div v-else class="flex h-full items-center justify-center">
<ElEmpty :description="emptyDescription" />
</div>
</div>
</template>
<style scoped>
.detail-content {
overflow: hidden;
}
.tabs-content {
padding: 0;
}
</style>
@@ -0,0 +1,327 @@
<script setup lang="ts">
import type {
DocumentTemplateListItem,
GeneratedDocument,
} from '#/api/online-dev/document-generator';
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { Download, FileText, Plus, RefreshCw } from '@vben/icons';
import {
ElButton,
ElDialog,
ElEmpty,
ElMessage,
ElOption,
ElSelect,
ElTable,
ElTableColumn,
ElTag,
} from 'element-plus';
import {
downloadDocumentApi,
generateDocumentApi,
getDocumentListApi,
getTemplateListApi,
regenerateDocumentApi,
} from '#/api/online-dev/document-generator';
const props = defineProps<{
formCode?: string;
instanceId: string;
workflowCode?: string;
}>();
// 状态
const loading = ref(false);
const documents = ref<GeneratedDocument[]>([]);
const templates = ref<DocumentTemplateListItem[]>([]);
// 生成弹窗
const generateDialogVisible = ref(false);
const selectedTemplateId = ref('');
const generating = ref(false);
// 加载文档列表
async function loadDocuments() {
if (!props.instanceId) return;
loading.value = true;
try {
const res = await getDocumentListApi({
instanceId: props.instanceId,
pageSize: 100,
});
documents.value = res.items;
} catch (error) {
console.error('Load documents failed:', error);
} finally {
loading.value = false;
}
}
// 加载可用模板
async function loadTemplates() {
try {
const res = await getTemplateListApi({
status: 'published',
workflowCode: props.workflowCode || undefined,
pageSize: 100,
});
templates.value = res.items;
} catch (error) {
console.error('Load templates failed:', error);
}
}
// 打开生成弹窗
function handleOpenGenerate() {
selectedTemplateId.value = '';
generateDialogVisible.value = true;
loadTemplates();
}
// 生成文档
async function handleGenerate() {
if (!selectedTemplateId.value) {
ElMessage.warning($t('documentGenerator.pleaseSelectTemplate'));
return;
}
generating.value = true;
try {
await generateDocumentApi({
template_id: selectedTemplateId.value,
instance_id: props.instanceId,
});
ElMessage.success($t('documentGenerator.generateSuccess'));
generateDialogVisible.value = false;
loadDocuments();
} catch (error: any) {
ElMessage.error(error?.message || $t('documentGenerator.generateFailed'));
} finally {
generating.value = false;
}
}
// 下载文档
async function handleDownload(doc: GeneratedDocument) {
try {
const blob = await downloadDocumentApi(doc.id);
const url = URL.createObjectURL(blob as Blob);
const a = document.createElement('a');
a.href = url;
a.download = `${doc.document_name}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (error: any) {
ElMessage.error(error?.message || $t('common.downloadFailed'));
}
}
// 重新生成
async function handleRegenerate(doc: GeneratedDocument) {
try {
await regenerateDocumentApi(doc.id);
ElMessage.success($t('documentGenerator.regenerateSuccess'));
loadDocuments();
} catch (error: any) {
ElMessage.error(error?.message || $t('documentGenerator.regenerateFailed'));
}
}
// 格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// 监听实例变化
watch(
() => props.instanceId,
(newId) => {
if (newId) {
loadDocuments();
}
},
{ immediate: true },
);
</script>
<template>
<div class="document-tab">
<!-- 工具栏 -->
<div class="toolbar">
<ElButton type="primary" @click="handleOpenGenerate">
<Plus class="mr-1 h-4 w-4" />
{{ $t('documentGenerator.generateDocument') }}
</ElButton>
<ElButton @click="loadDocuments">
<RefreshCw class="mr-1 h-4 w-4" />
{{ $t('common.refresh') }}
</ElButton>
</div>
<!-- 文档列表 -->
<div v-loading="loading" class="document-list">
<ElTable v-if="documents.length > 0" :data="documents" stripe>
<ElTableColumn
prop="document_name"
:label="$t('documentGenerator.documentName')"
min-width="200"
>
<template #default="{ row }">
<div class="flex items-center gap-2">
<FileText class="text-primary h-4 w-4" />
<span>{{ row.document_name }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn
prop="template_name"
:label="$t('documentGenerator.templateName')"
width="150"
/>
<ElTableColumn
prop="page_count"
:label="$t('documentGenerator.pageCount')"
width="80"
align="center"
/>
<ElTableColumn
prop="file_size"
:label="$t('documentGenerator.fileSize')"
width="100"
align="center"
>
<template #default="{ row }">
{{ formatFileSize(row.file_size) }}
</template>
</ElTableColumn>
<ElTableColumn
prop="generate_type"
:label="$t('documentGenerator.generateType')"
width="100"
align="center"
>
<template #default="{ row }">
<ElTag
:type="row.generate_type === 'auto' ? 'success' : 'info'"
size="small"
>
{{
row.generate_type === 'auto'
? $t('documentGenerator.auto')
: $t('documentGenerator.manual')
}}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn
prop="sys_create_datetime"
:label="$t('documentGenerator.generateTime')"
width="160"
>
<template #default="{ row }">
{{ row.sys_create_datetime?.slice(0, 16).replace('T', ' ') }}
</template>
</ElTableColumn>
<ElTableColumn :label="$t('common.actions')" width="150" fixed="right">
<template #default="{ row }">
<div class="flex gap-2">
<ElButton link type="primary" @click="handleDownload(row)">
<Download class="mr-1 h-4 w-4" />
{{ $t('common.download') }}
</ElButton>
<ElButton link @click="handleRegenerate(row)">
<RefreshCw class="mr-1 h-4 w-4" />
</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
<ElEmpty v-else :description="$t('documentGenerator.noDocuments')" />
</div>
<!-- 生成文档弹窗 -->
<ElDialog
v-model="generateDialogVisible"
:title="$t('documentGenerator.generateDocument')"
width="500px"
>
<div class="generate-form">
<div class="form-item">
<label>{{ $t('documentGenerator.selectTemplate') }}</label>
<ElSelect
v-model="selectedTemplateId"
:placeholder="$t('documentGenerator.pleaseSelectTemplate')"
style="width: 100%"
>
<ElOption
v-for="tpl in templates"
:key="tpl.id"
:label="tpl.name"
:value="tpl.id"
>
<div class="flex items-center justify-between">
<span>{{ tpl.name }}</span>
<ElTag size="small" type="info">{{ tpl.category }}</ElTag>
</div>
</ElOption>
</ElSelect>
</div>
</div>
<template #footer>
<ElButton @click="generateDialogVisible = false">
{{ $t('common.cancel') }}
</ElButton>
<ElButton type="primary" :loading="generating" @click="handleGenerate">
{{ $t('documentGenerator.generate') }}
</ElButton>
</template>
</ElDialog>
</div>
</template>
<style scoped>
.document-tab {
height: 100%;
display: flex;
flex-direction: column;
padding: 16px;
}
.toolbar {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-shrink: 0;
}
.document-list {
flex: 1;
overflow: auto;
}
.generate-form {
padding: 16px 0;
}
.form-item {
margin-bottom: 16px;
}
.form-item label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: var(--el-text-color-regular);
}
</style>
@@ -0,0 +1,234 @@
<script lang="ts" setup>
import type { WorkflowLog } from '#/api/online-dev/workflow';
/**
* 流程图 Tab
*/
import { computed, onMounted, ref } from 'vue';
import { ElEmpty, ElSkeleton, ElSkeletonItem } from 'element-plus';
import {
getInstanceLogsApi,
getWorkflowDetailApi,
} from '#/api/online-dev/workflow';
import FlowPreviewContent from '#/components/workflow/designer/components/FlowPreviewContent.vue';
interface Props {
instanceId: string;
workflowId?: string;
currentNodeId?: string;
instanceStatus?: string;
}
const props = defineProps<Props>();
// 状态
const loading = ref(false);
const flowDefinition = ref<any>(null);
const workflowName = ref('');
const logs = ref<WorkflowLog[]>([]);
// 计算节点状态
const nodeStatuses = computed(() => {
const statuses: Record<
string,
'active' | 'completed' | 'pending' | 'rejected'
> = {};
if (!flowDefinition.value?.nodes) return statuses;
// 从日志中提取已执行的节点
const executedNodes = new Set<string>();
const rejectedNodes = new Set<string>();
for (const log of logs.value) {
if (log.node_id) {
executedNodes.add(log.node_id);
if (log.action === 'reject') {
rejectedNodes.add(log.node_id);
}
}
}
// 流程是否已结束
const isFinished =
props.instanceStatus === 'approved' ||
props.instanceStatus === 'rejected' ||
props.instanceStatus === 'cancelled';
// 遍历流程定义,为所有节点设置状态
function processNode(node: any) {
if (!node) return;
const nodeId = node.id;
if (node.type === 'start') {
statuses[nodeId] = 'completed';
} else if (rejectedNodes.has(nodeId)) {
statuses[nodeId] = 'rejected';
} else if (nodeId === props.currentNodeId && !isFinished) {
// 如果节点已有执行日志且不是当前活跃节点(条件分支推进遗留),标记为已完成
statuses[nodeId] = executedNodes.has(nodeId) ? 'completed' : 'active';
} else if (executedNodes.has(nodeId)) {
statuses[nodeId] = 'completed';
} else if (
node.type === 'end' &&
isFinished &&
props.instanceStatus === 'approved'
) {
statuses[nodeId] = 'completed';
} else {
statuses[nodeId] = 'pending';
}
if (node.children) {
processNode(node.children);
}
if (node.branches) {
for (const branch of node.branches) {
if (branch.children) {
processNode(branch.children);
}
}
}
}
processNode(flowDefinition.value.nodes);
return statuses;
});
// 加载数据
async function loadData() {
if (!props.workflowId) return;
loading.value = true;
try {
const [workflowRes, logsRes] = await Promise.all([
getWorkflowDetailApi(props.workflowId),
getInstanceLogsApi(props.instanceId).catch(() => []),
]);
flowDefinition.value = workflowRes.flow_definition;
workflowName.value = workflowRes.name;
logs.value = logsRes || [];
} catch {
// 忽略加载失败
} finally {
loading.value = false;
}
}
onMounted(() => {
loadData();
});
defineExpose({
reload: loadData,
});
</script>
<template>
<div class="flowchart-tab">
<!-- 骨架屏 -->
<div v-if="loading" class="flowchart-skeleton">
<ElSkeleton animated>
<template #template>
<div class="skeleton-flowchart">
<!-- 模拟流程图节点 -->
<div class="skeleton-node start">
<ElSkeletonItem
variant="circle"
style="width: 60px; height: 60px"
/>
</div>
<div class="skeleton-line"></div>
<div class="skeleton-node">
<ElSkeletonItem
variant="rect"
style="width: 120px; height: 60px; border-radius: 8px"
/>
</div>
<div class="skeleton-line"></div>
<div class="skeleton-node">
<ElSkeletonItem
variant="rect"
style="width: 120px; height: 60px; border-radius: 8px"
/>
</div>
<div class="skeleton-line"></div>
<div class="skeleton-node">
<ElSkeletonItem
variant="rect"
style="width: 120px; height: 60px; border-radius: 8px"
/>
</div>
<div class="skeleton-line"></div>
<div class="skeleton-node end">
<ElSkeletonItem
variant="circle"
style="width: 60px; height: 60px"
/>
</div>
</div>
</template>
</ElSkeleton>
</div>
<!-- 内容 -->
<div v-else-if="flowDefinition?.nodes" class="flowchart-content">
<FlowPreviewContent
:flow-definition="flowDefinition"
:flow-name="workflowName"
:show-header="false"
:node-statuses="nodeStatuses"
height="100%"
/>
</div>
<ElEmpty v-else description="暂无流程图" />
</div>
</template>
<style scoped>
.flowchart-tab {
display: flex;
flex-direction: column;
height: 100%;
}
/* 骨架屏 */
.flowchart-skeleton {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 40px;
}
.skeleton-flowchart {
display: flex;
flex-direction: column;
gap: 0;
align-items: center;
}
.skeleton-node {
display: flex;
justify-content: center;
}
.skeleton-line {
width: 2px;
height: 30px;
background: var(--el-border-color);
}
/* 内容 */
.flowchart-content {
flex: 1;
min-height: 0;
overflow: hidden;
}
</style>
@@ -0,0 +1,195 @@
<script lang="ts" setup>
/**
* 表单内容 Tab
*/
import { computed, onMounted, ref } from 'vue';
import { $t } from '@vben/locales';
import {
ElEmpty,
ElForm,
ElMessage,
ElScrollbar,
ElSkeleton,
ElSkeletonItem,
} from 'element-plus';
import { getFormByCodeApi } from '#/api/online-dev/form-manager';
import { getInstanceFormDataApi } from '#/api/online-dev/workflow';
import PreviewItem from '#/components/form-design/components/PreviewItem.vue';
interface Props {
instanceId: string;
formCode?: string;
}
const props = defineProps<Props>();
// 状态
const loading = ref(false);
const formData = ref<Record<string, any>>({});
const formConfig = ref<any>(null);
// 表单项(只读模式)
const formItems = computed(() => {
if (!formConfig.value?.items) return [];
return processFormItems(formConfig.value.items);
});
// 递归处理表单项为只读
function processFormItems(items: any[]): any[] {
return items.map((item) => {
const newItem = JSON.parse(JSON.stringify(item));
newItem.props = { ...newItem.props, disabled: true };
// 子表禁用操作
if (item.type === 'sub-table') {
newItem.props = {
...newItem.props,
addable: false,
deletable: false,
sortable: false,
copyable: false,
};
}
if (item.columns) {
newItem.columns = item.columns.map((col: any) => ({
...col,
children: processFormItems(col.children || []),
}));
}
if (item.children) {
newItem.children = processFormItems(item.children);
}
return newItem;
});
}
// 加载数据
async function loadData() {
if (!props.instanceId || !props.formCode) return;
loading.value = true;
try {
const [formMetaRes, formDataRes] = await Promise.all([
getFormByCodeApi(props.formCode).catch(() => null),
getInstanceFormDataApi(props.instanceId).catch(() => ({})),
]);
if (formMetaRes) {
formConfig.value = formMetaRes.form_config;
}
// 处理表单数据
const data = (formDataRes as Record<string, any>) || {};
Object.keys(data).forEach((key) => {
if (key !== 'sub_tables') {
formData.value[key] = data[key];
}
});
if (data.sub_tables) {
Object.keys(data.sub_tables).forEach((tableName: string) => {
formData.value[tableName] = data.sub_tables[tableName];
});
}
} catch (error: any) {
ElMessage.error(
error?.message || $t('workflow-designer.detail.loadFormDataFailed'),
);
} finally {
loading.value = false;
}
}
onMounted(() => {
loadData();
});
defineExpose({
reload: loadData,
});
</script>
<template>
<div class="form-tab">
<ElScrollbar class="bg-background h-full rounded-[8px]">
<!-- 骨架屏 -->
<div v-if="loading" class="form-skeleton">
<ElSkeleton animated>
<template #template>
<div class="skeleton-form">
<div v-for="i in 6" :key="i" class="skeleton-form-item">
<ElSkeletonItem
variant="text"
style="width: 80px; margin-bottom: 8px"
/>
<ElSkeletonItem
variant="rect"
style="width: 100%; height: 32px"
/>
</div>
</div>
</template>
</ElSkeleton>
</div>
<!-- 内容 -->
<div v-else-if="formItems.length > 0" class="form-content">
<ElForm
:model="formData"
:label-width="formConfig?.labelWidth || 100"
:label-position="formConfig?.labelPosition || 'right'"
:size="formConfig?.size || 'default'"
>
<PreviewItem
v-for="item in formItems"
:key="item.id"
:item="item"
:model-value="formData"
/>
</ElForm>
</div>
<ElEmpty
v-else
:description="$t('workflow-designer.detail.noFormContent')"
/>
</ElScrollbar>
</div>
</template>
<style scoped>
.form-tab {
height: 100%;
padding: 16px;
}
/* 骨架屏 */
.form-skeleton {
padding: 16px;
background: var(--el-bg-color);
border-radius: 8px;
}
.skeleton-form {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
}
.skeleton-form-item {
display: flex;
flex-direction: column;
}
/* 内容 */
.form-content {
padding: 16px;
background: var(--el-bg-color);
border-radius: 8px;
}
</style>
@@ -0,0 +1,972 @@
<script lang="ts" setup>
import type {
WorkflowInstance,
WorkflowLog,
WorkflowTaskListItem,
} from '#/api/online-dev/workflow';
/**
* 流程进度 Tab
*/
import { computed, onMounted, ref } from 'vue';
import { Clock, Users } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElEmpty,
ElImage,
ElMessage,
ElScrollbar,
ElSkeleton,
ElSkeletonItem,
ElTag,
ElTimeline,
ElTimelineItem,
} from 'element-plus';
import {
getInstanceDetailApi,
getInstanceLogsApi,
getInstancePendingTasksApi,
} from '#/api/online-dev/workflow';
import { UserAvatar } from '#/components/user-avatar/index';
import { getFileUrl } from '#/composables/useFileUrl';
const props = defineProps<Props>();
const emit = defineEmits<{
loaded: [instance: WorkflowInstance];
}>();
// 签名图片URL缓存
const signatureUrls = ref<Record<string, string>>({});
interface Props {
instanceId: string;
}
// 状态
const loading = ref(false);
const instance = ref<null | WorkflowInstance>(null);
const logs = ref<WorkflowLog[]>([]);
const pendingTasks = ref<WorkflowTaskListItem[]>([]);
// 按节点分组的待办任务
const groupedPendingTasks = computed(() => {
const groups: { nodeName: string; tasks: WorkflowTaskListItem[] }[] = [];
const nodeMap = new Map<string, WorkflowTaskListItem[]>();
for (const task of pendingTasks.value) {
const key = task.node_name || $t('workflow-designer.detail.unknownNode');
if (!nodeMap.has(key)) {
nodeMap.set(key, []);
}
nodeMap.get(key)!.push(task);
}
for (const [nodeName, tasks] of nodeMap) {
groups.push({ nodeName, tasks });
}
return groups;
});
// 状态配置
const statusConfig = computed(() => ({
pending: {
type: 'warning' as const,
label: $t('workflow-designer.detail.status.pending'),
},
approved: {
type: 'success' as const,
label: $t('workflow-designer.detail.status.approved'),
},
rejected: {
type: 'danger' as const,
label: $t('workflow-designer.detail.status.rejected'),
},
cancelled: {
type: 'info' as const,
label: $t('workflow-designer.detail.status.cancelled'),
},
canceled: {
type: 'info' as const,
label: $t('workflow-designer.detail.status.cancelled'),
},
}));
// 操作配置
const actionConfig = computed(() => ({
start: {
label: $t('workflow-designer.detail.actions.start'),
type: 'primary',
},
approve: {
label: $t('workflow-designer.detail.actions.approve'),
type: 'success',
},
reject: {
label: $t('workflow-designer.detail.actions.reject'),
type: 'danger',
},
return: {
label: $t('workflow-designer.detail.actions.return'),
type: 'warning',
},
transfer: {
label: $t('workflow-designer.detail.actions.transfer'),
type: 'warning',
},
delegate: {
label: $t('workflow-designer.detail.actions.delegate'),
type: 'warning',
},
add_sign: {
label: $t('workflow-designer.detail.actions.add_sign'),
type: 'warning',
},
revise: {
label: $t('workflow-designer.detail.actions.revise'),
type: 'primary',
},
cancel: {
label: $t('workflow-designer.detail.actions.cancel'),
type: 'info',
},
copy: { label: $t('workflow-designer.detail.actions.copy'), type: 'info' },
handle: {
label: $t('workflow-designer.detail.actions.handle'),
type: 'primary',
},
urge: { label: $t('workflow-designer.detail.actions.urge'), type: 'warning' },
task_timeout: {
label: $t('workflow-designer.detail.actions.task_timeout'),
type: 'danger',
},
task_timeout_notify: {
label: $t('workflow-designer.detail.actions.task_timeout_notify'),
type: 'warning',
},
task_auto_approve: {
label: $t('workflow-designer.detail.actions.task_auto_approve'),
type: 'success',
},
task_auto_reject: {
label: $t('workflow-designer.detail.actions.task_auto_reject'),
type: 'danger',
},
condition: {
label: $t('workflow-designer.detail.actions.condition'),
type: 'info',
},
delay_start: {
label: $t('workflow-designer.detail.actions.delay_start'),
type: 'info',
},
delay_complete: {
label: $t('workflow-designer.detail.actions.delay_complete'),
type: 'info',
},
delay_skip: {
label: $t('workflow-designer.detail.actions.delay_skip'),
type: 'warning',
},
notify: {
label: $t('workflow-designer.detail.actions.notify'),
type: 'info',
},
parallel_start: {
label: $t('workflow-designer.detail.actions.parallel_start'),
type: 'info',
},
parallel_complete: {
label: $t('workflow-designer.detail.actions.parallel_complete'),
type: 'info',
},
subflow_start: {
label: $t('workflow-designer.detail.actions.subflow_start'),
type: 'info',
},
subflow_complete: {
label: $t('workflow-designer.detail.actions.subflow_complete'),
type: 'info',
},
}));
// 加载数据
async function loadData() {
if (!props.instanceId) return;
loading.value = true;
try {
const [instanceRes, logsRes, pendingTasksRes] = await Promise.all([
getInstanceDetailApi(props.instanceId),
getInstanceLogsApi(props.instanceId),
getInstancePendingTasksApi(props.instanceId).catch(() => []),
]);
instance.value = instanceRes;
logs.value = logsRes || [];
pendingTasks.value = pendingTasksRes || [];
emit('loaded', instanceRes);
// 加载签名图片URL
await loadSignatureUrls(logsRes || []);
} catch (error: any) {
ElMessage.error(
error?.message || $t('workflow-designer.detail.loadDataFailed'),
);
} finally {
loading.value = false;
}
}
// 加载签名图片URL
async function loadSignatureUrls(logList: WorkflowLog[]) {
const fileIds = logList
.filter((log) => log.extra_data?.signature_file_id)
.map((log) => log.extra_data!.signature_file_id!);
if (fileIds.length === 0) return;
// 并行加载所有签名图片URL
const results = await Promise.all(
fileIds.map(async (fileId) => {
try {
const url = await getFileUrl(fileId);
return { fileId, url };
} catch {
return { fileId, url: '' };
}
}),
);
// 更新缓存
for (const { fileId, url } of results) {
if (url) {
signatureUrls.value[fileId] = url;
}
}
}
// 格式化时间
function formatTime(time: null | string | undefined) {
if (!time) return '-';
return time;
}
// 格式化剩余时间
function formatRemainingTime(timeoutAt: null | string | undefined) {
if (!timeoutAt) return '';
const timeout = new Date(timeoutAt).getTime();
const now = Date.now();
const diff = timeout - now;
if (diff <= 0) return $t('workflow-designer.detail.timeout');
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days}${$t('workflow-designer.detail.daysLater')}`;
}
if (hours > 0) return `${hours}${$t('workflow-designer.detail.hoursLater')}`;
return `${minutes}${$t('workflow-designer.detail.minutesLater')}`;
}
// 计算耗时
const duration = computed(() => {
if (!instance.value?.started_at) return '-';
const start = new Date(instance.value.started_at).getTime();
const end = instance.value.completed_at
? new Date(instance.value.completed_at).getTime()
: Date.now();
const diff = end - start;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (days > 0)
return `${days}${$t('workflow-designer.detail.days')}${hours}${$t('workflow-designer.detail.hours')}`;
if (hours > 0)
return `${hours}${$t('workflow-designer.detail.hours')}${minutes}${$t('workflow-designer.detail.minutes')}`;
return `${minutes}${$t('workflow-designer.detail.minutes')}`;
});
onMounted(() => {
loadData();
});
// 暴露实例数据
defineExpose({
instance,
reload: loadData,
});
</script>
<template>
<div class="progress-tab">
<!-- 骨架屏 -->
<div v-if="loading" class="progress-skeleton">
<div class="skeleton-left">
<ElSkeleton animated>
<template #template>
<div class="skeleton-title">
<ElSkeletonItem variant="text" style="width: 100px" />
</div>
<div class="skeleton-card">
<div v-for="i in 20" :key="i" class="skeleton-row">
<ElSkeletonItem variant="text" style="width: 60px" />
<ElSkeletonItem variant="text" style="width: 150px" />
</div>
</div>
</template>
</ElSkeleton>
</div>
<div class="skeleton-right">
<ElSkeleton animated>
<template #template>
<div class="skeleton-title">
<ElSkeletonItem variant="text" style="width: 100px" />
</div>
<div class="skeleton-timeline">
<div v-for="i in 12" :key="i" class="skeleton-timeline-item">
<ElSkeletonItem
variant="circle"
style="width: 32px; height: 32px"
/>
<div class="skeleton-timeline-content">
<ElSkeletonItem variant="text" style="width: 200px" />
<ElSkeletonItem
variant="text"
style="width: 150px; margin-top: 8px"
/>
</div>
</div>
</div>
</template>
</ElSkeleton>
</div>
</div>
<!-- 内容 -->
<div v-else-if="instance" class="progress-layout">
<!-- 左侧流程信息 -->
<div class="info-panel">
<!-- 当前待办 -->
<template v-if="groupedPendingTasks.length > 0">
<div class="pending-section">
<div class="panel-title flex-shrink-0">
<Clock class="panel-icon" />
{{ $t('workflow-designer.detail.currentPending') }}
</div>
<ElScrollbar class="pending-scroll bg-background rounded-[8px]">
<div class="pending-groups">
<div
v-for="group in groupedPendingTasks"
:key="group.nodeName"
class="pending-group"
>
<div class="group-header">
<span class="group-node">{{ group.nodeName }}</span>
<ElTag
v-if="group.tasks.length > 1"
type="info"
size="small"
>
{{ group.tasks.length
}}{{ $t('workflow-designer.detail.person') }}
</ElTag>
</div>
<div class="group-users">
<div
v-for="task in group.tasks"
:key="task.id"
class="pending-user"
>
<UserAvatar
:user-id="task.assignee_id"
:name="task.assignee_name"
:size="32"
:font-size="12"
:show-info="true"
:shadow="false"
auto-load
/>
<ElTag
v-if="task.is_timeout"
type="danger"
size="small"
class="user-status"
>
{{ $t('workflow-designer.detail.timeout') }}
</ElTag>
<ElTag
v-else-if="task.timeout_at && !task.is_timeout"
type="warning"
size="small"
class="user-status"
:title="`超时时间: ${task.timeout_at}`"
>
{{ formatRemainingTime(task.timeout_at) }}
</ElTag>
<ElTag
v-else-if="task.status === 'waiting'"
type="info"
size="small"
class="user-status"
>
{{ $t('workflow-designer.detail.waiting') }}
</ElTag>
</div>
</div>
</div>
</div>
</ElScrollbar>
</div>
</template>
<!-- 流程信息 -->
<div class="flow-info-section">
<div class="panel-title flex-shrink-0">
<Users class="panel-icon" />
{{ $t('workflow-designer.detail.flowInfo') }}
</div>
<ElScrollbar class="flow-info-scroll bg-background rounded-[8px]">
<div class="info-card">
<div class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.flowTitle')
}}</span>
<span class="info-value title-value">{{ instance.title }}</span>
</div>
<div class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.flowType')
}}</span>
<span class="info-value">{{ instance.workflow_name }}</span>
</div>
<div class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.flowStatus')
}}</span>
<span class="info-value">
<ElTag
v-if="instance?.status && statusConfig[instance.status]"
:type="statusConfig[instance.status]?.type"
size="small"
>
{{ statusConfig[instance.status]?.label }}
</ElTag>
</span>
</div>
<div class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.flowNo')
}}</span>
<span class="info-value mono">{{ instance.instance_no }}</span>
</div>
<div class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.initiator')
}}</span>
<span class="info-value">
<UserAvatar
:user-id="instance.initiator_id"
:name="instance.initiator_name"
:size="28"
:font-size="12"
:show-info="true"
:shadow="false"
auto-load
/>
</span>
</div>
<div class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.currentNode')
}}</span>
<span class="info-value highlight">{{
instance.current_node_name || '-'
}}</span>
</div>
<div class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.startTime')
}}</span>
<span class="info-value">{{
formatTime(instance.started_at)
}}</span>
</div>
<div v-if="instance.completed_at" class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.completeTime')
}}</span>
<span class="info-value">{{
formatTime(instance.completed_at)
}}</span>
</div>
<div class="info-row">
<span class="info-label">{{
$t('workflow-designer.detail.duration')
}}</span>
<span class="info-value">
<Clock class="inline-icon" />
{{ duration }}
</span>
</div>
</div>
</ElScrollbar>
</div>
</div>
<!-- 右侧审批记录 -->
<div class="timeline-panel">
<div class="panel-title flex-shrink-0">
<Clock class="panel-icon" />
{{ $t('workflow-designer.detail.approvalRecord') }}
</div>
<ElScrollbar class="bg-background flex-1 rounded-[8px] px-4 pt-4">
<div class="timeline-content">
<ElTimeline v-if="logs.length > 0">
<ElTimelineItem
v-for="log in logs"
:key="log.id"
:type="(actionConfig[log.action]?.type || 'info') as any"
>
<template #dot>
<div
class="timeline-dot"
:class="actionConfig[log.action]?.type || 'info'"
>
<span class="dot-circle"></span>
<span class="dot-node-name">{{
log.node_name || $t('workflow-designer.detail.start')
}}</span>
</div>
</template>
<div class="log-card">
<div class="log-header">
<template v-if="log.operator_id">
<UserAvatar
:user-id="log.operator_id"
:name="log.operator_name"
:size="32"
:font-size="12"
:show-info="true"
:shadow="false"
auto-load
/>
</template>
<div v-else class="system-auto-label">
{{ $t('workflow-designer.detail.systemAuto') }}
</div>
<div class="log-action">
<ElTag
:type="
(actionConfig[log.action]?.type || 'info') as any
"
size="small"
>
{{ actionConfig[log.action]?.label || log.action }}
</ElTag>
<span class="log-time">{{
formatTime(log.sys_create_datetime)
}}</span>
</div>
</div>
<div v-if="log.comment" class="log-comment">
{{ log.comment }}
</div>
<!-- 签名图片 -->
<div
v-if="
log.extra_data?.signature_file_id &&
signatureUrls[log.extra_data.signature_file_id]
"
class="log-signature"
>
<span class="signature-label"
>{{ $t('workflow.pending.signature') }}:</span
>
<ElImage
:src="
signatureUrls[log.extra_data.signature_file_id] || ''
"
:preview-src-list="[
signatureUrls[log.extra_data.signature_file_id] || '',
]"
fit="contain"
class="signature-image"
/>
</div>
</div>
</ElTimelineItem>
</ElTimeline>
<ElEmpty
v-else
:description="$t('workflow-designer.detail.noApprovalRecord')"
/>
</div>
</ElScrollbar>
</div>
</div>
<ElEmpty v-else :description="$t('workflow-designer.detail.noData')" />
</div>
</template>
<style scoped>
.progress-tab {
height: 100%;
padding: 16px;
}
/* 骨架屏 */
.progress-skeleton {
display: flex;
gap: 16px;
height: 100%;
}
.skeleton-left {
flex-shrink: 0;
width: 360px;
}
.skeleton-right {
flex: 1;
}
.skeleton-title {
padding-bottom: 12px;
margin-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.skeleton-card {
padding: 16px;
background: var(--el-bg-color);
border-radius: 8px;
}
.skeleton-row {
display: flex;
gap: 16px;
padding: 10px 0;
border-bottom: 1px dashed var(--el-border-color-lighter);
}
.skeleton-row:last-child {
border-bottom: none;
}
.skeleton-timeline {
padding: 16px;
background: var(--el-bg-color);
border-radius: 8px;
}
.skeleton-timeline-item {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.skeleton-timeline-content {
flex: 1;
}
/* 内容布局 */
.progress-layout {
display: flex;
gap: 16px;
height: 100%;
}
.info-panel {
display: flex;
flex-shrink: 0;
flex-direction: column;
gap: 16px;
width: 360px;
}
/* 当前待办区域 */
.pending-section {
display: flex;
flex-direction: column;
flex-shrink: 0;
max-height: 300px;
}
.pending-scroll {
flex: 1;
min-height: 0;
}
/* 流程信息区域 */
.flow-info-section {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.flow-info-scroll {
flex: 1;
min-height: 0;
}
.timeline-panel {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.panel-title {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.panel-icon {
width: 18px;
height: 18px;
color: var(--el-color-primary);
}
/* 流程信息卡片 */
.info-card {
flex: 1;
padding: 16px;
background: var(--el-bg-color);
border-radius: 8px;
}
.info-row {
display: flex;
align-items: flex-start;
padding: 10px 0;
border-bottom: 1px dashed var(--el-border-color-lighter);
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
flex-shrink: 0;
width: 80px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.info-value {
display: flex;
flex: 1;
align-items: center;
font-size: 13px;
color: var(--el-text-color-primary);
}
.info-value.title-value {
font-weight: 500;
}
.info-value.mono {
font-family: monospace;
font-size: 12px;
}
.info-value.highlight {
font-weight: 500;
color: var(--el-color-primary);
}
.inline-icon {
width: 14px;
height: 14px;
margin-right: 4px;
color: var(--el-text-color-secondary);
}
/* 审批记录时间线 */
.timeline-content {
flex: 1;
padding: 16px;
overflow: auto;
border-radius: 8px;
}
/* 时间线圆点 */
.timeline-dot {
display: flex;
gap: 6px;
align-items: center;
white-space: nowrap;
}
.dot-circle {
flex-shrink: 0;
width: 10px;
height: 10px;
border-radius: 50%;
}
.timeline-dot.primary .dot-circle {
background: var(--el-color-primary);
}
.timeline-dot.success .dot-circle {
background: var(--el-color-success);
}
.timeline-dot.danger .dot-circle {
background: var(--el-color-danger);
}
.timeline-dot.warning .dot-circle {
background: var(--el-color-warning);
}
.timeline-dot.info .dot-circle {
background: var(--el-color-info);
}
.dot-node-name {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.log-card {
padding: 14px 14px;
margin-left: 60px;
background: var(--el-bg-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-radius: 6px;
}
.log-header {
display: flex;
gap: 12px;
align-items: flex-start;
}
.system-auto-label {
display: flex;
align-items: center;
height: 32px;
padding: 0 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
border-radius: 4px;
}
.log-action {
display: flex;
flex-direction: column;
gap: 4px;
}
.log-time {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.log-comment {
padding: 8px 10px;
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-regular);
background: var(--el-fill-color-lighter);
border-radius: 4px;
}
/* 签名图片 */
.log-signature {
display: flex;
gap: 8px;
align-items: flex-start;
margin-top: 8px;
padding: 8px;
background: var(--el-fill-color-lighter);
border-radius: 4px;
}
.signature-label {
flex-shrink: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.signature-image {
max-width: 120px;
max-height: 60px;
border-radius: 4px;
background: #fff;
}
/* 当前待办 */
.pending-groups {
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px;
background: var(--el-bg-color);
border-radius: 8px;
}
.pending-group {
padding: 10px 12px;
background: var(--el-bg-color);
border-radius: 6px;
}
.group-header {
display: flex;
gap: 8px;
align-items: center;
padding-bottom: 8px;
margin-bottom: 10px;
border-bottom: 1px dashed var(--el-border-color-lighter);
}
.group-node {
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.group-users {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.pending-user {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
}
.user-status {
position: absolute;
top: -4px;
right: -8px;
transform: scale(0.8);
}
</style>
@@ -0,0 +1,2 @@
export { default as DocumentPreviewDialog } from './DocumentPreviewDialog.vue';
export { default as InstanceDetailPanel } from './detial/InstanceDetailPanel.vue';
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue';
import type { DrawData, DrawElement, AppState, BinaryFiles } from './types';
import type { NormalizedZoomValue } from './types';
import { getCommonBounds } from './elements/bounds';
import { renderStaticScene } from './core/renderer/static-scene';
import { loadDrawFonts } from './fonts';
const props = withDefaults(
defineProps<{
data: DrawData;
width: number;
height: number;
}>(),
{
width: 800,
height: 450,
},
);
const canvasRef = ref<HTMLCanvasElement>();
const containerRef = ref<HTMLDivElement>();
const imageCache = new Map<string, HTMLImageElement>();
function buildImageCache(files: BinaryFiles): Promise<void> {
const promises: Promise<void>[] = [];
for (const [id, file] of Object.entries(files)) {
if (imageCache.has(id)) continue;
promises.push(
new Promise<void>((resolve) => {
const img = new Image();
img.onload = () => {
imageCache.set(id, img);
resolve();
};
img.onerror = () => resolve();
img.src = file.dataURL;
}),
);
}
return Promise.all(promises).then(() => {});
}
function render() {
const canvas = canvasRef.value;
if (!canvas || !props.data) return;
const elements = (props.data.elements || []).filter(
(el) => !el.isDeleted,
) as DrawElement[];
if (elements.length === 0) return;
const [x1, y1, x2, y2] = getCommonBounds(elements);
const contentWidth = x2 - x1;
const contentHeight = y2 - y1;
if (contentWidth <= 0 || contentHeight <= 0) return;
const displayWidth = props.width;
const displayHeight = props.height;
const padding = 20;
const zoom = Math.min(
displayWidth / (contentWidth + padding * 2),
displayHeight / (contentHeight + padding * 2),
);
const scrollX = -x1 + padding + (displayWidth / zoom - contentWidth - padding * 2) / 2;
const scrollY = -y1 + padding + (displayHeight / zoom - contentHeight - padding * 2) / 2;
const appState: AppState = {
viewBackgroundColor:
props.data.appState?.viewBackgroundColor || 'transparent',
zoom: { value: zoom as NormalizedZoomValue },
scrollX,
scrollY,
width: displayWidth,
height: displayHeight,
offsetTop: 0,
offsetLeft: 0,
theme: props.data.appState?.theme || 'light',
gridModeEnabled: false,
gridSize: 20,
gridStep: 5,
frameRendering: { enabled: true, name: true, outline: true, clip: true },
exportBackground: true,
exportScale: 2,
exportWithDarkMode: false,
} as AppState;
renderStaticScene(
canvas,
elements,
appState,
props.data.files || {},
imageCache,
);
}
async function doRender() {
if (props.data?.files) {
await buildImageCache(props.data.files);
}
await nextTick();
render();
}
onMounted(async () => {
await loadDrawFonts();
await doRender();
});
watch(
() => [props.data, props.width, props.height],
() => doRender(),
{ deep: true },
);
defineExpose({ render: doRender });
</script>
<template>
<div
ref="containerRef"
class="draw-preview"
:style="{ width: `${width}px`, height: `${height}px` }"
>
<canvas ref="canvasRef" class="draw-preview__canvas" />
</div>
</template>
<style scoped>
.draw-preview {
overflow: hidden;
}
.draw-preview__canvas {
display: block;
}
</style>
@@ -0,0 +1,397 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import type { DrawData, DrawProps } from './types';
import { useDrawStore } from './store/draw-store';
import { useDrawEngine } from './composables/use-draw-engine';
import { useKeyboard } from './composables/use-keyboard';
import DrawCanvas from './components/DrawCanvas.vue';
import DrawToolbar from './components/DrawToolbar.vue';
import ZoomControls from './components/ZoomControls.vue';
import UndoRedoControls from './components/UndoRedoControls.vue';
import TextEditor from './components/TextEditor.vue';
import PropertyPanel from './components/PropertyPanel.vue';
import ContextMenu from './components/ContextMenu.vue';
import HyperlinkPopup from './components/HyperlinkPopup.vue';
import StatsPanel from './components/StatsPanel.vue';
import MainMenu from './components/MainMenu.vue';
import { $t } from '@vben/locales';
import { Magnet, BarChart, Minimize2 } from '@vben/icons';
import { exportToBlob } from './data/export-canvas';
import { exportToSvgString } from './data/export-svg';
import { serializeAsJSON } from './data/json';
import { loadDrawFonts } from './fonts';
const contextMenuRef = ref<InstanceType<typeof ContextMenu> | null>(null);
function onContextMenu(e: MouseEvent) {
e.preventDefault();
contextMenuRef.value?.show(e);
}
const props = withDefaults(defineProps<DrawProps>(), {
readonly: false,
width: '100%',
height: '100%',
});
const emit = defineEmits<{
(e: 'update:modelValue', data: DrawData): void;
(e: 'change', elements: any[], appState: any): void;
(e: 'save', data: DrawData): void;
}>();
const store = useDrawStore();
useDrawEngine({
initialData: props.modelValue ?? null,
readonly: props.readonly,
});
useKeyboard();
loadDrawFonts().then(() => {
store.requestRender();
});
// sync props
watch(
() => props.theme,
(val) => {
if (val) store.theme = val;
},
{ immediate: true },
);
watch(
() => props.gridMode,
(val) => {
if (val !== undefined) store.gridModeEnabled = val;
},
{ immediate: true },
);
watch(
() => props.zenMode,
(val) => {
if (val !== undefined) store.zenModeEnabled = val;
},
{ immediate: true },
);
watch(
() => props.readonly,
(val) => {
store.viewModeEnabled = !!val;
store.requestRender();
},
);
// watch model value changes from outside
watch(
() => props.modelValue,
(val) => {
if (val) {
store.loadDrawData(val);
}
},
);
// emit changes
watch(
() => store.sceneVersion,
() => {
const data = store.getDrawData();
emit('update:modelValue', data);
emit('change', [...data.elements], data.appState);
},
);
const containerStyle = ref({
width: typeof props.width === 'number' ? `${props.width}px` : props.width,
height: typeof props.height === 'number' ? `${props.height}px` : props.height,
});
// Exposed API
function getJSON(): string {
return serializeAsJSON(
store.scene.getElements(),
store.getAppState(),
store.files,
);
}
async function getThumbnail(
opts: { scale?: number; maxWidth?: number } = {},
): Promise<string> {
const blob = await exportToBlob(
store.scene.getNonDeletedElements(),
store.getAppState(),
store.files,
store.imageCache,
{ scale: opts.scale ?? 1 },
);
return URL.createObjectURL(blob);
}
function save(): void {
const data = store.getDrawData();
emit('save', data);
}
function getElements() {
return store.scene.getNonDeletedElements();
}
function getDrawData(): DrawData {
return store.getDrawData();
}
async function exportToPng(
opts: { background?: boolean; scale?: number; darkMode?: boolean } = {},
): Promise<Blob> {
const appState = store.getAppState();
const background = opts.background ?? appState.exportBackground;
const scale = opts.scale ?? appState.exportScale;
if (opts.darkMode ?? appState.exportWithDarkMode) {
appState.viewBackgroundColor = '#121212';
}
return exportToBlob(
store.scene.getNonDeletedElements(),
appState,
store.files,
store.imageCache,
{ background, scale },
);
}
function exportToSvg(
opts: { background?: boolean } = {},
): string {
const appState = store.getAppState();
const background = opts.background ?? appState.exportBackground;
return exportToSvgString(
store.scene.getNonDeletedElements(),
appState,
store.files,
{ background },
);
}
async function copyAsPng(
opts: { background?: boolean; scale?: number } = {},
): Promise<void> {
const blob = await exportToPng(opts);
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
}
async function copyAsSvg(
opts: { background?: boolean } = {},
): Promise<void> {
const svgStr = exportToSvg(opts);
await navigator.clipboard.writeText(svgStr);
}
defineExpose({
getJSON,
getThumbnail,
save,
getElements,
getDrawData,
exportToPng,
exportToSvg,
copyAsPng,
copyAsSvg,
store,
});
</script>
<template>
<div class="zq-draw" :style="containerStyle" @contextmenu="onContextMenu">
<DrawCanvas />
<TextEditor />
<PropertyPanel
:class="{ 'zq-draw-zen-hidden': store.zenModeEnabled }"
/>
<HyperlinkPopup />
<ContextMenu ref="contextMenuRef" />
<StatsPanel
:class="{ 'zq-draw-zen-hidden': store.zenModeEnabled }"
/>
<template v-if="!props.readonly">
<div
class="zq-draw-top-toolbar"
:class="{ 'zq-draw-zen-hidden': store.zenModeEnabled }"
>
<DrawToolbar />
</div>
<div
class="zq-draw-top-left"
:class="{ 'zq-draw-zen-hidden': store.zenModeEnabled }"
>
<MainMenu />
</div>
<div
class="zq-draw-bottom-left"
:class="{ 'zq-draw-zen-hidden': store.zenModeEnabled }"
>
<UndoRedoControls />
<ZoomControls />
<div class="zq-draw-toggle-group">
<button
class="zq-draw-toggle-btn"
:class="{ 'is-active': store.objectsSnapModeEnabled }"
:title="$t('draw.action.toggleSnap') + ' (Alt+S)'"
@click="store.objectsSnapModeEnabled = !store.objectsSnapModeEnabled"
>
<Magnet class="zq-draw-toggle-icon" />
</button>
<button
class="zq-draw-toggle-btn"
:class="{ 'is-active': store.showStats }"
:title="$t('draw.action.toggleStats') + ' (Alt+I)'"
@click="store.showStats = !store.showStats"
>
<BarChart class="zq-draw-toggle-icon" />
</button>
</div>
</div>
</template>
<template v-else>
<div class="zq-draw-bottom-left">
<ZoomControls />
</div>
</template>
<!-- Zen Mode exit hint -->
<div
v-if="store.zenModeEnabled && !props.readonly"
class="zq-draw-zen-exit"
:title="$t('draw.action.toggleZenMode') + ' (Alt+Z)'"
@click="store.zenModeEnabled = false"
>
<Minimize2 class="h-4 w-4" />
</div>
</div>
</template>
<style scoped lang="scss">
.zq-draw {
position: relative;
overflow: hidden;
background: var(--el-bg-color);
}
.zq-draw-top-toolbar {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
}
.zq-draw-top-left {
position: absolute;
top: 12px;
left: 12px;
z-index: 10;
}
.zq-draw-bottom-left {
position: absolute;
bottom: 12px;
left: 12px;
z-index: 10;
display: flex;
gap: 8px;
align-items: flex-end;
}
.zq-draw-toggle-group {
display: flex;
align-items: center;
gap: 2px;
padding: 4px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
.zq-draw-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
color: var(--el-text-color-regular);
transition: all 0.15s;
&:hover {
background: var(--el-fill-color-light);
}
&.is-active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
}
.zq-draw-toggle-icon {
width: 16px;
height: 16px;
}
.zq-draw-zen-hidden {
opacity: 0;
pointer-events: none;
transform: translateY(8px);
transition:
opacity 0.25s ease,
transform 0.25s ease;
}
.zq-draw-top-toolbar,
.zq-draw-top-left,
.zq-draw-bottom-left,
.zq-draw-property-panel {
transition:
opacity 0.25s ease,
transform 0.25s ease;
}
.zq-draw-zen-exit {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
cursor: pointer;
color: var(--el-text-color-regular);
opacity: 0.15;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
box-shadow: 0 2px 8px rgb(0 0 0 / 12%);
}
}
</style>
@@ -0,0 +1,436 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useI18n } from '@vben/locales';
import {
Copy,
Scissors,
Clipboard,
Trash2,
Layers,
Lock,
Unlock,
Group,
Ungroup,
FlipHorizontal2,
FlipVertical2,
Link,
ChevronRight,
AlignLeft,
Paintbrush,
} from '@vben/icons';
import { useDrawStore } from '../store/draw-store';
const { t } = useI18n();
const store = useDrawStore();
const visible = ref(false);
const menuX = ref(0);
const menuY = ref(0);
const expandedSub = ref<string | null>(null);
const hasSelection = computed(() => store.selectedElements.length > 0);
const isMultiSelect = computed(() => store.selectedElements.length > 1);
const isLocked = computed(() => store.isSelectedLocked);
const hasGroup = computed(() => {
return store.selectedElements.some((el) => el.groupIds.length > 0);
});
function show(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
visible.value = true;
menuX.value = e.clientX;
menuY.value = e.clientY;
expandedSub.value = null;
}
function hide() {
visible.value = false;
expandedSub.value = null;
}
function handleAction(action: string) {
hide();
switch (action) {
case 'copy':
store.copySelectedElements();
break;
case 'cut':
store.cutSelectedElements();
break;
case 'paste':
store.pasteFromClipboard();
break;
case 'duplicate':
store.duplicateSelectedElements();
break;
case 'delete':
store.deleteSelectedElementsWithBindings();
break;
case 'selectAll':
store.selectAll();
break;
case 'bringToFront':
store.bringToFront();
break;
case 'sendToBack':
store.sendToBack();
break;
case 'bringForward':
store.bringForward();
break;
case 'sendBackward':
store.sendBackward();
break;
case 'lock':
store.lockSelected();
break;
case 'unlock':
store.unlockSelected();
break;
case 'group':
store.groupSelected();
break;
case 'ungroup':
store.ungroupSelected();
break;
case 'flipH':
store.flipSelectedHorizontal();
break;
case 'flipV':
store.flipSelectedVertical();
break;
case 'addLink':
store.showHyperlinkPopup = 'editor';
break;
case 'alignLeft':
store.alignSelected({ position: 'start', axis: 'x' });
break;
case 'alignCenter':
store.alignSelected({ position: 'center', axis: 'x' });
break;
case 'alignRight':
store.alignSelected({ position: 'end', axis: 'x' });
break;
case 'alignTop':
store.alignSelected({ position: 'start', axis: 'y' });
break;
case 'alignMiddle':
store.alignSelected({ position: 'center', axis: 'y' });
break;
case 'alignBottom':
store.alignSelected({ position: 'end', axis: 'y' });
break;
case 'distributeH':
store.distributeSelected({ space: 'between', axis: 'x' });
break;
case 'distributeV':
store.distributeSelected({ space: 'between', axis: 'y' });
break;
case 'copyStyle':
store.copyStyle();
break;
case 'pasteStyle':
store.pasteStyle();
break;
case 'toggleGrid':
store.gridModeEnabled = !store.gridModeEnabled;
store.requestRender();
break;
}
}
function onDocumentClick() {
if (visible.value) hide();
}
onMounted(() => {
document.addEventListener('click', onDocumentClick);
});
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick);
});
defineExpose({ show });
</script>
<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed z-[9999] min-w-48 rounded-lg border border-border bg-popover py-1 text-sm text-popover-foreground shadow-lg"
:style="{ left: `${menuX}px`, top: `${menuY}px` }"
@click.stop
@contextmenu.prevent
>
<!-- With selection -->
<template v-if="hasSelection">
<button class="menu-item" @click="handleAction('copy')">
<Copy class="menu-icon" />
{{ t('draw.menu.copy') }}
<span class="menu-shortcut">Ctrl+C</span>
</button>
<button class="menu-item" @click="handleAction('cut')">
<Scissors class="menu-icon" />
{{ t('draw.menu.cut') }}
<span class="menu-shortcut">Ctrl+X</span>
</button>
<button class="menu-item" @click="handleAction('paste')">
<Clipboard class="menu-icon" />
{{ t('draw.menu.paste') }}
<span class="menu-shortcut">Ctrl+V</span>
</button>
<button class="menu-item" @click="handleAction('duplicate')">
<Copy class="menu-icon" />
{{ t('draw.menu.duplicate') }}
<span class="menu-shortcut">Ctrl+D</span>
</button>
<div class="menu-separator" />
<button class="menu-item" @click="handleAction('copyStyle')">
<Paintbrush class="menu-icon" />
{{ t('draw.menu.copyStyle') }}
<span class="menu-shortcut">Ctrl+Alt+C</span>
</button>
<button
class="menu-item"
:class="{ 'opacity-50 pointer-events-none': !store.copiedStyle }"
@click="handleAction('pasteStyle')"
>
<Paintbrush class="menu-icon" />
{{ t('draw.menu.pasteStyle') }}
<span class="menu-shortcut">Ctrl+Alt+V</span>
</button>
<div class="menu-separator" />
<!-- Layer submenu -->
<div
class="menu-item-sub"
@mouseenter="expandedSub = 'layer'"
@mouseleave="expandedSub = null"
>
<button class="menu-item w-full">
<Layers class="menu-icon" />
{{ t('draw.menu.layer') }}
<ChevronRight class="ml-auto h-3.5 w-3.5" />
</button>
<div v-if="expandedSub === 'layer'" class="submenu">
<button class="menu-item" @click="handleAction('bringToFront')">
{{ t('draw.menu.bringToFront') }}
<span class="menu-shortcut">Ctrl+Shift+]</span>
</button>
<button class="menu-item" @click="handleAction('bringForward')">
{{ t('draw.menu.bringForward') }}
<span class="menu-shortcut">Ctrl+]</span>
</button>
<button class="menu-item" @click="handleAction('sendBackward')">
{{ t('draw.menu.sendBackward') }}
<span class="menu-shortcut">Ctrl+[</span>
</button>
<button class="menu-item" @click="handleAction('sendToBack')">
{{ t('draw.menu.sendToBack') }}
<span class="menu-shortcut">Ctrl+Shift+[</span>
</button>
</div>
</div>
<!-- Flip submenu -->
<div
class="menu-item-sub"
@mouseenter="expandedSub = 'flip'"
@mouseleave="expandedSub = null"
>
<button class="menu-item w-full">
<FlipHorizontal2 class="menu-icon" />
{{ t('draw.menu.flip') }}
<ChevronRight class="ml-auto h-3.5 w-3.5" />
</button>
<div v-if="expandedSub === 'flip'" class="submenu">
<button class="menu-item" @click="handleAction('flipH')">
<FlipHorizontal2 class="menu-icon" />
{{ t('draw.menu.flipH') }}
</button>
<button class="menu-item" @click="handleAction('flipV')">
<FlipVertical2 class="menu-icon" />
{{ t('draw.menu.flipV') }}
</button>
</div>
</div>
<!-- Align submenu (multi-select only) -->
<div
v-if="isMultiSelect"
class="menu-item-sub"
@mouseenter="expandedSub = 'align'"
@mouseleave="expandedSub = null"
>
<button class="menu-item w-full">
<AlignLeft class="menu-icon" />
{{ t('draw.menu.align') }}
<ChevronRight class="ml-auto h-3.5 w-3.5" />
</button>
<div v-if="expandedSub === 'align'" class="submenu">
<button class="menu-item" @click="handleAction('alignLeft')">
{{ t('draw.menu.alignLeft') }}
</button>
<button class="menu-item" @click="handleAction('alignCenter')">
{{ t('draw.menu.alignCenter') }}
</button>
<button class="menu-item" @click="handleAction('alignRight')">
{{ t('draw.menu.alignRight') }}
</button>
<div class="menu-separator" />
<button class="menu-item" @click="handleAction('alignTop')">
{{ t('draw.menu.alignTop') }}
</button>
<button class="menu-item" @click="handleAction('alignMiddle')">
{{ t('draw.menu.alignMiddle') }}
</button>
<button class="menu-item" @click="handleAction('alignBottom')">
{{ t('draw.menu.alignBottom') }}
</button>
<div class="menu-separator" />
<button class="menu-item" @click="handleAction('distributeH')">
{{ t('draw.menu.distributeH') }}
</button>
<button class="menu-item" @click="handleAction('distributeV')">
{{ t('draw.menu.distributeV') }}
</button>
</div>
</div>
<div class="menu-separator" />
<!-- Group / Ungroup -->
<button
v-if="isMultiSelect"
class="menu-item"
@click="handleAction('group')"
>
<Group class="menu-icon" />
{{ t('draw.menu.group') }}
<span class="menu-shortcut">Ctrl+G</span>
</button>
<button
v-if="hasGroup"
class="menu-item"
@click="handleAction('ungroup')"
>
<Ungroup class="menu-icon" />
{{ t('draw.menu.ungroup') }}
<span class="menu-shortcut">Ctrl+Shift+G</span>
</button>
<!-- Lock / Unlock -->
<button
v-if="!isLocked"
class="menu-item"
@click="handleAction('lock')"
>
<Lock class="menu-icon" />
{{ t('draw.menu.lock') }}
<span class="menu-shortcut">Ctrl+L</span>
</button>
<button
v-if="isLocked"
class="menu-item"
@click="handleAction('unlock')"
>
<Unlock class="menu-icon" />
{{ t('draw.menu.unlock') }}
</button>
<!-- Hyperlink -->
<button class="menu-item" @click="handleAction('addLink')">
<Link class="menu-icon" />
{{ t('draw.menu.addLink') }}
<span class="menu-shortcut">Ctrl+K</span>
</button>
<div class="menu-separator" />
<button class="menu-item text-destructive" @click="handleAction('delete')">
<Trash2 class="menu-icon" />
{{ t('draw.menu.delete') }}
<span class="menu-shortcut">Del</span>
</button>
</template>
<!-- No selection (canvas context menu) -->
<template v-else>
<button class="menu-item" @click="handleAction('paste')">
<Clipboard class="menu-icon" />
{{ t('draw.menu.paste') }}
<span class="menu-shortcut">Ctrl+V</span>
</button>
<button class="menu-item" @click="handleAction('selectAll')">
{{ t('draw.menu.selectAll') }}
<span class="menu-shortcut">Ctrl+A</span>
</button>
<div class="menu-separator" />
<button class="menu-item" @click="handleAction('toggleGrid')">
{{ t('draw.menu.toggleGrid') }}
</button>
</template>
</div>
</Teleport>
</template>
<style scoped>
.menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
text-align: left;
cursor: pointer;
border: none;
background: none;
font-size: 13px;
white-space: nowrap;
}
.menu-item:hover {
background-color: hsl(var(--accent));
}
.menu-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.menu-shortcut {
margin-left: auto;
font-size: 11px;
opacity: 0.5;
}
.menu-separator {
height: 1px;
margin: 4px 0;
background-color: hsl(var(--border));
}
.menu-item-sub {
position: relative;
}
.submenu {
position: absolute;
left: 100%;
top: 0;
min-width: 180px;
border-radius: 8px;
border: 1px solid hsl(var(--border));
background-color: hsl(var(--popover));
padding: 4px 0;
box-shadow: 0 4px 12px rgb(0 0 0 / 10%);
z-index: 1;
}
</style>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { useDrawStore } from '../store/draw-store';
import { useCanvasResize } from '../composables/use-canvas-resize';
import { useScrollWheel } from '../composables/use-scroll-wheel';
import { usePointerEvents } from '../composables/use-pointer-events';
const containerRef = ref<HTMLElement | null>(null);
const staticCanvasRef = ref<HTMLCanvasElement | null>(null);
const interactiveCanvasRef = ref<HTMLCanvasElement | null>(null);
const store = useDrawStore();
useCanvasResize(containerRef);
useScrollWheel(containerRef);
const pointer = usePointerEvents(containerRef);
onMounted(() => {
store.staticCanvas = staticCanvasRef.value;
store.interactiveCanvas = interactiveCanvasRef.value;
pointer.attach();
store.requestRender();
});
onBeforeUnmount(() => {
pointer.detach();
});
watch(
() => store.sceneVersion,
() => {
store.requestRender();
},
);
</script>
<template>
<div ref="containerRef" class="zq-draw-canvas-container">
<canvas ref="staticCanvasRef" class="zq-draw-static-canvas" />
<canvas ref="interactiveCanvasRef" class="zq-draw-interactive-canvas" />
</div>
</template>
<style scoped lang="scss">
.zq-draw-canvas-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
touch-action: none;
}
.zq-draw-static-canvas,
.zq-draw-interactive-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.zq-draw-interactive-canvas {
z-index: 1;
}
</style>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@vben/locales';
import {
MousePointer2,
Hand,
Square,
Circle,
Diamond,
Minus,
MoveRight,
Pencil,
Type,
Image,
Eraser,
Frame,
} from '@vben/icons';
import { useDrawStore } from '../store/draw-store';
import type { ToolType } from '../types';
const store = useDrawStore();
interface ToolItem {
type: ToolType;
icon: any;
labelKey: string;
shortcut: string;
}
const tools: ToolItem[] = [
{ type: 'selection', icon: MousePointer2, labelKey: 'draw.tool.selection', shortcut: 'V' },
{ type: 'hand', icon: Hand, labelKey: 'draw.tool.hand', shortcut: 'H' },
{ type: 'rectangle', icon: Square, labelKey: 'draw.tool.rectangle', shortcut: 'R' },
{ type: 'ellipse', icon: Circle, labelKey: 'draw.tool.ellipse', shortcut: 'O' },
{ type: 'diamond', icon: Diamond, labelKey: 'draw.tool.diamond', shortcut: 'D' },
{ type: 'line', icon: Minus, labelKey: 'draw.tool.line', shortcut: 'L' },
{ type: 'arrow', icon: MoveRight, labelKey: 'draw.tool.arrow', shortcut: 'A' },
{ type: 'freedraw', icon: Pencil, labelKey: 'draw.tool.freedraw', shortcut: 'P' },
{ type: 'text', icon: Type, labelKey: 'draw.tool.text', shortcut: 'T' },
{ type: 'image', icon: Image, labelKey: 'draw.tool.image', shortcut: 'I' },
{ type: 'eraser', icon: Eraser, labelKey: 'draw.tool.eraser', shortcut: 'E' },
{ type: 'frame', icon: Frame, labelKey: 'draw.tool.frame', shortcut: 'F' },
];
const currentTool = computed(() => store.activeTool.type);
function selectTool(type: ToolType) {
store.setActiveTool(type);
}
</script>
<template>
<div class="zq-draw-toolbar">
<div
v-for="tool in tools"
:key="tool.type"
class="zq-draw-toolbar-item"
:class="{ 'is-active': currentTool === tool.type }"
:title="`${$t(tool.labelKey)} (${tool.shortcut})`"
@click="selectTool(tool.type)"
>
<component :is="tool.icon" class="zq-draw-toolbar-icon" />
</div>
</div>
</template>
<style scoped lang="scss">
.zq-draw-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 10px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
.zq-draw-toolbar-item {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
cursor: pointer;
color: var(--el-text-color-regular);
transition: all 0.15s;
&:hover {
background: var(--el-fill-color-light);
}
&.is-active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
}
.zq-draw-toolbar-icon {
width: 18px;
height: 18px;
}
</style>
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from '@vben/locales';
import { ElInput } from 'element-plus';
import { Link, ExternalLink, Trash2, X } from '@vben/icons';
import { useDrawStore } from '../store/draw-store';
const { t } = useI18n();
const store = useDrawStore();
const linkValue = ref('');
const inputRef = ref<InstanceType<typeof ElInput> | null>(null);
const isVisible = computed(() => store.showHyperlinkPopup !== false);
const isEditor = computed(() => store.showHyperlinkPopup === 'editor');
const isInfo = computed(() => store.showHyperlinkPopup === 'info');
const selectedElement = computed(() => {
if (store.selectedElements.length !== 1) return null;
return store.selectedElements[0]!;
});
const currentLink = computed(() => selectedElement.value?.link || '');
watch(
() => store.showHyperlinkPopup,
(val) => {
if (val === 'editor') {
linkValue.value = currentLink.value;
nextTick(() => {
inputRef.value?.focus();
});
}
},
);
function saveLink() {
if (!selectedElement.value) return;
const link = linkValue.value.trim() || null;
store.setElementLink(selectedElement.value.id, link);
store.showHyperlinkPopup = link ? 'info' : false;
}
function removeLink() {
if (!selectedElement.value) return;
store.setElementLink(selectedElement.value.id, null);
store.showHyperlinkPopup = false;
}
function openLink() {
if (currentLink.value) {
window.open(currentLink.value, '_blank', 'noopener,noreferrer');
}
}
function editLink() {
store.showHyperlinkPopup = 'editor';
}
function close() {
store.showHyperlinkPopup = false;
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
saveLink();
}
if (e.key === 'Escape') {
e.preventDefault();
close();
}
}
</script>
<template>
<div
v-if="isVisible && selectedElement"
class="absolute left-1/2 top-2 z-30 -translate-x-1/2 rounded-lg border border-border bg-card p-3 shadow-lg"
@click.stop
>
<!-- Editor mode -->
<div v-if="isEditor" class="flex items-center gap-2">
<Link class="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<ElInput
ref="inputRef"
v-model="linkValue"
size="small"
:placeholder="t('draw.property.linkPlaceholder')"
class="w-60"
@keydown="onKeyDown"
/>
<button
class="rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground hover:opacity-90"
@click="saveLink"
>
OK
</button>
<button
class="rounded-md p-1 text-muted-foreground hover:bg-accent"
@click="close"
>
<X class="h-4 w-4" />
</button>
</div>
<!-- Info mode -->
<div v-if="isInfo" class="flex items-center gap-2">
<Link class="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<a
:href="currentLink"
class="max-w-60 truncate text-sm text-primary underline"
target="_blank"
rel="noopener noreferrer"
@click.prevent="openLink"
>
{{ currentLink }}
</a>
<button
class="rounded-md p-1 text-muted-foreground hover:bg-accent"
:title="t('draw.menu.editLink')"
@click="editLink"
>
<ExternalLink class="h-3.5 w-3.5" />
</button>
<button
class="rounded-md p-1 text-destructive hover:bg-accent"
:title="t('draw.menu.removeLink')"
@click="removeLink"
>
<Trash2 class="h-3.5 w-3.5" />
</button>
<button
class="rounded-md p-1 text-muted-foreground hover:bg-accent"
@click="close"
>
<X class="h-4 w-4" />
</button>
</div>
</div>
</template>
@@ -0,0 +1,336 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { $t } from '@vben/locales';
import {
Menu,
FolderOpen,
Save,
Download,
FileImage,
FileJson,
FileCode,
Copy,
ChevronRight,
} from '@vben/icons';
import { useDrawStore } from '../store/draw-store';
import { serializeAsJSON, deserializeFromJSON } from '../data/json';
import { exportToBlob } from '../data/export-canvas';
import { exportToSvgString } from '../data/export-svg';
const store = useDrawStore();
const exportSubOpen = ref(false);
const menuRef = ref<HTMLElement | null>(null);
function toggleMenu() {
store.mainMenuOpen = !store.mainMenuOpen;
if (!store.mainMenuOpen) {
exportSubOpen.value = false;
}
}
function closeMenu() {
store.mainMenuOpen = false;
exportSubOpen.value = false;
}
function onClickOutside(e: MouseEvent) {
if (menuRef.value && !menuRef.value.contains(e.target as Node)) {
closeMenu();
}
}
onMounted(() => {
document.addEventListener('mousedown', onClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onClickOutside);
});
function handleOpen() {
closeMenu();
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,.excalidraw';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const text = await file.text();
const data = deserializeFromJSON(text);
if (data) {
store.loadDrawData(data);
}
};
input.click();
}
function handleSaveTo() {
closeMenu();
const json = serializeAsJSON(
store.scene.getElements(),
store.getAppState(),
store.files,
);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${store.getAppState().name || 'drawing'}.zqdraw.json`;
a.click();
URL.revokeObjectURL(url);
}
async function handleExportPng() {
closeMenu();
const appState = store.getAppState();
const blob = await exportToBlob(
store.scene.getNonDeletedElements(),
appState,
store.files,
store.imageCache,
{ scale: appState.exportScale, background: appState.exportBackground },
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${appState.name || 'drawing'}.png`;
a.click();
URL.revokeObjectURL(url);
}
function handleExportSvg() {
closeMenu();
const appState = store.getAppState();
const svgStr = exportToSvgString(
store.scene.getNonDeletedElements(),
appState,
store.files,
{ background: appState.exportBackground },
);
const blob = new Blob([svgStr], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${appState.name || 'drawing'}.svg`;
a.click();
URL.revokeObjectURL(url);
}
function handleExportJson() {
closeMenu();
handleSaveTo();
}
async function handleCopyPng() {
closeMenu();
const appState = store.getAppState();
const blob = await exportToBlob(
store.scene.getNonDeletedElements(),
appState,
store.files,
store.imageCache,
{ scale: appState.exportScale, background: appState.exportBackground },
);
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
}
async function handleCopySvg() {
closeMenu();
const appState = store.getAppState();
const svgStr = exportToSvgString(
store.scene.getNonDeletedElements(),
appState,
store.files,
{ background: appState.exportBackground },
);
await navigator.clipboard.writeText(svgStr);
}
</script>
<template>
<div ref="menuRef" class="zq-draw-main-menu">
<button
class="zq-draw-main-menu-trigger"
:title="$t('draw.menu.openFile')"
@click="toggleMenu"
>
<Menu class="h-[18px] w-[18px]" />
</button>
<Transition name="zq-menu-fade">
<div v-if="store.mainMenuOpen" class="zq-draw-main-menu-dropdown">
<button class="zq-menu-item" @click="handleOpen">
<FolderOpen class="zq-menu-item-icon" />
<span>{{ $t('draw.menu.openFile') }}</span>
<span class="zq-menu-item-shortcut">Ctrl+O</span>
</button>
<button class="zq-menu-item" @click="handleSaveTo">
<Save class="zq-menu-item-icon" />
<span>{{ $t('draw.menu.saveTo') }}</span>
<span class="zq-menu-item-shortcut">Ctrl+Shift+S</span>
</button>
<div class="zq-menu-separator" />
<div
class="zq-menu-sub"
@mouseenter="exportSubOpen = true"
@mouseleave="exportSubOpen = false"
>
<button class="zq-menu-item">
<Download class="zq-menu-item-icon" />
<span>{{ $t('draw.menu.export') }}</span>
<ChevronRight class="zq-menu-item-arrow" />
</button>
<Transition name="zq-menu-fade">
<div v-if="exportSubOpen" class="zq-draw-main-menu-submenu">
<button class="zq-menu-item" @click="handleExportPng">
<FileImage class="zq-menu-item-icon" />
<span>{{ $t('draw.menu.exportPng') }}</span>
</button>
<button class="zq-menu-item" @click="handleExportSvg">
<FileCode class="zq-menu-item-icon" />
<span>{{ $t('draw.menu.exportSvg') }}</span>
</button>
<button class="zq-menu-item" @click="handleExportJson">
<FileJson class="zq-menu-item-icon" />
<span>{{ $t('draw.menu.exportJson') }}</span>
</button>
<div class="zq-menu-separator" />
<button class="zq-menu-item" @click="handleCopyPng">
<Copy class="zq-menu-item-icon" />
<span>{{ $t('draw.menu.exportCopyPng') }}</span>
</button>
<button class="zq-menu-item" @click="handleCopySvg">
<Copy class="zq-menu-item-icon" />
<span>{{ $t('draw.menu.exportCopySvg') }}</span>
</button>
</div>
</Transition>
</div>
</div>
</Transition>
</div>
</template>
<style scoped lang="scss">
.zq-draw-main-menu {
position: relative;
}
.zq-draw-main-menu-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
background: var(--el-bg-color);
cursor: pointer;
color: var(--el-text-color-regular);
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
transition: all 0.15s;
&:hover {
background: var(--el-fill-color-light);
}
}
.zq-draw-main-menu-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 220px;
padding: 4px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
z-index: 100;
}
.zq-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--el-text-color-regular);
font-size: 13px;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
&:hover {
background: var(--el-fill-color-light);
}
}
.zq-menu-item-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--el-text-color-secondary);
}
.zq-menu-item-shortcut {
margin-left: auto;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.zq-menu-item-arrow {
width: 14px;
height: 14px;
margin-left: auto;
flex-shrink: 0;
color: var(--el-text-color-placeholder);
}
.zq-menu-separator {
height: 1px;
margin: 4px 8px;
background: var(--el-border-color-lighter);
}
.zq-menu-sub {
position: relative;
}
.zq-draw-main-menu-submenu {
position: absolute;
top: -4px;
left: calc(100% + 4px);
min-width: 200px;
padding: 4px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
z-index: 101;
}
.zq-menu-fade-enter-active,
.zq-menu-fade-leave-active {
transition:
opacity 0.15s ease,
transform 0.15s ease;
}
.zq-menu-fade-enter-from,
.zq-menu-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
@@ -0,0 +1,972 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from '@vben/locales';
import { ElSlider } from 'element-plus';
import {
Link,
Minus as MinusIcon,
Plus as PlusIcon,
AlignStartHorizontal,
AlignCenterHorizontal,
AlignEndHorizontal,
AlignStartVertical,
AlignCenterVertical,
AlignEndVertical,
AlignHorizontalSpaceAround,
AlignVerticalSpaceAround,
ArrowDownToLine,
ArrowDown,
ArrowUp,
ArrowUpToLine,
Copy,
Trash2,
} from '@vben/icons';
import { useDrawStore } from '../store/draw-store';
import type {
FillStyle,
StrokeStyle,
Arrowhead,
DrawLinearElement,
DrawArrowElement,
DrawTextElement,
TextAlign,
VerticalAlign,
} from '../types';
import {
PRESET_STROKE_COLORS,
PRESET_BACKGROUND_COLORS,
FONT_FAMILY,
FONT_METADATA,
ROUNDNESS,
} from '../constants';
import {
FillHachureIcon,
FillCrossHatchIcon,
FillSolidIcon,
FillZigZagIcon,
StrokeWidthThinIcon,
StrokeWidthBoldIcon,
StrokeWidthExtraBoldIcon,
StrokeStyleSolidIcon,
StrokeStyleDashedIcon,
StrokeStyleDottedIcon,
SloppinessArchitectIcon,
SloppinessArtistIcon,
SloppinessCartoonistIcon,
EdgeSharpIcon,
EdgeRoundIcon,
ArrowTypeSharpIcon,
ArrowTypeRoundIcon,
ArrowTypeElbowIcon,
ArrowheadNoneIcon,
ArrowheadArrowIcon,
ArrowheadTriangleIcon,
ArrowheadBarIcon,
ArrowheadCircleIcon,
ArrowheadDiamondIcon,
ArrowheadArrowStartIcon,
ArrowheadTriangleStartIcon,
ArrowheadBarStartIcon,
ArrowheadCircleStartIcon,
ArrowheadDiamondStartIcon,
ArrowheadNoneStartIcon,
FontHandDrawnIcon,
FontNormalIcon,
FontCodeIcon,
TextAlignLeftIcon,
TextAlignCenterIcon,
TextAlignRightIcon,
VerticalAlignTopIcon,
VerticalAlignMiddleIcon,
VerticalAlignBottomIcon,
} from './icons/property-icons';
import {
redrawTextBoundingBox,
getContainerElement,
getBoundTextElement,
} from '../elements/bound-text';
const { t } = useI18n();
const store = useDrawStore();
const strokeColorInputRef = ref<HTMLInputElement | null>(null);
const bgColorInputRef = ref<HTMLInputElement | null>(null);
function openStrokeColorPicker() {
strokeColorInputRef.value?.click();
}
function onStrokeColorInput(e: Event) {
const val = (e.target as HTMLInputElement).value;
if (val) strokeColor.value = val;
}
function openBgColorPicker() {
bgColorInputRef.value?.click();
}
function onBgColorInput(e: Event) {
const val = (e.target as HTMLInputElement).value;
if (val) backgroundColor.value = val;
}
const isDrawingTool = computed(() => {
const t = store.activeTool.type;
return ['rectangle', 'ellipse', 'diamond', 'freedraw', 'arrow', 'line'].includes(t);
});
const showPanel = computed(() => {
return !store.viewModeEnabled && !store.mainMenuOpen && (store.selectedElements.length > 0 || isDrawingTool.value);
});
const isSingleSelect = computed(() => store.selectedElements.length === 1);
const isMultiSelect = computed(() => store.selectedElements.length > 1);
const primaryElement = computed(() => {
if (store.selectedElements.length === 0) return null;
return store.selectedElements[0]!;
});
const activeToolType = computed(() => store.activeTool.type);
const effectiveType = computed(() => {
if (primaryElement.value) return primaryElement.value.type;
if (isDrawingTool.value) return activeToolType.value;
return null;
});
const hasStrokeColor = computed(() => {
if (!effectiveType.value) return false;
return ['rectangle', 'ellipse', 'diamond', 'freedraw', 'arrow', 'line', 'text'].includes(effectiveType.value);
});
const hasBackground = computed(() => {
if (!effectiveType.value) return false;
return ['rectangle', 'ellipse', 'diamond', 'line', 'freedraw'].includes(effectiveType.value);
});
const showFillStyle = computed(() => {
if (!hasBackground.value) return false;
if (primaryElement.value) return primaryElement.value.backgroundColor !== 'transparent';
return store.currentItemBackgroundColor !== 'transparent';
});
const hasStrokeWidth = computed(() => {
if (!effectiveType.value) return false;
return ['rectangle', 'ellipse', 'diamond', 'freedraw', 'arrow', 'line'].includes(effectiveType.value);
});
const hasStrokeStyle = computed(() => {
if (!effectiveType.value) return false;
return ['rectangle', 'ellipse', 'diamond', 'arrow', 'line'].includes(effectiveType.value);
});
const canChangeRoundness = computed(() => {
if (!effectiveType.value) return false;
return ['rectangle', 'diamond', 'line', 'arrow', 'image'].includes(effectiveType.value);
});
const isLinearElement = computed(() => {
return effectiveType.value === 'arrow' || effectiveType.value === 'line';
});
const isArrowElement = computed(() => effectiveType.value === 'arrow');
const isTextOrHasText = computed(() => {
if (!primaryElement.value) return false;
if (primaryElement.value.type === 'text') return true;
return primaryElement.value.boundElements?.some((b) => b.type === 'text') ?? false;
});
const textElement = computed((): DrawTextElement | null => {
if (!primaryElement.value) return null;
if (primaryElement.value.type === 'text') return primaryElement.value as DrawTextElement;
const bound = primaryElement.value.boundElements?.find((b) => b.type === 'text');
if (bound) {
const el = store.scene.getElement(bound.id);
if (el && !el.isDeleted && el.type === 'text') return el as DrawTextElement;
}
return null;
});
// ---- Property values ----
const strokeColor = computed({
get: () => primaryElement.value?.strokeColor ?? store.currentItemStrokeColor,
set: (val: string) => {
store.recordHistory();
store.currentItemStrokeColor = val;
for (const el of store.selectedElements) {
store.updateElement(el.id, { strokeColor: val } as any);
}
},
});
const backgroundColor = computed({
get: () => primaryElement.value?.backgroundColor ?? store.currentItemBackgroundColor,
set: (val: string) => {
store.recordHistory();
store.currentItemBackgroundColor = val;
for (const el of store.selectedElements) {
store.updateElement(el.id, { backgroundColor: val } as any);
}
},
});
const fillStyle = computed({
get: () => (primaryElement.value?.fillStyle ?? store.currentItemFillStyle) as FillStyle,
set: (val: FillStyle) => {
store.recordHistory();
store.currentItemFillStyle = val;
for (const el of store.selectedElements) {
store.updateElement(el.id, { fillStyle: val } as any);
}
},
});
const strokeWidth = computed({
get: () => primaryElement.value?.strokeWidth ?? store.currentItemStrokeWidth,
set: (val: number) => {
store.recordHistory();
store.currentItemStrokeWidth = val;
for (const el of store.selectedElements) {
store.updateElement(el.id, { strokeWidth: val } as any);
}
},
});
const strokeStyle = computed({
get: () => (primaryElement.value?.strokeStyle ?? store.currentItemStrokeStyle) as StrokeStyle,
set: (val: StrokeStyle) => {
store.recordHistory();
store.currentItemStrokeStyle = val;
for (const el of store.selectedElements) {
store.updateElement(el.id, { strokeStyle: val } as any);
}
},
});
const roughness = computed({
get: () => primaryElement.value?.roughness ?? store.currentItemRoughness,
set: (val: number) => {
store.recordHistory();
store.currentItemRoughness = val;
for (const el of store.selectedElements) {
store.updateElement(el.id, { roughness: val } as any);
}
},
});
const isRound = computed({
get: () => {
if (!primaryElement.value) return true;
return primaryElement.value.roundness != null;
},
set: (val: boolean) => {
store.recordHistory();
for (const el of store.selectedElements) {
let roundness = null;
if (val) {
const isLinear = el.type === 'line' || el.type === 'arrow';
roundness = {
type: isLinear
? ROUNDNESS.PROPORTIONAL_RADIUS
: ROUNDNESS.ADAPTIVE_RADIUS,
};
}
store.updateElement(el.id, { roundness } as any);
}
},
});
const opacity = computed({
get: () => primaryElement.value?.opacity ?? store.currentItemOpacity,
set: (val: number) => {
store.recordHistory();
store.currentItemOpacity = val;
for (const el of store.selectedElements) {
store.updateElement(el.id, { opacity: val } as any);
}
},
});
const arrowType = computed({
get: () => {
if (!isArrowElement.value) return 'round';
return (primaryElement.value as DrawArrowElement)?.elbowed ? 'elbow' : store.currentItemArrowType;
},
set: (val: string) => {
store.recordHistory();
store.currentItemArrowType = val as any;
for (const el of store.selectedElements) {
if (el.type === 'arrow') {
store.updateElement(el.id, { elbowed: val === 'elbow' } as any);
if (val === 'elbow') store.updateBindingsAfterMove(el.id);
}
}
},
});
const startArrowhead = computed({
get: () => {
if (!isLinearElement.value) return null;
return (primaryElement.value as DrawLinearElement)?.startArrowhead ?? null;
},
set: (val: Arrowhead | null) => {
store.recordHistory();
store.currentItemStartArrowhead = val;
for (const el of store.selectedElements) {
if (el.type === 'arrow' || el.type === 'line') {
store.updateElement(el.id, { startArrowhead: val } as any);
}
}
},
});
const endArrowhead = computed({
get: () => {
if (!isLinearElement.value) return null;
return (primaryElement.value as DrawLinearElement)?.endArrowhead ?? null;
},
set: (val: Arrowhead | null) => {
store.recordHistory();
store.currentItemEndArrowhead = val;
for (const el of store.selectedElements) {
if (el.type === 'arrow' || el.type === 'line') {
store.updateElement(el.id, { endArrowhead: val } as any);
}
}
},
});
function updateTextProperty(updates: Partial<DrawTextElement>) {
for (const el of store.selectedElements) {
if (el.type === 'text') {
store.updateElement(el.id, updates as any);
const textEl = store.scene.getElement(el.id) as DrawTextElement;
if (textEl) {
const container = getContainerElement(textEl, store.scene);
redrawTextBoundingBox(textEl, container, store.scene);
}
} else {
const boundText = getBoundTextElement(el, store.scene);
if (boundText) {
store.updateElement(boundText.id, updates as any);
const updated = store.scene.getElement(boundText.id) as DrawTextElement;
if (updated) redrawTextBoundingBox(updated, el, store.scene);
}
}
}
store.requestRender();
}
const fontFamily = computed({
get: () => textElement.value?.fontFamily ?? store.currentItemFontFamily,
set: (val: number) => {
store.recordHistory();
store.currentItemFontFamily = val;
const lineHeight = FONT_METADATA[val]?.lineHeight ?? 1.25;
updateTextProperty({ fontFamily: val, lineHeight } as Partial<DrawTextElement>);
},
});
const fontSize = computed({
get: () => textElement.value?.fontSize ?? store.currentItemFontSize,
set: (val: number) => {
store.recordHistory();
store.currentItemFontSize = val;
updateTextProperty({ fontSize: val } as Partial<DrawTextElement>);
},
});
const textAlign = computed({
get: () => (textElement.value?.textAlign ?? 'left') as TextAlign,
set: (val: TextAlign) => {
store.recordHistory();
updateTextProperty({ textAlign: val } as Partial<DrawTextElement>);
},
});
const verticalAlign = computed({
get: () => (textElement.value?.verticalAlign ?? 'top') as VerticalAlign,
set: (val: VerticalAlign) => {
store.recordHistory();
updateTextProperty({ verticalAlign: val } as Partial<DrawTextElement>);
},
});
// ---- Options data ----
const fillStyleOptions = [
{ value: 'hachure' as FillStyle, icon: FillHachureIcon, label: 'draw.fillStyle.hachure' },
{ value: 'cross-hatch' as FillStyle, icon: FillCrossHatchIcon, label: 'draw.fillStyle.crossHatch' },
{ value: 'solid' as FillStyle, icon: FillSolidIcon, label: 'draw.fillStyle.solid' },
{ value: 'zigzag' as FillStyle, icon: FillZigZagIcon, label: 'draw.fillStyle.zigzag' },
];
const strokeWidthOptions = [
{ value: 1, icon: StrokeWidthThinIcon, label: 'draw.label.thin' },
{ value: 2, icon: StrokeWidthBoldIcon, label: 'draw.label.bold' },
{ value: 4, icon: StrokeWidthExtraBoldIcon, label: 'draw.label.extraBold' },
];
const strokeStyleOptions = [
{ value: 'solid' as StrokeStyle, icon: StrokeStyleSolidIcon, label: 'draw.strokeStyle.solid' },
{ value: 'dashed' as StrokeStyle, icon: StrokeStyleDashedIcon, label: 'draw.strokeStyle.dashed' },
{ value: 'dotted' as StrokeStyle, icon: StrokeStyleDottedIcon, label: 'draw.strokeStyle.dotted' },
];
const sloppinessOptions = [
{ value: 0, icon: SloppinessArchitectIcon, label: 'draw.label.architect' },
{ value: 1, icon: SloppinessArtistIcon, label: 'draw.label.artist' },
{ value: 2, icon: SloppinessCartoonistIcon, label: 'draw.label.cartoonist' },
];
const arrowTypeOptions = [
{ value: 'sharp', icon: ArrowTypeSharpIcon, label: 'draw.property.arrowTypeSharp' },
{ value: 'round', icon: ArrowTypeRoundIcon, label: 'draw.property.arrowTypeRound' },
{ value: 'elbow', icon: ArrowTypeElbowIcon, label: 'draw.property.arrowTypeElbow' },
];
const startArrowheadOptions = [
{ value: null as Arrowhead | null, icon: ArrowheadNoneStartIcon, label: 'draw.arrowhead.none' },
{ value: 'arrow' as Arrowhead, icon: ArrowheadArrowStartIcon, label: 'draw.arrowhead.arrow' },
{ value: 'triangle' as Arrowhead, icon: ArrowheadTriangleStartIcon, label: 'draw.arrowhead.triangle' },
{ value: 'bar' as Arrowhead, icon: ArrowheadBarStartIcon, label: 'draw.arrowhead.bar' },
{ value: 'circle' as Arrowhead, icon: ArrowheadCircleStartIcon, label: 'draw.arrowhead.circle' },
{ value: 'diamond' as Arrowhead, icon: ArrowheadDiamondStartIcon, label: 'draw.arrowhead.diamond' },
];
const endArrowheadOptions = [
{ value: null as Arrowhead | null, icon: ArrowheadNoneIcon, label: 'draw.arrowhead.none' },
{ value: 'arrow' as Arrowhead, icon: ArrowheadArrowIcon, label: 'draw.arrowhead.arrow' },
{ value: 'triangle' as Arrowhead, icon: ArrowheadTriangleIcon, label: 'draw.arrowhead.triangle' },
{ value: 'bar' as Arrowhead, icon: ArrowheadBarIcon, label: 'draw.arrowhead.bar' },
{ value: 'circle' as Arrowhead, icon: ArrowheadCircleIcon, label: 'draw.arrowhead.circle' },
{ value: 'diamond' as Arrowhead, icon: ArrowheadDiamondIcon, label: 'draw.arrowhead.diamond' },
];
const fontFamilyOptions = [
{ value: FONT_FAMILY.Excalifont, icon: FontHandDrawnIcon, label: 'draw.font.handDrawn' },
{ value: FONT_FAMILY.Nunito, icon: FontNormalIcon, label: 'draw.font.normal' },
{ value: FONT_FAMILY['Comic Shanns'], icon: FontCodeIcon, label: 'draw.font.code' },
];
const fontSizeOptions = [16, 20, 28, 36, 48, 64];
const hasLink = computed(() => {
return primaryElement.value?.link != null && primaryElement.value.link !== '';
});
const canDistribute = computed(() => store.selectedElements.length >= 3);
function openHyperlinkEditor() {
store.showHyperlinkPopup = 'editor';
}
</script>
<template>
<div
v-if="showPanel"
class="zq-draw-property-panel absolute right-2 top-8 z-20 w-[228px] overflow-y-auto rounded-lg border border-border bg-card p-3 shadow-md"
style="max-height: calc(100% - 80px)"
>
<!-- Stroke Color -->
<fieldset v-if="hasStrokeColor" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.strokeColor') }}
</legend>
<div class="flex flex-wrap gap-1.5">
<button
v-for="color in PRESET_STROKE_COLORS"
:key="color"
class="prop-color-btn"
:class="{ 'ring-2 ring-primary ring-offset-1 ring-offset-card': strokeColor === color }"
:style="{ backgroundColor: color }"
:title="color"
@click="strokeColor = color"
/>
<button
class="prop-color-btn prop-color-custom"
:class="{ 'ring-2 ring-primary ring-offset-1 ring-offset-card': !PRESET_STROKE_COLORS.includes(strokeColor) && strokeColor !== '' }"
:style="!PRESET_STROKE_COLORS.includes(strokeColor) ? { backgroundColor: strokeColor } : {}"
:title="t('draw.property.customColor')"
@click="openStrokeColorPicker"
/>
<input
ref="strokeColorInputRef"
type="color"
class="prop-color-native-input"
:value="strokeColor"
@input="onStrokeColorInput"
/>
</div>
</fieldset>
<!-- Background Color -->
<fieldset v-if="hasBackground" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.backgroundColor') }}
</legend>
<div class="flex flex-wrap gap-1.5">
<button
v-for="color in PRESET_BACKGROUND_COLORS"
:key="color"
class="prop-color-btn"
:class="{
'ring-2 ring-primary ring-offset-1 ring-offset-card': backgroundColor === color,
'prop-color-transparent': color === 'transparent',
}"
:style="color !== 'transparent' ? { backgroundColor: color } : {}"
:title="color === 'transparent' ? 'Transparent' : color"
@click="backgroundColor = color"
/>
<button
class="prop-color-btn prop-color-custom"
:class="{ 'ring-2 ring-primary ring-offset-1 ring-offset-card': !PRESET_BACKGROUND_COLORS.includes(backgroundColor) && backgroundColor !== '' }"
:style="!PRESET_BACKGROUND_COLORS.includes(backgroundColor) ? { backgroundColor } : {}"
:title="t('draw.property.customColor')"
@click="openBgColorPicker"
/>
<input
ref="bgColorInputRef"
type="color"
class="prop-color-native-input"
:value="backgroundColor === 'transparent' ? '#ffffff' : backgroundColor"
@input="onBgColorInput"
/>
</div>
</fieldset>
<!-- Fill Style -->
<fieldset v-if="showFillStyle" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.fillStyle') }}
</legend>
<div class="flex gap-1">
<button
v-for="opt in fillStyleOptions"
:key="opt.value"
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': fillStyle === opt.value }"
:title="t(opt.label)"
@click="fillStyle = opt.value"
v-html="opt.icon"
/>
</div>
</fieldset>
<!-- Stroke Width -->
<fieldset v-if="hasStrokeWidth" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.strokeWidth') }}
</legend>
<div class="flex gap-1">
<button
v-for="opt in strokeWidthOptions"
:key="opt.value"
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': strokeWidth === opt.value }"
:title="t(opt.label)"
@click="strokeWidth = opt.value"
v-html="opt.icon"
/>
</div>
</fieldset>
<!-- Stroke Style + Sloppiness -->
<fieldset v-if="hasStrokeStyle" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.strokeStyle') }}
</legend>
<div class="flex gap-1">
<button
v-for="opt in strokeStyleOptions"
:key="opt.value"
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': strokeStyle === opt.value }"
:title="t(opt.label)"
@click="strokeStyle = opt.value"
v-html="opt.icon"
/>
</div>
</fieldset>
<fieldset v-if="hasStrokeStyle" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.sloppiness') }}
</legend>
<div class="flex gap-1">
<button
v-for="opt in sloppinessOptions"
:key="opt.value"
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': roughness === opt.value }"
:title="t(opt.label)"
@click="roughness = opt.value"
v-html="opt.icon"
/>
</div>
</fieldset>
<!-- Edges (Roundness) -->
<fieldset v-if="canChangeRoundness" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.edges') }}
</legend>
<div class="flex gap-1">
<button
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': !isRound }"
:title="t('draw.property.edgesSharp')"
@click="isRound = false"
v-html="EdgeSharpIcon"
/>
<button
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': isRound }"
:title="t('draw.property.edgesRound')"
@click="isRound = true"
v-html="EdgeRoundIcon"
/>
</div>
</fieldset>
<!-- Arrow Type -->
<fieldset v-if="isArrowElement" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.arrowType') }}
</legend>
<div class="flex gap-1">
<button
v-for="opt in arrowTypeOptions"
:key="opt.value"
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': arrowType === opt.value }"
:title="t(opt.label)"
@click="arrowType = opt.value"
v-html="opt.icon"
/>
</div>
</fieldset>
<!-- Font Family -->
<fieldset v-if="isTextOrHasText" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.fontFamily') }}
</legend>
<div class="flex gap-1">
<button
v-for="opt in fontFamilyOptions"
:key="opt.value"
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': fontFamily === opt.value }"
:title="t(opt.label)"
@click="fontFamily = opt.value"
v-html="opt.icon"
/>
</div>
</fieldset>
<!-- Font Size -->
<fieldset v-if="isTextOrHasText" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.fontSize') }}
</legend>
<div class="flex flex-wrap gap-1">
<button
v-for="size in fontSizeOptions"
:key="size"
class="prop-text-btn"
:class="{ 'prop-text-btn--active': fontSize === size }"
@click="fontSize = size"
>
{{ size }}
</button>
</div>
<div class="mt-1.5 flex items-center gap-0.5">
<button class="prop-sm-btn" @click="fontSize = Math.max(8, fontSize - 2)">
<MinusIcon class="h-3 w-3" />
</button>
<span class="min-w-[2rem] text-center text-xs">{{ fontSize }}</span>
<button class="prop-sm-btn" @click="fontSize = Math.min(120, fontSize + 2)">
<PlusIcon class="h-3 w-3" />
</button>
</div>
</fieldset>
<!-- Text Align -->
<fieldset v-if="isTextOrHasText" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.textAlign') }}
</legend>
<div class="flex gap-1">
<button
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': textAlign === 'left' }"
:title="'Left'"
@click="textAlign = 'left'"
v-html="TextAlignLeftIcon"
/>
<button
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': textAlign === 'center' }"
:title="'Center'"
@click="textAlign = 'center'"
v-html="TextAlignCenterIcon"
/>
<button
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': textAlign === 'right' }"
:title="'Right'"
@click="textAlign = 'right'"
v-html="TextAlignRightIcon"
/>
</div>
</fieldset>
<!-- Vertical Align -->
<fieldset v-if="isTextOrHasText && primaryElement?.type !== 'text'" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.verticalAlign') }}
</legend>
<div class="flex gap-1">
<button
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': verticalAlign === 'top' }"
:title="t('draw.verticalAlign.top')"
@click="verticalAlign = 'top'"
v-html="VerticalAlignTopIcon"
/>
<button
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': verticalAlign === 'middle' }"
:title="t('draw.verticalAlign.middle')"
@click="verticalAlign = 'middle'"
v-html="VerticalAlignMiddleIcon"
/>
<button
class="prop-icon-btn"
:class="{ 'prop-icon-btn--active': verticalAlign === 'bottom' }"
:title="t('draw.verticalAlign.bottom')"
@click="verticalAlign = 'bottom'"
v-html="VerticalAlignBottomIcon"
/>
</div>
</fieldset>
<!-- Arrowheads -->
<fieldset v-if="isLinearElement" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.arrowheadStart') }}
</legend>
<div class="flex gap-0.5">
<button
v-for="opt in startArrowheadOptions"
:key="String(opt.value)"
class="prop-arrowhead-btn"
:class="{ 'prop-arrowhead-btn--active': startArrowhead === opt.value }"
:title="t(opt.label)"
@click="startArrowhead = opt.value"
v-html="opt.icon"
/>
</div>
<legend class="mb-1.5 mt-2 text-xs font-medium text-muted-foreground">
{{ t('draw.property.arrowheadEnd') }}
</legend>
<div class="flex gap-0.5">
<button
v-for="opt in endArrowheadOptions"
:key="String(opt.value)"
class="prop-arrowhead-btn"
:class="{ 'prop-arrowhead-btn--active': endArrowhead === opt.value }"
:title="t(opt.label)"
@click="endArrowhead = opt.value"
v-html="opt.icon"
/>
</div>
</fieldset>
<!-- Opacity -->
<fieldset class="mb-3">
<legend class="mb-1 text-xs font-medium text-muted-foreground">
{{ t('draw.property.opacity') }}
</legend>
<div class="flex items-center gap-2">
<ElSlider
:model-value="opacity"
:min="0"
:max="100"
:step="10"
class="flex-1"
@update:model-value="(v: any) => opacity = Number(v)"
/>
<span class="w-8 text-right text-xs text-muted-foreground">{{ opacity }}</span>
</div>
</fieldset>
<!-- Layers -->
<fieldset v-if="isSingleSelect || isMultiSelect" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.layers') }}
</legend>
<div class="flex gap-1">
<button
class="prop-action-btn"
:title="t('draw.menu.sendToBack')"
@click="store.sendToBack()"
>
<ArrowDownToLine class="h-4 w-4" />
</button>
<button
class="prop-action-btn"
:title="t('draw.menu.sendBackward')"
@click="store.sendBackward()"
>
<ArrowDown class="h-4 w-4" />
</button>
<button
class="prop-action-btn"
:title="t('draw.menu.bringForward')"
@click="store.bringForward()"
>
<ArrowUp class="h-4 w-4" />
</button>
<button
class="prop-action-btn"
:title="t('draw.menu.bringToFront')"
@click="store.bringToFront()"
>
<ArrowUpToLine class="h-4 w-4" />
</button>
</div>
</fieldset>
<!-- Actions -->
<fieldset v-if="isSingleSelect || isMultiSelect" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.actions') }}
</legend>
<div class="flex gap-1">
<button
class="prop-action-btn"
:title="t('draw.menu.duplicate')"
@click="store.duplicateSelectedElements()"
>
<Copy class="h-4 w-4" />
</button>
<button
class="prop-action-btn"
:title="t('draw.menu.delete')"
@click="store.deleteSelectedElementsWithBindings()"
>
<Trash2 class="h-4 w-4" />
</button>
<button
class="prop-action-btn"
:title="hasLink ? t('draw.menu.editLink') : t('draw.menu.addLink')"
@click="openHyperlinkEditor"
>
<Link class="h-4 w-4" />
</button>
</div>
</fieldset>
<!-- Align / Distribute (multi-select) -->
<fieldset v-if="isMultiSelect" class="mb-3">
<legend class="mb-1.5 text-xs font-medium text-muted-foreground">
{{ t('draw.property.alignDistribute') }}
</legend>
<div class="mb-1 flex gap-1">
<button class="prop-sm-btn" :title="t('draw.menu.alignLeft')" @click="store.alignSelected({ position: 'start', axis: 'x' })">
<AlignStartHorizontal class="h-3.5 w-3.5" />
</button>
<button class="prop-sm-btn" :title="t('draw.menu.alignCenter')" @click="store.alignSelected({ position: 'center', axis: 'x' })">
<AlignCenterHorizontal class="h-3.5 w-3.5" />
</button>
<button class="prop-sm-btn" :title="t('draw.menu.alignRight')" @click="store.alignSelected({ position: 'end', axis: 'x' })">
<AlignEndHorizontal class="h-3.5 w-3.5" />
</button>
<button class="prop-sm-btn" :title="t('draw.menu.alignTop')" @click="store.alignSelected({ position: 'start', axis: 'y' })">
<AlignStartVertical class="h-3.5 w-3.5" />
</button>
<button class="prop-sm-btn" :title="t('draw.menu.alignMiddle')" @click="store.alignSelected({ position: 'center', axis: 'y' })">
<AlignCenterVertical class="h-3.5 w-3.5" />
</button>
<button class="prop-sm-btn" :title="t('draw.menu.alignBottom')" @click="store.alignSelected({ position: 'end', axis: 'y' })">
<AlignEndVertical class="h-3.5 w-3.5" />
</button>
</div>
<div v-if="canDistribute" class="flex gap-1">
<button class="prop-sm-btn" :title="t('draw.menu.distributeH')" @click="store.distributeSelected({ space: 'between', axis: 'x' })">
<AlignHorizontalSpaceAround class="h-3.5 w-3.5" />
</button>
<button class="prop-sm-btn" :title="t('draw.menu.distributeV')" @click="store.distributeSelected({ space: 'between', axis: 'y' })">
<AlignVerticalSpaceAround class="h-3.5 w-3.5" />
</button>
</div>
</fieldset>
<div v-if="isMultiSelect" class="text-xs text-muted-foreground">
{{ t('draw.property.mixed') }}
</div>
</div>
</template>
<style scoped>
.prop-color-btn {
@apply h-[22px] w-[22px] rounded-md border border-border cursor-pointer transition-all hover:scale-110;
}
.prop-color-transparent {
background: repeating-conic-gradient(#ccc 0 25%, transparent 0 50%) 0 0 / 8px 8px;
}
.prop-color-custom {
background: conic-gradient(
from 0deg,
#ff0000, #ff8800, #ffff00, #00ff00, #00ffff, #0000ff, #8800ff, #ff0088, #ff0000
);
}
.prop-color-native-input {
@apply absolute h-0 w-0 overflow-hidden;
opacity: 0;
pointer-events: none;
}
.prop-icon-btn {
@apply flex h-8 w-8 items-center justify-center rounded-md border border-transparent cursor-pointer transition-colors text-foreground;
}
.prop-icon-btn:hover {
@apply bg-accent;
}
.prop-icon-btn--active {
@apply bg-primary/10 border-primary text-primary;
}
.prop-icon-btn :deep(svg) {
@apply h-5 w-5;
}
.prop-arrowhead-btn {
@apply flex h-7 flex-1 items-center justify-center rounded-md border border-transparent cursor-pointer transition-colors text-foreground;
}
.prop-arrowhead-btn:hover {
@apply bg-accent;
}
.prop-arrowhead-btn--active {
@apply bg-primary/10 border-primary text-primary;
}
.prop-arrowhead-btn :deep(svg) {
@apply h-5 w-full;
}
.prop-text-btn {
@apply flex h-7 min-w-[28px] items-center justify-center rounded-md border border-transparent px-1.5 text-xs cursor-pointer transition-colors;
}
.prop-text-btn:hover {
@apply bg-accent;
}
.prop-text-btn--active {
@apply bg-primary/10 border-primary text-primary;
}
.prop-action-btn {
@apply flex h-9 w-9 items-center justify-center rounded-lg cursor-pointer transition-colors text-foreground hover:bg-accent;
}
.prop-sm-btn {
@apply flex h-7 w-7 items-center justify-center rounded-md cursor-pointer transition-colors hover:bg-accent;
}
</style>

Some files were not shown because too many files have changed in this diff Show More