refactor(report): switch to HTML+Gotenberg for high-quality PDF export

- Feat: Add Gotenberg service to docker-compose for headless PDF rendering
- Feat: Implement /generate-pdf endpoint in report-generator-service
- Feat: Add PDF generation proxy route in api-gateway
- Refactor(frontend): Rewrite PDFExportButton to generate HTML with embedded styles and images
- Feat(frontend): Auto-crop React Flow screenshots to remove whitespace
- Style: Optimize report print layout with CSS (margins, image sizing)
- Chore: Remove legacy react-pdf code and font files
This commit is contained in:
Lv, Qi 2025-11-30 22:23:32 +08:00
parent 7933c706d1
commit abe47c4bc8
35 changed files with 3623 additions and 94 deletions

18
Cargo.lock generated
View File

@ -320,6 +320,7 @@ dependencies = [
"matchit",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"serde_core",
@ -2376,6 +2377,23 @@ dependencies = [
"workflow-context",
]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
]
[[package]]
name = "native-tls"
version = "0.2.14"

BIN
assets/flow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

View File

@ -312,12 +312,14 @@ services:
SERVER_PORT: 8004
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000
GOTENBERG_URL: http://gotenberg:3000
WORKFLOW_DATA_PATH: /mnt/workflow_data
RUST_LOG: info,axum=info
RUST_BACKTRACE: "1"
depends_on:
- nats
- data-persistence-service
- gotenberg
networks:
- app-network
healthcheck:
@ -326,6 +328,14 @@ services:
timeout: 5s
retries: 12
gotenberg:
image: gotenberg/gotenberg:8
container_name: gotenberg
ports:
- "3000:3000"
networks:
- app-network
workflow-orchestrator-service:
build:
context: .

View File

@ -19,14 +19,17 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-pdf/renderer": "^4.3.1",
"@tanstack/react-query": "^5.90.10",
"@zodios/core": "^10.9.6",
"axios": "^1.13.2",
"buffer": "^6.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"elkjs": "^0.11.0",
"html-to-image": "^1.11.13",
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@ -402,6 +405,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "http://npm.repo.lan/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@ -2265,6 +2277,180 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@react-pdf/fns": {
"version": "3.1.2",
"resolved": "http://npm.repo.lan/@react-pdf/fns/-/fns-3.1.2.tgz",
"integrity": "sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==",
"license": "MIT"
},
"node_modules/@react-pdf/font": {
"version": "4.0.3",
"resolved": "http://npm.repo.lan/@react-pdf/font/-/font-4.0.3.tgz",
"integrity": "sha512-N1qQDZr6phXYQOp033Hvm2nkUkx2LkszjGPbmRavs9VOYzi4sp31MaccMKptL24ii6UhBh/z9yPUhnuNe/qHwA==",
"license": "MIT",
"dependencies": {
"@react-pdf/pdfkit": "^4.0.4",
"@react-pdf/types": "^2.9.1",
"fontkit": "^2.0.2",
"is-url": "^1.2.4"
}
},
"node_modules/@react-pdf/image": {
"version": "3.0.3",
"resolved": "http://npm.repo.lan/@react-pdf/image/-/image-3.0.3.tgz",
"integrity": "sha512-lvP5ryzYM3wpbO9bvqLZYwEr5XBDX9jcaRICvtnoRqdJOo7PRrMnmB4MMScyb+Xw10mGeIubZAAomNAG5ONQZQ==",
"license": "MIT",
"dependencies": {
"@react-pdf/png-js": "^3.0.0",
"jay-peg": "^1.1.1"
}
},
"node_modules/@react-pdf/layout": {
"version": "4.4.1",
"resolved": "http://npm.repo.lan/@react-pdf/layout/-/layout-4.4.1.tgz",
"integrity": "sha512-GVzdlWoZWldRDzlWj3SttRXmVDxg7YfraAohwy+o9gb9hrbDJaaAV6jV3pc630Evd3K46OAzk8EFu8EgPDuVuA==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.2",
"@react-pdf/image": "^3.0.3",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/stylesheet": "^6.1.1",
"@react-pdf/textkit": "^6.0.0",
"@react-pdf/types": "^2.9.1",
"emoji-regex-xs": "^1.0.0",
"queue": "^6.0.1",
"yoga-layout": "^3.2.1"
}
},
"node_modules/@react-pdf/pdfkit": {
"version": "4.0.4",
"resolved": "http://npm.repo.lan/@react-pdf/pdfkit/-/pdfkit-4.0.4.tgz",
"integrity": "sha512-/nITLggsPlB66bVLnm0X7MNdKQxXelLGZG6zB5acF5cCgkFwmXHnLNyxYOUD4GMOMg1HOPShXDKWrwk2ZeHsvw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/png-js": "^3.0.0",
"browserify-zlib": "^0.2.0",
"crypto-js": "^4.2.0",
"fontkit": "^2.0.2",
"jay-peg": "^1.1.1",
"linebreak": "^1.1.0",
"vite-compatible-readable-stream": "^3.6.1"
}
},
"node_modules/@react-pdf/png-js": {
"version": "3.0.0",
"resolved": "http://npm.repo.lan/@react-pdf/png-js/-/png-js-3.0.0.tgz",
"integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==",
"license": "MIT",
"dependencies": {
"browserify-zlib": "^0.2.0"
}
},
"node_modules/@react-pdf/primitives": {
"version": "4.1.1",
"resolved": "http://npm.repo.lan/@react-pdf/primitives/-/primitives-4.1.1.tgz",
"integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==",
"license": "MIT"
},
"node_modules/@react-pdf/reconciler": {
"version": "1.1.4",
"resolved": "http://npm.repo.lan/@react-pdf/reconciler/-/reconciler-1.1.4.tgz",
"integrity": "sha512-oTQDiR/t4Z/Guxac88IavpU2UgN7eR0RMI9DRKvKnvPz2DUasGjXfChAdMqDNmJJxxV26mMy9xQOUV2UU5/okg==",
"license": "MIT",
"dependencies": {
"object-assign": "^4.1.1",
"scheduler": "0.25.0-rc-603e6108-20241029"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
"version": "0.25.0-rc-603e6108-20241029",
"resolved": "http://npm.repo.lan/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
"license": "MIT"
},
"node_modules/@react-pdf/render": {
"version": "4.3.1",
"resolved": "http://npm.repo.lan/@react-pdf/render/-/render-4.3.1.tgz",
"integrity": "sha512-v1WAaAhQShQZGcBxfjkEThGCHVH9CSuitrZ1bIOLvB5iBKM14abYK5D6djKhWCwF6FTzYeT2WRjRMVgze/ND2A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.2",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/textkit": "^6.0.0",
"@react-pdf/types": "^2.9.1",
"abs-svg-path": "^0.1.1",
"color-string": "^1.9.1",
"normalize-svg-path": "^1.1.0",
"parse-svg-path": "^0.1.2",
"svg-arc-to-cubic-bezier": "^3.2.0"
}
},
"node_modules/@react-pdf/renderer": {
"version": "4.3.1",
"resolved": "http://npm.repo.lan/@react-pdf/renderer/-/renderer-4.3.1.tgz",
"integrity": "sha512-dPKHiwGTaOsKqNWCHPYYrx8CDfAGsUnV4tvRsEu0VPGxuot1AOq/M+YgfN/Pb+MeXCTe2/lv6NvA8haUtj3tsA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.2",
"@react-pdf/font": "^4.0.3",
"@react-pdf/layout": "^4.4.1",
"@react-pdf/pdfkit": "^4.0.4",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/reconciler": "^1.1.4",
"@react-pdf/render": "^4.3.1",
"@react-pdf/types": "^2.9.1",
"events": "^3.3.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"queue": "^6.0.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/stylesheet": {
"version": "6.1.1",
"resolved": "http://npm.repo.lan/@react-pdf/stylesheet/-/stylesheet-6.1.1.tgz",
"integrity": "sha512-Iyw0A3wRIeQLN4EkaKf8yF9MvdMxiZ8JjoyzLzDHSxnKYoOA4UGu84veCb8dT9N8MxY5x7a0BUv/avTe586Plg==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.2",
"@react-pdf/types": "^2.9.1",
"color-string": "^1.9.1",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@react-pdf/textkit": {
"version": "6.0.0",
"resolved": "http://npm.repo.lan/@react-pdf/textkit/-/textkit-6.0.0.tgz",
"integrity": "sha512-fDt19KWaJRK/n2AaFoVm31hgGmpygmTV7LsHGJNGZkgzXcFyLsx+XUl63DTDPH3iqxj3xUX128t104GtOz8tTw==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.2",
"bidi-js": "^1.0.2",
"hyphen": "^1.6.4",
"unicode-properties": "^1.4.1"
}
},
"node_modules/@react-pdf/types": {
"version": "2.9.1",
"resolved": "http://npm.repo.lan/@react-pdf/types/-/types-2.9.1.tgz",
"integrity": "sha512-5GoCgG0G5NMgpPuHbKG2xcVRQt7+E5pg3IyzVIIozKG3nLcnsXW4zy25vG1ZBQA0jmo39q34au/sOnL/0d1A4w==",
"license": "MIT",
"dependencies": {
"@react-pdf/font": "^4.0.3",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/stylesheet": "^6.1.1"
}
},
"node_modules/@reactflow/background": {
"version": "11.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
@ -2850,6 +3036,15 @@
"win32"
]
},
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "http://npm.repo.lan/@swc/helpers/-/helpers-0.5.17.tgz",
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz",
@ -3932,6 +4127,12 @@
"zod": "^3.x"
}
},
"node_modules/abs-svg-path": {
"version": "0.1.1",
"resolved": "http://npm.repo.lan/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@ -4079,6 +4280,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "http://npm.repo.lan/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.30",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz",
@ -4089,6 +4310,15 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "http://npm.repo.lan/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@ -4113,6 +4343,24 @@
"node": ">=8"
}
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "http://npm.repo.lan/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "http://npm.repo.lan/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/browserslist": {
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz",
@ -4147,6 +4395,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "http://npm.repo.lan/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@ -4293,6 +4565,15 @@
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "http://npm.repo.lan/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -4335,9 +4616,18 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "http://npm.repo.lan/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -4398,6 +4688,12 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "http://npm.repo.lan/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -4616,6 +4912,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "http://npm.repo.lan/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -4643,6 +4945,12 @@
"integrity": "sha512-u4J8h9mwEDaYMqo0RYJpqNMFDoMK7f+pu4GjcV+N8jIC7TRdORgzkfSjTJemhqONFfH6fBI3wpysgWbhgVWIXw==",
"license": "EPL-2.0"
},
"node_modules/emoji-regex-xs": {
"version": "1.0.0",
"resolved": "http://npm.repo.lan/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@ -4968,6 +5276,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "http://npm.repo.lan/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@ -4978,7 +5295,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
@ -5136,6 +5452,23 @@
}
}
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "http://npm.repo.lan/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@ -5441,6 +5774,27 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/hsl-to-hex": {
"version": "1.0.0",
"resolved": "http://npm.repo.lan/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
"license": "MIT",
"dependencies": {
"hsl-to-rgb-for-reals": "^1.1.0"
}
},
"node_modules/hsl-to-rgb-for-reals": {
"version": "1.1.1",
"resolved": "http://npm.repo.lan/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
"license": "ISC"
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "http://npm.repo.lan/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@ -5451,6 +5805,32 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hyphen": {
"version": "1.10.6",
"resolved": "http://npm.repo.lan/hyphen/-/hyphen-1.10.6.tgz",
"integrity": "sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw==",
"license": "ISC"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "http://npm.repo.lan/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -5488,6 +5868,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "http://npm.repo.lan/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@ -5518,6 +5904,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "http://npm.repo.lan/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/is-decimal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
@ -5583,6 +5975,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "http://npm.repo.lan/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -5590,6 +5988,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/jay-peg": {
"version": "1.1.1",
"resolved": "http://npm.repo.lan/jay-peg/-/jay-peg-1.1.1.tgz",
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
"license": "MIT",
"dependencies": {
"restructure": "^3.0.0"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@ -5604,7 +6011,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@ -5965,6 +6371,25 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "http://npm.repo.lan/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "http://npm.repo.lan/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -6004,6 +6429,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "http://npm.repo.lan/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -6334,6 +6771,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/media-engine": {
"version": "1.0.3",
"resolved": "http://npm.repo.lan/media-engine/-/media-engine-1.0.3.tgz",
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -7021,6 +7464,24 @@
"node": ">=0.10.0"
}
},
"node_modules/normalize-svg-path": {
"version": "1.1.0",
"resolved": "http://npm.repo.lan/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
"license": "MIT",
"dependencies": {
"svg-arc-to-cubic-bezier": "^3.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "http://npm.repo.lan/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
@ -7114,6 +7575,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "http://npm.repo.lan/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -7152,6 +7619,12 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/parse-svg-path": {
"version": "0.1.2",
"resolved": "http://npm.repo.lan/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT"
},
"node_modules/pastable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/pastable/-/pastable-2.2.1.tgz",
@ -7265,7 +7738,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/prelude-ls": {
@ -7294,6 +7766,17 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "http://npm.repo.lan/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@ -7320,6 +7803,15 @@
"node": ">=6"
}
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "http://npm.repo.lan/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -7362,6 +7854,12 @@
"react": "^19.2.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "http://npm.repo.lan/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@ -7594,7 +8092,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -7610,6 +8107,12 @@
"node": ">=4"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "http://npm.repo.lan/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@ -7687,6 +8190,26 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "http://npm.repo.lan/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@ -7732,6 +8255,15 @@
"node": ">=8"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "http://npm.repo.lan/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@ -7762,6 +8294,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "http://npm.repo.lan/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@ -7820,6 +8361,12 @@
"node": ">=8"
}
},
"node_modules/svg-arc-to-cubic-bezier": {
"version": "3.2.0",
"resolved": "http://npm.repo.lan/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
"license": "ISC"
},
"node_modules/tailwind-merge": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
@ -7894,6 +8441,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "http://npm.repo.lan/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -8093,6 +8646,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "http://npm.repo.lan/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "http://npm.repo.lan/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "http://npm.repo.lan/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@ -8287,7 +8866,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/vfile": {
@ -8393,6 +8971,20 @@
}
}
},
"node_modules/vite-compatible-readable-stream": {
"version": "3.6.1",
"resolved": "http://npm.repo.lan/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -8510,6 +9102,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yoga-layout": {
"version": "3.2.1",
"resolved": "http://npm.repo.lan/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "http://npm.repo.lan/zod/-/zod-3.25.76.tgz",

View File

@ -22,14 +22,17 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-pdf/renderer": "^4.3.1",
"@tanstack/react-query": "^5.90.10",
"@zodios/core": "^10.9.6",
"axios": "^1.13.2",
"buffer": "^6.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"elkjs": "^0.11.0",
"html-to-image": "^1.11.13",
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",

Binary file not shown.

Binary file not shown.

View File

@ -1,11 +1,11 @@
# Fundamental Analysis Platform 用户指南 (v2.0 - Vite Refactor)
日期: 2025-11-22
版本: 2.0
# Fundamental Analysis Platform 用户指南 (v2.1 - Dynamic Refactor)
日期: 2025-11-30
版本: 2.1
## 1. 简介
Fundamental Analysis Platform 是一个基于 AI Agent 的深度基本面投研平台,旨在通过自动化工作流聚合多源金融数据,并利用 LLM大语言模型生成专业的财务分析报告。
v2.0 版本采用了全新的 Vite + React SPA 架构,提供了更流畅的交互体验和实时的分析状态可视化
v2.1 版本引入了动态配置架构、增强的实时日志流和结构化的数据报表展示,提供了更稳定和可视化的分析体验
## 2. 核心功能
@ -16,47 +16,57 @@ v2.0 版本采用了全新的 Vite + React SPA 架构,提供了更流畅的交
* **开始分析**: 点击“生成分析报告”按钮即可启动分析流程。
### 2.2 分析报告页 (Report View)
核心工作区,分为左侧状态栏和右侧详情区
核心工作区,采用**双栏布局**:左侧为实时状态监控,右侧为多标签页详情展示
#### 左侧:工作流状态
* **可视化 DAG**: 展示当前的分析任务依赖图。
#### 左侧:工作流状态 (Workflow Status)
* **可视化 DAG (Visualizer)**:
* 展示当前的分析任务依赖图。节点显示**人类可读的任务名称** (如 "新闻分析", "财务数据获取")。
* **节点颜色**: 灰色(等待)、蓝色(运行中)、绿色(完成)、红色(失败)。
* **动态连线**: 当任务运行时,连接线会有流光动画指示数据流向。
* **实时日志**: 滚动展示所有后台任务的执行日志,支持实时查看数据抓取和分析进度。
* **动态连线**: 任务运行时显示流光动画,指示数据流向。
* **实时日志 (Real-time Logs)**:
* 位于左侧底部(或独立面板),实时滚动展示所有后台任务的执行日志。
* **历史回放**: 即使刷新页面或断线重连,系统也会自动拉取完整的历史日志,确保信息不丢失。
#### 右侧:详情面板
* **Analysis Report**: 展示由 AI 生成的最终分析报告。支持 Markdown 格式(标题、表格、加粗、引用),并带有打字机生成特效。
* **Fundamental Data**: (开发中) 展示抓取到的原始财务数据表格。
* **Stock Chart**: (开发中) 展示股价走势图。
#### 右侧:详情面板 (Detail Tabs)
右侧区域根据分析流程动态生成多个标签页:
* **Overview (总览)**:
* 展示整体分析进度、任务完成统计和总耗时。
* 如果任务失败,会在此处显示具体的错误信息摘要。
* **任务详情页 (Task Tabs)**:
* 每个工作流节点(如 "Financial Data", "News Analysis")都有独立的标签页。
* **智能渲染**:
* **分析报告**: AI 生成的文本以 Markdown 格式渲染,支持富文本排版。
* **财务数据**: 原始财务数据(特别是 Tushare A股数据现在自动转换为**结构化 Markdown 表格**,按年份和报表类型分组,数值经过格式化(如 "14.20 亿"),便于阅读。
* **Inspector (调试器)**: 点击右上角的 "Inspector" 按钮,可以打开侧边栏,查看该任务的输入/输出文件差异 (Diff) 和上下文信息,方便调试。
### 2.3 系统配置 (Config)
集中管理平台的所有外部连接和参数。
* **AI Provider**:
* 管理 LLM 供应商 (OpenAI, Anthropic, Local Ollama 等)。
* 配置 API Key 和 Base URL。
* 刷新并选择可用的模型 (GPT-4o, Claude-3.5 等)。
* **数据源配置**:
* 启用/禁用金融数据源 (Tushare, Finnhub, AlphaVantage)。
* 输入对应的 API Token。
* 支持连接测试。
* 支持配置 API Key、Base URL 和模型选择。
* **数据源配置 (Dynamic Data Sources)**:
* **动态加载**: 支持的数据源列表Tushare, Finnhub, AlphaVantage 等)及其配置项由后端动态下发,无需升级前端即可支持新数据源。
* **功能**: 支持输入 Token/Key并提供 **"Test Connection" (测试连接)** 按钮以验证配置是否有效。
* **分析模板**:
* 查看当前的分析流程模板(如 "Quick Scan")。
* 查看每个模块使用的 Prompt 模板及模型配置。
* 查看当前的分析流程模板及各模块使用的 Prompt。
* **系统状态**:
* 监控微服务集群 (API Gateway, Orchestrator 等) 的健康状态。
## 3. 快速开始
1. 进入 **配置页** -> **AI Provider**,添加您的 OpenAI API Key。
2. 进入 **配置页** -> **数据源配置**启用 Tushare 并输入 Token
2. 进入 **配置页** -> **数据源配置**选择 **Tushare** (或其他源),输入 Token 并点击 **Test Connection** 确认连通性,最后保存
3. 回到 **首页**,输入 `600519.SS`,选择 `CN` 市场。
4. 点击 **生成分析报告**,观察工作流运行及报告生成。
4. 点击 **生成分析报告**
5. 在报告页观察左侧 DAG 运行状态,随着任务完成,点击右侧对应的标签页查看数据和分析结果。
## 4. 常见问题
* **Q: 报告生成卡住怎么办?**
* A: 检查左侧“实时日志”,查看是否有 API 连接超时或配额耗尽的错误。
* A: 检查左侧“实时日志”,查看是否有 API 连接超时或配额耗尽的错误。也可以在 "Overview" 标签页查看是否有任务标记为失败。
* **Q: 如何添加本地模型?**
* A: 在 AI Provider 页添加新的 ProviderBase URL 填入 `http://localhost:11434/v1` (Ollama 默认地址)。
* **Q: 为什么看不到某些数据源?**
* A: 数据源列表由后端服务动态注册。请确保对应的 Provider 微服务(如 `tushare-provider-service`)已正常启动并注册到网关。

View File

@ -13,6 +13,7 @@ import { History, Loader2 } from 'lucide-react';
import { WorkflowHistorySummaryDto } from '@/api/schema.gen';
import { z } from 'zod';
import { client } from '@/api/client';
import { useAnalysisTemplates } from "@/hooks/useConfig";
type WorkflowHistorySummary = z.infer<typeof WorkflowHistorySummaryDto>;
@ -20,6 +21,7 @@ export function RecentReportsDropdown() {
const [reports, setReports] = useState<WorkflowHistorySummary[]>([]);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { data: templates } = useAnalysisTemplates();
const loadReports = async () => {
setLoading(true);
@ -64,7 +66,7 @@ export function RecentReportsDropdown() {
<span className="text-xs font-normal text-muted-foreground">{new Date(report.start_time).toLocaleDateString()}</span>
</div>
<div className="flex justify-between w-full text-xs text-muted-foreground">
<span>{report.template_id || 'Default'}</span>
<span>{templates?.find(t => t.id === report.template_id)?.name || report.template_id || 'Default'}</span>
<span className={report.status === 'Completed' ? 'text-green-600' : report.status === 'Failed' ? 'text-destructive' : 'text-amber-600'}>{report.status}</span>
</div>
</DropdownMenuItem>

View File

@ -4,10 +4,11 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Loader2, ArrowRight, History, RefreshCw } from "lucide-react";
import { Loader2, ArrowRight, History, RefreshCw, Trash2 } from "lucide-react";
import { WorkflowHistorySummaryDto } from '@/api/schema.gen';
import { z } from 'zod';
import { client } from '@/api/client';
import { useAnalysisTemplates } from "@/hooks/useConfig";
type WorkflowHistorySummary = z.infer<typeof WorkflowHistorySummaryDto>;
@ -15,6 +16,7 @@ export function RecentWorkflowsList() {
const [history, setHistory] = useState<WorkflowHistorySummary[]>([]);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { data: templates } = useAnalysisTemplates();
const fetchHistory = async () => {
setLoading(true);
@ -29,6 +31,23 @@ export function RecentWorkflowsList() {
}
};
const handleClearHistory = async () => {
if (confirm("Are you sure you want to clear ALL history? This cannot be undone.")) {
try {
const res = await fetch('/api/v1/system/history', { method: 'DELETE' });
if (res.ok) {
fetchHistory();
} else {
console.error("Failed to clear history");
alert("Failed to clear history");
}
} catch (e) {
console.error(e);
alert("Error clearing history");
}
}
};
useEffect(() => {
fetchHistory();
}, []);
@ -49,9 +68,14 @@ export function RecentWorkflowsList() {
Your recently generated fundamental analysis reports.
</CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={fetchHistory} disabled={loading}>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
<div className="flex gap-2">
<Button variant="ghost" size="icon" onClick={handleClearHistory} title="Clear All History">
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
<Button variant="ghost" size="icon" onClick={fetchHistory} disabled={loading}>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
@ -77,7 +101,7 @@ export function RecentWorkflowsList() {
<TableRow key={item.request_id} className="group cursor-pointer hover:bg-muted/50" onClick={() => navigate(`/history/${item.request_id}`)}>
<TableCell className="font-medium">{item.symbol}</TableCell>
<TableCell>{item.market}</TableCell>
<TableCell className="text-muted-foreground">{item.template_id || 'Default'}</TableCell>
<TableCell className="text-muted-foreground">{templates?.find(t => t.id === item.template_id)?.name || item.template_id || 'Default'}</TableCell>
<TableCell>
<StatusBadge status={item.status} />
</TableCell>

View File

@ -0,0 +1,326 @@
import { useState } from 'react';
import * as htmlToImage from 'html-to-image';
import { Button } from '@/components/ui/button';
import { Loader2, FileDown } from 'lucide-react';
import { useWorkflowStore } from '@/stores/useWorkflowStore';
import { cn, formatNodeName } from '@/lib/utils';
import { cropImage } from '@/lib/image-processing';
import { schemas } from '@/api/schema.gen';
import { marked } from 'marked';
interface PDFExportButtonProps {
symbol?: string | null;
market?: string | null;
templateName?: string | null;
requestId?: string;
className?: string;
}
export function PDFExportButton({ symbol, market, templateName, requestId, className }: PDFExportButtonProps) {
// State to track PDF generation process
const [isGenerating, setIsGenerating] = useState(false);
const { tasks, dag } = useWorkflowStore();
// const apiBaseUrl = (import.meta as any).env.VITE_API_TARGET || '/v1';
const generateHTML = async (graphImage: string) => {
// CSS Styles (GitHub Markdown + Tailwind-like base styles)
const styles = `
<style>
@page {
margin: 0 2%;
}
body {
font-family: "ChineseFont", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
line-height: 1.6;
color: #24292f;
max-width: 900px;
margin: 0 auto;
padding: 40px;
background-color: #ffffff;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
h1 { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: .3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: .3em; }
h3 { font-size: 1.25em; }
p { margin-top: 0; margin-bottom: 16px; text-align: justify; }
code {
padding: .2em .4em;
margin: 0;
font-size: 85%;
background-color: rgba(27,31,35,.05);
border-radius: 3px;
font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;
}
pre {
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: #f6f8fa;
border-radius: 3px;
margin-bottom: 16px;
}
pre code {
display: inline;
padding: 0;
margin: 0;
overflow: visible;
line-height: inherit;
word-wrap: normal;
background-color: transparent;
border: 0;
}
blockquote {
padding: 0 1em;
color: #6a737d;
border-left: .25em solid #dfe2e5;
margin: 0 0 16px 0;
}
ul, ol { padding-left: 2em; margin-bottom: 16px; }
li { margin-bottom: 4px; }
img { max-width: 100%; box-sizing: content-box; background-color: #fff; }
table {
display: block;
width: 100%;
overflow: auto;
margin-bottom: 16px;
border-spacing: 0;
border-collapse: collapse;
}
table tr {
background-color: #fff;
border-top: 1px solid #c6cbd1;
}
table tr:nth-child(2n) {
background-color: #f6f8fa;
}
table th, table td {
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
table th {
font-weight: 600;
}
hr {
height: .25em;
padding: 0;
margin: 24px 0;
background-color: #e1e4e8;
border: 0;
}
/* Report Specific Styles */
.report-header {
margin-bottom: 40px;
border-bottom: 2px solid #eaecef;
padding-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.report-title {
font-size: 2.5em;
font-weight: bold;
margin: 0;
line-height: 1.2;
}
.report-meta {
text-align: right;
color: #586069;
font-size: 0.9em;
}
.section-title {
background-color: #f6f8fa;
padding: 10px 15px;
border-left: 5px solid #0366d6;
margin-top: 30px;
margin-bottom: 20px;
font-size: 1.5em;
font-weight: bold;
}
.workflow-graph {
margin: 30px 0;
text-align: center;
border: 1px solid #eaecef;
padding: 10px;
border-radius: 6px;
}
.workflow-graph img {
max-width: 100%;
max-height: 800px;
object-fit: contain;
margin: 0 auto;
display: block;
}
.footer {
margin-top: 50px;
padding-top: 20px;
border-top: 1px solid #eaecef;
text-align: center;
color: #586069;
font-size: 0.8em;
}
/* Printing Optimization */
@media print {
body { max-width: 100%; padding: 0; }
.section-title { break-after: avoid; }
pre, blockquote, table { break-inside: avoid; }
img { max-width: 100% !important; }
/* Ensure background colors are printed */
* { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
</style>
`;
// Generate Tasks HTML
let tasksHtml = '';
if (dag?.nodes) {
for (const node of dag.nodes) {
const task = tasks[node.id];
if (task && task.status === schemas.TaskStatus.enum.Completed && task.content) {
const parsedContent = await marked.parse(task.content);
tasksHtml += `
<div class="section">
<div class="section-title">${node.display_name || formatNodeName(node.name)}</div>
<div class="markdown-body">
${parsedContent}
</div>
</div>
`;
}
}
}
// Assemble Full HTML
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${symbol} - Analysis Report</title>
${styles}
</head>
<body>
<div class="report-header">
<div>
<div class="report-title">${symbol || 'Unknown Symbol'}</div>
<div style="font-size: 1.2em; color: #586069;">${market || 'Unknown Market'}</div>
</div>
<div class="report-meta">
<div>Template: ${templateName || 'Default'}</div>
<div>ID: ${requestId || 'N/A'}</div>
<div>Date: ${new Date().toLocaleDateString()}</div>
</div>
</div>
${graphImage ? `
<div class="section">
<div class="section-title">Workflow Topology</div>
<div class="workflow-graph">
<img src="${graphImage}" alt="Workflow Graph" />
</div>
</div>
` : ''}
${tasksHtml}
<div class="footer">
Generated by Fundamental Analysis Platform ${new Date().toISOString()}
</div>
</body>
</html>
`;
};
const handleExport = async () => {
setIsGenerating(true);
try {
// 1. Capture the Graph Image
const flowElement = document.querySelector('.react-flow') as HTMLElement;
let graphImage = '';
if (flowElement) {
try {
const filter = (node: HTMLElement) => {
const exclusionClasses = ['react-flow__controls', 'react-flow__minimap', 'react-flow__panel', 'react-flow__background'];
return !exclusionClasses.some(classname => node.classList?.contains(classname));
};
// Wait for animations
await new Promise(resolve => setTimeout(resolve, 100));
graphImage = await htmlToImage.toPng(flowElement, {
filter,
backgroundColor: '#ffffff',
pixelRatio: 2,
cacheBust: true,
});
// Auto-crop whitespace
graphImage = await cropImage(graphImage);
} catch (e) {
console.warn("Graph capture failed:", e);
}
}
// 2. Generate HTML Content
const htmlContent = await generateHTML(graphImage);
// 3. Send to Backend for PDF Conversion
const formData = new FormData();
const blob = new Blob([htmlContent], { type: 'text/html' });
formData.append('index.html', blob, 'index.html');
// Send to /api/v1/generate-pdf which is proxied by Vite to API Gateway
// The API Gateway then forwards it to the Report Service
const response = await fetch(`/api/v1/generate-pdf`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`PDF generation failed: ${response.statusText}`);
}
// 4. Download PDF
const pdfBlob = await response.blob();
const url = URL.createObjectURL(pdfBlob);
const link = document.createElement('a');
link.href = url;
link.download = `${symbol || 'Report'}_${market || 'Analysis'}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (e) {
console.error("Export failed", e);
alert("Failed to generate PDF. Please try again.");
} finally {
setIsGenerating(false);
}
};
return (
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className={cn("gap-2", className)}
onClick={handleExport}
disabled={isGenerating}
>
{isGenerating ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileDown className="h-4 w-4" />}
{isGenerating ? "Generating PDF..." : "Export PDF"}
</Button>
</div>
);
}

View File

@ -38,6 +38,43 @@
}
}
/*
Markdown Typography Overrides
Ensure high contrast for all text elements within prose content.
*/
@layer components {
.prose {
/* Force base text color */
@apply text-foreground;
/* Force specific elements to use foreground color to avoid "faint" gray defaults */
& :where(p, ul, ol, li, blockquote, strong, b, i, em, code, h1, h2, h3, h4, h5, h6, th, td, span) {
color: var(--color-foreground) !important;
}
/* Ensure links use primary color */
& a {
@apply text-primary hover:underline decoration-primary/30 underline-offset-4;
color: var(--color-primary) !important;
}
/* Table styling fixes */
& :where(thead, tbody, tr) {
border-color: var(--color-border) !important;
}
& :where(th, td) {
border-color: var(--color-border) !important;
}
/* Code block fixes */
& pre {
@apply bg-muted text-foreground;
border-color: var(--color-border) !important;
border-width: 1px !important;
}
}
}
.writing-vertical-lr {
writing-mode: vertical-lr;
}

View File

@ -0,0 +1,164 @@
/**
* Process image data to crop whitespace
*/
export async function cropImage(
imageSrc: string,
options: {
threshold?: number; // 0-255, higher means more colors are considered "white"
padding?: number; // padding in pixels to keep around content
} = {}
): Promise<string> {
const { threshold = 252, padding = 20 } = options; // 252 covers pure white and very light compression artifacts
return new Promise((resolve, reject) => {
const img = new Image();
// Enable CORS if needed, though usually data URLs don't need it
img.crossOrigin = "anonymous";
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve(imageSrc);
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const { width, height } = canvas;
// Check if pixel is "content" (darker than threshold)
// Returns true if it is CONTENT
const isContent = (r: number, g: number, b: number) => {
return r < threshold || g < threshold || b < threshold;
};
let top = -1, bottom = -1, left = -1, right = -1;
// Scan Top-down for top boundary
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
if (isContent(data[idx], data[idx+1], data[idx+2])) {
top = y;
break;
}
}
if (top !== -1) break;
}
// If top is still -1, the image is empty (all white)
if (top === -1) {
resolve(imageSrc);
return;
}
// Scan Bottom-up for bottom boundary
for (let y = height - 1; y >= top; y--) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
if (isContent(data[idx], data[idx+1], data[idx+2])) {
bottom = y + 1;
break;
}
}
if (bottom !== -1) break;
}
// Scan Left-right for left boundary
// We only need to scan within the vertical bounds we found
for (let x = 0; x < width; x++) {
for (let y = top; y < bottom; y++) {
const idx = (y * width + x) * 4;
if (isContent(data[idx], data[idx+1], data[idx+2])) {
left = x;
break;
}
}
if (left !== -1) break;
}
// Scan Right-left for right boundary
for (let x = width - 1; x >= left; x--) {
for (let y = top; y < bottom; y++) {
const idx = (y * width + x) * 4;
if (isContent(data[idx], data[idx+1], data[idx+2])) {
right = x + 1;
break;
}
}
if (right !== -1) break;
}
// Calculate crop coordinates with padding
const cropX = Math.max(0, left - padding);
const cropY = Math.max(0, top - padding);
// Calculate width/height ensuring we don't go out of bounds
// right is exclusive index + 1, so width is right - left.
// content width = right - left
// target width = content width + 2 * padding
// but limited by image bounds
const contentWidth = right - left;
const contentHeight = bottom - top;
// Check if we found valid bounds
if (contentWidth <= 0 || contentHeight <= 0) {
resolve(imageSrc);
return;
}
// Adjust padding to not exceed original image
// Actually, we want the crop rect to include padding if it exists in source,
// or maybe just white pad if it doesn't?
// The current logic `Math.max(0, left - padding)` keeps padding INSIDE the original image.
// If the content is at the edge, we might want to ADD whitespace?
// Usually cropping means reducing size. Let's stick to keeping what's in the image.
const rectRight = Math.min(width, right + padding);
const rectBottom = Math.min(height, bottom + padding);
const finalWidth = rectRight - cropX;
const finalHeight = rectBottom - cropY;
const finalCanvas = document.createElement('canvas');
finalCanvas.width = finalWidth;
finalCanvas.height = finalHeight;
const finalCtx = finalCanvas.getContext('2d');
if (!finalCtx) {
resolve(imageSrc);
return;
}
// Fill with white in case of transparency (though we are copying from opaque usually)
finalCtx.fillStyle = '#ffffff';
finalCtx.fillRect(0, 0, finalWidth, finalHeight);
finalCtx.drawImage(
canvas,
cropX, cropY, finalWidth, finalHeight, // Source rect
0, 0, finalWidth, finalHeight // Dest rect
);
resolve(finalCanvas.toDataURL('image/png'));
} catch (e) {
console.error("Image processing failed", e);
resolve(imageSrc);
}
};
img.onerror = (e) => {
console.error("Image loading failed", e);
resolve(imageSrc);
};
img.src = imageSrc;
});
}

View File

@ -1,6 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import * as buffer from 'buffer';
// Polyfill Buffer for @react-pdf/renderer
if (typeof window !== 'undefined') {
// @ts-ignore
window.Buffer = window.Buffer || buffer.Buffer;
}
import './index.css'
import App from './App'

View File

@ -203,7 +203,7 @@ export function Dashboard() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 max-w-4xl mx-auto text-left">
<FeatureCard title="多源数据聚合" desc="集成 Tushare, Finnhub 等多个专业金融数据源。" />
<FeatureCard title="AI 驱动分析" desc="使用 GPT-4o 等大模型进行深度财务指标解读。" />
<FeatureCard title="AI 驱动分析" desc="配置任意大模型、Prompt和分析流,进行深度财务指标解读。" />
<FeatureCard title="可视化工作流" desc="全流程透明化,实时查看每个分析步骤的状态。" />
</div>
</div>

View File

@ -12,10 +12,10 @@ import { Button } from '@/components/ui/button';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useAnalysisTemplates } from "@/hooks/useConfig"
import { RecentReportsDropdown } from '@/components/RecentReportsDropdown';
import { WorkflowStatus, ConnectionStatus, TaskState, TaskNode } from '@/types/workflow';
import { Progress } from "@/components/ui/progress"
import { cn, formatNodeName } from '@/lib/utils';
import { PDFExportButton } from '@/components/report/PDFExportButton';
export function HistoricalReportPage() {
const { id } = useParams();
@ -46,7 +46,7 @@ export function HistoricalReportPage() {
} = useWorkflowStore();
const { data: templates } = useAnalysisTemplates();
const templateName = templates && templateId ? templates[templateId]?.name : templateId;
const templateName = templates?.find(t => t.id === templateId)?.name || templateId;
// Initialization Logic - Historical Mode Only
useEffect(() => {
@ -132,8 +132,12 @@ export function HistoricalReportPage() {
</div>
</div>
<div className="flex gap-2">
<RecentReportsDropdown />
<Button size="sm" variant="outline">Export PDF</Button>
<PDFExportButton
symbol={symbol}
market={market}
templateName={templateName}
requestId={id}
/>
</div>
</div>

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
@ -7,17 +7,17 @@ import { WorkflowVisualizer } from '@/components/workflow/WorkflowVisualizer';
import { ContextExplorer } from '@/components/workflow/ContextExplorer';
import { useWorkflowStore } from '@/stores/useWorkflowStore';
import { TaskStatus, schemas } from '@/api/schema.gen';
import { Loader2, CheckCircle2, AlertCircle, Clock, PanelLeftClose, PanelLeftOpen, FileText, GitBranch, TerminalSquare, X, List, Trash2 } from 'lucide-react';
import { Loader2, CheckCircle2, AlertCircle, Clock, PanelLeftClose, PanelLeftOpen, TerminalSquare, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useAnalysisTemplates } from "@/hooks/useConfig"
import { RecentReportsDropdown } from '@/components/RecentReportsDropdown';
import { WorkflowStatus, ConnectionStatus, TaskState } from '@/types/workflow';
import { Progress } from "@/components/ui/progress"
import { cn, formatNodeName } from '@/lib/utils';
import { RealtimeLogs } from '@/components/workflow/RealtimeLogs';
import { PDFExportButton } from '@/components/report/PDFExportButton';
export function ReportPage() {
const { id } = useParams();
@ -50,7 +50,7 @@ export function ReportPage() {
} = useWorkflowStore();
const { data: templates } = useAnalysisTemplates();
const templateName = templates && templateId ? templates[templateId]?.name : templateId;
const templateName = templates?.find(t => t.id === templateId)?.name || templateId;
// Initialization & Connection Logic
useEffect(() => {
@ -155,32 +155,12 @@ export function ReportPage() {
</div>
</div>
<div className="flex gap-2">
<RecentReportsDropdown />
<Button
size="sm"
variant="destructive"
className="gap-2"
onClick={async () => {
if (confirm("Are you sure you want to clear ALL history? This cannot be undone.")) {
try {
const res = await fetch('/api/v1/system/history', { method: 'DELETE' });
if (res.ok) {
window.location.href = '/';
} else {
console.error("Failed to clear history");
alert("Failed to clear history");
}
} catch (e) {
console.error(e);
alert("Error clearing history");
}
}
}}
>
<Trash2 className="h-4 w-4" />
Clear History
</Button>
<Button size="sm" variant="outline">Export PDF</Button>
<PDFExportButton
symbol={symbol}
market={market}
templateName={templateName}
requestId={id}
/>
</div>
</div>
@ -480,7 +460,7 @@ function TaskDetailView({ taskId, task, requestId, mode }: { taskId: string, tas
{/* Main Report View */}
<div className="flex-1 overflow-auto p-8 bg-background">
<div className="w-full">
<div className="prose dark:prose-invert max-w-none prose-p:text-foreground prose-headings:text-foreground prose-li:text-foreground prose-strong:text-foreground prose-span:text-foreground prose-td:text-foreground prose-th:text-foreground">
<div className="prose dark:prose-invert max-w-none">
{task?.content ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{task.content || ''}

View File

@ -7,17 +7,26 @@ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [react()],
// Explicitly configure public directory behavior if needed,
// but usually 'public' is default.
publicDir: 'public',
optimizeDeps: {
exclude: ['dagre'],
// 'web-worker' needs to be optimized or handled correctly by Vite for elkjs
include: ['elkjs/lib/elk.bundled.js']
include: ['elkjs/lib/elk.bundled.js', 'buffer']
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
// Force buffer to resolve to the installed package, not node built-in
buffer: "buffer",
},
},
server: {
// Ensure static files are served correctly
fs: {
strict: false,
},
proxy: {
'/api': {
target: env.VITE_API_TARGET || 'http://localhost:4000',

2126
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,9 +3,14 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@react-pdf/renderer": "^4.3.1",
"cmdk": "^1.1.1",
"elkjs": "^0.11.0",
"html-to-image": "^1.11.13",
"immer": "^10.2.0",
"marked": "^17.0.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"zustand": "^5.0.8"
}
}

View File

@ -5,7 +5,7 @@ edition = "2024"
[dependencies]
# Web Service
axum = "0.8.7"
axum = { version = "0.8.7", features = ["multipart"] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6.6", features = ["cors", "trace"] }
utoipa = { version = "5.4", features = ["chrono", "uuid"] }
@ -21,7 +21,7 @@ futures-util = "0.3"
async-stream = "0.3"
# HTTP Client
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "multipart"] }
# Concurrency & Async
uuid = { version = "1.8", features = ["v4"] }

View File

@ -2,7 +2,7 @@ use crate::error::Result;
use crate::state::AppState;
use axum::{
Router,
extract::{Path, Query, State},
extract::{Path, Query, State, Multipart},
http::StatusCode,
response::{IntoResponse, Json},
routing::{get, post},
@ -13,7 +13,7 @@ use common_contracts::config_models::{
AnalysisTemplateSummary, AnalysisTemplateSet
};
use common_contracts::dtos::{SessionDataDto, WorkflowHistoryDto, WorkflowHistorySummaryDto};
use common_contracts::messages::GenerateReportCommand;
use common_contracts::messages::{GenerateReportCommand, StartWorkflowCommand, SyncStateCommand, WorkflowEvent};
use common_contracts::observability::{TaskProgress, ObservabilityTaskStatus};
use common_contracts::registry::ProviderMetadata;
use common_contracts::subjects::{NatsSubject, SubjectMessage};
@ -133,8 +133,6 @@ async fn mock_models() -> impl IntoResponse {
(StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], Json(body))
}
use common_contracts::messages::{StartWorkflowCommand, SyncStateCommand, WorkflowEvent};
/// [DELETE /v1/system/history]
#[utoipa::path(
delete,
@ -213,8 +211,64 @@ fn create_v1_router() -> Router<AppState> {
.route("/registry/heartbeat", post(registry::heartbeat))
.route("/registry/deregister", post(registry::deregister_service))
.route("/registry/providers", get(get_registered_providers))
// PDF Generation Proxy
.route("/generate-pdf", post(proxy_generate_pdf))
}
async fn proxy_generate_pdf(
State(state): State<AppState>,
multipart: Multipart,
) -> Result<impl IntoResponse> {
let url = format!(
"{}/generate-pdf",
state.config.report_generator_service_url.trim_end_matches('/')
);
let mut form = reqwest::multipart::Form::new();
let mut multipart = multipart;
while let Some(field) = multipart.next_field().await.map_err(|e| crate::error::AppError::BadRequest(e.to_string()))? {
let name = field.name().unwrap_or("").to_string();
if name == "index.html" {
let data = field.bytes().await.map_err(|e| crate::error::AppError::BadRequest(e.to_string()))?;
let part = reqwest::multipart::Part::bytes(data.to_vec())
.file_name("index.html")
.mime_str("text/html")
.map_err(|e| crate::error::AppError::Internal(anyhow::anyhow!(e)))?;
form = form.part("index.html", part);
}
}
let client = reqwest::Client::new();
let response = client.post(&url)
.multipart(form)
.send()
.await
.map_err(|e| crate::error::AppError::Internal(anyhow::anyhow!(e)))?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(crate::error::AppError::Internal(anyhow::anyhow!("Report service failed: {}", error_text)));
}
let content_type = response.headers().get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/pdf")
.to_string();
let body_stream = axum::body::Body::from_stream(response.bytes_stream());
Ok(axum::response::Response::builder()
.status(StatusCode::OK)
.header("Content-Type", content_type)
.header("Content-Disposition", "attachment; filename=\"report.pdf\"")
.body(body_stream)
.unwrap())
}
// ... rest of file (unchanged) ...
// Including legacy config and other handlers here to complete file write...
// --- Legacy Config Compatibility ---
#[derive(Serialize, Default)]
@ -327,7 +381,7 @@ fn project_data_sources(
(key, entry)
})
.collect()
}
}
fn provider_id(provider: &DataSourceProvider) -> &'static str {
match provider {
@ -889,7 +943,7 @@ pub struct TestLlmConfigRequest {
pub model_id: String,
}
/// [POST /v1/configs/llm/test]
/// [POST /api/v1/configs/llm/test]
#[utoipa::path(
post,
path = "/api/v1/configs/llm/test",
@ -1366,4 +1420,4 @@ async fn proxy_context_diff(
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
axum::body::Body::from(body),
))
}
}

View File

@ -5,7 +5,7 @@ edition = "2024"
[dependencies]
# Web Service
axum = "0.8.7"
axum = { version = "0.8.7", features = ["multipart"] }
tokio = { version = "1.0", features = ["full"] }
tower-http = { version = "0.6.6", features = ["cors"] }
@ -18,7 +18,7 @@ async-nats = "0.45.0"
futures = "0.3"
# Data Persistence Client
reqwest = { version = "0.12.4", default-features = false, features = ["json", "rustls-tls"] }
reqwest = { version = "0.12.4", default-features = false, features = ["json", "rustls-tls", "multipart"] }
# Concurrency & Async
async-trait = "0.1.80"

View File

@ -1,17 +1,19 @@
use std::collections::HashMap;
use axum::{
extract::{State, Query},
extract::{State, Query, Multipart},
http::StatusCode,
response::{Json, sse::{Event, Sse}},
response::{Json, sse::{Event, Sse}, IntoResponse, Response},
routing::{get, post},
Router,
body::Body,
};
use common_contracts::observability::{HealthStatus, ServiceStatus, TaskProgress};
use serde::Deserialize;
use uuid::Uuid;
use crate::state::AppState;
use crate::llm_client::LlmClient;
use futures::Stream; // Ensure futures is available
use futures::Stream;
use reqwest::multipart;
pub fn create_router(app_state: AppState) -> Router {
Router::new()
@ -19,6 +21,7 @@ pub fn create_router(app_state: AppState) -> Router {
.route("/tasks", get(get_current_tasks))
.route("/test-llm", post(test_llm_connection))
.route("/analysis-results/stream", get(stream_analysis_results))
.route("/generate-pdf", post(generate_pdf))
.with_state(app_state)
}
@ -26,7 +29,6 @@ pub fn create_router(app_state: AppState) -> Router {
/// Provides the current health status of the module.
async fn health_check(State(_state): State<AppState>) -> Json<HealthStatus> {
let mut details = HashMap::new();
// In a real scenario, we would check connections to the message bus, etc.
details.insert("message_bus_connection".to_string(), "ok".to_string());
let status = HealthStatus {
@ -79,7 +81,6 @@ pub struct StreamQuery {
}
/// [GET /analysis-results/stream]
/// SSE endpoint for streaming analysis results.
async fn stream_analysis_results(
State(state): State<AppState>,
Query(query): Query<StreamQuery>,
@ -97,7 +98,6 @@ async fn stream_analysis_results(
break;
},
Err(tokio::sync::broadcast::error::RecvError::Lagged(cnt)) => {
// Lagged, maybe log it.
tracing::warn!("Stream lagged by {} messages", cnt);
}
}
@ -106,3 +106,69 @@ async fn stream_analysis_results(
Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default())
}
/// [POST /generate-pdf]
/// Receives HTML content as multipart form data, sends to Gotenberg, and returns PDF.
async fn generate_pdf(
State(state): State<AppState>,
mut multipart: Multipart,
) -> Result<impl IntoResponse, (StatusCode, String)> {
// 1. Extract HTML content from multipart
let mut html_content = String::new();
while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? {
let name = field.name().unwrap_or("").to_string();
if name == "index.html" {
html_content = field.text().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
}
}
if html_content.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Missing index.html field".to_string()));
}
// 2. Prepare Gotenberg Request
// Gotenberg expects multipart/form-data with 'files' (index.html)
let form = multipart::Form::new()
.part("files", multipart::Part::text(html_content).file_name("index.html").mime_str("text/html").unwrap())
// Optional: Customize PDF options
.text("marginTop", "0")
.text("marginBottom", "0")
.text("marginLeft", "0")
.text("marginRight", "0")
.text("printBackground", "true") // Print background graphics
.text("preferCssPageSize", "true"); // Use CSS @page size
let gotenberg_url = format!("{}/forms/chromium/convert/html", state.config.gotenberg_url);
tracing::info!("Sending request to Gotenberg: {}", gotenberg_url);
// 3. Call Gotenberg
let client = reqwest::Client::new();
let response = client.post(&gotenberg_url)
.multipart(form)
.send()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to contact Gotenberg: {}", e)))?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
tracing::error!("Gotenberg error: {}", error_text);
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Gotenberg conversion failed: {}", error_text)));
}
// 4. Stream PDF back to client
let content_type = response.headers().get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/pdf")
.to_string();
let body_stream = Body::from_stream(response.bytes_stream());
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", content_type)
.header("Content-Disposition", "attachment; filename=\"report.pdf\"")
.body(body_stream)
.unwrap())
}

View File

@ -6,6 +6,12 @@ pub struct AppConfig {
pub nats_addr: String,
pub data_persistence_service_url: String,
pub workflow_data_path: String,
#[serde(default = "default_gotenberg_url")]
pub gotenberg_url: String,
}
fn default_gotenberg_url() -> String {
"http://gotenberg:3000".to_string()
}
#[allow(dead_code)]

View File

@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
use common_contracts::workflow_types::{TaskStatus, TaskContext};
use common_contracts::messages::{TaskType, TaskMetadata};
@ -87,6 +87,9 @@ pub struct DagScheduler {
pub forward_deps: HashMap<String, Vec<String>>,
/// TaskID -> List of upstream TaskIDs
pub reverse_deps: HashMap<String, Vec<String>>,
/// Set of (from, to) pairs representing weak dependencies
#[serde(default)]
pub weak_dependencies: HashSet<(String, String)>,
pub commit_tracker: CommitTracker,
@ -162,6 +165,7 @@ impl DagScheduler {
nodes: HashMap::new(),
forward_deps: HashMap::new(),
reverse_deps: HashMap::new(),
weak_dependencies: HashSet::new(),
commit_tracker: CommitTracker::new(initial_commit),
workflow_finished_flag: false,
start_time: chrono::Utc::now(),
@ -193,6 +197,11 @@ impl DagScheduler {
self.forward_deps.entry(from.to_string()).or_default().push(to.to_string());
self.reverse_deps.entry(to.to_string()).or_default().push(from.to_string());
}
pub fn add_weak_dependency(&mut self, from: &str, to: &str) {
self.add_dependency(from, to);
self.weak_dependencies.insert((from.to_string(), to.to_string()));
}
/// Get all tasks that have no dependencies (roots)
pub fn get_initial_tasks(&self) -> Vec<String> {
@ -347,6 +356,48 @@ impl DagScheduler {
}
true
}
/// Check if a task *should* be dispatched based on the success/failure of its dependencies.
/// Returns OK(()) if it can run, Err(reason) if it should fail/skip.
pub fn check_dispatch_conditions(&self, task_id: &str) -> Result<(), String> {
let deps = match self.reverse_deps.get(task_id) {
Some(d) => d,
None => return Ok(()), // No dependencies
};
let mut weak_deps_total = 0;
let mut weak_deps_failed = 0;
for dep_id in deps {
let status = self.nodes.get(dep_id).map(|n| n.status).unwrap_or(TaskStatus::Failed);
let is_weak = self.weak_dependencies.contains(&(dep_id.clone(), task_id.to_string()));
if is_weak {
weak_deps_total += 1;
if status != TaskStatus::Completed {
weak_deps_failed += 1;
}
} else {
// Strong dependency
if status != TaskStatus::Completed {
return Err(format!("Strong dependency {} failed or skipped", dep_id));
}
}
}
// Weak Dependency Logic:
// If there are weak dependencies, at least ONE must succeed?
// User says: "Weak dependency... if ALL of them fail... then it will fail."
// This implies that if *any* weak dependency succeeds, we proceed (assuming strong ones also succeeded).
// But wait, what if there are NO strong dependencies? Then we rely solely on weak ones.
// If there are strong dependencies and they succeeded, but ALL weak ones failed -> Fail?
if weak_deps_total > 0 && weak_deps_failed == weak_deps_total {
return Err(format!("All weak dependencies failed ({}/{})", weak_deps_failed, weak_deps_total));
}
Ok(())
}
/// Resolve the context (Base Commit) for a task.
/// If multiple dependencies, perform Merge or Fast-Forward.

View File

@ -340,6 +340,34 @@ impl WorkflowEngine {
if evt.status == TaskStatus::Completed || evt.status == TaskStatus::Failed || evt.status == TaskStatus::Skipped {
let ready_tasks = dag.get_ready_downstream_tasks(&evt.task_id);
for task_id in ready_tasks {
// Check dependency constraints (Weak/Strong)
if let Err(reason) = dag.check_dispatch_conditions(&task_id) {
warn!("Task {} execution prevented: {}", task_id, reason);
// Fail the task
dag.update_status(&task_id, TaskStatus::Failed);
dag.cancel_downstream(&task_id);
// Notify Failure
let fail_event = WorkflowEvent::TaskStateChanged {
task_id: task_id.clone(),
task_type: dag.nodes.get(&task_id).map(|n| n.task_type).unwrap_or(TaskType::Analysis),
status: MsgTaskStatus::Failed,
message: Some(format!("Dependency failure: {}", reason)),
timestamp: chrono::Utc::now().timestamp_millis(),
progress: None,
input_commit: None,
output_commit: None,
};
let subject = common_contracts::subjects::NatsSubject::WorkflowProgress(req_id).to_string();
if let Ok(payload) = serde_json::to_vec(&fail_event) {
let _ = self.nats.publish(subject, payload.into()).await;
}
continue;
}
if let Err(e) = self.dispatch_task(&mut dag, &task_id, &self.state.vgcs).await {
error!("Failed to dispatch task {}: {}", task_id, e);
info!("Failed to dispatch task {}: {}", task_id, e);
@ -868,9 +896,9 @@ impl WorkflowEngine {
// Dependencies
if module_config.dependencies.is_empty() {
// If no analysis dependencies, depend on Data Fetch
// If no analysis dependencies, depend on Data Fetch (Weak Dependency)
for fetch_task in &fetch_tasks {
dag.add_dependency(fetch_task, &task_id);
dag.add_weak_dependency(fetch_task, &task_id);
}
} else {
// Depend on other analysis modules