From 0793eb82d60e7df0ad19706b574a3a2ab7813a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?cls=5F=E5=AE=81=E6=B3=A2=E6=9C=AC=E6=9C=BA?= <908705107@qq.com> Date: Mon, 22 Jun 2026 11:17:26 +0800 Subject: [PATCH] feat: restore source parity and harden agent runtime --- .gitignore | 111 +- .../ai_platform/nodes/builtin/dialog_nodes.py | 8 +- .../ai_platform/nodes/builtin/llm_node.py | 1 + .../ai_platform/services/llm_service.py | 48 + .../f6g7h8i9j0k1_add_form_globally_visible.py | 34 + ...g7h8i9j0k1l2_add_data_source_db_context.py | 34 + ...8i9j0k1l2m3_add_data_source_list_config.py | 33 + .../online_dev/contract/__init__.py | 5 + backend-fastapi/online_dev/contract/api.py | 773 ++++ backend-fastapi/online_dev/contract/model.py | 129 + backend-fastapi/online_dev/contract/schema.py | 212 + .../online_dev/contract/service.py | 736 +++ .../online_dev/document_generator/__init__.py | 5 + .../online_dev/document_generator/api.py | 819 ++++ .../document_generator/calculation_engine.py | 546 +++ .../document_generator/font_service.py | 157 + .../document_generator/generator.py | 1259 +++++ .../online_dev/document_generator/model.py | 117 + .../document_generator/page_number.py | 44 + .../online_dev/document_generator/schema.py | 267 ++ .../online_dev/document_generator/service.py | 496 ++ .../document_generator/template_renderer.py | 1026 +++++ .../document_generator/templates/__init__.py | 386 ++ .../document_generator/templates/base.html | 96 + .../templates/elements/barcode.html | 28 + .../templates/elements/container.html | 1 + .../templates/elements/detail_table.html | 33 + .../templates/elements/divider.html | 1 + .../templates/elements/doc_info.html | 8 + .../templates/elements/field.html | 1 + .../templates/elements/footer.html | 5 + .../templates/elements/header.html | 13 + .../templates/elements/image.html | 27 + .../templates/elements/info_row.html | 12 + .../templates/elements/info_table.html | 14 + .../templates/elements/label_field.html | 10 + .../templates/elements/paragraph.html | 13 + .../templates/elements/qrcode.html | 27 + .../templates/elements/rich_text.html | 1 + .../templates/elements/row.html | 7 + .../templates/elements/seal.html | 42 + .../templates/elements/signature.html | 5 + .../templates/elements/smart_table.html | 52 + .../templates/elements/smart_text.html | 4 + .../templates/elements/spacer.html | 1 + .../templates/elements/table.html | 16 + .../templates/elements/text.html | 1 + .../templates/elements/title.html | 2 + .../templates/elements/wysiwyg.html | 11 + .../online_dev/electronic_seal/__init__.py | 5 + .../online_dev/electronic_seal/api.py | 162 + .../online_dev/electronic_seal/model.py | 68 + .../online_dev/electronic_seal/schema.py | 79 + .../online_dev/electronic_seal/service.py | 195 + .../online_dev/form_data_manager/api.py | 1120 +++++ .../form_data_manager/dependencies.py | 242 + .../online_dev/form_data_manager/schema.py | 44 + .../online_dev/form_manager/api.py | 393 ++ .../online_dev/form_manager/schema.py | 259 ++ .../online_dev/page_manager/schema.py | 112 + .../online_dev/report_manager/__init__.py | 2 + .../report_manager/acceptance_e2e.py | 74 + .../online_dev/report_manager/api.py | 388 ++ .../online_dev/report_manager/constants.py | 29 + .../online_dev/report_manager/data_api.py | 626 +++ .../report_manager/dataset_bridge.py | 168 + .../report_manager/engine/__init__.py | 2 + .../report_manager/engine/chart_data.py | 172 + .../report_manager/engine/code_cells.py | 41 + .../report_manager/engine/column_layout.py | 315 ++ .../report_manager/engine/convert.py | 61 + .../report_manager/engine/convert_lookup.py | 101 + .../report_manager/engine/data_expand.py | 943 ++++ .../engine/dataset_transform.py | 222 + .../report_manager/engine/export_excel.py | 271 ++ .../engine/export_excel_extras.py | 913 ++++ .../report_manager/engine/export_pdf.py | 93 + .../report_manager/engine/expression_eval.py | 491 ++ .../engine/fixtures/JNPF_DIFF.md | 51 + .../report_manager/engine/fixtures/README.md | 61 + .../engine/fixtures/extract_jnpf_fixtures.py | 327 ++ .../fixtures/golden_column_col_type1.json | 42 + .../fixtures/golden_column_col_type2.json | 40 + .../fixtures/golden_expression_chain.json | 39 + .../fixtures/golden_fill_empty_rows.json | 43 + .../fixtures/golden_jnpf_cross_row.json | 76 + .../fixtures/golden_jnpf_db_user_group.json | 686 +++ .../fixtures/golden_jnpf_db_user_list.json | 592 +++ .../fixtures/golden_jnpf_db_user_matrix.json | 295 ++ .../fixtures/golden_jnpf_export_list.json | 49 + .../golden_jnpf_header_merge_list.json | 60 + .../fixtures/golden_jnpf_parent_group.json | 66 + .../fixtures/golden_jnpf_poly_summary.json | 43 + .../golden_jnpf_prod_convert_date.json | 51 + .../golden_jnpf_prod_convert_number.json | 48 + .../golden_jnpf_prod_convert_select.json | 69 + .../golden_jnpf_prod_convert_user_inline.json | 74 + .../golden_jnpf_prod_field_mapping.json | 48 + .../engine/fixtures/golden_jnpf_style.json | 80 + .../fixtures/golden_jnpf_system_params.json | 49 + .../fixtures/golden_jnpf_top_parent.json | 83 + .../engine/fixtures/golden_list_down.json | 63 + .../fixtures/golden_multi_col_down.json | 60 + .../fixtures/golden_param_placeholder.json | 32 + .../report_manager/engine/import_excel.py | 45 + .../report_manager/engine/merge_recalc.py | 83 + .../engine/parameter_resolver.py | 55 + .../report_manager/engine/parent_cells.py | 221 + .../report_manager/engine/polymerize.py | 149 + .../report_manager/engine/preview_guard.py | 43 + .../report_manager/engine/preview_mvp.py | 91 + .../report_manager/engine/sort_apply.py | 47 + .../report_manager/engine/test_chart_data.py | 33 + .../report_manager/engine/test_code_cells.py | 29 + .../engine/test_column_layout.py | 148 + .../report_manager/engine/test_convert.py | 119 + .../engine/test_dataset_transform.py | 84 + .../engine/test_export_excel.py | 248 + .../engine/test_expression_cycles.py | 22 + .../engine/test_expression_eval.py | 183 + .../report_manager/engine/test_golden.py | 97 + .../engine/test_parameter_resolver.py | 32 + .../engine/test_placeholders.py | 18 + .../report_manager/engine/test_polymerize.py | 27 + .../engine/test_preview_guard.py | 59 + .../report_manager/engine/test_sort_apply.py | 10 + .../engine/test_summary_row_shift.py | 305 ++ .../report_manager/engine/test_watermark.py | 36 + .../report_manager/engine/watermark.py | 79 + .../online_dev/report_manager/enums.py | 22 + .../online_dev/report_manager/exceptions.py | 6 + .../online_dev/report_manager/model.py | 49 + .../report_manager/permission_templates.py | 35 + .../online_dev/report_manager/schema.py | 208 + .../online_dev/report_manager/service.py | 603 +++ .../report_manager/version_service.py | 289 ++ .../online_dev/screen_design/__init__.py | 9 + .../online_dev/screen_design/material_api.py | 255 ++ .../screen_design/material_model.py | 32 + .../screen_design/material_schema.py | 105 + .../screen_design/material_service.py | 100 + .../online_dev/screen_design/screen_api.py | 276 ++ .../online_dev/screen_design/screen_model.py | 35 + .../online_dev/screen_design/screen_schema.py | 149 + .../screen_design/screen_service.py | 272 ++ .../online_dev/workflow/__init__.py | 18 + backend-fastapi/online_dev/workflow/api.py | 1498 ++++++ .../online_dev/workflow/engine/__init__.py | 26 + .../workflow/engine/assignee_resolver.py | 353 ++ .../online_dev/workflow/engine/base.py | 100 + .../workflow/engine/condition_evaluator.py | 218 + .../workflow/engine/delay_callback.py | 290 ++ .../workflow/engine/delay_process.py | 272 ++ .../workflow/engine/handlers/__init__.py | 31 + .../engine/handlers/approval_handler.py | 375 ++ .../workflow/engine/handlers/base_handler.py | 221 + .../engine/handlers/condition_handler.py | 92 + .../workflow/engine/handlers/copy_handler.py | 58 + .../engine/handlers/data_update_handler.py | 280 ++ .../workflow/engine/handlers/delay_handler.py | 143 + .../engine/handlers/handle_handler.py | 154 + .../engine/handlers/notification_service.py | 412 ++ .../engine/handlers/notify_handler.py | 100 + .../engine/handlers/parallel_handler.py | 178 + .../engine/handlers/service_handler.py | 133 + .../engine/handlers/subflow_handler.py | 262 ++ .../workflow/engine/task_timeout_process.py | 420 ++ .../online_dev/workflow/engine/utils.py | 449 ++ .../workflow/engine/workflow_engine.py | 644 +++ .../workflow/flow_definition_validator.py | 191 + backend-fastapi/online_dev/workflow/model.py | 163 + .../online_dev/workflow/progress_service.py | 730 +++ backend-fastapi/online_dev/workflow/schema.py | 302 ++ .../online_dev/workflow/service.py | 1389 ++++++ backend-fastapi/zq_demo/__init__.py | 0 backend-fastapi/zq_demo/api.py | 229 + backend-fastapi/zq_demo/model.py | 27 + backend-fastapi/zq_demo/router.py | 11 + backend-fastapi/zq_demo/schema.py | 100 + backend-fastapi/zq_demo/service.py | 109 + backend-fastapi/zq_smart_table/__init__.py | 0 backend-fastapi/zq_smart_table/api.py | 1645 +++++++ backend-fastapi/zq_smart_table/formula.py | 612 +++ backend-fastapi/zq_smart_table/model.py | 138 + .../zq_smart_table/permission/__init__.py | 0 .../zq_smart_table/permission/api.py | 299 ++ .../zq_smart_table/permission/model.py | 115 + .../zq_smart_table/permission/schema.py | 122 + .../zq_smart_table/permission/service.py | 709 +++ backend-fastapi/zq_smart_table/router.py | 8 + backend-fastapi/zq_smart_table/schema.py | 495 ++ backend-fastapi/zq_smart_table/service.py | 2074 +++++++++ web/apps/web-ele/.env.development | 2 - web/apps/web-ele/.env.production | 2 - web/apps/web-ele/src/api/smart-table.ts | 675 +++ .../components/ai-chat-panel/AiChatPanel.vue | 99 +- .../composables/useEventHandler.ts | 10 +- .../components/WidgetRenderer.vue | 150 +- .../components/AttributePanel.vue | 3448 ++++++++++++++ .../components/DesignCanvas.vue | 475 ++ .../components/ElementWrapper.vue | 1695 +++++++ .../components/FloatElementWrapper.vue | 309 ++ .../components/MaterialPanel.vue | 287 ++ .../components/SmartTable.vue | 2184 +++++++++ .../components/SmartText.vue | 1353 ++++++ .../components/VariableSelector.vue | 139 + .../components/WysiwygCanvas.vue | 1177 +++++ .../src/components/document-designer/index.ts | 7 + .../components/document-designer/index.vue | 568 +++ .../store/documentDesignStore.ts | 1271 ++++++ .../document-designer/utils/pageNumber.ts | 151 + .../document-designer/utils/pageSize.ts | 65 + .../import-export-manager.vue | 826 ++++ .../components/import-export-manager/index.ts | 2 + .../components/import-export-manager/types.ts | 67 + .../report-design/hooks/useReportChart.ts | 147 + .../hooks/useReportDatasetFields.ts | 137 + .../report-design/hooks/useReportQuery.ts | 216 + .../src/components/report-design/index.vue | 389 ++ .../modules/cell-chart-panel.vue | 111 + .../modules/cell-property-panel.vue | 675 +++ .../report-design/modules/chart-bind-form.vue | 545 +++ .../report-design/modules/chart-bind-utils.ts | 70 + .../modules/column-config-dialog.vue | 193 + .../modules/convert-config-dialog.vue | 325 ++ .../modules/dataset-field-mapping-dialog.vue | 117 + .../report-design/modules/dataset-panel.vue | 369 ++ .../modules/float-echart-panel.vue | 70 + .../modules/float-image-panel.vue | 151 + .../report-design/modules/preview-dialog.vue | 289 ++ .../modules/query-config-dialog.vue | 224 + .../modules/report-config-features-panel.vue | 174 + .../modules/report-config-toggle-item.vue | 98 + .../modules/report-field-select.vue | 76 + .../modules/report-query-form.vue | 155 + .../modules/report-settings-panel.vue | 206 + .../modules/sort-config-dialog.vue | 147 + .../components/report-design/univer-host.vue | 321 ++ .../report-design/utils/dataset-fields.ts | 199 + .../report-design/utils/preview-warnings.ts | 20 + .../report-design/utils/report-media-url.ts | 144 + .../report-design/utils/report-settings.ts | 49 + .../src/components/wiki/CreateWikiDialog.vue | 141 + .../src/components/wiki/EditWikiDialog.vue | 151 + .../src/components/wiki/WikiHomeSidebar.vue | 950 ++++ .../workflow/DocumentPreviewDialog.vue | 256 ++ .../workflow/designer/FlowDesigner.vue | 497 ++ .../designer/components/AddNodeButton.vue | 229 + .../designer/components/ConditionEditor.vue | 568 +++ .../designer/components/FlowCanvas.vue | 449 ++ .../designer/components/FlowMinimap.vue | 423 ++ .../workflow/designer/components/FlowNode.vue | 473 ++ .../designer/components/FlowPreview.vue | 74 + .../components/FlowPreviewContent.vue | 1625 +++++++ .../components/FormFieldPermissions.vue | 407 ++ .../designer/components/NodeNameField.vue | 45 + .../designer/components/PropertyPanel.vue | 4059 +++++++++++++++++ .../workflow/designer/components/Toolbar.vue | 201 + .../components/nodes/ApprovalNode.vue | 274 ++ .../components/nodes/ConditionNode.vue | 499 ++ .../designer/components/nodes/CopyNode.vue | 244 + .../components/nodes/DataUpdateNode.vue | 162 + .../designer/components/nodes/DelayNode.vue | 155 + .../designer/components/nodes/EndNode.vue | 67 + .../designer/components/nodes/HandleNode.vue | 263 ++ .../designer/components/nodes/NotifyNode.vue | 205 + .../components/nodes/ParallelNode.vue | 385 ++ .../designer/components/nodes/ServiceNode.vue | 192 + .../designer/components/nodes/StartNode.vue | 89 + .../designer/components/nodes/SubflowNode.vue | 175 + .../workflow/designer/hooks/useFlowData.ts | 394 ++ .../designer/hooks/useFlowValidation.ts | 723 +++ .../components/workflow/designer/index.vue | 244 + .../src/components/workflow/designer/types.ts | 813 ++++ .../designer/utils/ensure-node-names.ts | 77 + .../workflow/designer/utils/node-display.ts | 109 + .../workflow/detial/FlowPathPreview.vue | 527 +++ .../workflow/detial/FlowProgressTab.vue | 908 ++++ .../workflow/detial/InstanceDetailPanel.vue | 208 + .../workflow/detial/tabs/DocumentTab.vue | 327 ++ .../workflow/detial/tabs/FlowchartTab.vue | 234 + .../workflow/detial/tabs/FormTab.vue | 195 + .../workflow/detial/tabs/ProgressTab.vue | 972 ++++ .../web-ele/src/components/workflow/index.ts | 2 + .../src/components/zq-draw/DrawPreview.vue | 140 + .../web-ele/src/components/zq-draw/ZqDraw.vue | 397 ++ .../zq-draw/components/ContextMenu.vue | 436 ++ .../zq-draw/components/DrawCanvas.vue | 65 + .../zq-draw/components/DrawToolbar.vue | 104 + .../zq-draw/components/HyperlinkPopup.vue | 141 + .../zq-draw/components/MainMenu.vue | 336 ++ .../zq-draw/components/PropertyPanel.vue | 972 ++++ .../zq-draw/components/StatsPanel.vue | 230 + .../zq-draw/components/TextEditor.vue | 356 ++ .../zq-draw/components/UndoRedoControls.vue | 71 + .../zq-draw/components/ZoomControls.vue | 104 + .../components/icons/property-icons.ts | 121 + .../zq-draw/composables/use-canvas-resize.ts | 32 + .../zq-draw/composables/use-draw-engine.ts | 34 + .../zq-draw/composables/use-keyboard.ts | 294 ++ .../zq-draw/composables/use-pointer-events.ts | 1347 ++++++ .../zq-draw/composables/use-scroll-wheel.ts | 52 + .../src/components/zq-draw/constants.ts | 198 + .../src/components/zq-draw/core/clipboard.ts | 90 + .../src/components/zq-draw/core/cursor.ts | 38 + .../src/components/zq-draw/core/eraser.ts | 132 + .../src/components/zq-draw/core/gesture.ts | 39 + .../src/components/zq-draw/core/history.ts | 55 + .../zq-draw/core/renderer/helpers.ts | 78 + .../core/renderer/interactive-scene.ts | 408 ++ .../zq-draw/core/renderer/static-scene.ts | 216 + .../src/components/zq-draw/core/scene.ts | 142 + .../src/components/zq-draw/core/snap.ts | 475 ++ .../components/zq-draw/data/export-canvas.ts | 99 + .../src/components/zq-draw/data/export-svg.ts | 439 ++ .../src/components/zq-draw/data/json.ts | 58 + .../src/components/zq-draw/elements/align.ts | 127 + .../components/zq-draw/elements/binding.ts | 425 ++ .../components/zq-draw/elements/bound-text.ts | 506 ++ .../src/components/zq-draw/elements/bounds.ts | 157 + .../components/zq-draw/elements/collision.ts | 230 + .../components/zq-draw/elements/duplicate.ts | 41 + .../zq-draw/elements/elbow-arrow.ts | 214 + .../src/components/zq-draw/elements/flip.ts | 96 + .../src/components/zq-draw/elements/frame.ts | 137 + .../src/components/zq-draw/elements/group.ts | 145 + .../zq-draw/elements/linear-element-editor.ts | 198 + .../zq-draw/elements/mutate-element.ts | 33 + .../zq-draw/elements/new-element.ts | 294 ++ .../zq-draw/elements/shape-generator.ts | 479 ++ .../zq-draw/elements/transform-handles.ts | 218 + .../src/components/zq-draw/elements/zindex.ts | 121 + ...lar-279a7b317d12eb88de06167bd672b4b4.woff2 | Bin 0 -> 17488 bytes ...lar-6e066e8de2ac57ea9283adb9c24d7f0c.woff2 | Bin 0 -> 1292 bytes ...lar-dc6a8806fa96795d7b3be5026f989a17.woff2 | Bin 0 -> 2816 bytes ...lar-fcb0fc02dcbee4c9846b3e2508668039.woff2 | Bin 0 -> 9856 bytes ...lar-349fac6ca4700ffec595a7150a0d1e1d.woff2 | Bin 0 -> 2656 bytes ...lar-3f2c5db56cc93c5a6873b1361d730c16.woff2 | Bin 0 -> 2104 bytes ...lar-41b173a47b57366892116a575a43e2b6.woff2 | Bin 0 -> 8712 bytes ...lar-623ccf21b21ef6b3a0d87738f77eb071.woff2 | Bin 0 -> 824 bytes ...lar-a88b72a24fb54c9f94e3b5fdaa7481c9.woff2 | Bin 0 -> 24956 bytes ...lar-b9dcf9d2e50a1eaf42fc664b50a3fd0d.woff2 | Bin 0 -> 13296 bytes ...lar-be310b9bcd4f1a43f571c46df7809174.woff2 | Bin 0 -> 12220 bytes ...KofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2 | Bin 0 -> 8392 bytes ...1BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2 | Bin 0 -> 16476 bytes ...KofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2 | Bin 0 -> 11104 bytes ...KofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2 | Bin 0 -> 15476 bytes ...KofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2 | Bin 0 -> 6116 bytes .../zq-draw/fonts/Virgil/Virgil-Regular.woff2 | Bin 0 -> 56156 bytes .../src/components/zq-draw/fonts/fonts.d.ts | 4 + .../src/components/zq-draw/fonts/index.ts | 98 + .../src/components/zq-draw/fonts/xiaolai.css | 1880 ++++++++ .../web-ele/src/components/zq-draw/index.ts | 12 + .../src/components/zq-draw/math/curve.ts | 80 + .../src/components/zq-draw/math/line.ts | 59 + .../src/components/zq-draw/math/point.ts | 75 + .../src/components/zq-draw/math/polygon.ts | 54 + .../src/components/zq-draw/math/types.ts | 5 + .../components/zq-draw/store/draw-store.ts | 978 ++++ .../src/components/zq-draw/styles/draw.scss | 17 + .../web-ele/src/components/zq-draw/types.ts | 412 ++ .../src/components/zq-editor/ZqEditor.vue | 430 ++ .../composables/use-block-actions.ts | 52 + .../zq-editor/composables/use-doc-export.ts | 64 + .../zq-editor/composables/use-editor.ts | 199 + .../zq-editor/composables/use-file-upload.ts | 195 + .../attachment/AttachmentComponent.vue | 200 + .../zq-editor/extensions/attachment/index.ts | 112 + .../extensions/callout/CalloutComponent.vue | 153 + .../zq-editor/extensions/callout/index.ts | 113 + .../code-block/CodeBlockComponent.vue | 341 ++ .../zq-editor/extensions/code-block/index.ts | 20 + .../extensions/columns/ColumnsComponent.vue | 49 + .../zq-editor/extensions/columns/index.ts | 96 + .../extensions/draw/DrawBlockComponent.vue | 486 ++ .../zq-editor/extensions/draw/index.ts | 67 + .../extensions/embed/EmbedComponent.vue | 330 ++ .../zq-editor/extensions/embed/index.ts | 73 + .../zq-editor/extensions/font-size/index.ts | 50 + .../extensions/image/ImageComponent.vue | 557 +++ .../zq-editor/extensions/image/index.ts | 107 + .../components/zq-editor/extensions/index.ts | 166 + .../zq-editor/extensions/math/index.ts | 9 + .../mention/MentionSuggestionMenu.vue | 195 + .../zq-editor/extensions/mention/index.ts | 92 + .../extensions/search-replace/index.ts | 321 ++ .../slash-command/SlashCommandMenu.vue | 277 ++ .../extensions/slash-command/commands.ts | 369 ++ .../extensions/slash-command/index.ts | 93 + .../table-of-contents/TocComponent.vue | 152 + .../extensions/table-of-contents/index.ts | 45 + .../toggle-list/ToggleComponent.vue | 142 + .../zq-editor/extensions/toggle-list/index.ts | 74 + .../extensions/video/VideoComponent.vue | 219 + .../zq-editor/extensions/video/index.ts | 103 + .../whiteboard/WhiteboardBlockComponent.vue | 255 ++ .../zq-editor/extensions/whiteboard/index.ts | 80 + .../web-ele/src/components/zq-editor/index.ts | 7 + .../zq-editor/menus/BubbleToolbar.vue | 561 +++ .../zq-editor/menus/ColorPicker.vue | 185 + .../zq-editor/menus/DragHandleMenu.vue | 554 +++ .../zq-editor/menus/EmojiPicker.vue | 177 + .../components/zq-editor/menus/LinkEditor.vue | 105 + .../zq-editor/menus/SearchReplacePanel.vue | 407 ++ .../zq-editor/menus/TableContextMenu.vue | 751 +++ .../zq-editor/menus/TableFloatingToolbar.vue | 423 ++ .../zq-editor/menus/TableHandles.vue | 357 ++ .../components/zq-editor/menus/TableMenu.vue | 882 ++++ .../zq-editor/menus/TableSizePicker.vue | 105 + .../components/zq-editor/styles/editor.scss | 687 +++ .../web-ele/src/components/zq-editor/types.ts | 66 + .../document/SaveAsTemplateDialog.vue | 105 + .../document/TemplateSelectDialog.vue | 247 + .../document/VersionHistoryPanel.vue | 428 ++ .../document/VersionPreviewDialog.vue | 145 + .../zq-smart-table/icons/FieldIcon.vue | 68 + .../layout/SmartTableContent.vue | 991 ++++ .../zq-smart-table/layout/TemplateDialog.vue | 252 + .../zq-smart-table/layout/ZqTableSidebar.vue | 975 ++++ .../permission/PermissionDialog.vue | 680 +++ .../zq-smart-table/table/AddFieldDialog.vue | 224 + .../zq-smart-table/table/CalendarView.vue | 827 ++++ .../zq-smart-table/table/CellRenderer.vue | 939 ++++ .../table/FieldConfigPopover.vue | 786 ++++ .../zq-smart-table/table/FormView.vue | 394 ++ .../zq-smart-table/table/GallerySkeleton.vue | 52 + .../zq-smart-table/table/GalleryView.vue | 581 +++ .../zq-smart-table/table/GanttView.vue | 1659 +++++++ .../zq-smart-table/table/KanbanSkeleton.vue | 62 + .../zq-smart-table/table/KanbanView.vue | 984 ++++ .../table/LinkRecordSelector.vue | 121 + .../table/RecordDetailDrawer.vue | 605 +++ .../zq-smart-table/table/TableGrid.vue | 1952 ++++++++ .../zq-smart-table/table/TableSkeleton.vue | 43 + .../zq-smart-table/table/TrashDrawer.vue | 267 ++ .../templates/document-templates.ts | 564 +++ .../templates/table-templates.ts | 507 ++ .../toolbar/ConditionalFormatPanel.vue | 135 + .../toolbar/FieldVisibilityPanel.vue | 90 + .../zq-smart-table/toolbar/FilterPanel.vue | 123 + .../zq-smart-table/toolbar/GroupPanel.vue | 76 + .../zq-smart-table/toolbar/SortPanel.vue | 62 + .../zq-smart-table/toolbar/TableToolbar.vue | 483 ++ .../zq-smart-table/toolbar/ViewTabs.vue | 490 ++ .../components/zq-whiteboard/ZqWhiteboard.vue | 150 + .../components/ConnectorToolbar.vue | 782 ++++ .../zq-whiteboard/components/ContextMenu.vue | 497 ++ .../components/DirectionHandles.vue | 383 ++ .../zq-whiteboard/components/Minimap.vue | 155 + .../components/PropertyPanel.vue | 284 ++ .../components/SelectionToolbar.vue | 1142 +++++ .../components/UndoRedoControls.vue | 58 + .../components/WhiteboardCanvas.vue | 30 + .../components/WhiteboardHeader.vue | 187 + .../components/WhiteboardToolbar.vue | 520 +++ .../zq-whiteboard/components/ZoomControls.vue | 84 + .../components/toolbar/PenOptions.vue | 99 + .../components/toolbar/ShapeDropdown.vue | 66 + .../src/components/zq-whiteboard/constants.ts | 229 + .../zq-whiteboard/core/canvas-manager.ts | 460 ++ .../zq-whiteboard/core/clipboard-manager.ts | 94 + .../zq-whiteboard/core/export-manager.ts | 62 + .../zq-whiteboard/core/history-manager.ts | 126 + .../zq-whiteboard/core/shortcut-manager.ts | 129 + .../zq-whiteboard/core/snap-manager.ts | 161 + .../zq-whiteboard/core/tool-manager.ts | 104 + .../src/components/zq-whiteboard/index.ts | 3 + .../zq-whiteboard/store/whiteboard-store.ts | 1557 +++++++ .../zq-whiteboard/tools/arrow-tool.ts | 150 + .../zq-whiteboard/tools/connector-tool.ts | 388 ++ .../zq-whiteboard/tools/eraser-tool.ts | 94 + .../zq-whiteboard/tools/hand-tool.ts | 54 + .../zq-whiteboard/tools/highlighter-tool.ts | 54 + .../zq-whiteboard/tools/image-tool.ts | 90 + .../zq-whiteboard/tools/line-tool.ts | 113 + .../zq-whiteboard/tools/pen-tool.ts | 55 + .../zq-whiteboard/tools/select-tool.ts | 26 + .../zq-whiteboard/tools/shape-tool.ts | 458 ++ .../zq-whiteboard/tools/sticky-note-tool.ts | 82 + .../zq-whiteboard/tools/text-tool.ts | 76 + .../src/components/zq-whiteboard/types.ts | 99 + .../zq-whiteboard/utils/connector-routing.ts | 52 + .../zq-whiteboard/utils/geometry.ts | 67 + .../src/composables/use-offline-cache.ts | 313 ++ .../web-ele/src/locales/langs/en-US/draw.json | 164 + .../src/locales/langs/en-US/whiteboard.json | 124 + .../web-ele/src/locales/langs/en-US/wiki.json | 56 + .../src/locales/langs/en-US/zq-editor.json | 251 + .../locales/langs/en-US/zq-smart-table.json | 550 +++ .../web-ele/src/locales/langs/zh-CN/draw.json | 164 + .../src/locales/langs/zh-CN/whiteboard.json | 124 + .../web-ele/src/locales/langs/zh-CN/wiki.json | 56 + .../src/locales/langs/zh-CN/zq-editor.json | 251 + .../locales/langs/zh-CN/zq-smart-table.json | 550 +++ .../web-ele/src/locales/langs/zh-TW/draw.json | 164 + .../src/locales/langs/zh-TW/whiteboard.json | 124 + .../web-ele/src/locales/langs/zh-TW/wiki.json | 56 + .../src/locales/langs/zh-TW/zq-editor.json | 251 + .../locales/langs/zh-TW/zq-smart-table.json | 550 +++ .../web-ele/src/router/routes/modules/chat.ts | 18 + .../src/router/routes/modules/demos.ts | 37 + .../src/router/routes/modules/form-layout.ts | 166 + .../src/router/routes/modules/online-dev.ts | 222 + .../router/routes/modules/zq-smart-table.ts | 36 + web/apps/web-ele/src/store/wiki.ts | 102 + web/apps/web-ele/src/store/zq-smart-table.ts | 1691 +++++++ .../src/styles/zq-smart-table/index.css | 454 ++ .../src/styles/zq-smart-table/theme.css | 45 + .../src/views/demos/code-editor/examples.ts | 165 + .../src/views/demos/code-editor/index.vue | 347 ++ .../src/views/demos/contract-design/index.vue | 157 + .../src/views/demos/contract-design/sign.vue | 443 ++ .../views/demos/dashboard-design/index.vue | 26 + .../web-ele/src/views/demos/element/index.vue | 117 + .../src/views/demos/form-design-demo.vue | 139 + .../web-ele/src/views/demos/form/basic.vue | 191 + .../web-ele/src/views/demos/fu-page-demo.vue | 359 ++ .../src/views/demos/multi-data-view/index.vue | 195 + .../web-ele/src/views/demos/notion-editor.vue | 194 + .../src/views/demos/rich-text-editor.vue | 222 + .../views/demos/rich-text-editor/index.vue | 437 ++ .../web-ele/src/views/demos/splitter-demo.vue | 493 ++ .../src/views/demos/zq-api-select/index.vue | 197 + .../src/views/demos/zq-table/custom.vue | 97 + .../src/views/demos/zq-table/index.vue | 50 + .../src/views/demos/zq-table/search.vue | 80 + .../components/CreateDialog.vue | 619 +++ .../components/DetailDialog.vue | 527 +++ .../components/MobileSignDialog.vue | 235 + .../contract/contract-instance/index.vue | 401 ++ .../components/TemplateDialog.vue | 371 ++ .../contract/contract-template/index.vue | 373 ++ .../online-dev/contract/mobile-sign/index.vue | 466 ++ .../components/TemplateDesigner.vue | 918 ++++ .../document-generator/editor/index.vue | 619 +++ .../online-dev/document-generator/index.vue | 846 ++++ .../modules/calculation-config.vue | 460 ++ .../modules/import-dialog.vue | 304 ++ .../modules/preview-dialog.vue | 88 + .../modules/template-dialog.vue | 997 ++++ .../online-dev/electronic-seal/index.vue | 389 ++ .../electronic-seal/modules/seal-dialog.vue | 396 ++ .../online-dev/form-manager/editor/index.vue | 782 ++++ .../views/online-dev/form-manager/index.vue | 1048 +++++ .../form-manager/modules/api-info-dialog.vue | 1072 +++++ .../modules/form-editor-modal.vue | 1083 +++++ .../form-manager/modules/import-dialog.vue | 1854 ++++++++ .../form-manager/modules/preview-dialog.vue | 151 + .../form-manager/modules/publish-dialog.vue | 114 + .../form-manager/utils/sync-form-db-config.ts | 42 + .../utils/validate-form-tables.ts | 58 + .../online-dev/form-render/form-layout.vue | 453 ++ .../online-dev/form-render/form-page.vue | 537 +++ .../views/online-dev/form-render/index.vue | 532 +++ .../styles/sidebar-filter-tree.scss | 91 + .../online-dev/page-manager/editor/index.vue | 140 + .../views/online-dev/page-manager/index.vue | 718 +++ .../page-manager/modules/import-dialog.vue | 262 ++ .../modules/page-editor-modal.vue | 286 ++ .../page-manager/modules/preview-dialog.vue | 86 + .../page-manager/modules/publish-dialog.vue | 123 + .../report-manager/editor/index.vue | 250 + .../views/online-dev/report-manager/index.vue | 646 +++ .../report-manager/modules/import-dialog.vue | 255 ++ .../report-manager/modules/publish-dialog.vue | 139 + .../modules/settings-dialog.vue | 157 + .../report-manager/modules/version-panel.vue | 190 + .../views/online-dev/report-render/index.vue | 331 ++ .../components/PublishDialog.vue | 245 + .../views/online-dev/screen-design/editor.vue | 117 + .../views/online-dev/screen-design/index.vue | 381 ++ .../screen-design/modules/import-dialog.vue | 254 ++ .../online-dev/screen-design/preview.vue | 355 ++ .../workflow-copy/components/TaskList.vue | 295 ++ .../workflow/workflow-copy/index.vue | 150 + .../workflow-handled/components/TaskList.vue | 316 ++ .../workflow/workflow-handled/index.vue | 150 + .../components/InstanceList.vue | 385 ++ .../workflow/workflow-initiated/index.vue | 168 + .../workflow/workflow-instance/index.vue | 293 ++ .../components/ValidationDialog.vue | 265 ++ .../components/WorkflowDialog.vue | 623 +++ .../components/workflow-icons.ts | 250 + .../workflow-manager/editor/index.vue | 188 + .../workflow/workflow-manager/index.vue | 793 ++++ .../modules/import-dialog.vue | 264 ++ .../components/ApprovalSuccessPanel.vue | 123 + .../components/TaskDetail.vue | 1300 ++++++ .../workflow-pending/components/TaskList.vue | 380 ++ .../workflow/workflow-pending/index.vue | 191 + .../components/StartWorkflowDialog.vue | 237 + .../components/StartWorkflowPanel.vue | 322 ++ .../components/SubmitSuccessPanel.vue | 100 + .../workflow/workflow-start/index.vue | 497 ++ .../src/views/zq-smart-table/dashboard.vue | 260 ++ .../src/views/zq-smart-table/index.vue | 78 + 596 files changed, 168879 insertions(+), 290 deletions(-) create mode 100644 backend-fastapi/alembic/versions/f6g7h8i9j0k1_add_form_globally_visible.py create mode 100644 backend-fastapi/alembic/versions/g7h8i9j0k1l2_add_data_source_db_context.py create mode 100644 backend-fastapi/alembic/versions/h8i9j0k1l2m3_add_data_source_list_config.py create mode 100644 backend-fastapi/online_dev/contract/__init__.py create mode 100644 backend-fastapi/online_dev/contract/api.py create mode 100644 backend-fastapi/online_dev/contract/model.py create mode 100644 backend-fastapi/online_dev/contract/schema.py create mode 100644 backend-fastapi/online_dev/contract/service.py create mode 100644 backend-fastapi/online_dev/document_generator/__init__.py create mode 100644 backend-fastapi/online_dev/document_generator/api.py create mode 100644 backend-fastapi/online_dev/document_generator/calculation_engine.py create mode 100644 backend-fastapi/online_dev/document_generator/font_service.py create mode 100644 backend-fastapi/online_dev/document_generator/generator.py create mode 100644 backend-fastapi/online_dev/document_generator/model.py create mode 100644 backend-fastapi/online_dev/document_generator/page_number.py create mode 100644 backend-fastapi/online_dev/document_generator/schema.py create mode 100644 backend-fastapi/online_dev/document_generator/service.py create mode 100644 backend-fastapi/online_dev/document_generator/template_renderer.py create mode 100644 backend-fastapi/online_dev/document_generator/templates/__init__.py create mode 100644 backend-fastapi/online_dev/document_generator/templates/base.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/barcode.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/container.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/detail_table.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/divider.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/doc_info.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/field.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/footer.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/header.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/image.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/info_row.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/info_table.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/label_field.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/paragraph.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/qrcode.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/rich_text.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/row.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/seal.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/signature.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/smart_table.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/smart_text.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/spacer.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/table.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/text.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/title.html create mode 100644 backend-fastapi/online_dev/document_generator/templates/elements/wysiwyg.html create mode 100644 backend-fastapi/online_dev/electronic_seal/__init__.py create mode 100644 backend-fastapi/online_dev/electronic_seal/api.py create mode 100644 backend-fastapi/online_dev/electronic_seal/model.py create mode 100644 backend-fastapi/online_dev/electronic_seal/schema.py create mode 100644 backend-fastapi/online_dev/electronic_seal/service.py create mode 100644 backend-fastapi/online_dev/form_data_manager/api.py create mode 100644 backend-fastapi/online_dev/form_data_manager/dependencies.py create mode 100644 backend-fastapi/online_dev/form_data_manager/schema.py create mode 100644 backend-fastapi/online_dev/form_manager/api.py create mode 100644 backend-fastapi/online_dev/form_manager/schema.py create mode 100644 backend-fastapi/online_dev/page_manager/schema.py create mode 100644 backend-fastapi/online_dev/report_manager/__init__.py create mode 100644 backend-fastapi/online_dev/report_manager/acceptance_e2e.py create mode 100644 backend-fastapi/online_dev/report_manager/api.py create mode 100644 backend-fastapi/online_dev/report_manager/constants.py create mode 100644 backend-fastapi/online_dev/report_manager/data_api.py create mode 100644 backend-fastapi/online_dev/report_manager/dataset_bridge.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/__init__.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/chart_data.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/code_cells.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/column_layout.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/convert.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/convert_lookup.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/data_expand.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/dataset_transform.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/export_excel.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/export_excel_extras.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/export_pdf.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/expression_eval.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/JNPF_DIFF.md create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/README.md create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/extract_jnpf_fixtures.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_column_col_type1.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_column_col_type2.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_expression_chain.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_fill_empty_rows.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_cross_row.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_db_user_group.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_db_user_list.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_db_user_matrix.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_export_list.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_header_merge_list.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_parent_group.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_poly_summary.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_convert_date.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_convert_number.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_convert_select.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_convert_user_inline.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_field_mapping.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_style.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_system_params.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_top_parent.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_list_down.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_multi_col_down.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/fixtures/golden_param_placeholder.json create mode 100644 backend-fastapi/online_dev/report_manager/engine/import_excel.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/merge_recalc.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/parameter_resolver.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/parent_cells.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/polymerize.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/preview_guard.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/preview_mvp.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/sort_apply.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/test_chart_data.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/test_code_cells.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/test_column_layout.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/test_convert.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/test_dataset_transform.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/test_export_excel.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/test_expression_cycles.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/test_expression_eval.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/test_golden.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/test_parameter_resolver.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/test_placeholders.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/test_polymerize.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/test_preview_guard.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/test_sort_apply.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/test_summary_row_shift.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/test_watermark.py create mode 100644 backend-fastapi/online_dev/report_manager/engine/watermark.py create mode 100644 backend-fastapi/online_dev/report_manager/enums.py create mode 100644 backend-fastapi/online_dev/report_manager/exceptions.py create mode 100644 backend-fastapi/online_dev/report_manager/model.py create mode 100644 backend-fastapi/online_dev/report_manager/permission_templates.py create mode 100644 backend-fastapi/online_dev/report_manager/schema.py create mode 100644 backend-fastapi/online_dev/report_manager/service.py create mode 100644 backend-fastapi/online_dev/report_manager/version_service.py create mode 100644 backend-fastapi/online_dev/screen_design/__init__.py create mode 100644 backend-fastapi/online_dev/screen_design/material_api.py create mode 100644 backend-fastapi/online_dev/screen_design/material_model.py create mode 100644 backend-fastapi/online_dev/screen_design/material_schema.py create mode 100644 backend-fastapi/online_dev/screen_design/material_service.py create mode 100644 backend-fastapi/online_dev/screen_design/screen_api.py create mode 100644 backend-fastapi/online_dev/screen_design/screen_model.py create mode 100644 backend-fastapi/online_dev/screen_design/screen_schema.py create mode 100644 backend-fastapi/online_dev/screen_design/screen_service.py create mode 100644 backend-fastapi/online_dev/workflow/__init__.py create mode 100644 backend-fastapi/online_dev/workflow/api.py create mode 100644 backend-fastapi/online_dev/workflow/engine/__init__.py create mode 100644 backend-fastapi/online_dev/workflow/engine/assignee_resolver.py create mode 100644 backend-fastapi/online_dev/workflow/engine/base.py create mode 100644 backend-fastapi/online_dev/workflow/engine/condition_evaluator.py create mode 100644 backend-fastapi/online_dev/workflow/engine/delay_callback.py create mode 100644 backend-fastapi/online_dev/workflow/engine/delay_process.py create mode 100644 backend-fastapi/online_dev/workflow/engine/handlers/__init__.py create mode 100644 backend-fastapi/online_dev/workflow/engine/handlers/approval_handler.py create mode 100644 backend-fastapi/online_dev/workflow/engine/handlers/base_handler.py create mode 100644 backend-fastapi/online_dev/workflow/engine/handlers/condition_handler.py create mode 100644 backend-fastapi/online_dev/workflow/engine/handlers/copy_handler.py create mode 100644 backend-fastapi/online_dev/workflow/engine/handlers/data_update_handler.py create mode 100644 backend-fastapi/online_dev/workflow/engine/handlers/delay_handler.py create mode 100644 backend-fastapi/online_dev/workflow/engine/handlers/handle_handler.py create mode 100644 backend-fastapi/online_dev/workflow/engine/handlers/notification_service.py create mode 100644 backend-fastapi/online_dev/workflow/engine/handlers/notify_handler.py create mode 100644 backend-fastapi/online_dev/workflow/engine/handlers/parallel_handler.py create mode 100644 backend-fastapi/online_dev/workflow/engine/handlers/service_handler.py create mode 100644 backend-fastapi/online_dev/workflow/engine/handlers/subflow_handler.py create mode 100644 backend-fastapi/online_dev/workflow/engine/task_timeout_process.py create mode 100644 backend-fastapi/online_dev/workflow/engine/utils.py create mode 100644 backend-fastapi/online_dev/workflow/engine/workflow_engine.py create mode 100644 backend-fastapi/online_dev/workflow/flow_definition_validator.py create mode 100644 backend-fastapi/online_dev/workflow/model.py create mode 100644 backend-fastapi/online_dev/workflow/progress_service.py create mode 100644 backend-fastapi/online_dev/workflow/schema.py create mode 100644 backend-fastapi/online_dev/workflow/service.py create mode 100644 backend-fastapi/zq_demo/__init__.py create mode 100644 backend-fastapi/zq_demo/api.py create mode 100644 backend-fastapi/zq_demo/model.py create mode 100644 backend-fastapi/zq_demo/router.py create mode 100644 backend-fastapi/zq_demo/schema.py create mode 100644 backend-fastapi/zq_demo/service.py create mode 100644 backend-fastapi/zq_smart_table/__init__.py create mode 100644 backend-fastapi/zq_smart_table/api.py create mode 100644 backend-fastapi/zq_smart_table/formula.py create mode 100644 backend-fastapi/zq_smart_table/model.py create mode 100644 backend-fastapi/zq_smart_table/permission/__init__.py create mode 100644 backend-fastapi/zq_smart_table/permission/api.py create mode 100644 backend-fastapi/zq_smart_table/permission/model.py create mode 100644 backend-fastapi/zq_smart_table/permission/schema.py create mode 100644 backend-fastapi/zq_smart_table/permission/service.py create mode 100644 backend-fastapi/zq_smart_table/router.py create mode 100644 backend-fastapi/zq_smart_table/schema.py create mode 100644 backend-fastapi/zq_smart_table/service.py create mode 100644 web/apps/web-ele/src/api/smart-table.ts create mode 100644 web/apps/web-ele/src/components/document-designer/components/AttributePanel.vue create mode 100644 web/apps/web-ele/src/components/document-designer/components/DesignCanvas.vue create mode 100644 web/apps/web-ele/src/components/document-designer/components/ElementWrapper.vue create mode 100644 web/apps/web-ele/src/components/document-designer/components/FloatElementWrapper.vue create mode 100644 web/apps/web-ele/src/components/document-designer/components/MaterialPanel.vue create mode 100644 web/apps/web-ele/src/components/document-designer/components/SmartTable.vue create mode 100644 web/apps/web-ele/src/components/document-designer/components/SmartText.vue create mode 100644 web/apps/web-ele/src/components/document-designer/components/VariableSelector.vue create mode 100644 web/apps/web-ele/src/components/document-designer/components/WysiwygCanvas.vue create mode 100644 web/apps/web-ele/src/components/document-designer/index.ts create mode 100644 web/apps/web-ele/src/components/document-designer/index.vue create mode 100644 web/apps/web-ele/src/components/document-designer/store/documentDesignStore.ts create mode 100644 web/apps/web-ele/src/components/document-designer/utils/pageNumber.ts create mode 100644 web/apps/web-ele/src/components/document-designer/utils/pageSize.ts create mode 100644 web/apps/web-ele/src/components/import-export-manager/import-export-manager.vue create mode 100644 web/apps/web-ele/src/components/import-export-manager/index.ts create mode 100644 web/apps/web-ele/src/components/import-export-manager/types.ts create mode 100644 web/apps/web-ele/src/components/report-design/hooks/useReportChart.ts create mode 100644 web/apps/web-ele/src/components/report-design/hooks/useReportDatasetFields.ts create mode 100644 web/apps/web-ele/src/components/report-design/hooks/useReportQuery.ts create mode 100644 web/apps/web-ele/src/components/report-design/index.vue create mode 100644 web/apps/web-ele/src/components/report-design/modules/cell-chart-panel.vue create mode 100644 web/apps/web-ele/src/components/report-design/modules/cell-property-panel.vue create mode 100644 web/apps/web-ele/src/components/report-design/modules/chart-bind-form.vue create mode 100644 web/apps/web-ele/src/components/report-design/modules/chart-bind-utils.ts create mode 100644 web/apps/web-ele/src/components/report-design/modules/column-config-dialog.vue create mode 100644 web/apps/web-ele/src/components/report-design/modules/convert-config-dialog.vue create mode 100644 web/apps/web-ele/src/components/report-design/modules/dataset-field-mapping-dialog.vue create mode 100644 web/apps/web-ele/src/components/report-design/modules/dataset-panel.vue create mode 100644 web/apps/web-ele/src/components/report-design/modules/float-echart-panel.vue create mode 100644 web/apps/web-ele/src/components/report-design/modules/float-image-panel.vue create mode 100644 web/apps/web-ele/src/components/report-design/modules/preview-dialog.vue create mode 100644 web/apps/web-ele/src/components/report-design/modules/query-config-dialog.vue create mode 100644 web/apps/web-ele/src/components/report-design/modules/report-config-features-panel.vue create mode 100644 web/apps/web-ele/src/components/report-design/modules/report-config-toggle-item.vue create mode 100644 web/apps/web-ele/src/components/report-design/modules/report-field-select.vue create mode 100644 web/apps/web-ele/src/components/report-design/modules/report-query-form.vue create mode 100644 web/apps/web-ele/src/components/report-design/modules/report-settings-panel.vue create mode 100644 web/apps/web-ele/src/components/report-design/modules/sort-config-dialog.vue create mode 100644 web/apps/web-ele/src/components/report-design/univer-host.vue create mode 100644 web/apps/web-ele/src/components/report-design/utils/dataset-fields.ts create mode 100644 web/apps/web-ele/src/components/report-design/utils/preview-warnings.ts create mode 100644 web/apps/web-ele/src/components/report-design/utils/report-media-url.ts create mode 100644 web/apps/web-ele/src/components/report-design/utils/report-settings.ts create mode 100644 web/apps/web-ele/src/components/wiki/CreateWikiDialog.vue create mode 100644 web/apps/web-ele/src/components/wiki/EditWikiDialog.vue create mode 100644 web/apps/web-ele/src/components/wiki/WikiHomeSidebar.vue create mode 100644 web/apps/web-ele/src/components/workflow/DocumentPreviewDialog.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/FlowDesigner.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/AddNodeButton.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/ConditionEditor.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/FlowCanvas.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/FlowMinimap.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/FlowNode.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/FlowPreview.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/FlowPreviewContent.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/FormFieldPermissions.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/NodeNameField.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/PropertyPanel.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/Toolbar.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/nodes/ApprovalNode.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/nodes/ConditionNode.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/nodes/CopyNode.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/nodes/DataUpdateNode.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/nodes/DelayNode.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/nodes/EndNode.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/nodes/HandleNode.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/nodes/NotifyNode.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/nodes/ParallelNode.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/nodes/ServiceNode.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/nodes/StartNode.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/components/nodes/SubflowNode.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/hooks/useFlowData.ts create mode 100644 web/apps/web-ele/src/components/workflow/designer/hooks/useFlowValidation.ts create mode 100644 web/apps/web-ele/src/components/workflow/designer/index.vue create mode 100644 web/apps/web-ele/src/components/workflow/designer/types.ts create mode 100644 web/apps/web-ele/src/components/workflow/designer/utils/ensure-node-names.ts create mode 100644 web/apps/web-ele/src/components/workflow/designer/utils/node-display.ts create mode 100644 web/apps/web-ele/src/components/workflow/detial/FlowPathPreview.vue create mode 100644 web/apps/web-ele/src/components/workflow/detial/FlowProgressTab.vue create mode 100644 web/apps/web-ele/src/components/workflow/detial/InstanceDetailPanel.vue create mode 100644 web/apps/web-ele/src/components/workflow/detial/tabs/DocumentTab.vue create mode 100644 web/apps/web-ele/src/components/workflow/detial/tabs/FlowchartTab.vue create mode 100644 web/apps/web-ele/src/components/workflow/detial/tabs/FormTab.vue create mode 100644 web/apps/web-ele/src/components/workflow/detial/tabs/ProgressTab.vue create mode 100644 web/apps/web-ele/src/components/workflow/index.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/DrawPreview.vue create mode 100644 web/apps/web-ele/src/components/zq-draw/ZqDraw.vue create mode 100644 web/apps/web-ele/src/components/zq-draw/components/ContextMenu.vue create mode 100644 web/apps/web-ele/src/components/zq-draw/components/DrawCanvas.vue create mode 100644 web/apps/web-ele/src/components/zq-draw/components/DrawToolbar.vue create mode 100644 web/apps/web-ele/src/components/zq-draw/components/HyperlinkPopup.vue create mode 100644 web/apps/web-ele/src/components/zq-draw/components/MainMenu.vue create mode 100644 web/apps/web-ele/src/components/zq-draw/components/PropertyPanel.vue create mode 100644 web/apps/web-ele/src/components/zq-draw/components/StatsPanel.vue create mode 100644 web/apps/web-ele/src/components/zq-draw/components/TextEditor.vue create mode 100644 web/apps/web-ele/src/components/zq-draw/components/UndoRedoControls.vue create mode 100644 web/apps/web-ele/src/components/zq-draw/components/ZoomControls.vue create mode 100644 web/apps/web-ele/src/components/zq-draw/components/icons/property-icons.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/composables/use-canvas-resize.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/composables/use-draw-engine.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/composables/use-keyboard.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/composables/use-pointer-events.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/composables/use-scroll-wheel.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/constants.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/core/clipboard.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/core/cursor.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/core/eraser.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/core/gesture.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/core/history.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/core/renderer/helpers.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/core/renderer/interactive-scene.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/core/renderer/static-scene.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/core/scene.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/core/snap.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/data/export-canvas.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/data/export-svg.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/data/json.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/elements/align.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/elements/binding.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/elements/bound-text.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/elements/bounds.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/elements/collision.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/elements/duplicate.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/elements/elbow-arrow.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/elements/flip.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/elements/frame.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/elements/group.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/elements/linear-element-editor.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/elements/mutate-element.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/elements/new-element.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/elements/shape-generator.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/elements/transform-handles.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/elements/zindex.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/ComicShanns/ComicShanns-Regular-279a7b317d12eb88de06167bd672b4b4.woff2 create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/ComicShanns/ComicShanns-Regular-6e066e8de2ac57ea9283adb9c24d7f0c.woff2 create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/ComicShanns/ComicShanns-Regular-dc6a8806fa96795d7b3be5026f989a17.woff2 create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/ComicShanns/ComicShanns-Regular-fcb0fc02dcbee4c9846b3e2508668039.woff2 create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/Excalifont/Excalifont-Regular-349fac6ca4700ffec595a7150a0d1e1d.woff2 create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/Excalifont/Excalifont-Regular-3f2c5db56cc93c5a6873b1361d730c16.woff2 create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/Excalifont/Excalifont-Regular-41b173a47b57366892116a575a43e2b6.woff2 create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/Excalifont/Excalifont-Regular-623ccf21b21ef6b3a0d87738f77eb071.woff2 create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/Excalifont/Excalifont-Regular-a88b72a24fb54c9f94e3b5fdaa7481c9.woff2 create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/Excalifont/Excalifont-Regular-b9dcf9d2e50a1eaf42fc664b50a3fd0d.woff2 create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/Excalifont/Excalifont-Regular-be310b9bcd4f1a43f571c46df7809174.woff2 create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/Nunito/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2 create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/Nunito/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2 create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/Nunito/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2 create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/Nunito/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2 create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/Nunito/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2 create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/Virgil/Virgil-Regular.woff2 create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/fonts.d.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/index.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/fonts/xiaolai.css create mode 100644 web/apps/web-ele/src/components/zq-draw/index.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/math/curve.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/math/line.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/math/point.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/math/polygon.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/math/types.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/store/draw-store.ts create mode 100644 web/apps/web-ele/src/components/zq-draw/styles/draw.scss create mode 100644 web/apps/web-ele/src/components/zq-draw/types.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/ZqEditor.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/composables/use-block-actions.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/composables/use-doc-export.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/composables/use-editor.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/composables/use-file-upload.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/attachment/AttachmentComponent.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/attachment/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/callout/CalloutComponent.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/callout/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/code-block/CodeBlockComponent.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/code-block/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/columns/ColumnsComponent.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/columns/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/draw/DrawBlockComponent.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/draw/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/embed/EmbedComponent.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/embed/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/font-size/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/image/ImageComponent.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/image/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/math/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/mention/MentionSuggestionMenu.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/mention/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/search-replace/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/slash-command/SlashCommandMenu.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/slash-command/commands.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/slash-command/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/table-of-contents/TocComponent.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/table-of-contents/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/toggle-list/ToggleComponent.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/toggle-list/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/video/VideoComponent.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/video/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/whiteboard/WhiteboardBlockComponent.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/extensions/whiteboard/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/index.ts create mode 100644 web/apps/web-ele/src/components/zq-editor/menus/BubbleToolbar.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/menus/ColorPicker.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/menus/DragHandleMenu.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/menus/EmojiPicker.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/menus/LinkEditor.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/menus/SearchReplacePanel.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/menus/TableContextMenu.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/menus/TableFloatingToolbar.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/menus/TableHandles.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/menus/TableMenu.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/menus/TableSizePicker.vue create mode 100644 web/apps/web-ele/src/components/zq-editor/styles/editor.scss create mode 100644 web/apps/web-ele/src/components/zq-editor/types.ts create mode 100644 web/apps/web-ele/src/components/zq-smart-table/document/SaveAsTemplateDialog.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/document/TemplateSelectDialog.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/document/VersionHistoryPanel.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/document/VersionPreviewDialog.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/icons/FieldIcon.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/layout/SmartTableContent.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/layout/TemplateDialog.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/layout/ZqTableSidebar.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/permission/PermissionDialog.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/table/AddFieldDialog.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/table/CalendarView.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/table/CellRenderer.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/table/FieldConfigPopover.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/table/FormView.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/table/GallerySkeleton.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/table/GalleryView.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/table/GanttView.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/table/KanbanSkeleton.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/table/KanbanView.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/table/LinkRecordSelector.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/table/RecordDetailDrawer.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/table/TableGrid.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/table/TableSkeleton.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/table/TrashDrawer.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/templates/document-templates.ts create mode 100644 web/apps/web-ele/src/components/zq-smart-table/templates/table-templates.ts create mode 100644 web/apps/web-ele/src/components/zq-smart-table/toolbar/ConditionalFormatPanel.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/toolbar/FieldVisibilityPanel.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/toolbar/FilterPanel.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/toolbar/GroupPanel.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/toolbar/SortPanel.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/toolbar/TableToolbar.vue create mode 100644 web/apps/web-ele/src/components/zq-smart-table/toolbar/ViewTabs.vue create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/ZqWhiteboard.vue create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/components/ConnectorToolbar.vue create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/components/ContextMenu.vue create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/components/DirectionHandles.vue create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/components/Minimap.vue create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/components/PropertyPanel.vue create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/components/SelectionToolbar.vue create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/components/UndoRedoControls.vue create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/components/WhiteboardCanvas.vue create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/components/WhiteboardHeader.vue create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/components/WhiteboardToolbar.vue create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/components/ZoomControls.vue create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/components/toolbar/PenOptions.vue create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/components/toolbar/ShapeDropdown.vue create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/constants.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/core/canvas-manager.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/core/clipboard-manager.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/core/export-manager.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/core/history-manager.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/core/shortcut-manager.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/core/snap-manager.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/core/tool-manager.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/index.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/store/whiteboard-store.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/tools/arrow-tool.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/tools/connector-tool.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/tools/eraser-tool.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/tools/hand-tool.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/tools/highlighter-tool.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/tools/image-tool.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/tools/line-tool.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/tools/pen-tool.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/tools/select-tool.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/tools/shape-tool.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/tools/sticky-note-tool.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/tools/text-tool.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/types.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/utils/connector-routing.ts create mode 100644 web/apps/web-ele/src/components/zq-whiteboard/utils/geometry.ts create mode 100644 web/apps/web-ele/src/composables/use-offline-cache.ts create mode 100644 web/apps/web-ele/src/locales/langs/en-US/draw.json create mode 100644 web/apps/web-ele/src/locales/langs/en-US/whiteboard.json create mode 100644 web/apps/web-ele/src/locales/langs/en-US/wiki.json create mode 100644 web/apps/web-ele/src/locales/langs/en-US/zq-editor.json create mode 100644 web/apps/web-ele/src/locales/langs/en-US/zq-smart-table.json create mode 100644 web/apps/web-ele/src/locales/langs/zh-CN/draw.json create mode 100644 web/apps/web-ele/src/locales/langs/zh-CN/whiteboard.json create mode 100644 web/apps/web-ele/src/locales/langs/zh-CN/wiki.json create mode 100644 web/apps/web-ele/src/locales/langs/zh-CN/zq-editor.json create mode 100644 web/apps/web-ele/src/locales/langs/zh-CN/zq-smart-table.json create mode 100644 web/apps/web-ele/src/locales/langs/zh-TW/draw.json create mode 100644 web/apps/web-ele/src/locales/langs/zh-TW/whiteboard.json create mode 100644 web/apps/web-ele/src/locales/langs/zh-TW/wiki.json create mode 100644 web/apps/web-ele/src/locales/langs/zh-TW/zq-editor.json create mode 100644 web/apps/web-ele/src/locales/langs/zh-TW/zq-smart-table.json create mode 100644 web/apps/web-ele/src/router/routes/modules/chat.ts create mode 100644 web/apps/web-ele/src/router/routes/modules/demos.ts create mode 100644 web/apps/web-ele/src/router/routes/modules/form-layout.ts create mode 100644 web/apps/web-ele/src/router/routes/modules/online-dev.ts create mode 100644 web/apps/web-ele/src/router/routes/modules/zq-smart-table.ts create mode 100644 web/apps/web-ele/src/store/wiki.ts create mode 100644 web/apps/web-ele/src/store/zq-smart-table.ts create mode 100644 web/apps/web-ele/src/styles/zq-smart-table/index.css create mode 100644 web/apps/web-ele/src/styles/zq-smart-table/theme.css create mode 100644 web/apps/web-ele/src/views/demos/code-editor/examples.ts create mode 100644 web/apps/web-ele/src/views/demos/code-editor/index.vue create mode 100644 web/apps/web-ele/src/views/demos/contract-design/index.vue create mode 100644 web/apps/web-ele/src/views/demos/contract-design/sign.vue create mode 100644 web/apps/web-ele/src/views/demos/dashboard-design/index.vue create mode 100644 web/apps/web-ele/src/views/demos/element/index.vue create mode 100644 web/apps/web-ele/src/views/demos/form-design-demo.vue create mode 100644 web/apps/web-ele/src/views/demos/form/basic.vue create mode 100644 web/apps/web-ele/src/views/demos/fu-page-demo.vue create mode 100644 web/apps/web-ele/src/views/demos/multi-data-view/index.vue create mode 100644 web/apps/web-ele/src/views/demos/notion-editor.vue create mode 100644 web/apps/web-ele/src/views/demos/rich-text-editor.vue create mode 100644 web/apps/web-ele/src/views/demos/rich-text-editor/index.vue create mode 100644 web/apps/web-ele/src/views/demos/splitter-demo.vue create mode 100644 web/apps/web-ele/src/views/demos/zq-api-select/index.vue create mode 100644 web/apps/web-ele/src/views/demos/zq-table/custom.vue create mode 100644 web/apps/web-ele/src/views/demos/zq-table/index.vue create mode 100644 web/apps/web-ele/src/views/demos/zq-table/search.vue create mode 100644 web/apps/web-ele/src/views/online-dev/contract/contract-instance/components/CreateDialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/contract/contract-instance/components/DetailDialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/contract/contract-instance/components/MobileSignDialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/contract/contract-instance/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/contract/contract-template/components/TemplateDialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/contract/contract-template/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/contract/mobile-sign/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/document-generator/components/TemplateDesigner.vue create mode 100644 web/apps/web-ele/src/views/online-dev/document-generator/editor/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/document-generator/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/document-generator/modules/calculation-config.vue create mode 100644 web/apps/web-ele/src/views/online-dev/document-generator/modules/import-dialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/document-generator/modules/preview-dialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/document-generator/modules/template-dialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/electronic-seal/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/electronic-seal/modules/seal-dialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/form-manager/editor/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/form-manager/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/form-manager/modules/api-info-dialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/form-manager/modules/form-editor-modal.vue create mode 100644 web/apps/web-ele/src/views/online-dev/form-manager/modules/import-dialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/form-manager/modules/preview-dialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/form-manager/modules/publish-dialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/form-manager/utils/sync-form-db-config.ts create mode 100644 web/apps/web-ele/src/views/online-dev/form-manager/utils/validate-form-tables.ts create mode 100644 web/apps/web-ele/src/views/online-dev/form-render/form-layout.vue create mode 100644 web/apps/web-ele/src/views/online-dev/form-render/form-page.vue create mode 100644 web/apps/web-ele/src/views/online-dev/form-render/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/form-render/styles/sidebar-filter-tree.scss create mode 100644 web/apps/web-ele/src/views/online-dev/page-manager/editor/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/page-manager/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/page-manager/modules/import-dialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/page-manager/modules/page-editor-modal.vue create mode 100644 web/apps/web-ele/src/views/online-dev/page-manager/modules/preview-dialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/page-manager/modules/publish-dialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/report-manager/editor/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/report-manager/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/report-manager/modules/import-dialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/report-manager/modules/publish-dialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/report-manager/modules/settings-dialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/report-manager/modules/version-panel.vue create mode 100644 web/apps/web-ele/src/views/online-dev/report-render/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/screen-design/components/PublishDialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/screen-design/editor.vue create mode 100644 web/apps/web-ele/src/views/online-dev/screen-design/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/screen-design/modules/import-dialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/screen-design/preview.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-copy/components/TaskList.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-copy/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-handled/components/TaskList.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-handled/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-initiated/components/InstanceList.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-initiated/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-instance/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-manager/components/ValidationDialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-manager/components/WorkflowDialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-manager/components/workflow-icons.ts create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-manager/editor/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-manager/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-manager/modules/import-dialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-pending/components/ApprovalSuccessPanel.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-pending/components/TaskDetail.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-pending/components/TaskList.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-pending/index.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-start/components/StartWorkflowDialog.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-start/components/StartWorkflowPanel.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-start/components/SubmitSuccessPanel.vue create mode 100644 web/apps/web-ele/src/views/online-dev/workflow/workflow-start/index.vue create mode 100644 web/apps/web-ele/src/views/zq-smart-table/dashboard.vue create mode 100644 web/apps/web-ele/src/views/zq-smart-table/index.vue diff --git a/.gitignore b/.gitignore index ad78ae5..f5c180d 100644 --- a/.gitignore +++ b/.gitignore @@ -181,123 +181,18 @@ dmypy.json /go-view/ node_modules -# ai-agent-admin: exclude generated bundles and unused product entries. -# Keep design/runtime source used by AI workflow panels tracked; lightness is -# enforced by route/menu filtering and lazy imports, not by missing source. +# ai-agent-admin: exclude generated bundles and runtime artifacts. +# Source code stays tracked; lightness is enforced by route/menu filtering, +# lazy imports, and build-time entry selection. .ops-backups/ docker/uniapp/ docker/web/index/* !docker/web/index/.gitkeep uniapp-zq/ -web/apps/web-ele/src/components/import-export-manager/ -web/apps/web-ele/src/components/wiki/ -web/apps/web-ele/src/components/zq-draw/ -web/apps/web-ele/src/components/zq-editor/ -web/apps/web-ele/src/components/zq-smart-table/ -web/apps/web-ele/src/components/zq-whiteboard/ -web/apps/web-ele/src/components/whiteboard/ -web/apps/web-ele/src/components/workflow/ -web/apps/web-ele/src/views/online-dev/ -!web/apps/web-ele/src/views/online-dev/ -web/apps/web-ele/src/views/online-dev/* -!web/apps/web-ele/src/views/online-dev/page-render/ -!web/apps/web-ele/src/views/online-dev/page-render/** -!web/apps/web-ele/src/views/online-dev/form-manager/ -web/apps/web-ele/src/views/online-dev/form-manager/* -!web/apps/web-ele/src/views/online-dev/form-manager/composables/ -web/apps/web-ele/src/views/online-dev/form-manager/composables/* -!web/apps/web-ele/src/views/online-dev/form-manager/composables/useFormData.ts -!web/apps/web-ele/src/views/online-dev/form-manager/modules/ -web/apps/web-ele/src/views/online-dev/form-manager/modules/* -!web/apps/web-ele/src/views/online-dev/form-manager/modules/ListTypePicker.vue -!web/apps/web-ele/src/views/online-dev/form-manager/modules/data-source-config.vue -!web/apps/web-ele/src/views/online-dev/form-manager/modules/list-design.vue -!web/apps/web-ele/src/views/online-dev/form-manager/utils/ -web/apps/web-ele/src/views/online-dev/form-manager/utils/* -!web/apps/web-ele/src/views/online-dev/form-manager/utils/formValidator.ts -!web/apps/web-ele/src/views/online-dev/form-render/ -web/apps/web-ele/src/views/online-dev/form-render/* -!web/apps/web-ele/src/views/online-dev/form-render/components/ -!web/apps/web-ele/src/views/online-dev/form-render/components/** -!web/apps/web-ele/src/views/online-dev/form-render/utils/ -web/apps/web-ele/src/views/online-dev/form-render/utils/* -!web/apps/web-ele/src/views/online-dev/form-render/utils/list-filter-fields.ts -web/apps/web-ele/src/views/dashboard/ -!web/apps/web-ele/src/views/dashboard/ -web/apps/web-ele/src/views/dashboard/* -!web/apps/web-ele/src/views/dashboard/analytics/ -!web/apps/web-ele/src/views/dashboard/analytics/** -!web/apps/web-ele/src/views/dashboard/workspace/ -!web/apps/web-ele/src/views/dashboard/workspace/** -web/apps/web-ele/src/views/demos/ -web/apps/web-ele/src/views/zq-smart-table/ -web/apps/web-ele/src/components/document-designer/ -web/apps/web-ele/src/components/workflow/designer/ web/docs/src/public/guide/ web/apps/web-ele/src/locales/langs/*/*.json_new web/apps/web-ele/src/locales/langs/*/*.json_sync web/apps/web-ele/src/locales/langs/*/*.json.tmp -web/apps/web-ele/src/locales/langs/*/draw.json -web/apps/web-ele/src/locales/langs/*/whiteboard.json -web/apps/web-ele/src/locales/langs/*/wiki.json -web/apps/web-ele/src/locales/langs/*/zq-editor.json -web/apps/web-ele/src/locales/langs/*/zq-smart-table.json -web/apps/web-ele/src/router/routes/modules/form-layout.ts -web/apps/web-ele/src/router/routes/modules/online-dev.ts -web/apps/web-ele/src/router/routes/modules/chat.ts -web/apps/web-ele/src/router/routes/modules/demos.ts -web/apps/web-ele/src/router/routes/modules/zq-smart-table.ts -web/apps/web-ele/src/api/smart-table.ts -web/apps/web-ele/src/composables/use-offline-cache.ts -web/apps/web-ele/src/store/wiki.ts -web/apps/web-ele/src/store/zq-smart-table.ts -web/apps/web-ele/src/styles/zq-smart-table/ -web/apps/web-ele/src/components/report-design/ -backend-fastapi/online_dev/ -!backend-fastapi/online_dev/ -backend-fastapi/online_dev/* -!backend-fastapi/online_dev/__init__.py -!backend-fastapi/online_dev/router.py -!backend-fastapi/online_dev/page_manager/ -!backend-fastapi/online_dev/page_manager/** -backend-fastapi/online_dev/page_manager/* -!backend-fastapi/online_dev/page_manager/__init__.py -!backend-fastapi/online_dev/page_manager/api.py -!backend-fastapi/online_dev/page_manager/model.py -!backend-fastapi/online_dev/page_manager/service.py -!backend-fastapi/online_dev/form_manager/ -!backend-fastapi/online_dev/form_manager/** -backend-fastapi/online_dev/form_manager/* -!backend-fastapi/online_dev/form_manager/__init__.py -!backend-fastapi/online_dev/form_manager/model.py -!backend-fastapi/online_dev/form_manager/service.py -!backend-fastapi/online_dev/ -backend-fastapi/online_dev/* -!backend-fastapi/online_dev/__init__.py -!backend-fastapi/online_dev/router.py -!backend-fastapi/online_dev/form_manager/ -!backend-fastapi/online_dev/form_manager/** -!backend-fastapi/online_dev/form_data_manager/ -!backend-fastapi/online_dev/form_data_manager/** -!backend-fastapi/online_dev/page_manager/ -!backend-fastapi/online_dev/page_manager/** -backend-fastapi/zq_demo/ -backend-fastapi/zq_smart_table/ -backend-fastapi/ai_platform/nodes/builtin/app_*_node.py -backend-fastapi/ai_platform/nodes/builtin/dashboard_*_node.py -backend-fastapi/ai_platform/nodes/builtin/form_*_node.py -backend-fastapi/ai_platform/nodes/builtin/system_summary_node.py -!backend-fastapi/ai_platform/nodes/builtin/app_create_node.py -!backend-fastapi/ai_platform/nodes/builtin/app_design_node.py -!backend-fastapi/ai_platform/nodes/builtin/app_settings_node.py -!backend-fastapi/ai_platform/nodes/builtin/app_update_node.py -!backend-fastapi/ai_platform/nodes/builtin/dashboard_basic_info_node.py -!backend-fastapi/ai_platform/nodes/builtin/dashboard_create_node.py -!backend-fastapi/ai_platform/nodes/builtin/dashboard_design_node.py -!backend-fastapi/ai_platform/nodes/builtin/dashboard_publish_node.py -!backend-fastapi/ai_platform/nodes/builtin/form_basic_info_node.py -!backend-fastapi/ai_platform/nodes/builtin/form_create_node.py -!backend-fastapi/ai_platform/nodes/builtin/form_data_node.py !backend-fastapi/ai_platform/nodes/builtin/form_database_create_node.py !backend-fastapi/ai_platform/nodes/builtin/form_database_design_node.py !backend-fastapi/ai_platform/nodes/builtin/form_list_design_node.py diff --git a/backend-fastapi/ai_platform/nodes/builtin/dialog_nodes.py b/backend-fastapi/ai_platform/nodes/builtin/dialog_nodes.py index 9f67e25..46616e6 100644 --- a/backend-fastapi/ai_platform/nodes/builtin/dialog_nodes.py +++ b/backend-fastapi/ai_platform/nodes/builtin/dialog_nodes.py @@ -570,7 +570,7 @@ class ConfirmNode(BaseNode): return False, False - def _llm_intent_recognition(self, user_input: str, context_content: str, model_id: str) -> bool: + def _llm_intent_recognition(self, user_input: str, context_content: str, model_id: str, db_session=None) -> bool: """ 使用 LLM 进行意图识别 @@ -595,7 +595,7 @@ class ConfirmNode(BaseNode): 请判断用户意图:""" - llm_service = LLMService() + llm_service = LLMService(db_session) response = llm_service.chat( model_id=model_id, messages=[ @@ -639,7 +639,9 @@ class ConfirmNode(BaseNode): if not matched and use_llm_intent and llm_model_id: # 关键词未匹配,使用 LLM 意图识别 logger.info(f'关键词未匹配,使用 LLM 意图识别: {user_input}') - confirmed = self._llm_intent_recognition(str(user_input), content, llm_model_id) + confirmed = self._llm_intent_recognition( + str(user_input), content, llm_model_id, context.db_session + ) elif not matched: # 关键词未匹配且未启用 LLM,默认为取消 logger.info(f'关键词未匹配,默认取消: {user_input}') diff --git a/backend-fastapi/ai_platform/nodes/builtin/llm_node.py b/backend-fastapi/ai_platform/nodes/builtin/llm_node.py index bb04d15..a771af9 100644 --- a/backend-fastapi/ai_platform/nodes/builtin/llm_node.py +++ b/backend-fastapi/ai_platform/nodes/builtin/llm_node.py @@ -313,6 +313,7 @@ class LLMNode(BaseNode): messages.append({'role': 'user', 'content': user_prompt}) llm_service = LLMService(context.db_session) + model_id = llm_service.resolve_chat_model_id_sync(model_id) output_var = self.config.get('output_variable', 'llm_response') # 根据输出模式选择执行方式 diff --git a/backend-fastapi/ai_platform/services/llm_service.py b/backend-fastapi/ai_platform/services/llm_service.py index 3c0d90c..2e6e07d 100644 --- a/backend-fastapi/ai_platform/services/llm_service.py +++ b/backend-fastapi/ai_platform/services/llm_service.py @@ -64,6 +64,26 @@ class LLMService: logger.info("No model_id supplied, fallback to default chat model %s", model.id) return str(model.id) + + @staticmethod + def _run_async_sync(coro): + import asyncio + import concurrent.futures + + try: + loop = asyncio.get_event_loop() + except RuntimeError: + return asyncio.run(coro) + + if loop.is_running(): + with concurrent.futures.ThreadPoolExecutor() as executor: + return executor.submit(asyncio.run, coro).result() + + return loop.run_until_complete(coro) + + def resolve_chat_model_id_sync(self, model_id: Optional[str]) -> str: + """同步解析 chat 模型,供同步节点和流式生成器复用。""" + return self._run_async_sync(self.resolve_chat_model_id(model_id)) async def _get_provider_async(self, model_id: str) -> tuple: """ @@ -272,6 +292,34 @@ class LLMService: return provider.chat(llm_messages, config) except Exception as exc: raise RuntimeError(self._format_provider_error(exc, provider, model_name)) from exc + + def chat( + self, + model_id: Optional[str], + messages: List[Dict[str, str]], + temperature: float = 0.7, + max_tokens: int = 2048, + tools: List[Dict] = None, + tool_choice: str = 'auto', + **kwargs + ) -> LLMResponse: + """ + 同步对话入口。 + + 旧节点仍会调用该方法;这里统一走 provider 解析和默认 chat 模型兜底, + 避免不同节点各自处理 model_id 为空的情况。 + """ + provider, model_name = self._run_async_sync(self._get_provider_async(model_id)) + return self.chat_with_provider( + provider=provider, + model_name=model_name, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + **kwargs, + ) def _convert_messages(self, messages: List[Dict]) -> List[LLMMessage]: """转换消息格式,支持 tool 消息""" diff --git a/backend-fastapi/alembic/versions/f6g7h8i9j0k1_add_form_globally_visible.py b/backend-fastapi/alembic/versions/f6g7h8i9j0k1_add_form_globally_visible.py new file mode 100644 index 0000000..e98f41b --- /dev/null +++ b/backend-fastapi/alembic/versions/f6g7h8i9j0k1_add_form_globally_visible.py @@ -0,0 +1,34 @@ +"""add form_meta globally_visible column + +Revision ID: f6g7h8i9j0k1 +Revises: e5f6g7h8i9j0 +Create Date: 2026-06-04 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = 'f6g7h8i9j0k1' +down_revision: Union[str, None] = 'e5f6g7h8i9j0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'form_meta', + sa.Column( + 'globally_visible', + sa.Boolean(), + nullable=True, + server_default=sa.text('false'), + comment='是否全局可见(供其他应用引用)', + ), + ) + op.execute("UPDATE form_meta SET globally_visible = false WHERE globally_visible IS NULL") + + +def downgrade() -> None: + op.drop_column('form_meta', 'globally_visible') diff --git a/backend-fastapi/alembic/versions/g7h8i9j0k1l2_add_data_source_db_context.py b/backend-fastapi/alembic/versions/g7h8i9j0k1l2_add_data_source_db_context.py new file mode 100644 index 0000000..c8f4582 --- /dev/null +++ b/backend-fastapi/alembic/versions/g7h8i9j0k1l2_add_data_source_db_context.py @@ -0,0 +1,34 @@ +"""add data source db_database and db_schema columns + +Revision ID: g7h8i9j0k1l2 +Revises: f6g7h8i9j0k1 +Create Date: 2026-06-04 14:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = 'g7h8i9j0k1l2' +down_revision: Union[str, None] = 'f6g7h8i9j0k1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'core_data_source', + sa.Column('db_database', sa.String(length=100), nullable=True, server_default='', comment='SQL执行目标库/catalog'), + ) + op.add_column( + 'core_data_source', + sa.Column('db_schema', sa.String(length=100), nullable=True, server_default='', comment='SQL执行目标模式/schema'), + ) + op.execute("UPDATE core_data_source SET db_database = '' WHERE db_database IS NULL") + op.execute("UPDATE core_data_source SET db_schema = '' WHERE db_schema IS NULL") + + +def downgrade() -> None: + op.drop_column('core_data_source', 'db_schema') + op.drop_column('core_data_source', 'db_database') diff --git a/backend-fastapi/alembic/versions/h8i9j0k1l2m3_add_data_source_list_config.py b/backend-fastapi/alembic/versions/h8i9j0k1l2m3_add_data_source_list_config.py new file mode 100644 index 0000000..ee65881 --- /dev/null +++ b/backend-fastapi/alembic/versions/h8i9j0k1l2m3_add_data_source_list_config.py @@ -0,0 +1,33 @@ +"""add data source list_config column + +Revision ID: h8i9j0k1l2m3 +Revises: g7h8i9j0k1l2 +Create Date: 2026-06-05 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = 'h8i9j0k1l2m3' +down_revision: Union[str, None] = 'g7h8i9j0k1l2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'core_data_source', + sa.Column( + 'list_config', + sa.JSON(), + nullable=True, + comment='列表后处理: sort_field/sort_order/limit', + ), + ) + op.execute("UPDATE core_data_source SET list_config = '{}' WHERE list_config IS NULL") + + +def downgrade() -> None: + op.drop_column('core_data_source', 'list_config') diff --git a/backend-fastapi/online_dev/contract/__init__.py b/backend-fastapi/online_dev/contract/__init__.py new file mode 100644 index 0000000..e8e3723 --- /dev/null +++ b/backend-fastapi/online_dev/contract/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +合同管理模块 +""" diff --git a/backend-fastapi/online_dev/contract/api.py b/backend-fastapi/online_dev/contract/api.py new file mode 100644 index 0000000..d1b60fa --- /dev/null +++ b/backend-fastapi/online_dev/contract/api.py @@ -0,0 +1,773 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +合同管理 API(异步版本) +""" +import logging +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.base_schema import PaginatedResponse +from online_dev.contract.schema import ( + ContractInstanceCreate, + ContractInstanceListItem, + ContractInstanceOut, + ContractInstanceUpdate, + ContractLogOut, + ContractSignatureCreate, + ContractSignatureOut, + ContractTemplateCreate, + ContractTemplateListItem, + ContractTemplateOut, + ContractTemplateUpdate, + MobileContractInfo, + MobileSignRequest, + MobileSignTokenCreate, + MobileSignTokenOut, +) +from online_dev.contract.service import ( + ContractInstanceService, + ContractSignatureService, + ContractTemplateService, + MobileSignService, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/contract", tags=["合同管理"]) + + +# ============ 辅助函数 ============ + +def _format_datetime(dt) -> str: + """格式化日期时间""" + if dt: + return dt.strftime("%Y-%m-%d %H:%M:%S") + return "" + + +async def _build_template_out(template) -> dict: + """构建模板输出""" + return { + "id": str(template.id), + "name": template.name, + "code": template.code, + "category": template.category or "", + "description": template.description or "", + "status": template.status, + "version": template.version, + "template_config": template.template_config or {}, + "thumbnail": template.thumbnail or "", + "sys_create_datetime": template.sys_create_datetime, + "sys_update_datetime": template.sys_update_datetime, + } + + +async def _build_template_list_item(template) -> dict: + """构建模板列表项""" + return { + "id": str(template.id), + "name": template.name, + "code": template.code, + "category": template.category or "", + "description": template.description or "", + "status": template.status, + "version": template.version, + "thumbnail": template.thumbnail or "", + "sys_create_datetime": template.sys_create_datetime, + "sys_update_datetime": template.sys_update_datetime, + } + + +async def _build_instance_out(db: AsyncSession, instance) -> dict: + """构建合同实例输出""" + # 获取关联的模板信息 + template = await ContractTemplateService.get_by_id(db, instance.template_id) + template_name = template.name if template else "" + template_code = template.code if template else "" + + # 获取创建人信息 + creator_name = "" + if instance.creator_id: + from core.user.model import User + from sqlalchemy import select + stmt = select(User).where(User.id == instance.creator_id) + result = await db.execute(stmt) + creator = result.scalar_one_or_none() + if creator: + creator_name = creator.name or creator.username or "" + + return { + "id": str(instance.id), + "contract_no": instance.contract_no, + "title": instance.title, + "status": instance.status, + "template_id": str(instance.template_id), + "contract_config": instance.contract_config or {}, + "variable_data": instance.variable_data or {}, + "signature_data": instance.signature_data or {}, + "created_at": instance.created_at, + "completed_at": instance.completed_at, + "expired_at": instance.expired_at, + "pdf_file": instance.pdf_file or "", + "template_name": template_name, + "template_code": template_code, + "creator_id": str(instance.creator_id) if instance.creator_id else "", + "creator_name": creator_name, + } + + +async def _build_instance_list_item(db: AsyncSession, instance) -> dict: + """构建合同实例列表项""" + # 获取关联的模板信息 + template = await ContractTemplateService.get_by_id(db, instance.template_id) + template_name = template.name if template else "" + + # 获取创建人信息 + creator_name = "" + if instance.creator_id: + from core.user.model import User + from sqlalchemy import select + stmt = select(User).where(User.id == instance.creator_id) + result = await db.execute(stmt) + creator = result.scalar_one_or_none() + if creator: + creator_name = creator.name or creator.username or "" + + return { + "id": str(instance.id), + "contract_no": instance.contract_no, + "title": instance.title, + "status": instance.status, + "created_at": instance.created_at, + "completed_at": instance.completed_at, + "template_name": template_name, + "creator_name": creator_name, + } + + +def _build_signature_out(signature) -> dict: + """构建签署记录输出""" + return { + "id": str(signature.id), + "element_id": signature.element_id, + "party_type": signature.party_type, + "party_label": signature.party_label or "", + "sign_type": signature.sign_type, + "signer_id": str(signature.signer_id) if signature.signer_id else None, + "signer_name": signature.signer_name or "", + "signature_image": signature.signature_image or "", + "status": signature.status, + "signed_at": signature.signed_at, + } + + +async def _build_log_out(db: AsyncSession, log) -> dict: + """构建日志输出""" + # 获取操作人信息 + operator_name = "" + if log.operator_id: + from core.user.model import User + from sqlalchemy import select + stmt = select(User).where(User.id == log.operator_id) + result = await db.execute(stmt) + operator = result.scalar_one_or_none() + if operator: + operator_name = operator.name or operator.username or "" + + return { + "id": str(log.id), + "action": log.action, + "operator_id": str(log.operator_id) if log.operator_id else "", + "comment": log.comment or "", + "sys_create_datetime": log.sys_create_datetime, + "operator_name": operator_name, + } + + +# ============ 合同模板 API ============ + +@router.get("/template/list", response_model=PaginatedResponse[ContractTemplateListItem], summary="模板列表") +async def list_templates( + name: str = Query(None, description="模板名称"), + code: str = Query(None, description="模板编码"), + category: str = Query(None, description="分类"), + status: str = Query(None, description="状态"), + page: int = Query(default=1, ge=1, description="页码"), + page_size: int = Query(default=20, ge=1, le=100, alias="pageSize", description="每页数量"), + db: AsyncSession = Depends(get_db), +): + """分页查询合同模板列表""" + result = await ContractTemplateService.get_list( + db=db, + page=page, + page_size=page_size, + name=name, + code=code, + category=category, + status=status, + ) + + items = [await _build_template_list_item(item) for item in result["items"]] + + return PaginatedResponse( + items=items, + total=result["total"], + ) + + +@router.get("/template/categories", response_model=List[str], summary="获取模板分类") +async def get_template_categories(db: AsyncSession = Depends(get_db)): + """获取所有模板分类""" + return await ContractTemplateService.get_categories(db) + + +@router.get("/template/{template_id}", response_model=ContractTemplateOut, summary="模板详情") +async def get_template( + template_id: str, + db: AsyncSession = Depends(get_db), +): + """获取模板详情""" + template = await ContractTemplateService.get_by_id(db, template_id) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + return await _build_template_out(template) + + +@router.get("/template/code/{code}", response_model=ContractTemplateOut, summary="根据编码获取模板") +async def get_template_by_code( + code: str, + db: AsyncSession = Depends(get_db), +): + """根据编码获取模板""" + template = await ContractTemplateService.get_by_code(db, code) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + return await _build_template_out(template) + + +@router.post("/template", response_model=ContractTemplateOut, summary="创建模板") +async def create_template( + request: Request, + data: ContractTemplateCreate, + db: AsyncSession = Depends(get_db), +): + """创建合同模板""" + user_id = request.state.user_id + + # 检查编码是否已存在 + if await ContractTemplateService.get_by_code(db, data.code): + raise HTTPException(status_code=400, detail=f"模板编码 {data.code} 已存在") + + try: + template = await ContractTemplateService.create( + db=db, + name=data.name, + code=data.code, + category=data.category, + description=data.description, + template_config=data.template_config, + creator_id=user_id, + ) + return await _build_template_out(template) + except Exception as e: + logger.exception("创建模板失败") + raise HTTPException(status_code=400, detail=str(e)) + + +@router.put("/template/{template_id}", response_model=ContractTemplateOut, summary="更新模板") +async def update_template( + template_id: str, + data: ContractTemplateUpdate, + db: AsyncSession = Depends(get_db), +): + """更新合同模板""" + template = await ContractTemplateService.get_by_id(db, template_id) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + + try: + template = await ContractTemplateService.update( + db=db, + template=template, + name=data.name, + category=data.category, + description=data.description, + template_config=data.template_config, + thumbnail=data.thumbnail, + ) + return await _build_template_out(template) + except Exception as e: + logger.exception("更新模板失败") + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/template/{template_id}", summary="删除模板") +async def delete_template( + template_id: str, + db: AsyncSession = Depends(get_db), +): + """删除合同模板""" + template = await ContractTemplateService.get_by_id(db, template_id) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + + await ContractTemplateService.delete(db, template) + return {"message": "删除成功"} + + +@router.delete("/template/batch", summary="批量删除模板") +async def batch_delete_templates( + ids: List[str] = Query(..., description="模板ID列表"), + db: AsyncSession = Depends(get_db), +): + """批量删除合同模板""" + count = 0 + for template_id in ids: + template = await ContractTemplateService.get_by_id(db, template_id) + if template: + await ContractTemplateService.delete(db, template) + count += 1 + return {"count": count} + + +@router.post("/template/{template_id}/publish", response_model=ContractTemplateOut, summary="发布模板") +async def publish_template( + template_id: str, + db: AsyncSession = Depends(get_db), +): + """发布合同模板""" + template = await ContractTemplateService.get_by_id(db, template_id) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + + template = await ContractTemplateService.publish(db, template) + return await _build_template_out(template) + + +@router.post("/template/{template_id}/disable", response_model=ContractTemplateOut, summary="停用模板") +async def disable_template( + template_id: str, + db: AsyncSession = Depends(get_db), +): + """停用合同模板""" + template = await ContractTemplateService.get_by_id(db, template_id) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + + template = await ContractTemplateService.disable(db, template) + return await _build_template_out(template) + + +@router.post("/template/{template_id}/copy", response_model=ContractTemplateOut, summary="复制模板") +async def copy_template( + request: Request, + template_id: str, + new_code: str = Query(..., alias="newCode", description="新模板编码"), + new_name: str = Query(None, alias="newName", description="新模板名称"), + db: AsyncSession = Depends(get_db), +): + """复制合同模板""" + user_id = request.state.user_id + + template = await ContractTemplateService.get_by_id(db, template_id) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + + # 检查新编码是否已存在 + if await ContractTemplateService.get_by_code(db, new_code): + raise HTTPException(status_code=400, detail=f"模板编码 {new_code} 已存在") + + new_template = await ContractTemplateService.copy( + db=db, + template=template, + new_code=new_code, + new_name=new_name, + creator_id=user_id, + ) + return await _build_template_out(new_template) + + +# ============ 合同实例 API ============ + +@router.get("/instance/generate-no", summary="生成合同编号") +async def generate_contract_no(): + """生成一个新的合同编号""" + return {"contract_no": ContractInstanceService.generate_contract_no()} + + +@router.get("/instance/check-no", summary="检查合同编号唯一性") +async def check_contract_no( + contract_no: str = Query(..., alias="contractNo", description="合同编号"), + exclude_id: str = Query(None, alias="excludeId", description="排除的合同ID"), + db: AsyncSession = Depends(get_db), +): + """检查合同编号是否已存在""" + exists = await ContractInstanceService.check_contract_no_exists(db, contract_no, exclude_id) + return {"exists": exists, "valid": not exists} + + +@router.get("/instance/list", response_model=PaginatedResponse[ContractInstanceListItem], summary="合同列表") +async def list_instances( + title: str = Query(None, description="合同标题"), + contract_no: str = Query(None, alias="contractNo", description="合同编号"), + status: str = Query(None, description="状态"), + template_id: str = Query(None, alias="templateId", description="模板ID"), + page: int = Query(default=1, ge=1, description="页码"), + page_size: int = Query(default=20, ge=1, le=100, alias="pageSize", description="每页数量"), + db: AsyncSession = Depends(get_db), +): + """分页查询合同实例列表""" + result = await ContractInstanceService.get_list( + db=db, + page=page, + page_size=page_size, + title=title, + contract_no=contract_no, + status=status, + template_id=template_id, + ) + + items = [await _build_instance_list_item(db, item) for item in result["items"]] + + return PaginatedResponse( + items=items, + total=result["total"], + ) + + +@router.get("/instance/my", response_model=PaginatedResponse[ContractInstanceListItem], summary="我的合同") +async def list_my_instances( + request: Request, + title: str = Query(None, description="合同标题"), + status: str = Query(None, description="状态"), + page: int = Query(default=1, ge=1, description="页码"), + page_size: int = Query(default=20, ge=1, le=100, alias="pageSize", description="每页数量"), + db: AsyncSession = Depends(get_db), +): + """获取我创建的合同""" + user_id = request.state.user_id + + result = await ContractInstanceService.get_list( + db=db, + page=page, + page_size=page_size, + title=title, + status=status, + creator_id=user_id, + ) + + items = [await _build_instance_list_item(db, item) for item in result["items"]] + + return PaginatedResponse( + items=items, + total=result["total"], + ) + + +@router.get("/instance/{instance_id}", response_model=ContractInstanceOut, summary="合同详情") +async def get_instance( + instance_id: str, + db: AsyncSession = Depends(get_db), +): + """获取合同详情""" + instance = await ContractInstanceService.get_by_id(db, instance_id) + if not instance: + raise HTTPException(status_code=404, detail="合同不存在") + return await _build_instance_out(db, instance) + + +@router.post("/instance", response_model=ContractInstanceOut, summary="创建合同") +async def create_instance( + request: Request, + data: ContractInstanceCreate, + db: AsyncSession = Depends(get_db), +): + """创建合同实例""" + user_id = request.state.user_id + + try: + instance = await ContractInstanceService.create( + db=db, + template_id=data.template_id, + title=data.title, + creator_id=user_id, + variable_data=data.variable_data, + contract_no=data.contract_no, + ) + return await _build_instance_out(db, instance) + except Exception as e: + logger.exception("创建合同失败") + raise HTTPException(status_code=400, detail=str(e)) + + +@router.put("/instance/{instance_id}", response_model=ContractInstanceOut, summary="更新合同") +async def update_instance( + request: Request, + instance_id: str, + data: ContractInstanceUpdate, + db: AsyncSession = Depends(get_db), +): + """更新合同""" + user_id = request.state.user_id + + instance = await ContractInstanceService.get_by_id(db, instance_id) + if not instance: + raise HTTPException(status_code=404, detail="合同不存在") + + try: + instance = await ContractInstanceService.update( + db=db, + instance=instance, + title=data.title, + variable_data=data.variable_data, + signature_data=data.signature_data, + operator_id=user_id, + ) + return await _build_instance_out(db, instance) + except Exception as e: + logger.exception("更新合同失败") + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/instance/{instance_id}", summary="删除合同") +async def delete_instance( + instance_id: str, + db: AsyncSession = Depends(get_db), +): + """删除合同""" + instance = await ContractInstanceService.get_by_id(db, instance_id) + if not instance: + raise HTTPException(status_code=404, detail="合同不存在") + + await ContractInstanceService.delete(db, instance) + return {"message": "删除成功"} + + +@router.post("/instance/{instance_id}/submit", response_model=ContractInstanceOut, summary="提交合同") +async def submit_instance( + request: Request, + instance_id: str, + db: AsyncSession = Depends(get_db), +): + """提交合同(进入待签署状态)""" + user_id = request.state.user_id + + instance = await ContractInstanceService.get_by_id(db, instance_id) + if not instance: + raise HTTPException(status_code=404, detail="合同不存在") + + instance = await ContractInstanceService.submit(db, instance, user_id) + return await _build_instance_out(db, instance) + + +@router.post("/instance/{instance_id}/complete", response_model=ContractInstanceOut, summary="完成合同") +async def complete_instance( + request: Request, + instance_id: str, + db: AsyncSession = Depends(get_db), +): + """完成合同""" + user_id = request.state.user_id + + instance = await ContractInstanceService.get_by_id(db, instance_id) + if not instance: + raise HTTPException(status_code=404, detail="合同不存在") + + instance = await ContractInstanceService.complete(db, instance, user_id) + return await _build_instance_out(db, instance) + + +@router.post("/instance/{instance_id}/cancel", summary="取消合同") +async def cancel_instance( + request: Request, + instance_id: str, + comment: str = Query("", description="取消原因"), + db: AsyncSession = Depends(get_db), +): + """取消合同""" + user_id = request.state.user_id + + instance = await ContractInstanceService.get_by_id(db, instance_id) + if not instance: + raise HTTPException(status_code=404, detail="合同不存在") + + await ContractInstanceService.cancel(db, instance, user_id, comment) + return {"message": "取消成功"} + + +@router.get("/instance/{instance_id}/logs", response_model=List[ContractLogOut], summary="合同日志") +async def get_instance_logs( + instance_id: str, + db: AsyncSession = Depends(get_db), +): + """获取合同操作日志""" + logs = await ContractInstanceService.get_logs(db, instance_id) + return [await _build_log_out(db, log) for log in logs] + + +# ============ 签署 API ============ + +@router.post("/instance/{instance_id}/sign", response_model=ContractSignatureOut, summary="签署合同") +async def sign_instance( + request: Request, + instance_id: str, + data: ContractSignatureCreate, + db: AsyncSession = Depends(get_db), +): + """签署合同""" + user_id = request.state.user_id + user_name = getattr(request.state, "username", "") + + instance = await ContractInstanceService.get_by_id(db, instance_id) + if not instance: + raise HTTPException(status_code=404, detail="合同不存在") + + # 获取客户端信息 + sign_ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "") + sign_device = request.headers.get("User-Agent", "") + + try: + signature = await ContractSignatureService.sign( + db=db, + instance=instance, + element_id=data.element_id, + signature_image=data.signature_image, + signer_id=user_id, + signer_name=data.signer_name or user_name, + sign_ip=sign_ip, + sign_device=sign_device, + ) + return _build_signature_out(signature) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/instance/{instance_id}/signatures", response_model=List[ContractSignatureOut], summary="签署记录") +async def get_instance_signatures( + instance_id: str, + db: AsyncSession = Depends(get_db), +): + """获取合同签署记录""" + signatures = await ContractSignatureService.get_signatures(db, instance_id) + return [_build_signature_out(sig) for sig in signatures] + + +# ============ 手机签署 API ============ + +@router.post("/instance/{instance_id}/mobile-sign-token", response_model=MobileSignTokenOut, summary="生成手机签署二维码") +async def create_mobile_sign_token( + instance_id: str, + data: MobileSignTokenCreate, + db: AsyncSession = Depends(get_db), +): + """生成手机签署令牌和二维码""" + instance = await ContractInstanceService.get_by_id(db, instance_id) + if not instance: + raise HTTPException(status_code=404, detail="合同不存在") + + if instance.status not in ["pending", "signing"]: + raise HTTPException(status_code=400, detail="合同状态不允许签署") + + try: + sign_token = await MobileSignService.create_sign_token( + db=db, + instance=instance, + party_type=data.party_type, + signer_name=data.signer_name, + expire_minutes=data.expire_minutes, + ) + + # 只返回 token,由前端构建完整 URL + return { + "token": sign_token.token, + "sign_url": "", # 由前端构建 + "expired_at": sign_token.expired_at, + "qrcode_data": "", # 由前端构建 + } + except Exception as e: + logger.exception("生成签署令牌失败") + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/mobile/contract/{token}", response_model=MobileContractInfo, summary="获取移动端合同信息") +async def get_mobile_contract( + token: str, + db: AsyncSession = Depends(get_db), +): + """根据令牌获取合同信息(无需登录)""" + sign_token = await MobileSignService.get_by_token(db, token) + if not sign_token: + raise HTTPException(status_code=404, detail="签署链接无效或已过期") + + # 验证令牌 + is_valid, error_msg = MobileSignService.validate_token(sign_token) + if not is_valid: + raise HTTPException(status_code=400, detail=error_msg) + + # 获取合同 + contract = await ContractInstanceService.get_by_id(db, sign_token.contract_id) + if not contract: + raise HTTPException(status_code=404, detail="合同不存在") + + # 获取模板名称 + template = await ContractTemplateService.get_by_id(db, contract.template_id) + template_name = template.name if template else "" + + return { + "id": str(contract.id), + "contract_no": contract.contract_no, + "title": contract.title, + "status": contract.status, + "contract_config": contract.contract_config or {}, + "variable_data": contract.variable_data or {}, + "signature_data": contract.signature_data or {}, + "party_type": sign_token.party_type, + "signer_name": sign_token.signer_name or "", + "template_name": template_name, + } + + +@router.post("/mobile/sign/{token}", response_model=ContractSignatureOut, summary="移动端签署") +async def mobile_sign( + request: Request, + token: str, + data: MobileSignRequest, + db: AsyncSession = Depends(get_db), +): + """移动端签署合同(无需登录)""" + sign_token = await MobileSignService.get_by_token(db, token) + if not sign_token: + raise HTTPException(status_code=404, detail="签署链接无效或已过期") + + # 验证令牌 + is_valid, error_msg = MobileSignService.validate_token(sign_token) + if not is_valid: + raise HTTPException(status_code=400, detail=error_msg) + + # 获取合同 + instance = await ContractInstanceService.get_by_id(db, sign_token.contract_id) + if not instance: + raise HTTPException(status_code=404, detail="合同不存在") + + # 获取客户端信息 + sign_ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "") + sign_device = request.headers.get("User-Agent", "") + + try: + signature = await MobileSignService.mobile_sign( + db=db, + sign_token=sign_token, + instance=instance, + element_id=data.element_id, + signature_image=data.signature_image, + signer_name=data.signer_name, + sign_ip=sign_ip, + sign_device=sign_device, + ) + return _build_signature_out(signature) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend-fastapi/online_dev/contract/model.py b/backend-fastapi/online_dev/contract/model.py new file mode 100644 index 0000000..4ff1b73 --- /dev/null +++ b/backend-fastapi/online_dev/contract/model.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +合同管理数据模型 +""" +from sqlalchemy import Column, String, Text, Integer, DateTime, Boolean, JSON + +from app.base_model import BaseModel + + +class ContractTemplate(BaseModel): + """合同模板""" + __tablename__ = "contract_template" + + name = Column(String(100), nullable=False, comment="模板名称") + code = Column(String(100), unique=True, nullable=False, comment="模板编码") + category = Column(String(50), default="", comment="分类") + description = Column(Text, default="", comment="描述") + status = Column(String(20), default="draft", index=True, comment="状态: draft/published/disabled") + version = Column(Integer, default=1, comment="版本号") + + # 模板配置(JSON格式存储页面设置、元素、变量等) + template_config = Column(JSON, default=dict, comment="模板配置") + + # 缩略图 + thumbnail = Column(Text, default="", comment="缩略图(base64)") + + +class ContractInstance(BaseModel): + """合同实例(基于模板创建的合同)""" + __tablename__ = "contract_instance" + + # 关联模板(逻辑外键) + template_id = Column(String(21), nullable=False, index=True, comment="合同模板ID") + + # 合同信息 + contract_no = Column(String(50), unique=True, nullable=False, comment="合同编号") + title = Column(String(200), nullable=False, comment="合同标题") + status = Column(String(20), default="draft", index=True, comment="状态: draft/pending/signing/completed/expired/canceled") + + # 创建人(逻辑外键) + creator_id = Column(String(21), nullable=False, index=True, comment="创建人ID") + + # 合同配置(继承自模板,可修改) + contract_config = Column(JSON, default=dict, comment="合同配置") + + # 变量数据 + variable_data = Column(JSON, default=dict, comment="变量数据") + + # 签署数据 + signature_data = Column(JSON, default=dict, comment="签署数据") + + # 时间记录 + created_at = Column(DateTime, nullable=True, comment="创建时间") + completed_at = Column(DateTime, nullable=True, comment="完成时间") + expired_at = Column(DateTime, nullable=True, comment="过期时间") + + # PDF 文件 + pdf_file = Column(Text, default="", comment="PDF文件路径") + + +class ContractSignature(BaseModel): + """合同签署记录""" + __tablename__ = "contract_signature" + + # 关联合同(逻辑外键) + contract_id = Column(String(21), nullable=False, index=True, comment="合同实例ID") + + # 签署区信息 + element_id = Column(String(50), nullable=False, comment="元素ID") + party_type = Column(String(20), default="party_a", comment="签署方类型: party_a/party_b/party_c/witness") + party_label = Column(String(50), default="", comment="签署方标签") + sign_type = Column(String(20), default="signature", comment="签署类型: signature/seal") + + # 签署人(逻辑外键) + signer_id = Column(String(21), nullable=True, comment="签署人ID") + signer_name = Column(String(50), default="", comment="签署人姓名") + + # 签署数据 + signature_image = Column(Text, default="", comment="签名/印章图片(base64)") + status = Column(String(20), default="pending", comment="状态: pending/signed/rejected") + signed_at = Column(DateTime, nullable=True, comment="签署时间") + + # IP 和设备信息 + sign_ip = Column(String(50), default="", comment="签署IP") + sign_device = Column(String(200), default="", comment="签署设备") + + +class ContractSignToken(BaseModel): + """合同签署令牌(用于手机扫码签署)""" + __tablename__ = "contract_sign_token" + + # 关联合同(逻辑外键) + contract_id = Column(String(21), nullable=False, index=True, comment="合同实例ID") + + # 令牌 + token = Column(String(64), unique=True, nullable=False, comment="签署令牌") + + # 签署方信息 + party_type = Column(String(20), default="party_a", comment="签署方类型") + signer_name = Column(String(50), default="", comment="签署人姓名") + + # 有效期 + expired_at = Column(DateTime, nullable=False, comment="过期时间") + + # 是否已使用 + is_used = Column(Boolean, default=False, comment="是否已使用") + used_at = Column(DateTime, nullable=True, comment="使用时间") + + +class ContractLog(BaseModel): + """合同操作日志""" + __tablename__ = "contract_log" + + # 关联合同(逻辑外键) + contract_id = Column(String(21), nullable=False, index=True, comment="合同实例ID") + + # 操作信息 + action = Column(String(20), nullable=False, comment="操作类型: create/update/submit/sign/reject/cancel/complete/export/view") + + # 操作人(逻辑外键) + operator_id = Column(String(21), nullable=False, comment="操作人ID") + + # 操作详情 + comment = Column(Text, default="", comment="备注") + extra_data = Column(JSON, default=dict, comment="额外数据") + + # IP 信息 + ip_address = Column(String(50), default="", comment="IP地址") diff --git a/backend-fastapi/online_dev/contract/schema.py b/backend-fastapi/online_dev/contract/schema.py new file mode 100644 index 0000000..8241a04 --- /dev/null +++ b/backend-fastapi/online_dev/contract/schema.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +合同管理 Schema 定义 +""" +from datetime import datetime +from typing import Any, Dict, Optional + +from pydantic import BaseModel, Field, ConfigDict + +from app.base_schema import CSTDatetime + + +# ============ 合同模板 Schema ============ + +class ContractTemplateCreate(BaseModel): + """创建合同模板""" + name: str = Field(..., max_length=100, description="模板名称") + code: str = Field(..., max_length=100, description="模板编码") + category: str = Field(default="", max_length=50, description="分类") + description: str = Field(default="", description="描述") + template_config: Dict[str, Any] = Field(default_factory=dict, description="模板配置") + + +class ContractTemplateUpdate(BaseModel): + """更新合同模板""" + name: Optional[str] = Field(None, max_length=100, description="模板名称") + category: Optional[str] = Field(None, max_length=50, description="分类") + description: Optional[str] = Field(None, description="描述") + template_config: Optional[Dict[str, Any]] = Field(None, description="模板配置") + thumbnail: Optional[str] = Field(None, description="缩略图") + + +class ContractTemplateOut(BaseModel): + """合同模板输出""" + id: str + name: str + code: str + category: str + description: str + status: str + version: int + template_config: Dict[str, Any] + thumbnail: str + sys_create_datetime: CSTDatetime + sys_update_datetime: CSTDatetime + + model_config = ConfigDict(from_attributes=True) + + +class ContractTemplateListItem(BaseModel): + """合同模板列表项""" + id: str + name: str + code: str + category: str + description: str + status: str + version: int + thumbnail: str + sys_create_datetime: CSTDatetime + sys_update_datetime: CSTDatetime + + model_config = ConfigDict(from_attributes=True) + + +# ============ 合同实例 Schema ============ + +class ContractInstanceCreate(BaseModel): + """创建合同实例""" + template_id: str = Field(..., description="模板ID") + title: str = Field(..., max_length=200, description="合同标题") + contract_no: Optional[str] = Field(None, max_length=50, description="合同编号(可选,不填则自动生成)") + variable_data: Dict[str, Any] = Field(default_factory=dict, description="变量数据") + + +class ContractInstanceUpdate(BaseModel): + """更新合同实例""" + title: Optional[str] = Field(None, max_length=200, description="合同标题") + variable_data: Optional[Dict[str, Any]] = Field(None, description="变量数据") + signature_data: Optional[Dict[str, Any]] = Field(None, description="签署数据") + + +class ContractInstanceOut(BaseModel): + """合同实例输出""" + id: str + contract_no: str + title: str + status: str + template_id: str + contract_config: Dict[str, Any] + variable_data: Dict[str, Any] + signature_data: Dict[str, Any] + created_at: Optional[CSTDatetime] = None + completed_at: Optional[CSTDatetime] = None + expired_at: Optional[CSTDatetime] = None + pdf_file: str + + # 关联字段 + template_name: str = "" + template_code: str = "" + creator_id: str = "" + creator_name: str = "" + + model_config = ConfigDict(from_attributes=True) + + +class ContractInstanceListItem(BaseModel): + """合同实例列表项""" + id: str + contract_no: str + title: str + status: str + created_at: Optional[CSTDatetime] = None + completed_at: Optional[CSTDatetime] = None + + # 关联字段 + template_name: str = "" + creator_name: str = "" + + model_config = ConfigDict(from_attributes=True) + + +# ============ 签署记录 Schema ============ + +class ContractSignatureCreate(BaseModel): + """创建签署记录""" + element_id: str = Field(..., description="元素ID") + signature_image: str = Field(..., description="签名图片(base64)") + signer_name: str = Field(default="", description="签署人姓名") + + +class ContractSignatureOut(BaseModel): + """签署记录输出""" + id: str + element_id: str + party_type: str + party_label: str + sign_type: str + signer_id: Optional[str] = None + signer_name: str + signature_image: str + status: str + signed_at: Optional[CSTDatetime] = None + + model_config = ConfigDict(from_attributes=True) + + +# ============ 操作日志 Schema ============ + +class ContractLogOut(BaseModel): + """操作日志输出""" + id: str + action: str + operator_id: str + comment: str + sys_create_datetime: CSTDatetime + + # 关联字段 + operator_name: str = "" + + model_config = ConfigDict(from_attributes=True) + + +# ============ 手机签署 Schema ============ + +class MobileSignTokenCreate(BaseModel): + """创建手机签署令牌""" + party_type: str = Field(default="party_a", description="签署方类型") + signer_name: str = Field(default="", description="签署人姓名") + expire_minutes: int = Field(default=30, description="有效期(分钟)") + + +class MobileSignTokenOut(BaseModel): + """手机签署令牌输出""" + token: str + sign_url: str + expired_at: CSTDatetime + qrcode_data: str # 二维码内容 + + +class MobileContractInfo(BaseModel): + """移动端合同信息(简化版)""" + id: str + contract_no: str + title: str + status: str + contract_config: Dict[str, Any] + variable_data: Dict[str, Any] + signature_data: Dict[str, Any] + party_type: str = "" + signer_name: str = "" + template_name: str = "" + + +class MobileSignRequest(BaseModel): + """移动端签署请求""" + element_id: str = Field(..., description="元素ID") + signature_image: str = Field(..., description="签名图片(base64)") + signer_name: str = Field(default="", description="签署人姓名") + + +# ============ 通用响应 ============ + +class MessageResponse(BaseModel): + """消息响应""" + message: str + + +class CountResponse(BaseModel): + """计数响应""" + count: int diff --git a/backend-fastapi/online_dev/contract/service.py b/backend-fastapi/online_dev/contract/service.py new file mode 100644 index 0000000..fd8d124 --- /dev/null +++ b/backend-fastapi/online_dev/contract/service.py @@ -0,0 +1,736 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +合同管理服务(异步版本) +""" +import secrets +import uuid +import logging +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from online_dev.contract.model import ( + ContractInstance, + ContractLog, + ContractSignature, + ContractSignToken, + ContractTemplate, +) + +logger = logging.getLogger(__name__) + + +class ContractTemplateService: + """合同模板服务""" + + @staticmethod + async def get_list( + db: AsyncSession, + page: int = 1, + page_size: int = 20, + name: str = None, + code: str = None, + category: str = None, + status: str = None, + ) -> Dict[str, Any]: + """获取模板列表""" + conditions = [ContractTemplate.is_deleted == False] + + if name: + conditions.append(ContractTemplate.name.ilike(f"%{name}%")) + if code: + conditions.append(ContractTemplate.code.ilike(f"%{code}%")) + if category: + conditions.append(ContractTemplate.category == category) + if status: + conditions.append(ContractTemplate.status == status) + + # 获取总数 + count_stmt = select(func.count(ContractTemplate.id)).where(and_(*conditions)) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 获取列表 + offset = (page - 1) * page_size + stmt = select(ContractTemplate).where(and_(*conditions)).order_by( + ContractTemplate.sort, ContractTemplate.sys_create_datetime.desc() + ).offset(offset).limit(page_size) + + result = await db.execute(stmt) + items = list(result.scalars().all()) + + return { + "items": items, + "total": total, + "page": page, + "pageSize": page_size, + } + + @staticmethod + async def get_by_id(db: AsyncSession, template_id: str) -> Optional[ContractTemplate]: + """根据ID获取模板""" + stmt = select(ContractTemplate).where( + ContractTemplate.id == template_id, + ContractTemplate.is_deleted == False + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + @staticmethod + async def get_by_code(db: AsyncSession, code: str) -> Optional[ContractTemplate]: + """根据编码获取模板""" + stmt = select(ContractTemplate).where( + ContractTemplate.code == code, + ContractTemplate.is_deleted == False + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + @staticmethod + async def create( + db: AsyncSession, + name: str, + code: str, + category: str = "", + description: str = "", + template_config: Dict = None, + creator_id: str = None, + ) -> ContractTemplate: + """创建模板""" + template = ContractTemplate( + name=name, + code=code, + category=category, + description=description, + template_config=template_config or {}, + sys_creator_id=creator_id, + sys_modifier_id=creator_id, + ) + db.add(template) + await db.commit() + await db.refresh(template) + return template + + @staticmethod + async def update( + db: AsyncSession, + template: ContractTemplate, + name: str = None, + category: str = None, + description: str = None, + template_config: Dict = None, + thumbnail: str = None, + ) -> ContractTemplate: + """更新模板""" + if name is not None: + template.name = name + if category is not None: + template.category = category + if description is not None: + template.description = description + if template_config is not None: + template.template_config = template_config + if thumbnail is not None: + template.thumbnail = thumbnail + + await db.commit() + await db.refresh(template) + return template + + @staticmethod + async def delete(db: AsyncSession, template: ContractTemplate): + """删除模板(软删除)""" + template.is_deleted = True + await db.commit() + + @staticmethod + async def publish(db: AsyncSession, template: ContractTemplate) -> ContractTemplate: + """发布模板""" + template.status = "published" + template.version += 1 + await db.commit() + await db.refresh(template) + return template + + @staticmethod + async def disable(db: AsyncSession, template: ContractTemplate) -> ContractTemplate: + """停用模板""" + template.status = "disabled" + await db.commit() + await db.refresh(template) + return template + + @staticmethod + async def copy( + db: AsyncSession, + template: ContractTemplate, + new_code: str, + new_name: str = None, + creator_id: str = None, + ) -> ContractTemplate: + """复制模板""" + new_template = ContractTemplate( + name=new_name or f"{template.name}_副本", + code=new_code, + category=template.category, + description=template.description, + template_config=template.template_config, + thumbnail=template.thumbnail, + status="draft", + version=1, + sys_creator_id=creator_id, + sys_modifier_id=creator_id, + ) + db.add(new_template) + await db.commit() + await db.refresh(new_template) + return new_template + + @staticmethod + async def get_categories(db: AsyncSession) -> List[str]: + """获取所有分类""" + stmt = select(ContractTemplate.category).where( + ContractTemplate.is_deleted == False, + ContractTemplate.category != "" + ).distinct() + + result = await db.execute(stmt) + return [row[0] for row in result.fetchall()] + + +class ContractInstanceService: + """合同实例服务""" + + @staticmethod + def generate_contract_no() -> str: + """生成合同编号""" + now = datetime.now() + prefix = now.strftime("JZ%Y%m%d") + suffix = uuid.uuid4().hex[:6].upper() + return f"{prefix}{suffix}" + + @staticmethod + async def check_contract_no_exists( + db: AsyncSession, + contract_no: str, + exclude_id: str = None + ) -> bool: + """检查合同编号是否已存在""" + conditions = [ + ContractInstance.contract_no == contract_no, + ContractInstance.is_deleted == False + ] + if exclude_id: + conditions.append(ContractInstance.id != exclude_id) + + stmt = select(func.count(ContractInstance.id)).where(and_(*conditions)) + result = await db.execute(stmt) + count = result.scalar() or 0 + return count > 0 + + @staticmethod + async def get_list( + db: AsyncSession, + page: int = 1, + page_size: int = 20, + title: str = None, + contract_no: str = None, + status: str = None, + template_id: str = None, + creator_id: str = None, + ) -> Dict[str, Any]: + """获取合同列表""" + conditions = [ContractInstance.is_deleted == False] + + if title: + conditions.append(ContractInstance.title.ilike(f"%{title}%")) + if contract_no: + conditions.append(ContractInstance.contract_no.ilike(f"%{contract_no}%")) + if status: + conditions.append(ContractInstance.status == status) + if template_id: + conditions.append(ContractInstance.template_id == template_id) + if creator_id: + conditions.append(ContractInstance.creator_id == creator_id) + + # 获取总数 + count_stmt = select(func.count(ContractInstance.id)).where(and_(*conditions)) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 获取列表 + offset = (page - 1) * page_size + stmt = select(ContractInstance).where(and_(*conditions)).order_by( + ContractInstance.sys_create_datetime.desc() + ).offset(offset).limit(page_size) + + result = await db.execute(stmt) + items = list(result.scalars().all()) + + return { + "items": items, + "total": total, + "page": page, + "pageSize": page_size, + } + + @staticmethod + async def get_by_id(db: AsyncSession, instance_id: str) -> Optional[ContractInstance]: + """根据ID获取合同""" + stmt = select(ContractInstance).where( + ContractInstance.id == instance_id, + ContractInstance.is_deleted == False + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + @staticmethod + async def create( + db: AsyncSession, + template_id: str, + title: str, + creator_id: str, + variable_data: Dict = None, + contract_no: str = None, + ) -> ContractInstance: + """创建合同实例""" + # 获取模板 + template = await ContractTemplateService.get_by_id(db, template_id) + if not template: + raise ValueError(f"模板不存在: {template_id}") + + # 如果没有提供合同编号,自动生成 + if not contract_no: + contract_no = ContractInstanceService.generate_contract_no() + + # 检查合同编号唯一性 + if await ContractInstanceService.check_contract_no_exists(db, contract_no): + raise ValueError(f"合同编号 {contract_no} 已存在") + + instance = ContractInstance( + template_id=template_id, + contract_no=contract_no, + title=title, + creator_id=creator_id, + contract_config=template.template_config, + variable_data=variable_data or {}, + created_at=datetime.now(), + sys_creator_id=creator_id, + sys_modifier_id=creator_id, + ) + db.add(instance) + await db.flush() + + # 记录日志 + log = ContractLog( + contract_id=instance.id, + action="create", + operator_id=creator_id, + sys_creator_id=creator_id, + ) + db.add(log) + + await db.commit() + await db.refresh(instance) + return instance + + @staticmethod + async def update( + db: AsyncSession, + instance: ContractInstance, + title: str = None, + variable_data: Dict = None, + signature_data: Dict = None, + operator_id: str = None, + ) -> ContractInstance: + """更新合同""" + if title is not None: + instance.title = title + if variable_data is not None: + instance.variable_data = variable_data + if signature_data is not None: + instance.signature_data = signature_data + + instance.sys_modifier_id = operator_id + + # 记录日志 + if operator_id: + log = ContractLog( + contract_id=instance.id, + action="update", + operator_id=operator_id, + sys_creator_id=operator_id, + ) + db.add(log) + + await db.commit() + await db.refresh(instance) + return instance + + @staticmethod + async def submit( + db: AsyncSession, + instance: ContractInstance, + operator_id: str + ) -> ContractInstance: + """提交合同(进入待签署状态)""" + instance.status = "pending" + instance.sys_modifier_id = operator_id + + log = ContractLog( + contract_id=instance.id, + action="submit", + operator_id=operator_id, + sys_creator_id=operator_id, + ) + db.add(log) + + await db.commit() + await db.refresh(instance) + return instance + + @staticmethod + async def complete( + db: AsyncSession, + instance: ContractInstance, + operator_id: str + ) -> ContractInstance: + """完成合同""" + instance.status = "completed" + instance.completed_at = datetime.now() + instance.sys_modifier_id = operator_id + + log = ContractLog( + contract_id=instance.id, + action="complete", + operator_id=operator_id, + sys_creator_id=operator_id, + ) + db.add(log) + + await db.commit() + await db.refresh(instance) + return instance + + @staticmethod + async def cancel( + db: AsyncSession, + instance: ContractInstance, + operator_id: str, + comment: str = "" + ) -> ContractInstance: + """取消合同""" + instance.status = "canceled" + instance.sys_modifier_id = operator_id + + log = ContractLog( + contract_id=instance.id, + action="cancel", + operator_id=operator_id, + comment=comment, + sys_creator_id=operator_id, + ) + db.add(log) + + await db.commit() + await db.refresh(instance) + return instance + + @staticmethod + async def delete(db: AsyncSession, instance: ContractInstance): + """删除合同(软删除)""" + instance.is_deleted = True + await db.commit() + + @staticmethod + async def get_logs(db: AsyncSession, instance_id: str) -> List[ContractLog]: + """获取合同日志""" + stmt = select(ContractLog).where( + ContractLog.contract_id == instance_id + ).order_by(ContractLog.sys_create_datetime) + + result = await db.execute(stmt) + return list(result.scalars().all()) + + +class ContractSignatureService: + """合同签署服务""" + + @staticmethod + async def sign( + db: AsyncSession, + instance: ContractInstance, + element_id: str, + signature_image: str, + signer_id: str, + signer_name: str = "", + sign_ip: str = "", + sign_device: str = "", + ) -> ContractSignature: + """签署合同""" + # 查找元素信息 + element = None + for el in instance.contract_config.get("elements", []): + if el.get("id") == element_id: + element = el + break + + if not element: + raise ValueError("签署区域不存在") + + # 获取签署方信息 + signature_config = element.get("signature", {}) + party_type = signature_config.get("partyType", "party_a") + party_label = signature_config.get("partyLabel", "") + sign_type = "seal" if element.get("type") == "seal-zone" else "signature" + + # 查找是否已存在签署记录 + stmt = select(ContractSignature).where( + ContractSignature.contract_id == instance.id, + ContractSignature.element_id == element_id + ) + result = await db.execute(stmt) + existing = result.scalar_one_or_none() + + if existing: + # 更新现有记录 + existing.party_type = party_type + existing.party_label = party_label + existing.sign_type = sign_type + existing.signer_id = signer_id + existing.signer_name = signer_name + existing.signature_image = signature_image + existing.status = "signed" + existing.signed_at = datetime.now() + existing.sign_ip = sign_ip + existing.sign_device = sign_device + signature = existing + else: + # 创建新记录 + signature = ContractSignature( + contract_id=instance.id, + element_id=element_id, + party_type=party_type, + party_label=party_label, + sign_type=sign_type, + signer_id=signer_id, + signer_name=signer_name, + signature_image=signature_image, + status="signed", + signed_at=datetime.now(), + sign_ip=sign_ip, + sign_device=sign_device, + sys_creator_id=signer_id, + ) + db.add(signature) + + # 更新合同签署数据 + signature_data = instance.signature_data or {} + signature_data[element_id] = signature_image + instance.signature_data = signature_data + + # 检查是否所有必须签署区域都已签署 + all_signed = await ContractSignatureService.check_all_signed(instance) + if all_signed and instance.status == "pending": + instance.status = "signing" + + # 记录日志 + log = ContractLog( + contract_id=instance.id, + action="sign", + operator_id=signer_id, + comment=f"{party_label}签署", + extra_data={"element_id": element_id}, + ip_address=sign_ip, + sys_creator_id=signer_id, + ) + db.add(log) + + await db.commit() + await db.refresh(signature) + return signature + + @staticmethod + async def check_all_signed(instance: ContractInstance) -> bool: + """检查是否所有必须签署区域都已签署""" + elements = instance.contract_config.get("elements", []) + signature_data = instance.signature_data or {} + + for element in elements: + if element.get("type") in ["signature-zone", "seal-zone"]: + signature_config = element.get("signature", {}) + if signature_config.get("required", True): + if element.get("id") not in signature_data: + return False + + return True + + @staticmethod + async def get_signatures(db: AsyncSession, instance_id: str) -> List[ContractSignature]: + """获取合同签署记录""" + stmt = select(ContractSignature).where( + ContractSignature.contract_id == instance_id + ).order_by(ContractSignature.sys_create_datetime) + + result = await db.execute(stmt) + return list(result.scalars().all()) + + +class MobileSignService: + """移动端签署服务""" + + @staticmethod + async def create_sign_token( + db: AsyncSession, + instance: ContractInstance, + party_type: str = "party_a", + signer_name: str = "", + expire_minutes: int = 30, + ) -> ContractSignToken: + """创建签署令牌""" + # 生成随机令牌 + token = secrets.token_urlsafe(32) + + # 计算过期时间 + expired_at = datetime.now() + timedelta(minutes=expire_minutes) + + # 创建令牌记录 + sign_token = ContractSignToken( + contract_id=instance.id, + token=token, + party_type=party_type, + signer_name=signer_name, + expired_at=expired_at, + ) + db.add(sign_token) + await db.commit() + await db.refresh(sign_token) + + return sign_token + + @staticmethod + async def get_by_token(db: AsyncSession, token: str) -> Optional[ContractSignToken]: + """根据令牌获取签署令牌""" + stmt = select(ContractSignToken).where( + ContractSignToken.token == token, + ContractSignToken.is_used == False + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + @staticmethod + def validate_token(sign_token: ContractSignToken) -> Tuple[bool, str]: + """验证令牌有效性""" + if sign_token.is_used: + return False, "该签署链接已被使用" + + if sign_token.expired_at < datetime.now(): + return False, "该签署链接已过期" + + return True, "" + + @staticmethod + async def mobile_sign( + db: AsyncSession, + sign_token: ContractSignToken, + instance: ContractInstance, + element_id: str, + signature_image: str, + signer_name: str = "", + sign_ip: str = "", + sign_device: str = "", + ) -> ContractSignature: + """移动端签署""" + # 查找元素信息 + element = None + for el in instance.contract_config.get("elements", []): + if el.get("id") == element_id: + element = el + break + + if not element: + raise ValueError("签署区域不存在") + + # 获取签署方信息 + signature_config = element.get("signature", {}) + party_type = signature_config.get("partyType", "party_a") + party_label = signature_config.get("partyLabel", "") + sign_type = "seal" if element.get("type") == "seal-zone" else "signature" + + # 使用令牌中的签署人姓名(如果提供) + final_signer_name = signer_name or sign_token.signer_name + + # 截断设备信息,避免超出数据库字段长度 + device_info = (sign_device + " (Mobile)")[:200] if sign_device else "Mobile" + + # 查找是否已存在签署记录 + stmt = select(ContractSignature).where( + ContractSignature.contract_id == instance.id, + ContractSignature.element_id == element_id + ) + result = await db.execute(stmt) + existing = result.scalar_one_or_none() + + if existing: + # 更新现有记录 + existing.party_type = party_type + existing.party_label = party_label + existing.sign_type = sign_type + existing.signer_id = None + existing.signer_name = final_signer_name + existing.signature_image = signature_image + existing.status = "signed" + existing.signed_at = datetime.now() + existing.sign_ip = sign_ip + existing.sign_device = device_info + signature = existing + else: + # 创建新记录 + signature = ContractSignature( + contract_id=instance.id, + element_id=element_id, + party_type=party_type, + party_label=party_label, + sign_type=sign_type, + signer_id=None, + signer_name=final_signer_name, + signature_image=signature_image, + status="signed", + signed_at=datetime.now(), + sign_ip=sign_ip, + sign_device=device_info, + ) + db.add(signature) + + # 更新合同签署数据 + signature_data = instance.signature_data or {} + signature_data[element_id] = signature_image + instance.signature_data = signature_data + + # 检查是否所有必须签署区域都已签署 + all_signed = await ContractSignatureService.check_all_signed(instance) + if all_signed and instance.status == "pending": + instance.status = "signing" + + # 标记令牌已使用 + sign_token.is_used = True + sign_token.used_at = datetime.now() + + # 记录日志 + log = ContractLog( + contract_id=instance.id, + action="sign", + operator_id=instance.creator_id, + comment=f"{party_label}通过手机签署 (签署人: {final_signer_name})", + extra_data={"element_id": element_id, "mobile": True}, + ip_address=sign_ip, + sys_creator_id=instance.creator_id, + ) + db.add(log) + + await db.commit() + await db.refresh(signature) + return signature diff --git a/backend-fastapi/online_dev/document_generator/__init__.py b/backend-fastapi/online_dev/document_generator/__init__.py new file mode 100644 index 0000000..5437bd0 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +文档生成模块 +""" diff --git a/backend-fastapi/online_dev/document_generator/api.py b/backend-fastapi/online_dev/document_generator/api.py new file mode 100644 index 0000000..243fdbe --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/api.py @@ -0,0 +1,819 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +文档生成API接口 +""" +from typing import Optional, List +from datetime import datetime +import json +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession +import io + +from app.database import get_db +from app.config import settings +from app.base_schema import PaginatedResponse, ResponseModel + +from online_dev.document_generator.schema import ( + DocumentTemplateCreate, DocumentTemplateUpdate, DocumentTemplateOut, DocumentTemplateListOut, + DocumentTemplateImportCheckIn, DocumentTemplateImportCheckOut, DocumentTemplateImportIn, + GeneratedDocumentOut, GenerateDocumentIn, BatchGenerateDocumentIn, PreviewDocumentIn, + TemplateCategory, +) +from online_dev.document_generator.service import ( + DocumentTemplateService, GeneratedDocumentService +) +from online_dev.document_generator.generator import pdf_generator + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/document-generator", tags=["文档生成"]) + + +# ==================== 文档模板 ==================== + +@router.get("/templates", response_model=PaginatedResponse[DocumentTemplateListOut], summary="获取模板列表") +async def get_template_list( + page: int = Query(default=1, ge=1, description="页码"), + page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=settings.PAGE_MAX_SIZE, alias="pageSize"), + application_id: Optional[str] = Query(default=None, alias="applicationId", description="应用ID"), + category: Optional[str] = Query(default=None, description="分类"), + status: Optional[str] = Query(default=None, description="状态"), + form_code: Optional[str] = Query(default=None, alias="formCode", description="表单编码"), + workflow_code: Optional[str] = Query(default=None, alias="workflowCode", description="流程编码"), + keyword: Optional[str] = Query(default=None, description="关键词"), + is_builtin: Optional[bool] = Query(default=None, alias="isBuiltin", description="是否内置"), + db: AsyncSession = Depends(get_db), +): + """获取文档模板列表""" + items, total = await DocumentTemplateService.get_list( + db, + page=page, + page_size=page_size, + application_id=application_id, + category=category, + status=status, + form_code=form_code, + workflow_code=workflow_code, + keyword=keyword, + is_builtin=is_builtin, + ) + return PaginatedResponse(items=items, total=total) + + +@router.get("/templates/categories", response_model=List[TemplateCategory], summary="获取模板分类") +async def get_template_categories( + application_id: Optional[str] = Query(default=None, alias="applicationId"), + db: AsyncSession = Depends(get_db), +): + """获取模板分类统计""" + return await DocumentTemplateService.get_categories(db, application_id) + + +@router.get("/templates/builtin", response_model=List[DocumentTemplateListOut], summary="获取内置模板") +async def get_builtin_templates( + db: AsyncSession = Depends(get_db), +): + """获取内置模板列表""" + items, _ = await DocumentTemplateService.get_list(db, page=1, page_size=100, is_builtin=True) + return items + + +@router.get("/templates/by-form/{form_code}", response_model=List[DocumentTemplateListOut], summary="根据表单编码获取模板") +async def get_templates_by_form_code( + form_code: str, + db: AsyncSession = Depends(get_db), +): + """根据表单编码获取绑定的已发布单据模板列表""" + templates = await DocumentTemplateService.get_by_form_code(db, form_code) + return templates + + +@router.get("/templates/check-form/{form_code}", response_model=ResponseModel, summary="检查表单是否绑定模板") +async def check_form_has_templates( + form_code: str, + db: AsyncSession = Depends(get_db), +): + """检查表单是否绑定了已发布的单据模板""" + has_templates = await DocumentTemplateService.has_templates_by_form_code(db, form_code) + return ResponseModel(message="success", data={"hasTemplates": has_templates}) + + +@router.post("/templates", response_model=DocumentTemplateOut, summary="创建模板") +async def create_template( + data: DocumentTemplateCreate, + db: AsyncSession = Depends(get_db), +): + """创建文档模板""" + # 检查编码唯一性 + existing = await DocumentTemplateService.get_by_code(db, data.code) + if existing: + raise HTTPException(status_code=400, detail="模板编码已存在") + + template = await DocumentTemplateService.create(db, data) + return template + + +@router.get("/templates/{template_id}", response_model=DocumentTemplateOut, summary="获取模板详情") +async def get_template( + template_id: str, + db: AsyncSession = Depends(get_db), +): + """获取模板详情""" + template = await DocumentTemplateService.get_by_id(db, template_id) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + return template + + +@router.get("/templates/code/{code}", response_model=DocumentTemplateOut, summary="根据编码获取模板") +async def get_template_by_code( + code: str, + db: AsyncSession = Depends(get_db), +): + """根据编码获取模板""" + template = await DocumentTemplateService.get_by_code(db, code) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + return template + + +@router.put("/templates/{template_id}", response_model=DocumentTemplateOut, summary="更新模板") +async def update_template( + template_id: str, + data: DocumentTemplateUpdate, + db: AsyncSession = Depends(get_db), +): + """更新模板""" + template = await DocumentTemplateService.update(db, template_id, data) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + return template + + +@router.delete("/templates/{template_id}", response_model=ResponseModel, summary="删除模板") +async def delete_template( + template_id: str, + db: AsyncSession = Depends(get_db), +): + """删除模板""" + template = await DocumentTemplateService.get_by_id(db, template_id) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + + if template.is_builtin: + raise HTTPException(status_code=400, detail="内置模板不能删除") + + await DocumentTemplateService.delete(db, template_id) + return ResponseModel(message="删除成功") + + +@router.post("/templates/{template_id}/publish", response_model=DocumentTemplateOut, summary="发布模板") +async def publish_template( + template_id: str, + db: AsyncSession = Depends(get_db), +): + """发布模板""" + template = await DocumentTemplateService.publish(db, template_id) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + return template + + +@router.post("/templates/{template_id}/unpublish", response_model=DocumentTemplateOut, summary="取消发布") +async def unpublish_template( + template_id: str, + db: AsyncSession = Depends(get_db), +): + """取消发布模板""" + template = await DocumentTemplateService.unpublish(db, template_id) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + return template + + +@router.post("/templates/{template_id}/copy", response_model=DocumentTemplateOut, summary="复制模板") +async def copy_template( + template_id: str, + new_code: str = Query(..., alias="newCode", description="新编码"), + new_name: str = Query(..., alias="newName", description="新名称"), + db: AsyncSession = Depends(get_db), +): + """复制模板""" + # 检查新编码唯一性 + existing = await DocumentTemplateService.get_by_code(db, new_code) + if existing: + raise HTTPException(status_code=400, detail="模板编码已存在") + + template = await DocumentTemplateService.copy(db, template_id, new_code, new_name) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + return template + + +@router.get("/templates/{template_id}/export", summary="导出单据模板配置") +async def export_template_config( + template_id: str, + db: AsyncSession = Depends(get_db), +): + """导出单据模板配置为 JSON 文件""" + config = await DocumentTemplateService.export_config(db, template_id) + if not config: + raise HTTPException(status_code=404, detail="模板不存在") + + content = json.dumps(config, ensure_ascii=False, indent=2) + return StreamingResponse( + iter([content]), + media_type="application/json", + headers={ + "Content-Disposition": f'attachment; filename="{config["code"]}.json"' + }, + ) + + +@router.post( + "/templates/import/check", + response_model=DocumentTemplateImportCheckOut, + summary="单据模板导入预检查", +) +async def check_import_template_config( + data: DocumentTemplateImportCheckIn, + db: AsyncSession = Depends(get_db), +): + """导入预检查:检查模板编码是否冲突""" + return await DocumentTemplateService.check_import(db, data.code) + + +@router.post( + "/templates/import", + response_model=DocumentTemplateOut, + summary="导入单据模板配置", +) +async def import_template_config( + data: DocumentTemplateImportIn, + db: AsyncSession = Depends(get_db), +): + """导入单据模板配置""" + try: + return await DocumentTemplateService.import_config(db, data.model_dump()) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +# ==================== 文档生成 ==================== + +@router.post("/generate", response_model=GeneratedDocumentOut, summary="生成文档") +async def generate_document( + data: GenerateDocumentIn, + db: AsyncSession = Depends(get_db), +): + """生成文档""" + from core.file_manager.service import FileManagerService + + # 获取模板 + template = await DocumentTemplateService.get_by_id(db, data.template_id) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + + if template.status != "published": + raise HTTPException(status_code=400, detail="模板未发布") + + # 删除同模板+同表单数据的旧单据(每个模板只保留一个单据) + if data.form_data_id: + existing_docs = await GeneratedDocumentService.get_by_template_and_form_data( + db, data.template_id, data.form_data_id + ) + for old_doc in existing_docs: + # 删除关联的文件 + if old_doc.file_id: + try: + await FileManagerService.delete_item(db, old_doc.file_id, hard=True, is_superuser=True) + except Exception as e: + logger.warning(f"删除旧单据文件失败: {e}") + # 删除单据记录 + await GeneratedDocumentService.delete(db, old_doc.id, hard=True, auto_commit=False) + + # 加载表单数据 + form_data = {} + if data.form_data_id and template.form_code: + form_data = await _load_form_data(db, template.form_code, data.form_data_id) + + # 加载流程实例数据 + if data.instance_id: + instance_data = await _load_instance_data(db, data.instance_id) + form_data.update(instance_data) + + # 添加签名文件URL + if data.signature_file_ids: + for field_name, file_id in data.signature_file_ids.items(): + # 获取文件URL + file_info = await FileManagerService.get_by_id(db, file_id) + if file_info: + form_data[field_name] = file_info.url or "" + + # 执行计算规则(聚合字段和计算字段) + template_data = dict(form_data) + if template.calculation_rules: + try: + from online_dev.document_generator.calculation_engine import CalculationEngine + logger.info(f"开始执行计算规则: {template.calculation_rules}") + calculated_values = await CalculationEngine.calculate_all( + template.calculation_rules, + form_data + ) + # 将计算结果合并到模板数据中 + template_data.update(calculated_values) + logger.info(f"生成文档计算完成,计算结果: {calculated_values}") + except Exception as calc_error: + logger.error(f"生成文档计算规则执行失败: {calc_error}", exc_info=True) + + # 生成 PDF + try: + page_config = { + "size": template.page_size, + "orientation": template.page_orientation, + "margin": template.page_margin or {"top": 20, "right": 20, "bottom": 20, "left": 20}, + } + + logger.info( + f"[生成文档] 开始生成PDF | template_id={template.id}, " + f"template_type={template.template_type}, data_keys={list(template_data.keys())}, " + f"page_config={page_config}" + ) + pdf_bytes = pdf_generator.generate( + template_type=template.template_type, + template_content=template.template_content or "", + data=template_data, + css=template.template_css, + page_config=page_config, + ) + logger.info(f"[生成文档] PDF生成成功 | template_id={template.id}, 大小={len(pdf_bytes)} bytes") + except Exception as e: + logger.error( + f"[生成文档] PDF生成失败 | template_id={template.id}, " + f"template_type={template.template_type}, error={type(e).__name__}: {e}", + exc_info=True, + ) + raise HTTPException(status_code=500, detail=f"PDF生成失败: {str(e)}") + + # 获取页数 + page_count = pdf_generator.get_page_count(pdf_bytes) + + # 保存文件 + document_name = data.document_name or f"{template.name}_{datetime.now().strftime('%Y%m%d%H%M%S')}" + filename = f"{document_name}.pdf" + + file_record = await FileManagerService.upload_file( + db=db, + file_content=pdf_bytes, + filename=filename, + file_size=len(pdf_bytes), + parent_id=None, + is_public=False, + source="document", + ) + + # 创建文档记录 + document = await GeneratedDocumentService.create_document( + db=db, + template=template, + file_id=file_record.id, + file_size=len(pdf_bytes), + page_count=page_count, + document_name=document_name, + form_data_id=data.form_data_id, + instance_id=data.instance_id, + generate_type="manual", + ) + + return document + + +@router.post("/preview", summary="预览文档") +async def preview_document( + data: PreviewDocumentIn, + db: AsyncSession = Depends(get_db), +): + """预览文档(返回 PDF 流) + + 支持两种方式: + 1. 传入 template_id,从数据库读取模板 + 2. 传入 template_content,直接使用传入的 JSON 配置(无需保存) + """ + template_content = "" + template_type = "designer" + template_css = None + page_config = {"top": 20, "right": 20, "bottom": 20, "left": 20} + + if data.template_content: + # 直接使用传入的 JSON 配置 + template_content = data.template_content + # 从 JSON 中解析页面配置 + try: + import json + config = json.loads(data.template_content) + page_config = { + "size": config.get("pageSize", "A4"), + "orientation": config.get("pageOrientation", "portrait"), + "margin": config.get("pageMargin", {"top": 20, "right": 20, "bottom": 20, "left": 20}), + "customPageWidth": config.get("customPageWidth"), + "customPageHeight": config.get("customPageHeight"), + "showPageNumber": config.get("showPageNumber"), + "pageNumberPosition": config.get("pageNumberPosition"), + "pageNumberAlign": config.get("pageNumberAlign"), + "pageNumberFormat": config.get("pageNumberFormat"), + "pageNumberFontSize": config.get("pageNumberFontSize"), + "pageNumberColor": config.get("pageNumberColor"), + } + except json.JSONDecodeError: + pass + elif data.template_id: + # 从数据库读取模板 + template = await DocumentTemplateService.get_by_id(db, data.template_id) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + template_content = template.template_content or "" + template_type = template.template_type + template_css = template.template_css + page_config = { + "size": template.page_size, + "orientation": template.page_orientation, + "margin": template.page_margin or {"top": 20, "right": 20, "bottom": 20, "left": 20}, + } + else: + raise HTTPException(status_code=400, detail="请提供 template_id 或 template_content") + + # 加载数据 + form_data = data.test_data or {} + + if data.template_id: + template = await DocumentTemplateService.get_by_id(db, data.template_id) + if not form_data and data.form_data_id and template and template.form_code: + form_data = await _load_form_data(db, template.form_code, data.form_data_id) + + if data.instance_id: + instance_data = await _load_instance_data(db, data.instance_id) + form_data.update(instance_data) + + # 执行计算规则 + template_data = dict(form_data) + calculation_rules = None + + # 优先使用请求中的 calculation_rules(用于未保存的模板预览) + if data.calculation_rules: + calculation_rules = data.calculation_rules + logger.info(f"使用请求中的计算规则: {calculation_rules}") + # 否则从模板中获取 + elif data.template_id: + template = await DocumentTemplateService.get_by_id(db, data.template_id) + if template and template.calculation_rules: + calculation_rules = template.calculation_rules + logger.info(f"使用模板中的计算规则: {calculation_rules}") + + # 执行计算 + if calculation_rules: + try: + from online_dev.document_generator.calculation_engine import CalculationEngine + logger.info(f"开始执行计算规则") + calculated_values = await CalculationEngine.calculate_all( + calculation_rules, + form_data + ) + # 将计算结果合并到模板数据中 + template_data.update(calculated_values) + logger.info(f"预览计算完成,计算结果: {calculated_values}") + except Exception as calc_error: + logger.error(f"预览计算规则执行失败: {calc_error}", exc_info=True) + else: + logger.info(f"没有计算规则需要执行") + + # 生成 PDF + try: + logger.info( + f"[预览文档] 开始生成PDF | template_id={data.template_id}, " + f"template_type={template_type}, has_template_content={bool(data.template_content)}, " + f"data_keys={list(template_data.keys())}, has_css={template_css is not None}, " + f"page_config={page_config}" + ) + pdf_bytes = pdf_generator.generate( + template_type=template_type, + template_content=template_content, + data=template_data, + css=template_css, + page_config=page_config, + ) + logger.info(f"[预览文档] PDF生成成功 | 大小={len(pdf_bytes)} bytes") + except Exception as e: + logger.error( + f"[预览文档] PDF生成失败 | template_id={data.template_id}, " + f"template_type={template_type}, error={type(e).__name__}: {e}", + exc_info=True, + ) + raise HTTPException(status_code=500, detail=f"PDF生成失败: {str(e)}") + + return StreamingResponse( + io.BytesIO(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": f"inline; filename=preview.pdf"} + ) + + +@router.post("/preview-html", summary="预览HTML") +async def preview_html( + data: PreviewDocumentIn, + db: AsyncSession = Depends(get_db), +): + """预览文档HTML(返回 HTML 字符串) + + 支持两种方式: + 1. 传入 template_id,从数据库读取模板 + 2. 传入 template_content,直接使用传入的 JSON 配置(无需保存) + """ + template_content = "" + template_type = "designer" + template_css = None + + if data.template_content: + # 直接使用传入的 JSON 配置 + template_content = data.template_content + elif data.template_id: + # 从数据库读取模板 + template = await DocumentTemplateService.get_by_id(db, data.template_id) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + template_content = template.template_content or "" + template_type = template.template_type + template_css = template.template_css + else: + raise HTTPException(status_code=400, detail="请提供 template_id 或 template_content") + + # 加载数据 + form_data = data.test_data or {} + + if data.template_id: + template = await DocumentTemplateService.get_by_id(db, data.template_id) + if not form_data and data.form_data_id and template and template.form_code: + form_data = await _load_form_data(db, template.form_code, data.form_data_id) + + if data.instance_id: + instance_data = await _load_instance_data(db, data.instance_id) + form_data.update(instance_data) + + # 执行计算规则 + template_data = dict(form_data) + calculation_rules = None + + # 优先使用请求中的 calculation_rules(用于未保存的模板预览) + if data.calculation_rules: + calculation_rules = data.calculation_rules + # 否则从模板中获取 + elif data.template_id: + template = await DocumentTemplateService.get_by_id(db, data.template_id) + if template and template.calculation_rules: + calculation_rules = template.calculation_rules + + # 执行计算 + if calculation_rules: + try: + from online_dev.document_generator.calculation_engine import CalculationEngine + calculated_values = await CalculationEngine.calculate_all( + calculation_rules, + form_data + ) + # 将计算结果合并到模板数据中 + template_data.update(calculated_values) + logger.info(f"预览HTML计算完成,计算结果: {calculated_values}") + except Exception as calc_error: + logger.error(f"预览HTML计算规则执行失败: {calc_error}", exc_info=True) + + # 生成 HTML + try: + from online_dev.document_generator.generator import DesignerTemplateRenderer + + if template_type == "designer": + html_content = DesignerTemplateRenderer.render( + template_content, + template_data, + template_css, + ) + else: + from online_dev.document_generator.generator import TemplateEngine + engine = TemplateEngine() + html_content = engine.render_html(template_content, template_data) + + return {"html": html_content} + except Exception as e: + raise HTTPException(status_code=500, detail=f"HTML生成失败: {str(e)}") + + +@router.post("/batch-generate", response_model=ResponseModel, summary="批量生成文档") +async def batch_generate_documents( + data: BatchGenerateDocumentIn, + db: AsyncSession = Depends(get_db), +): + """批量生成文档""" + template = await DocumentTemplateService.get_by_id(db, data.template_id) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + + if template.status != "published": + raise HTTPException(status_code=400, detail="模板未发布") + + success_count = 0 + fail_count = 0 + + for form_data_id in data.form_data_ids: + try: + await generate_document( + GenerateDocumentIn(template_id=data.template_id, form_data_id=form_data_id), + db=db, + ) + success_count += 1 + except Exception: + fail_count += 1 + + return ResponseModel( + message=f"批量生成完成: 成功 {success_count} 个, 失败 {fail_count} 个", + data={"success": success_count, "fail": fail_count} + ) + + +# ==================== 生成的文档 ==================== + +@router.get("/documents", response_model=PaginatedResponse[GeneratedDocumentOut], summary="获取文档列表") +async def get_document_list( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=settings.PAGE_MAX_SIZE, alias="pageSize"), + template_id: Optional[str] = Query(default=None, alias="templateId"), + form_code: Optional[str] = Query(default=None, alias="formCode"), + form_data_id: Optional[str] = Query(default=None, alias="formDataId"), + instance_id: Optional[str] = Query(default=None, alias="instanceId"), + keyword: Optional[str] = Query(default=None), + db: AsyncSession = Depends(get_db), +): + """获取生成的文档列表""" + items, total = await GeneratedDocumentService.get_list( + db, + page=page, + page_size=page_size, + template_id=template_id, + form_code=form_code, + form_data_id=form_data_id, + instance_id=instance_id, + keyword=keyword, + ) + return PaginatedResponse(items=items, total=total) + + +@router.get("/documents/{document_id}", response_model=GeneratedDocumentOut, summary="获取文档详情") +async def get_document( + document_id: str, + db: AsyncSession = Depends(get_db), +): + """获取文档详情""" + document = await GeneratedDocumentService.get_by_id(db, document_id) + if not document: + raise HTTPException(status_code=404, detail="文档不存在") + return document + + +@router.get("/documents/{document_id}/download", summary="下载文档") +async def download_document( + document_id: str, + db: AsyncSession = Depends(get_db), +): + """下载文档""" + from core.file_manager.service import FileManagerService + + document = await GeneratedDocumentService.get_by_id(db, document_id) + if not document: + raise HTTPException(status_code=404, detail="文档不存在") + + # 获取文件内容 + file_content = await FileManagerService.get_file_content(db, document.file_id) + if not file_content: + raise HTTPException(status_code=404, detail="文件不存在") + + # 增加下载次数 + await GeneratedDocumentService.increment_download_count(db, document_id) + + filename = f"{document.document_name}.pdf" + + return StreamingResponse( + io.BytesIO(file_content), + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + +@router.delete("/documents/{document_id}", response_model=ResponseModel, summary="删除文档") +async def delete_document( + document_id: str, + db: AsyncSession = Depends(get_db), +): + """删除文档""" + document = await GeneratedDocumentService.get_by_id(db, document_id) + if not document: + raise HTTPException(status_code=404, detail="文档不存在") + + await GeneratedDocumentService.delete(db, document_id) + return ResponseModel(message="删除成功") + + +@router.post("/documents/{document_id}/regenerate", response_model=GeneratedDocumentOut, summary="重新生成文档") +async def regenerate_document( + document_id: str, + db: AsyncSession = Depends(get_db), +): + """重新生成文档""" + document = await GeneratedDocumentService.get_by_id(db, document_id) + if not document: + raise HTTPException(status_code=404, detail="文档不存在") + + # 使用原文档的参数重新生成 + new_document = await generate_document( + GenerateDocumentIn( + template_id=document.template_id, + form_data_id=document.form_data_id, + instance_id=document.instance_id, + document_name=document.document_name, + ), + db=db, + ) + + # 删除旧文档 + await GeneratedDocumentService.delete(db, document_id) + + return new_document + + +# ==================== 辅助函数 ==================== + +async def _load_form_data(db: AsyncSession, form_code: str, form_data_id: str) -> dict: + """加载表单数据(含关联选择器的显示名称)""" + try: + from online_dev.form_data_manager.service import FormDataService + + service = await FormDataService.create_service(db, form_code) + data = await service.get(db, form_data_id) + return data or {} + except Exception as e: + logger.error(f"加载表单数据失败 form_code={form_code}, id={form_data_id}: {e}") + return {} + + +async def _load_instance_data(db: AsyncSession, instance_id: str) -> dict: + """加载流程实例数据""" + try: + from online_dev.workflow.model import WorkflowInstance, WorkflowLog + from sqlalchemy import select + + # 获取实例 + stmt = select(WorkflowInstance).where(WorkflowInstance.id == instance_id) + result = await db.execute(stmt) + instance = result.scalar_one_or_none() + + if not instance: + return {} + + # 获取审批日志 + log_stmt = select(WorkflowLog).where( + WorkflowLog.instance_id == instance_id + ).order_by(WorkflowLog.sys_create_datetime) + log_result = await db.execute(log_stmt) + logs = list(log_result.scalars().all()) + + return { + "_instance": { + "id": instance.id, + "instance_no": instance.instance_no, + "title": instance.title, + "status": instance.status, + "started_at": instance.started_at, + "completed_at": instance.completed_at, + }, + "_logs": [ + { + "node_name": log.node_name, + "action": log.action, + "comment": log.comment, + "created_at": log.sys_create_datetime, + } + for log in logs + ], + } + except Exception: + return {} + + +# ==================== 系统字体 ==================== + +@router.get("/fonts", summary="获取服务器支持的字体列表") +async def get_available_fonts(): + """获取服务器上可用的字体列表""" + from online_dev.document_generator.font_service import FontService + fonts = FontService.get_available_fonts() + return fonts diff --git a/backend-fastapi/online_dev/document_generator/calculation_engine.py b/backend-fastapi/online_dev/document_generator/calculation_engine.py new file mode 100644 index 0000000..15fce22 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/calculation_engine.py @@ -0,0 +1,546 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +单据计算引擎 + +支持: +- 基本运算:+, -, *, /, %, ** +- 聚合函数:sum, avg, max, min, count +- 内置函数:round, abs, ceil, floor, numberToChinese +- 条件表达式:if-else (三元运算符) +- 安全执行:使用 simpleeval 库防止代码注入 +""" +import logging +import math +import re +from decimal import Decimal, ROUND_HALF_UP +from typing import Any, Dict, List, Optional, Union + +logger = logging.getLogger(__name__) + +# 中文数字映射 +CHINESE_DIGITS = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'] +CHINESE_UNITS = ['', '拾', '佰', '仟'] +CHINESE_GROUP_UNITS = ['', '万', '亿', '兆'] +CHINESE_DECIMAL_UNITS = ['角', '分', '厘', '毫'] + + +def number_to_chinese(num: Union[int, float, Decimal, str]) -> str: + """ + 将数字转换为中文大写金额 + + Args: + num: 数字(支持整数、浮点数、Decimal、字符串) + + Returns: + 中文大写金额字符串 + + Examples: + >>> number_to_chinese(1234.56) + '壹仟贰佰叁拾肆元伍角陆分' + >>> number_to_chinese(0) + '零元整' + """ + if num is None: + return '' + + try: + # 转换为 Decimal 以保证精度 + if isinstance(num, str): + num = Decimal(num.replace(',', '')) + elif isinstance(num, float): + num = Decimal(str(num)) + elif isinstance(num, int): + num = Decimal(num) + elif not isinstance(num, Decimal): + num = Decimal(str(num)) + except Exception: + return str(num) + + # 处理负数 + if num < 0: + return '负' + number_to_chinese(-num) + + # 处理零 + if num == 0: + return '零元整' + + # 分离整数和小数部分 + num = num.quantize(Decimal('0.0001'), rounding=ROUND_HALF_UP) + str_num = str(num) + + if '.' in str_num: + int_part, dec_part = str_num.split('.') + else: + int_part, dec_part = str_num, '' + + result = '' + + # 处理整数部分 + if int_part and int(int_part) > 0: + int_part = int_part.lstrip('0') or '0' + length = len(int_part) + + # 按4位分组处理 + groups = [] + while int_part: + groups.insert(0, int_part[-4:]) + int_part = int_part[:-4] + + for i, group in enumerate(groups): + group_result = '' + group = group.zfill(4) + + for j, digit in enumerate(group): + d = int(digit) + unit_index = 3 - j + + if d != 0: + group_result += CHINESE_DIGITS[d] + CHINESE_UNITS[unit_index] + else: + # 处理连续零 + if group_result and not group_result.endswith('零'): + group_result += '零' + + # 移除末尾的零 + group_result = group_result.rstrip('零') + + if group_result: + group_unit_index = len(groups) - 1 - i + group_result += CHINESE_GROUP_UNITS[group_unit_index] if group_unit_index < len(CHINESE_GROUP_UNITS) else '' + result += group_result + + result += '元' + else: + result = '零元' + + # 处理小数部分 + if dec_part: + dec_part = dec_part[:4] # 最多4位小数 + has_decimal = False + + for i, digit in enumerate(dec_part): + d = int(digit) + if d != 0: + result += CHINESE_DIGITS[d] + CHINESE_DECIMAL_UNITS[i] + has_decimal = True + + if not has_decimal: + result += '整' + else: + result += '整' + + return result + + +def safe_get_value(data: Dict[str, Any], key: str, default: Any = 0) -> Any: + """ + 安全地从字典中获取值,支持点号路径 + + Args: + data: 数据字典 + key: 键名(支持点号路径,如 'order.total') + default: 默认值 + + Returns: + 获取的值或默认值 + """ + if not key: + return default + + parts = key.split('.') + value = data + + for part in parts: + if isinstance(value, dict): + value = value.get(part) + elif isinstance(value, list): + # 支持数组索引 + if part.lstrip('-').isdigit(): + index = int(part) + if -len(value) <= index < len(value): + value = value[index] + else: + return default + else: + return default + else: + return default + + if value is None: + return default + + return value if value is not None else default + + +class CalculationEngine: + """单据计算引擎""" + + # 支持的聚合函数 + AGGREGATE_FUNCTIONS = { + 'sum': lambda values: sum(v for v in values if v is not None), + 'avg': lambda values: sum(v for v in values if v is not None) / len([v for v in values if v is not None]) if values else 0, + 'max': lambda values: max((v for v in values if v is not None), default=0), + 'min': lambda values: min((v for v in values if v is not None), default=0), + 'count': lambda values: len([v for v in values if v is not None]), + } + + # 安全的内置函数 + SAFE_FUNCTIONS = { + 'abs': abs, + 'round': round, + 'ceil': math.ceil, + 'floor': math.floor, + 'max': max, + 'min': min, + 'sum': sum, + 'len': len, + 'float': float, + 'int': int, + 'str': str, + 'numberToChinese': number_to_chinese, + 'toChineseAmount': number_to_chinese, + } + + # 安全的运算符 + SAFE_OPERATORS = { + '+', '-', '*', '/', '//', '%', '**', + '==', '!=', '<', '>', '<=', '>=', + 'and', 'or', 'not', + '(', ')', ',', '.', + } + + @classmethod + def evaluate_formula(cls, formula: str, context: Dict[str, Any]) -> Any: + """ + 安全地执行计算公式 + + Args: + formula: 计算公式,如 "quantity * unit_price * (1 - discount_rate)" + context: 上下文数据 + + Returns: + 计算结果 + """ + if not formula: + return None + + try: + # 替换公式中的变量 + evaluated_formula = cls._replace_variables(formula, context) + logger.info(f"公式: {formula} -> 替换后: {evaluated_formula}") + + # 使用 eval 执行(在受限环境中) + # 注意:这里使用了安全的方式,只允许特定的函数和运算 + result = cls._safe_eval(evaluated_formula, context) + logger.info(f"公式执行结果: {result}") + + return result + except Exception as e: + logger.error(f"公式计算失败: {formula}, 错误: {e}", exc_info=True) + return None + + @classmethod + def _replace_variables(cls, formula: str, context: Dict[str, Any]) -> str: + """ + 替换公式中的变量为实际值 + + 支持的变量格式: + - 简单变量:quantity, unit_price + - 点号路径:order.total, items[0].price + """ + # 匹配变量名(字母开头,可包含字母、数字、下划线、点号、方括号) + pattern = r'\b([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*|\[\d+\])*)\b' + + def replace_var(match): + var_name = match.group(1) + + # 跳过函数名 + if var_name in cls.SAFE_FUNCTIONS: + return var_name + + # 跳过 Python 关键字 + if var_name in ('and', 'or', 'not', 'if', 'else', 'True', 'False', 'None'): + return var_name + + # 获取变量值 + value = safe_get_value(context, var_name, 0) + + # 转换为字符串表示 + if value is None: + return '0' + elif isinstance(value, str): + # 尝试转换为数字 + try: + return str(float(value)) + except ValueError: + return f'"{value}"' + elif isinstance(value, bool): + return str(value) + elif isinstance(value, (int, float, Decimal)): + return str(float(value)) + else: + return '0' + + return re.sub(pattern, replace_var, formula) + + @classmethod + def _safe_eval(cls, expression: str, context: Dict[str, Any]) -> Any: + """ + 安全地执行表达式 + + 使用受限的 eval 环境,只允许特定的函数和运算 + """ + # 构建安全的执行环境 + safe_globals = { + '__builtins__': {}, + **cls.SAFE_FUNCTIONS, + } + + # 添加上下文数据 + safe_locals = dict(context) + + try: + result = eval(expression, safe_globals, safe_locals) + return result + except Exception as e: + logger.warning(f"表达式执行失败: {expression}, 错误: {e}") + raise + + @classmethod + def calculate_aggregation( + cls, + data: Dict[str, Any], + source: str, + field: str, + function: str + ) -> Any: + """ + 计算聚合值 + + Args: + data: 数据字典 + source: 数据源(子表名) + field: 聚合字段 + function: 聚合函数名 + + Returns: + 聚合结果 + """ + logger.info(f"聚合计算开始: source={source}, field={field}, function={function}") + logger.info(f"数据中的顶层键: {list(data.keys())}") + + # 获取子表数据 + sub_table_data = None + + # 1. 优先从 sub_tables 中查找(表单数据的标准结构) + if 'sub_tables' in data and isinstance(data['sub_tables'], dict): + sub_tables = data['sub_tables'] + logger.info(f"sub_tables 中的键: {list(sub_tables.keys())}") + + # 直接匹配 + if source in sub_tables: + sub_table_data = sub_tables[source] + logger.info(f"从 sub_tables 中直接匹配到 ({source}): 找到 {len(sub_table_data) if isinstance(sub_table_data, list) else 0} 条") + else: + # 尝试模糊匹配(source 可能是字段名,sub_tables 的键可能是表名) + # 例如:source='product_details',sub_tables 键可能是 'fd_product_details' 或 'contract_product_details' + for key in sub_tables.keys(): + if source in key or key in source or key.endswith(f'_{source}') or key.endswith(source): + sub_table_data = sub_tables[key] + logger.info(f"从 sub_tables 中模糊匹配到 ({source} -> {key}): 找到 {len(sub_table_data) if isinstance(sub_table_data, list) else 0} 条") + break + + # 2. 如果 sub_tables 中没有,尝试直接从顶层获取 + if not sub_table_data: + sub_table_data = safe_get_value(data, source, []) + logger.info(f"从顶层获取子表数据 ({source}): {type(sub_table_data)}") + + if not isinstance(sub_table_data, list): + logger.warning(f"聚合数据源不是数组: {source}, 实际类型: {type(sub_table_data)}") + return 0 + + if len(sub_table_data) == 0: + logger.warning(f"聚合数据源为空数组: {source}") + return 0 + + # 提取字段值 + values = [] + for i, item in enumerate(sub_table_data): + if isinstance(item, dict): + logger.info(f"子表第{i}行的键: {list(item.keys())}") + value = safe_get_value(item, field, None) + logger.info(f"子表第{i}行的 {field} 值: {value}") + if value is not None: + try: + values.append(float(value)) + except (ValueError, TypeError) as e: + logger.warning(f"无法转换为数字: {value}, 错误: {e}") + + logger.info(f"提取到的数值列表: {values}") + + # 执行聚合函数 + agg_func = cls.AGGREGATE_FUNCTIONS.get(function.lower()) + if not agg_func: + logger.warning(f"不支持的聚合函数: {function}") + return 0 + + try: + result = agg_func(values) + logger.info(f"聚合计算结果: {result}") + return result + except Exception as e: + logger.warning(f"聚合计算失败: {e}") + return 0 + + @classmethod + def format_value( + cls, + value: Any, + format_type: str = 'number', + decimal_places: int = 2 + ) -> Any: + """ + 格式化计算结果 + + Args: + value: 原始值 + format_type: 格式化类型 (number/money/percent/chinese) + decimal_places: 小数位数 + + Returns: + 格式化后的值(字符串,保留指定小数位数) + """ + if value is None: + return None + + try: + num_value = float(value) + except (ValueError, TypeError): + return value + + if format_type == 'chinese': + return number_to_chinese(num_value) + elif format_type == 'percent': + # 百分比:乘以100后保留指定小数位,返回格式化字符串 + percent_value = num_value * 100 + if decimal_places == 0: + return str(int(round(percent_value))) + return f"{percent_value:.{decimal_places}f}" + else: + # number 和 money:保留指定小数位,返回格式化字符串 + if decimal_places == 0: + return str(int(round(num_value))) + return f"{num_value:.{decimal_places}f}" + + @classmethod + async def calculate_all( + cls, + calculation_rules: Optional[Dict[str, Any]], + form_data: Dict[str, Any] + ) -> Dict[str, Any]: + """ + 执行所有计算规则 + + Args: + calculation_rules: 计算规则配置 + form_data: 表单数据 + + Returns: + 计算结果字典 + """ + if not calculation_rules: + return {} + + results = {} + + # 创建计算上下文(包含原始数据和已计算的结果) + context = dict(form_data) + + # 1. 先执行聚合计算(因为计算字段可能依赖聚合结果) + aggregations = calculation_rules.get('aggregations', []) + logger.info(f"开始执行聚合计算,共 {len(aggregations)} 个") + for agg in aggregations: + try: + logger.info(f"聚合配置原始数据: {agg}") + name = agg.get('name') + source = agg.get('source') + field = agg.get('field') + function = agg.get('function') or 'sum' + format_type = agg.get('format') or 'number' + # 确保 decimal_places 是整数,处理 None 和非数字情况 + decimal_places_raw = agg.get('decimal_places') + decimal_places = int(decimal_places_raw) if decimal_places_raw is not None else 2 + + logger.info(f"聚合字段解析: name={name}, source={source}, field={field}, function={function}, format={format_type}, decimal_places={decimal_places}") + + if not all([name, source, field]): + logger.warning(f"聚合字段配置不完整,跳过: name={name}, source={source}, field={field}") + continue + + # 计算聚合值 + raw_value = cls.calculate_aggregation(context, source, field, function) + logger.info(f"聚合原始值: {raw_value}, 类型: {type(raw_value)}") + + # 格式化 + formatted_value = cls.format_value(raw_value, format_type, decimal_places) + logger.info(f"格式化后: {formatted_value}, decimal_places={decimal_places}") + + results[name] = formatted_value + context[name] = raw_value # 使用原始值用于后续计算 + + # 如果是中文格式,同时保存原始数值 + if format_type == 'chinese': + results[f'{name}_raw'] = raw_value + + logger.info(f"聚合计算完成: {name} = {formatted_value}") + + except Exception as e: + logger.warning(f"聚合计算失败: {agg}, 错误: {e}") + + # 2. 执行计算字段(按顺序,支持依赖) + fields = calculation_rules.get('fields', []) + logger.info(f"开始执行计算字段,共 {len(fields)} 个") + for field_config in fields: + try: + name = field_config.get('name') + formula = field_config.get('formula') + format_type = field_config.get('format') or 'number' + # 确保 decimal_places 是整数,处理 None 和非数字情况 + decimal_places_raw = field_config.get('decimal_places') + decimal_places = int(decimal_places_raw) if decimal_places_raw is not None else 2 + + logger.info(f"处理计算字段: name={name}, formula={formula}, format={format_type}, decimal_places={decimal_places}") + + if not all([name, formula]): + logger.warning(f"计算字段配置不完整,跳过: {field_config}") + continue + + # 计算公式 + raw_value = cls.evaluate_formula(formula, context) + logger.info(f"计算字段 {name} 原始值: {raw_value}") + + if raw_value is not None: + # 格式化 + formatted_value = cls.format_value(raw_value, format_type, decimal_places) + logger.info(f"计算字段 {name} 格式化后: {formatted_value}") + + results[name] = formatted_value + context[name] = raw_value # 使用原始值用于后续计算 + + # 如果是中文格式,同时保存原始数值 + if format_type == 'chinese': + results[f'{name}_raw'] = raw_value + + logger.info(f"公式计算完成: {name} = {formatted_value}") + else: + logger.warning(f"计算字段 {name} 返回 None") + + except Exception as e: + logger.error(f"公式计算失败: {field_config}, 错误: {e}", exc_info=True) + + return results + + +# 导出 +__all__ = ['CalculationEngine', 'number_to_chinese', 'safe_get_value'] diff --git a/backend-fastapi/online_dev/document_generator/font_service.py b/backend-fastapi/online_dev/document_generator/font_service.py new file mode 100644 index 0000000..3dc4a2c --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/font_service.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +字体服务 - 获取服务器上可用的字体列表 +""" +import logging +import os +import platform +import subprocess +from functools import lru_cache +from typing import Dict, List, Optional, Set + +logger = logging.getLogger(__name__) + + +class FontService: + """字体服务类""" + + CHINESE_FONTS: List[Dict[str, str]] = [ + # 宋体系列 + {"label": "宋体 (SimSun)", "value": "SimSun, STSong, Songti SC, Noto Serif CJK SC, serif", "family": "SimSun"}, + {"label": "华文宋体 (STSong)", "value": "STSong, SimSun, Songti SC, Noto Serif CJK SC, serif", "family": "STSong"}, + {"label": "Noto 宋体 (Noto Serif CJK)", "value": "Noto Serif CJK SC, SimSun, STSong, serif", "family": "Noto Serif CJK SC"}, + # 黑体系列 + {"label": "黑体 (SimHei)", "value": "SimHei, STHeiti, Heiti SC, Noto Sans CJK SC, sans-serif", "family": "SimHei"}, + {"label": "Noto 黑体 (Noto Sans CJK)", "value": "Noto Sans CJK SC, SimHei, STHeiti, sans-serif", "family": "Noto Sans CJK SC"}, + {"label": "思源黑体 (Source Han Sans)", "value": "Source Han Sans CN, Noto Sans CJK SC, SimHei, sans-serif", "family": "Source Han Sans CN"}, + {"label": "微软雅黑 (Microsoft YaHei)", "value": "Microsoft YaHei, Noto Sans CJK SC, SimHei, sans-serif", "family": "Microsoft YaHei"}, + {"label": "苹方 (PingFang SC)", "value": "PingFang SC, Noto Sans CJK SC, SimHei, sans-serif", "family": "PingFang SC"}, + {"label": "文泉驿微米黑", "value": "WenQuanYi Micro Hei, Noto Sans CJK SC, sans-serif", "family": "WenQuanYi Micro Hei"}, + {"label": "文泉驿正黑", "value": "WenQuanYi Zen Hei, Noto Sans CJK SC, sans-serif", "family": "WenQuanYi Zen Hei"}, + # 楷体系列 + {"label": "楷体 (KaiTi)", "value": "KaiTi, STKaiti, Kaiti SC, serif", "family": "KaiTi"}, + {"label": "华文楷体 (STKaiti)", "value": "STKaiti, KaiTi, Kaiti SC, serif", "family": "STKaiti"}, + # 仿宋系列 + {"label": "仿宋 (FangSong)", "value": "FangSong, STFangsong, serif", "family": "FangSong"}, + {"label": "华文仿宋 (STFangsong)", "value": "STFangsong, FangSong, serif", "family": "STFangsong"}, + # 其他 + {"label": "华文细黑 (STXihei)", "value": "STXihei, Noto Sans CJK SC, sans-serif", "family": "STXihei"}, + {"label": "冬青黑体 (Hiragino Sans GB)", "value": "Hiragino Sans GB, Noto Sans CJK SC, sans-serif", "family": "Hiragino Sans GB"}, + ] + + ENGLISH_FONTS: List[Dict[str, str]] = [ + {"label": "Arial", "value": "Arial, sans-serif", "family": "Arial"}, + {"label": "Helvetica", "value": "Helvetica, Arial, sans-serif", "family": "Helvetica"}, + {"label": "Times New Roman", "value": "Times New Roman, serif", "family": "Times New Roman"}, + {"label": "Georgia", "value": "Georgia, serif", "family": "Georgia"}, + {"label": "Verdana", "value": "Verdana, sans-serif", "family": "Verdana"}, + {"label": "Courier New", "value": "Courier New, monospace", "family": "Courier New"}, + ] + + @classmethod + def _get_fc_list_families(cls) -> Optional[Set[str]]: + """通过 fc-list 获取系统已安装的字体族名""" + try: + result = subprocess.run( + ["fc-list", "--format", "%{family}\n"], + capture_output=True, text=True, timeout=10, + ) + if result.returncode != 0: + return None + families: Set[str] = set() + for line in result.stdout.splitlines(): + for part in line.split(","): + families.add(part.strip()) + return families + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + return None + + @classmethod + def _scan_font_files(cls) -> Set[str]: + """扫描字体目录,返回字体文件名集合(备用方案)""" + system = platform.system() + dirs: List[str] = [] + + if system == "Darwin": + dirs = ["/System/Library/Fonts", "/Library/Fonts", os.path.expanduser("~/Library/Fonts")] + elif system == "Linux": + dirs = ["/usr/share/fonts", "/usr/local/share/fonts", os.path.expanduser("~/.fonts")] + elif system == "Windows": + dirs = [os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts")] + + font_names: Set[str] = set() + extensions = {".ttf", ".ttc", ".otf", ".woff", ".woff2"} + + for font_dir in dirs: + if not os.path.exists(font_dir): + continue + try: + for root, _, files in os.walk(font_dir): + for file in files: + if os.path.splitext(file)[1].lower() in extensions: + font_names.add(os.path.splitext(file)[0]) + except (PermissionError, OSError): + continue + + return font_names + + @classmethod + def _is_font_available(cls, family: str, fc_families: Optional[Set[str]], file_names: Set[str]) -> bool: + """判断字体是否在系统中可用""" + if fc_families is not None: + family_lower = family.lower() + for fc_fam in fc_families: + if family_lower == fc_fam.lower(): + return True + return False + + family_normalized = family.lower().replace(" ", "") + for name in file_names: + name_normalized = name.lower().replace(" ", "").replace("-", "").replace("_", "") + if family_normalized in name_normalized or name_normalized in family_normalized: + return True + return False + + @classmethod + @lru_cache(maxsize=1) + def get_available_fonts(cls) -> List[Dict[str, str]]: + """获取服务器上可用的字体列表""" + result: List[Dict[str, str]] = [{"label": "默认字体", "value": ""}] + + fc_families = cls._get_fc_list_families() + file_names = cls._scan_font_files() if fc_families is None else set() + + if fc_families is not None: + logger.info(f"[字体服务] 通过 fc-list 检测到 {len(fc_families)} 个字体族") + else: + logger.info(f"[字体服务] fc-list 不可用,使用文件名扫描检测到 {len(file_names)} 个字体文件") + + added_labels: Set[str] = set() + + for font in cls.CHINESE_FONTS: + if cls._is_font_available(font["family"], fc_families, file_names): + if font["label"] not in added_labels: + result.append({"label": font["label"], "value": font["value"]}) + added_labels.add(font["label"]) + + if len(result) <= 1: + logger.warning("[字体服务] 未检测到中文字体,使用平台默认列表") + system = platform.system() + if system == "Darwin": + defaults = ["PingFang SC", "STSong", "STKaiti", "Hiragino Sans GB"] + elif system == "Linux": + defaults = ["Noto Sans CJK SC", "Noto Serif CJK SC", "WenQuanYi Zen Hei", "WenQuanYi Micro Hei"] + else: + defaults = ["Microsoft YaHei", "SimSun", "SimHei", "KaiTi", "FangSong"] + + for font in cls.CHINESE_FONTS: + if font["family"] in defaults and font["label"] not in added_labels: + result.append({"label": font["label"], "value": font["value"]}) + added_labels.add(font["label"]) + + for font in cls.ENGLISH_FONTS: + result.append({"label": font["label"], "value": font["value"]}) + + logger.info(f"[字体服务] 返回 {len(result)} 个字体选项") + return result diff --git a/backend-fastapi/online_dev/document_generator/generator.py b/backend-fastapi/online_dev/document_generator/generator.py new file mode 100644 index 0000000..4a11679 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/generator.py @@ -0,0 +1,1259 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +PDF生成引擎 +使用 WeasyPrint 将 HTML 转换为 PDF(对中文字体支持更好) +""" +import io +import json +import re +from typing import Optional, Dict, Any +from datetime import datetime + +from jinja2 import Environment, BaseLoader, select_autoescape + +# WeasyPrint 导入 +import logging +logger = logging.getLogger(__name__) + +try: + from weasyprint import HTML, CSS + from weasyprint.text.fonts import FontConfiguration + WEASYPRINT_AVAILABLE = True +except (ImportError, OSError) as e: + WEASYPRINT_AVAILABLE = False + HTML = None + CSS = None + FontConfiguration = None + logger.warning( + f"WeasyPrint 不可用,PDF 生成功能将被禁用。" + f"如需使用 PDF 生成功能,请安装系统依赖: brew install pango gdk-pixbuf libffi " + f"(错误: {e})" + ) + + +# 默认中文字体列表 +DEFAULT_CHINESE_FONTS = '"Noto Sans CJK SC", "Noto Serif CJK SC", "WenQuanYi Zen Hei", "WenQuanYi Micro Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "SimSun", "SimHei", "STSong", "STHeiti", "Source Han Sans CN", sans-serif' + + +class TemplateEngine: + """模板渲染引擎""" + + def __init__(self): + self.env = Environment( + loader=BaseLoader(), + autoescape=select_autoescape(['html', 'xml']), + ) + # 注册自定义过滤器 + self.env.filters['date'] = self._format_date + self.env.filters['datetime'] = self._format_datetime + self.env.filters['money'] = self._format_money + self.env.filters['number'] = self._format_number + self.env.filters['default'] = self._default_value + + def _format_date(self, value, format_str: str = "%Y-%m-%d") -> str: + """日期格式化""" + if not value: + return "" + if isinstance(value, str): + try: + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return value + if isinstance(value, datetime): + return value.strftime(format_str) + return str(value) + + def _format_datetime(self, value, format_str: str = "%Y-%m-%d %H:%M:%S") -> str: + """日期时间格式化""" + return self._format_date(value, format_str) + + def _format_money(self, value, decimal_places: int = 2) -> str: + """金额格式化""" + if value is None: + return "0.00" + try: + return f"{float(value):,.{decimal_places}f}" + except (ValueError, TypeError): + return str(value) + + def _format_number(self, value, decimal_places: int = 0) -> str: + """数字格式化""" + if value is None: + return "0" + try: + if decimal_places > 0: + return f"{float(value):,.{decimal_places}f}" + return f"{int(value):,}" + except (ValueError, TypeError): + return str(value) + + def _default_value(self, value, default: str = "") -> str: + """默认值""" + return value if value else default + + def render_html(self, template_content: str, data: Dict[str, Any]) -> str: + """渲染 HTML 模板""" + template = self.env.from_string(template_content) + return template.render(**data) + + +class DesignerTemplateRenderer: + """设计器模板渲染器 - 将设计器 JSON 转换为 HTML""" + + @classmethod + def render(cls, template_json: str, data: Dict[str, Any], css: Optional[str] = None) -> str: + """将设计器模板渲染为 HTML""" + try: + config = json.loads(template_json) if isinstance(template_json, str) else template_json + except json.JSONDecodeError: + raise ValueError("Invalid template JSON") + + # 支持两种格式:嵌套的 pageConfig 或扁平的 pageSize/pageOrientation/pageMargin + page_config = config.get("pageConfig", {}) + if not page_config: + page_config = { + "size": config.get("pageSize", "A4"), + "orientation": config.get("pageOrientation", "portrait"), + "margin": config.get("pageMargin", {"top": 20, "right": 20, "bottom": 20, "left": 20}), + "customPageWidth": config.get("customPageWidth"), + "customPageHeight": config.get("customPageHeight"), + } + elements = config.get("elements", []) + + # 构建 HTML + html_parts = [cls._build_html_header(page_config, css, config)] + html_parts.append('