feat: restore source parity and harden agent runtime
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
+117
@@ -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>
|
||||
+174
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
+407
@@ -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>
|
||||
+162
@@ -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
Reference in New Issue
Block a user