feat(health): 内容管理模块 — 审核/分类/标签/富文本编辑器
后端: - 文章审核状态机:draft → pending_review → published(含 reject/unpublish) - 文章分类 CRUD(article_category entity + service + handler) - 文章标签 CRUD(article_tag + article_article_tag 关联) - 文章修订版快照(article_revision) - 阅读计数、排序、slug、审核备注 - 新增 health.articles.review 权限 前端: - ArticleManageList:状态标签页 + 分类筛选 + 关键字搜索 + 审核操作 - ArticleEditor:Wangeditor 富文本编辑器 + 元数据侧栏 - ArticleCategoryManage:分类 CRUD + 父子层级 - ArticleTagManage:标签 CRUD 修复: - diagnosis_service/health_data_service/dialysis_service: 补充 key_version 字段 - ArticleCategoryManage: 补充 Select 组件导入
This commit is contained in:
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -1391,13 +1391,20 @@ dependencies = [
|
|||||||
name = "erp-core"
|
name = "erp-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"dashmap",
|
||||||
|
"hex",
|
||||||
|
"hmac",
|
||||||
|
"rand",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
"@ant-design/icons": "^6.1.1",
|
"@ant-design/icons": "^6.1.1",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@wangeditor/editor": "^5.1.23",
|
||||||
|
"@wangeditor/editor-for-react": "^1.0.6",
|
||||||
"@xyflow/react": "^12.10.2",
|
"@xyflow/react": "^12.10.2",
|
||||||
"antd": "^6.3.5",
|
"antd": "^6.3.5",
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.15.0",
|
||||||
|
|||||||
500
apps/web/pnpm-lock.yaml
generated
500
apps/web/pnpm-lock.yaml
generated
@@ -20,9 +20,15 @@ importers:
|
|||||||
'@dnd-kit/sortable':
|
'@dnd-kit/sortable':
|
||||||
specifier: ^10.0.0
|
specifier: ^10.0.0
|
||||||
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
|
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
|
||||||
|
'@wangeditor/editor':
|
||||||
|
specifier: ^5.1.23
|
||||||
|
version: 5.1.23
|
||||||
|
'@wangeditor/editor-for-react':
|
||||||
|
specifier: ^1.0.6
|
||||||
|
version: 1.0.6(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(@wangeditor/editor@5.1.23)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
'@xyflow/react':
|
'@xyflow/react':
|
||||||
specifier: ^12.10.2
|
specifier: ^12.10.2
|
||||||
version: 12.10.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 12.10.2(@types/react@19.2.14)(immer@9.0.21)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
antd:
|
antd:
|
||||||
specifier: ^6.3.5
|
specifier: ^6.3.5
|
||||||
version: 6.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 6.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
@@ -43,7 +49,7 @@ importers:
|
|||||||
version: 7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.12
|
specifier: ^5.0.12
|
||||||
version: 5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))
|
version: 5.0.12(@types/react@19.2.14)(immer@9.0.21)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.39.4
|
specifier: ^9.39.4
|
||||||
@@ -1018,6 +1024,9 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@transloadit/prettier-bytes@0.0.7':
|
||||||
|
resolution: {integrity: sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==}
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
@@ -1102,6 +1111,9 @@ packages:
|
|||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@types/event-emitter@0.3.5':
|
||||||
|
resolution: {integrity: sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==}
|
||||||
|
|
||||||
'@types/geojson@7946.0.16':
|
'@types/geojson@7946.0.16':
|
||||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||||
|
|
||||||
@@ -1178,6 +1190,23 @@ packages:
|
|||||||
resolution: {integrity: sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==}
|
resolution: {integrity: sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@uppy/companion-client@2.2.2':
|
||||||
|
resolution: {integrity: sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==}
|
||||||
|
|
||||||
|
'@uppy/core@2.3.4':
|
||||||
|
resolution: {integrity: sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==}
|
||||||
|
|
||||||
|
'@uppy/store-default@2.1.1':
|
||||||
|
resolution: {integrity: sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==}
|
||||||
|
|
||||||
|
'@uppy/utils@4.1.3':
|
||||||
|
resolution: {integrity: sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==}
|
||||||
|
|
||||||
|
'@uppy/xhr-upload@2.1.3':
|
||||||
|
resolution: {integrity: sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@uppy/core': ^2.3.3
|
||||||
|
|
||||||
'@vitejs/plugin-react@6.0.1':
|
'@vitejs/plugin-react@6.0.1':
|
||||||
resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
|
resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1220,6 +1249,95 @@ packages:
|
|||||||
'@vitest/utils@4.1.5':
|
'@vitest/utils@4.1.5':
|
||||||
resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==}
|
resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==}
|
||||||
|
|
||||||
|
'@wangeditor/basic-modules@1.1.7':
|
||||||
|
resolution: {integrity: sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@wangeditor/core': 1.x
|
||||||
|
dom7: ^3.0.0
|
||||||
|
lodash.throttle: ^4.1.1
|
||||||
|
nanoid: ^3.2.0
|
||||||
|
slate: ^0.72.0
|
||||||
|
snabbdom: ^3.1.0
|
||||||
|
|
||||||
|
'@wangeditor/code-highlight@1.0.3':
|
||||||
|
resolution: {integrity: sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@wangeditor/core': 1.x
|
||||||
|
dom7: ^3.0.0
|
||||||
|
slate: ^0.72.0
|
||||||
|
snabbdom: ^3.1.0
|
||||||
|
|
||||||
|
'@wangeditor/core@1.1.19':
|
||||||
|
resolution: {integrity: sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@uppy/core': ^2.1.1
|
||||||
|
'@uppy/xhr-upload': ^2.0.3
|
||||||
|
dom7: ^3.0.0
|
||||||
|
is-hotkey: ^0.2.0
|
||||||
|
lodash.camelcase: ^4.3.0
|
||||||
|
lodash.clonedeep: ^4.5.0
|
||||||
|
lodash.debounce: ^4.0.8
|
||||||
|
lodash.foreach: ^4.5.0
|
||||||
|
lodash.isequal: ^4.5.0
|
||||||
|
lodash.throttle: ^4.1.1
|
||||||
|
lodash.toarray: ^4.4.0
|
||||||
|
nanoid: ^3.2.0
|
||||||
|
slate: ^0.72.0
|
||||||
|
snabbdom: ^3.1.0
|
||||||
|
|
||||||
|
'@wangeditor/editor-for-react@1.0.6':
|
||||||
|
resolution: {integrity: sha512-KJNSfgMr5Blzae3oyaiz20flMKHZHnvsz4bCYQKDCUs/qkvC+xNTnwedlCmhGP187oPWPEypCIYI8Zg6sz0psQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@wangeditor/core': '>=1.1.0'
|
||||||
|
'@wangeditor/editor': '>=5.1.0'
|
||||||
|
react: '>=17.0.2'
|
||||||
|
react-dom: '>=17.0.2'
|
||||||
|
|
||||||
|
'@wangeditor/editor@5.1.23':
|
||||||
|
resolution: {integrity: sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==}
|
||||||
|
|
||||||
|
'@wangeditor/list-module@1.0.5':
|
||||||
|
resolution: {integrity: sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@wangeditor/core': 1.x
|
||||||
|
dom7: ^3.0.0
|
||||||
|
slate: ^0.72.0
|
||||||
|
snabbdom: ^3.1.0
|
||||||
|
|
||||||
|
'@wangeditor/table-module@1.1.4':
|
||||||
|
resolution: {integrity: sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@wangeditor/core': 1.x
|
||||||
|
dom7: ^3.0.0
|
||||||
|
lodash.isequal: ^4.5.0
|
||||||
|
lodash.throttle: ^4.1.1
|
||||||
|
nanoid: ^3.2.0
|
||||||
|
slate: ^0.72.0
|
||||||
|
snabbdom: ^3.1.0
|
||||||
|
|
||||||
|
'@wangeditor/upload-image-module@1.0.2':
|
||||||
|
resolution: {integrity: sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@uppy/core': ^2.0.3
|
||||||
|
'@uppy/xhr-upload': ^2.0.3
|
||||||
|
'@wangeditor/basic-modules': 1.x
|
||||||
|
'@wangeditor/core': 1.x
|
||||||
|
dom7: ^3.0.0
|
||||||
|
lodash.foreach: ^4.5.0
|
||||||
|
slate: ^0.72.0
|
||||||
|
snabbdom: ^3.1.0
|
||||||
|
|
||||||
|
'@wangeditor/video-module@1.1.4':
|
||||||
|
resolution: {integrity: sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@uppy/core': ^2.1.4
|
||||||
|
'@uppy/xhr-upload': ^2.0.7
|
||||||
|
'@wangeditor/core': 1.x
|
||||||
|
dom7: ^3.0.0
|
||||||
|
nanoid: ^3.2.0
|
||||||
|
slate: ^0.72.0
|
||||||
|
snabbdom: ^3.1.0
|
||||||
|
|
||||||
'@xyflow/react@12.10.2':
|
'@xyflow/react@12.10.2':
|
||||||
resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==}
|
resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1361,6 +1479,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
|
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
|
compute-scroll-into-view@1.0.20:
|
||||||
|
resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
|
||||||
|
|
||||||
compute-scroll-into-view@3.1.1:
|
compute-scroll-into-view@3.1.1:
|
||||||
resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==}
|
resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==}
|
||||||
|
|
||||||
@@ -1508,6 +1629,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d@1.0.2:
|
||||||
|
resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
dagre@0.8.5:
|
dagre@0.8.5:
|
||||||
resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==}
|
resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==}
|
||||||
|
|
||||||
@@ -1551,6 +1676,9 @@ packages:
|
|||||||
dom-accessibility-api@0.6.3:
|
dom-accessibility-api@0.6.3:
|
||||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||||
|
|
||||||
|
dom7@3.0.0:
|
||||||
|
resolution: {integrity: sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1585,6 +1713,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es5-ext@0.10.64:
|
||||||
|
resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==}
|
||||||
|
engines: {node: '>=0.10'}
|
||||||
|
|
||||||
|
es6-iterator@2.0.3:
|
||||||
|
resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==}
|
||||||
|
|
||||||
|
es6-symbol@3.1.4:
|
||||||
|
resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
escalade@3.2.0:
|
escalade@3.2.0:
|
||||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1630,6 +1769,10 @@ packages:
|
|||||||
jiti:
|
jiti:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
esniff@2.0.1:
|
||||||
|
resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==}
|
||||||
|
engines: {node: '>=0.10'}
|
||||||
|
|
||||||
espree@10.4.0:
|
espree@10.4.0:
|
||||||
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
|
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -1653,6 +1796,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
event-emitter@0.3.5:
|
||||||
|
resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==}
|
||||||
|
|
||||||
eventemitter3@5.0.4:
|
eventemitter3@5.0.4:
|
||||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||||
|
|
||||||
@@ -1660,6 +1806,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
ext@1.7.0:
|
||||||
|
resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3:
|
fast-deep-equal@3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
@@ -1789,10 +1938,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||||
|
|
||||||
|
html-void-elements@2.0.1:
|
||||||
|
resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==}
|
||||||
|
|
||||||
html2canvas@1.4.1:
|
html2canvas@1.4.1:
|
||||||
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
|
i18next@20.6.1:
|
||||||
|
resolution: {integrity: sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==}
|
||||||
|
|
||||||
iconv-lite@0.6.3:
|
iconv-lite@0.6.3:
|
||||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1805,6 +1960,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
immer@9.0.21:
|
||||||
|
resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1835,12 +1993,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
is-hotkey@0.2.0:
|
||||||
|
resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==}
|
||||||
|
|
||||||
is-mobile@5.0.0:
|
is-mobile@5.0.0:
|
||||||
resolution: {integrity: sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==}
|
resolution: {integrity: sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==}
|
||||||
|
|
||||||
|
is-plain-object@5.0.0:
|
||||||
|
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
is-potential-custom-element-name@1.0.1:
|
is-potential-custom-element-name@1.0.1:
|
||||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||||
|
|
||||||
|
is-url@1.2.4:
|
||||||
|
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||||
|
|
||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
@@ -1971,9 +2139,31 @@ packages:
|
|||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
lodash.camelcase@4.3.0:
|
||||||
|
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
||||||
|
|
||||||
|
lodash.clonedeep@4.5.0:
|
||||||
|
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
|
||||||
|
|
||||||
|
lodash.debounce@4.0.8:
|
||||||
|
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||||
|
|
||||||
|
lodash.foreach@4.5.0:
|
||||||
|
resolution: {integrity: sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==}
|
||||||
|
|
||||||
|
lodash.isequal@4.5.0:
|
||||||
|
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||||
|
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||||
|
|
||||||
lodash.merge@4.6.2:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
|
lodash.throttle@4.1.1:
|
||||||
|
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
|
||||||
|
|
||||||
|
lodash.toarray@4.4.0:
|
||||||
|
resolution: {integrity: sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==}
|
||||||
|
|
||||||
lodash@4.18.1:
|
lodash@4.18.1:
|
||||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||||
|
|
||||||
@@ -2002,6 +2192,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
mime-match@1.0.2:
|
||||||
|
resolution: {integrity: sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==}
|
||||||
|
|
||||||
mime-types@2.1.35:
|
mime-types@2.1.35:
|
||||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -2032,6 +2225,9 @@ packages:
|
|||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
|
namespace-emitter@2.0.1:
|
||||||
|
resolution: {integrity: sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==}
|
||||||
|
|
||||||
nanoid@3.3.11:
|
nanoid@3.3.11:
|
||||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
@@ -2040,6 +2236,9 @@ packages:
|
|||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
|
|
||||||
|
next-tick@1.1.0:
|
||||||
|
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
|
||||||
|
|
||||||
node-releases@2.0.37:
|
node-releases@2.0.37:
|
||||||
resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==}
|
resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==}
|
||||||
|
|
||||||
@@ -2100,6 +2299,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
|
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
preact@10.29.1:
|
||||||
|
resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==}
|
||||||
|
|
||||||
prelude-ls@1.2.1:
|
prelude-ls@1.2.1:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -2108,6 +2310,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||||
|
|
||||||
|
prismjs@1.30.0:
|
||||||
|
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
proxy-from-env@2.1.0:
|
proxy-from-env@2.1.0:
|
||||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2178,6 +2384,9 @@ packages:
|
|||||||
scheduler@0.27.0:
|
scheduler@0.27.0:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
|
|
||||||
|
scroll-into-view-if-needed@2.2.31:
|
||||||
|
resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==}
|
||||||
|
|
||||||
scroll-into-view-if-needed@3.1.0:
|
scroll-into-view-if-needed@3.1.0:
|
||||||
resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==}
|
resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==}
|
||||||
|
|
||||||
@@ -2207,10 +2416,25 @@ packages:
|
|||||||
simple-swizzle@0.2.4:
|
simple-swizzle@0.2.4:
|
||||||
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||||
|
|
||||||
|
slate-history@0.66.0:
|
||||||
|
resolution: {integrity: sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==}
|
||||||
|
peerDependencies:
|
||||||
|
slate: '>=0.65.3'
|
||||||
|
|
||||||
|
slate@0.72.8:
|
||||||
|
resolution: {integrity: sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==}
|
||||||
|
|
||||||
|
snabbdom@3.6.3:
|
||||||
|
resolution: {integrity: sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==}
|
||||||
|
engines: {node: '>=12.17.0'}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
ssr-window@3.0.0:
|
||||||
|
resolution: {integrity: sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==}
|
||||||
|
|
||||||
stackback@0.0.2:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
@@ -2271,6 +2495,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
|
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
|
||||||
engines: {node: '>=12.22'}
|
engines: {node: '>=12.22'}
|
||||||
|
|
||||||
|
tiny-warning@1.0.3:
|
||||||
|
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
|
||||||
|
|
||||||
tinybench@2.9.0:
|
tinybench@2.9.0:
|
||||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
@@ -2314,6 +2541,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
type@2.7.3:
|
||||||
|
resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==}
|
||||||
|
|
||||||
typescript-eslint@8.58.1:
|
typescript-eslint@8.58.1:
|
||||||
resolution: {integrity: sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==}
|
resolution: {integrity: sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -2460,6 +2690,9 @@ packages:
|
|||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
wildcard@1.1.2:
|
||||||
|
resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==}
|
||||||
|
|
||||||
word-wrap@1.2.5:
|
word-wrap@1.2.5:
|
||||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3617,6 +3850,8 @@ snapshots:
|
|||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||||
|
|
||||||
|
'@transloadit/prettier-bytes@0.0.7': {}
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -3696,6 +3931,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@types/event-emitter@0.3.5': {}
|
||||||
|
|
||||||
'@types/geojson@7946.0.16': {}
|
'@types/geojson@7946.0.16': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
@@ -3803,6 +4040,35 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.58.1
|
'@typescript-eslint/types': 8.58.1
|
||||||
eslint-visitor-keys: 5.0.1
|
eslint-visitor-keys: 5.0.1
|
||||||
|
|
||||||
|
'@uppy/companion-client@2.2.2':
|
||||||
|
dependencies:
|
||||||
|
'@uppy/utils': 4.1.3
|
||||||
|
namespace-emitter: 2.0.1
|
||||||
|
|
||||||
|
'@uppy/core@2.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@transloadit/prettier-bytes': 0.0.7
|
||||||
|
'@uppy/store-default': 2.1.1
|
||||||
|
'@uppy/utils': 4.1.3
|
||||||
|
lodash.throttle: 4.1.1
|
||||||
|
mime-match: 1.0.2
|
||||||
|
namespace-emitter: 2.0.1
|
||||||
|
nanoid: 3.3.11
|
||||||
|
preact: 10.29.1
|
||||||
|
|
||||||
|
'@uppy/store-default@2.1.1': {}
|
||||||
|
|
||||||
|
'@uppy/utils@4.1.3':
|
||||||
|
dependencies:
|
||||||
|
lodash.throttle: 4.1.1
|
||||||
|
|
||||||
|
'@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4)':
|
||||||
|
dependencies:
|
||||||
|
'@uppy/companion-client': 2.2.2
|
||||||
|
'@uppy/core': 2.3.4
|
||||||
|
'@uppy/utils': 4.1.3
|
||||||
|
nanoid: 3.3.11
|
||||||
|
|
||||||
'@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@24.12.2)(jiti@2.6.1))':
|
'@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@24.12.2)(jiti@2.6.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.0-rc.7
|
'@rolldown/pluginutils': 1.0.0-rc.7
|
||||||
@@ -3849,13 +4115,123 @@ snapshots:
|
|||||||
convert-source-map: 2.0.0
|
convert-source-map: 2.0.0
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
'@xyflow/react@12.10.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
'@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||||
|
dependencies:
|
||||||
|
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
dom7: 3.0.0
|
||||||
|
is-url: 1.2.4
|
||||||
|
lodash.throttle: 4.1.1
|
||||||
|
nanoid: 3.3.11
|
||||||
|
slate: 0.72.8
|
||||||
|
snabbdom: 3.6.3
|
||||||
|
|
||||||
|
'@wangeditor/code-highlight@1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||||
|
dependencies:
|
||||||
|
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
dom7: 3.0.0
|
||||||
|
prismjs: 1.30.0
|
||||||
|
slate: 0.72.8
|
||||||
|
snabbdom: 3.6.3
|
||||||
|
|
||||||
|
'@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||||
|
dependencies:
|
||||||
|
'@types/event-emitter': 0.3.5
|
||||||
|
'@uppy/core': 2.3.4
|
||||||
|
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
|
||||||
|
dom7: 3.0.0
|
||||||
|
event-emitter: 0.3.5
|
||||||
|
html-void-elements: 2.0.1
|
||||||
|
i18next: 20.6.1
|
||||||
|
is-hotkey: 0.2.0
|
||||||
|
lodash.camelcase: 4.3.0
|
||||||
|
lodash.clonedeep: 4.5.0
|
||||||
|
lodash.debounce: 4.0.8
|
||||||
|
lodash.foreach: 4.5.0
|
||||||
|
lodash.isequal: 4.5.0
|
||||||
|
lodash.throttle: 4.1.1
|
||||||
|
lodash.toarray: 4.4.0
|
||||||
|
nanoid: 3.3.11
|
||||||
|
scroll-into-view-if-needed: 2.2.31
|
||||||
|
slate: 0.72.8
|
||||||
|
slate-history: 0.66.0(slate@0.72.8)
|
||||||
|
snabbdom: 3.6.3
|
||||||
|
|
||||||
|
'@wangeditor/editor-for-react@1.0.6(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(@wangeditor/editor@5.1.23)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||||
|
dependencies:
|
||||||
|
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
'@wangeditor/editor': 5.1.23
|
||||||
|
react: 19.2.5
|
||||||
|
react-dom: 19.2.5(react@19.2.5)
|
||||||
|
|
||||||
|
'@wangeditor/editor@5.1.23':
|
||||||
|
dependencies:
|
||||||
|
'@uppy/core': 2.3.4
|
||||||
|
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
|
||||||
|
'@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
'@wangeditor/code-highlight': 1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
'@wangeditor/list-module': 1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
'@wangeditor/table-module': 1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
'@wangeditor/upload-image-module': 1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
'@wangeditor/video-module': 1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
dom7: 3.0.0
|
||||||
|
is-hotkey: 0.2.0
|
||||||
|
lodash.camelcase: 4.3.0
|
||||||
|
lodash.clonedeep: 4.5.0
|
||||||
|
lodash.debounce: 4.0.8
|
||||||
|
lodash.foreach: 4.5.0
|
||||||
|
lodash.isequal: 4.5.0
|
||||||
|
lodash.throttle: 4.1.1
|
||||||
|
lodash.toarray: 4.4.0
|
||||||
|
nanoid: 3.3.11
|
||||||
|
slate: 0.72.8
|
||||||
|
snabbdom: 3.6.3
|
||||||
|
|
||||||
|
'@wangeditor/list-module@1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||||
|
dependencies:
|
||||||
|
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
dom7: 3.0.0
|
||||||
|
slate: 0.72.8
|
||||||
|
snabbdom: 3.6.3
|
||||||
|
|
||||||
|
'@wangeditor/table-module@1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||||
|
dependencies:
|
||||||
|
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
dom7: 3.0.0
|
||||||
|
lodash.isequal: 4.5.0
|
||||||
|
lodash.throttle: 4.1.1
|
||||||
|
nanoid: 3.3.11
|
||||||
|
slate: 0.72.8
|
||||||
|
snabbdom: 3.6.3
|
||||||
|
|
||||||
|
'@wangeditor/upload-image-module@1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||||
|
dependencies:
|
||||||
|
'@uppy/core': 2.3.4
|
||||||
|
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
|
||||||
|
'@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
dom7: 3.0.0
|
||||||
|
lodash.foreach: 4.5.0
|
||||||
|
slate: 0.72.8
|
||||||
|
snabbdom: 3.6.3
|
||||||
|
|
||||||
|
'@wangeditor/video-module@1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||||
|
dependencies:
|
||||||
|
'@uppy/core': 2.3.4
|
||||||
|
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
|
||||||
|
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
dom7: 3.0.0
|
||||||
|
nanoid: 3.3.11
|
||||||
|
slate: 0.72.8
|
||||||
|
snabbdom: 3.6.3
|
||||||
|
|
||||||
|
'@xyflow/react@12.10.2(@types/react@19.2.14)(immer@9.0.21)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@xyflow/system': 0.0.76
|
'@xyflow/system': 0.0.76
|
||||||
classcat: 5.0.5
|
classcat: 5.0.5
|
||||||
react: 19.2.5
|
react: 19.2.5
|
||||||
react-dom: 19.2.5(react@19.2.5)
|
react-dom: 19.2.5(react@19.2.5)
|
||||||
zustand: 4.5.7(@types/react@19.2.14)(react@19.2.5)
|
zustand: 4.5.7(@types/react@19.2.14)(immer@9.0.21)(react@19.2.5)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
- immer
|
- immer
|
||||||
@@ -4040,6 +4416,8 @@ snapshots:
|
|||||||
|
|
||||||
commander@7.2.0: {}
|
commander@7.2.0: {}
|
||||||
|
|
||||||
|
compute-scroll-into-view@1.0.20: {}
|
||||||
|
|
||||||
compute-scroll-into-view@3.1.1: {}
|
compute-scroll-into-view@3.1.1: {}
|
||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
@@ -4182,6 +4560,11 @@ snapshots:
|
|||||||
d3-selection: 3.0.0
|
d3-selection: 3.0.0
|
||||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||||
|
|
||||||
|
d@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
es5-ext: 0.10.64
|
||||||
|
type: 2.7.3
|
||||||
|
|
||||||
dagre@0.8.5:
|
dagre@0.8.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
graphlib: 2.1.8
|
graphlib: 2.1.8
|
||||||
@@ -4214,6 +4597,10 @@ snapshots:
|
|||||||
|
|
||||||
dom-accessibility-api@0.6.3: {}
|
dom-accessibility-api@0.6.3: {}
|
||||||
|
|
||||||
|
dom7@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
ssr-window: 3.0.0
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
@@ -4246,6 +4633,24 @@ snapshots:
|
|||||||
has-tostringtag: 1.0.2
|
has-tostringtag: 1.0.2
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
|
|
||||||
|
es5-ext@0.10.64:
|
||||||
|
dependencies:
|
||||||
|
es6-iterator: 2.0.3
|
||||||
|
es6-symbol: 3.1.4
|
||||||
|
esniff: 2.0.1
|
||||||
|
next-tick: 1.1.0
|
||||||
|
|
||||||
|
es6-iterator@2.0.3:
|
||||||
|
dependencies:
|
||||||
|
d: 1.0.2
|
||||||
|
es5-ext: 0.10.64
|
||||||
|
es6-symbol: 3.1.4
|
||||||
|
|
||||||
|
es6-symbol@3.1.4:
|
||||||
|
dependencies:
|
||||||
|
d: 1.0.2
|
||||||
|
ext: 1.7.0
|
||||||
|
|
||||||
escalade@3.2.0: {}
|
escalade@3.2.0: {}
|
||||||
|
|
||||||
escape-string-regexp@4.0.0: {}
|
escape-string-regexp@4.0.0: {}
|
||||||
@@ -4317,6 +4722,13 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
esniff@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
d: 1.0.2
|
||||||
|
es5-ext: 0.10.64
|
||||||
|
event-emitter: 0.3.5
|
||||||
|
type: 2.7.3
|
||||||
|
|
||||||
espree@10.4.0:
|
espree@10.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.16.0
|
acorn: 8.16.0
|
||||||
@@ -4339,10 +4751,19 @@ snapshots:
|
|||||||
|
|
||||||
esutils@2.0.3: {}
|
esutils@2.0.3: {}
|
||||||
|
|
||||||
|
event-emitter@0.3.5:
|
||||||
|
dependencies:
|
||||||
|
d: 1.0.2
|
||||||
|
es5-ext: 0.10.64
|
||||||
|
|
||||||
eventemitter3@5.0.4: {}
|
eventemitter3@5.0.4: {}
|
||||||
|
|
||||||
expect-type@1.3.0: {}
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
|
ext@1.7.0:
|
||||||
|
dependencies:
|
||||||
|
type: 2.7.3
|
||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
fast-json-stable-stringify@2.1.0: {}
|
fast-json-stable-stringify@2.1.0: {}
|
||||||
@@ -4453,11 +4874,17 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@noble/hashes'
|
- '@noble/hashes'
|
||||||
|
|
||||||
|
html-void-elements@2.0.1: {}
|
||||||
|
|
||||||
html2canvas@1.4.1:
|
html2canvas@1.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
css-line-break: 2.1.0
|
css-line-break: 2.1.0
|
||||||
text-segmentation: 1.0.3
|
text-segmentation: 1.0.3
|
||||||
|
|
||||||
|
i18next@20.6.1:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.2
|
||||||
|
|
||||||
iconv-lite@0.6.3:
|
iconv-lite@0.6.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@@ -4466,6 +4893,8 @@ snapshots:
|
|||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
|
|
||||||
|
immer@9.0.21: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
parent-module: 1.0.1
|
parent-module: 1.0.1
|
||||||
@@ -4487,10 +4916,16 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-extglob: 2.1.1
|
is-extglob: 2.1.1
|
||||||
|
|
||||||
|
is-hotkey@0.2.0: {}
|
||||||
|
|
||||||
is-mobile@5.0.0: {}
|
is-mobile@5.0.0: {}
|
||||||
|
|
||||||
|
is-plain-object@5.0.0: {}
|
||||||
|
|
||||||
is-potential-custom-element-name@1.0.1: {}
|
is-potential-custom-element-name@1.0.1: {}
|
||||||
|
|
||||||
|
is-url@1.2.4: {}
|
||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
jiti@2.6.1: {}
|
jiti@2.6.1: {}
|
||||||
@@ -4603,8 +5038,22 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
|
|
||||||
|
lodash.camelcase@4.3.0: {}
|
||||||
|
|
||||||
|
lodash.clonedeep@4.5.0: {}
|
||||||
|
|
||||||
|
lodash.debounce@4.0.8: {}
|
||||||
|
|
||||||
|
lodash.foreach@4.5.0: {}
|
||||||
|
|
||||||
|
lodash.isequal@4.5.0: {}
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
|
lodash.throttle@4.1.1: {}
|
||||||
|
|
||||||
|
lodash.toarray@4.4.0: {}
|
||||||
|
|
||||||
lodash@4.18.1: {}
|
lodash@4.18.1: {}
|
||||||
|
|
||||||
lru-cache@11.3.5: {}
|
lru-cache@11.3.5: {}
|
||||||
@@ -4625,6 +5074,10 @@ snapshots:
|
|||||||
|
|
||||||
mime-db@1.52.0: {}
|
mime-db@1.52.0: {}
|
||||||
|
|
||||||
|
mime-match@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
wildcard: 1.1.2
|
||||||
|
|
||||||
mime-types@2.1.35:
|
mime-types@2.1.35:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-db: 1.52.0
|
mime-db: 1.52.0
|
||||||
@@ -4660,10 +5113,14 @@ snapshots:
|
|||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
|
namespace-emitter@2.0.1: {}
|
||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
|
next-tick@1.1.0: {}
|
||||||
|
|
||||||
node-releases@2.0.37: {}
|
node-releases@2.0.37: {}
|
||||||
|
|
||||||
obug@2.1.1: {}
|
obug@2.1.1: {}
|
||||||
@@ -4719,6 +5176,8 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
preact@10.29.1: {}
|
||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
pretty-format@27.5.1:
|
pretty-format@27.5.1:
|
||||||
@@ -4727,6 +5186,8 @@ snapshots:
|
|||||||
ansi-styles: 5.2.0
|
ansi-styles: 5.2.0
|
||||||
react-is: 17.0.2
|
react-is: 17.0.2
|
||||||
|
|
||||||
|
prismjs@1.30.0: {}
|
||||||
|
|
||||||
proxy-from-env@2.1.0: {}
|
proxy-from-env@2.1.0: {}
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
@@ -4796,6 +5257,10 @@ snapshots:
|
|||||||
|
|
||||||
scheduler@0.27.0: {}
|
scheduler@0.27.0: {}
|
||||||
|
|
||||||
|
scroll-into-view-if-needed@2.2.31:
|
||||||
|
dependencies:
|
||||||
|
compute-scroll-into-view: 1.0.20
|
||||||
|
|
||||||
scroll-into-view-if-needed@3.1.0:
|
scroll-into-view-if-needed@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
compute-scroll-into-view: 3.1.1
|
compute-scroll-into-view: 3.1.1
|
||||||
@@ -4818,8 +5283,23 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-arrayish: 0.3.4
|
is-arrayish: 0.3.4
|
||||||
|
|
||||||
|
slate-history@0.66.0(slate@0.72.8):
|
||||||
|
dependencies:
|
||||||
|
is-plain-object: 5.0.0
|
||||||
|
slate: 0.72.8
|
||||||
|
|
||||||
|
slate@0.72.8:
|
||||||
|
dependencies:
|
||||||
|
immer: 9.0.21
|
||||||
|
is-plain-object: 5.0.0
|
||||||
|
tiny-warning: 1.0.3
|
||||||
|
|
||||||
|
snabbdom@3.6.3: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
ssr-window@3.0.0: {}
|
||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
std-env@4.1.0: {}
|
std-env@4.1.0: {}
|
||||||
@@ -4861,6 +5341,8 @@ snapshots:
|
|||||||
|
|
||||||
throttle-debounce@5.0.2: {}
|
throttle-debounce@5.0.2: {}
|
||||||
|
|
||||||
|
tiny-warning@1.0.3: {}
|
||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
tinyexec@1.1.1: {}
|
tinyexec@1.1.1: {}
|
||||||
@@ -4896,6 +5378,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
|
|
||||||
|
type@2.7.3: {}
|
||||||
|
|
||||||
typescript-eslint@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2):
|
typescript-eslint@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/eslint-plugin': 8.58.1(@typescript-eslint/parser@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2)
|
'@typescript-eslint/eslint-plugin': 8.58.1(@typescript-eslint/parser@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2)
|
||||||
@@ -4996,6 +5480,8 @@ snapshots:
|
|||||||
siginfo: 2.0.0
|
siginfo: 2.0.0
|
||||||
stackback: 0.0.2
|
stackback: 0.0.2
|
||||||
|
|
||||||
|
wildcard@1.1.2: {}
|
||||||
|
|
||||||
word-wrap@1.2.5: {}
|
word-wrap@1.2.5: {}
|
||||||
|
|
||||||
xml-name-validator@5.0.0: {}
|
xml-name-validator@5.0.0: {}
|
||||||
@@ -5012,15 +5498,17 @@ snapshots:
|
|||||||
|
|
||||||
zod@4.3.6: {}
|
zod@4.3.6: {}
|
||||||
|
|
||||||
zustand@4.5.7(@types/react@19.2.14)(react@19.2.5):
|
zustand@4.5.7(@types/react@19.2.14)(immer@9.0.21)(react@19.2.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
use-sync-external-store: 1.6.0(react@19.2.5)
|
use-sync-external-store: 1.6.0(react@19.2.5)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
immer: 9.0.21
|
||||||
react: 19.2.5
|
react: 19.2.5
|
||||||
|
|
||||||
zustand@5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)):
|
zustand@5.0.12(@types/react@19.2.14)(immer@9.0.21)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
immer: 9.0.21
|
||||||
react: 19.2.5
|
react: 19.2.5
|
||||||
use-sync-external-store: 1.6.0(react@19.2.5)
|
use-sync-external-store: 1.6.0(react@19.2.5)
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ const AiPromptList = lazy(() => import('./pages/health/AiPromptList'));
|
|||||||
const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList'));
|
const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList'));
|
||||||
const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
|
const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
|
||||||
|
|
||||||
|
// 内容管理
|
||||||
|
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
|
||||||
|
const ArticleEditor = lazy(() => import('./pages/health/ArticleEditor'));
|
||||||
|
const ArticleCategoryManage = lazy(() => import('./pages/health/ArticleCategoryManage'));
|
||||||
|
const ArticleTagManage = lazy(() => import('./pages/health/ArticleTagManage'));
|
||||||
|
|
||||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||||
@@ -192,6 +198,12 @@ export default function App() {
|
|||||||
<Route path="/health/ai-prompts" element={<AiPromptList />} />
|
<Route path="/health/ai-prompts" element={<AiPromptList />} />
|
||||||
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
|
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
|
||||||
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
|
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
|
||||||
|
{/* 内容管理 */}
|
||||||
|
<Route path="/health/articles" element={<ArticleManageList />} />
|
||||||
|
<Route path="/health/articles/new" element={<ArticleEditor />} />
|
||||||
|
<Route path="/health/articles/:id/edit" element={<ArticleEditor />} />
|
||||||
|
<Route path="/health/article-categories" element={<ArticleCategoryManage />} />
|
||||||
|
<Route path="/health/article-tags" element={<ArticleTagManage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -1,52 +1,122 @@
|
|||||||
import client from '../client';
|
import client from '../client';
|
||||||
import type { PaginatedResponse } from '../types';
|
import type { PaginatedResponse } from '../types';
|
||||||
|
|
||||||
// --- Types ---
|
// --- Article Types ---
|
||||||
|
|
||||||
|
export type ArticleStatus = 'draft' | 'pending_review' | 'published' | 'rejected';
|
||||||
|
export type ArticleContentType = 'rich_text' | 'markdown';
|
||||||
|
|
||||||
export interface ArticleListItem {
|
export interface ArticleListItem {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
cover_image?: string;
|
cover_image?: string;
|
||||||
category?: string;
|
content_type: ArticleContentType;
|
||||||
|
status: ArticleStatus;
|
||||||
|
slug?: string;
|
||||||
|
category_id?: string;
|
||||||
|
category_name?: string;
|
||||||
|
tags?: ArticleTagItem[];
|
||||||
author?: string;
|
author?: string;
|
||||||
|
reviewed_by?: string;
|
||||||
|
reviewed_at?: string;
|
||||||
|
review_note?: string;
|
||||||
|
view_count: number;
|
||||||
|
sort_order: number;
|
||||||
published_at?: string;
|
published_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Article extends ArticleListItem {
|
export interface Article extends ArticleListItem {
|
||||||
content?: string;
|
content?: string;
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
version: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateArticleReq {
|
export interface CreateArticleReq {
|
||||||
title: string;
|
title: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
content_type?: ArticleContentType;
|
||||||
cover_image?: string;
|
cover_image?: string;
|
||||||
category?: string;
|
slug?: string;
|
||||||
author?: string;
|
category_id?: string;
|
||||||
published_at?: string;
|
tag_ids?: string[];
|
||||||
|
sort_order?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateArticleReq {
|
export interface UpdateArticleReq {
|
||||||
title?: string;
|
title?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
content_type?: ArticleContentType;
|
||||||
cover_image?: string;
|
cover_image?: string;
|
||||||
category?: string;
|
slug?: string;
|
||||||
author?: string;
|
category_id?: string;
|
||||||
published_at?: string;
|
tag_ids?: string[];
|
||||||
|
sort_order?: number;
|
||||||
version: number;
|
version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- API ---
|
export interface ArticleListParams {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
status?: ArticleStatus;
|
||||||
|
category_id?: string;
|
||||||
|
tag_id?: string;
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Category Types ---
|
||||||
|
|
||||||
|
export interface ArticleCategory {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
parent_name?: string;
|
||||||
|
sort_order: number;
|
||||||
|
description?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCategoryReq {
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
sort_order?: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCategoryReq {
|
||||||
|
name?: string;
|
||||||
|
slug?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
sort_order?: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tag Types ---
|
||||||
|
|
||||||
|
export interface ArticleTagItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
color?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTagReq {
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Article API ---
|
||||||
|
|
||||||
export const articleApi = {
|
export const articleApi = {
|
||||||
list: async (params: {
|
list: async (params: ArticleListParams) => {
|
||||||
page?: number;
|
|
||||||
page_size?: number;
|
|
||||||
category?: string;
|
|
||||||
}) => {
|
|
||||||
const { data } = await client.get<{
|
const { data } = await client.get<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: PaginatedResponse<ArticleListItem>;
|
data: PaginatedResponse<ArticleListItem>;
|
||||||
@@ -85,4 +155,108 @@ export const articleApi = {
|
|||||||
}>(`/health/articles/${id}`);
|
}>(`/health/articles/${id}`);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
submit: async (id: string, version: number) => {
|
||||||
|
const { data } = await client.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: Article;
|
||||||
|
}>(`/health/articles/${id}/submit`, { version });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
approve: async (id: string, version: number) => {
|
||||||
|
const { data } = await client.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: Article;
|
||||||
|
}>(`/health/articles/${id}/approve`, { version });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
reject: async (id: string, version: number, review_note: string) => {
|
||||||
|
const { data } = await client.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: Article;
|
||||||
|
}>(`/health/articles/${id}/reject`, { version, review_note });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
unpublish: async (id: string, version: number) => {
|
||||||
|
const { data } = await client.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: Article;
|
||||||
|
}>(`/health/articles/${id}/unpublish`, { version });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
view: async (id: string) => {
|
||||||
|
const { data } = await client.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: Article;
|
||||||
|
}>(`/health/articles/${id}/view`);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Category API ---
|
||||||
|
|
||||||
|
export const articleCategoryApi = {
|
||||||
|
list: async () => {
|
||||||
|
const { data } = await client.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: ArticleCategory[];
|
||||||
|
}>('/health/article-categories');
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (req: CreateCategoryReq) => {
|
||||||
|
const { data } = await client.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: ArticleCategory;
|
||||||
|
}>('/health/article-categories', req);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, req: UpdateCategoryReq) => {
|
||||||
|
const { data } = await client.put<{
|
||||||
|
success: boolean;
|
||||||
|
data: ArticleCategory;
|
||||||
|
}>(`/health/article-categories/${id}`, req);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string) => {
|
||||||
|
const { data } = await client.delete<{
|
||||||
|
success: boolean;
|
||||||
|
data: null;
|
||||||
|
}>(`/health/article-categories/${id}`);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Tag API ---
|
||||||
|
|
||||||
|
export const articleTagApi = {
|
||||||
|
list: async () => {
|
||||||
|
const { data } = await client.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: ArticleTagItem[];
|
||||||
|
}>('/health/article-tags');
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (req: CreateTagReq) => {
|
||||||
|
const { data } = await client.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: ArticleTagItem;
|
||||||
|
}>('/health/article-tags', req);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string) => {
|
||||||
|
const { data } = await client.delete<{
|
||||||
|
success: boolean;
|
||||||
|
data: null;
|
||||||
|
}>(`/health/article-tags/${id}`);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ const routeTitleFallback: Record<string, string> = {
|
|||||||
'/health/consultations/:id': '咨询详情',
|
'/health/consultations/:id': '咨询详情',
|
||||||
'/health/points-rules': '积分规则管理',
|
'/health/points-rules': '积分规则管理',
|
||||||
'/health/offline-events': '线下活动管理',
|
'/health/offline-events': '线下活动管理',
|
||||||
|
'/health/articles': '内容管理',
|
||||||
|
'/health/articles/new': '新建文章',
|
||||||
|
'/health/articles/:id/edit': '编辑文章',
|
||||||
|
'/health/article-categories': '分类管理',
|
||||||
|
'/health/article-tags': '标签管理',
|
||||||
};
|
};
|
||||||
|
|
||||||
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
|
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
|
||||||
|
|||||||
273
apps/web/src/pages/health/ArticleCategoryManage.tsx
Normal file
273
apps/web/src/pages/health/ArticleCategoryManage.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Select,
|
||||||
|
Popconfirm,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
articleCategoryApi,
|
||||||
|
type ArticleCategory,
|
||||||
|
type CreateCategoryReq,
|
||||||
|
type UpdateCategoryReq,
|
||||||
|
} from '../../api/health/articles';
|
||||||
|
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||||
|
import { AuthButton } from '../../components/AuthButton';
|
||||||
|
|
||||||
|
export default function ArticleCategoryManage() {
|
||||||
|
const [categories, setCategories] = useState<ArticleCategory[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editingCategory, setEditingCategory] = useState<ArticleCategory | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const isDark = useThemeMode();
|
||||||
|
|
||||||
|
const fetchCategories = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await articleCategoryApi.list();
|
||||||
|
setCategories(result);
|
||||||
|
} catch {
|
||||||
|
message.error('加载分类列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCategories();
|
||||||
|
}, [fetchCategories]);
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setEditingCategory(null);
|
||||||
|
form.resetFields();
|
||||||
|
form.setFieldsValue({ sort_order: 0 });
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (record: ArticleCategory) => {
|
||||||
|
setEditingCategory(record);
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: record.name,
|
||||||
|
slug: record.slug,
|
||||||
|
parent_id: record.parent_id,
|
||||||
|
sort_order: record.sort_order,
|
||||||
|
description: record.description,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditingCategory(null);
|
||||||
|
form.resetFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (values: {
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
sort_order?: number;
|
||||||
|
description?: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
if (editingCategory) {
|
||||||
|
const req: UpdateCategoryReq = {
|
||||||
|
name: values.name,
|
||||||
|
slug: values.slug,
|
||||||
|
parent_id: values.parent_id,
|
||||||
|
sort_order: values.sort_order,
|
||||||
|
description: values.description,
|
||||||
|
};
|
||||||
|
await articleCategoryApi.update(editingCategory.id, req);
|
||||||
|
message.success('分类更新成功');
|
||||||
|
} else {
|
||||||
|
const req: CreateCategoryReq = {
|
||||||
|
name: values.name,
|
||||||
|
slug: values.slug,
|
||||||
|
parent_id: values.parent_id,
|
||||||
|
sort_order: values.sort_order,
|
||||||
|
description: values.description,
|
||||||
|
};
|
||||||
|
await articleCategoryApi.create(req);
|
||||||
|
message.success('分类创建成功');
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
fetchCategories();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMsg =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||||
|
'操作失败';
|
||||||
|
message.error(errorMsg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await articleCategoryApi.delete(id);
|
||||||
|
message.success('分类已删除');
|
||||||
|
fetchCategories();
|
||||||
|
} catch {
|
||||||
|
message.error('删除失败,可能该分类下还有文章');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建父分类选项(排除自身)
|
||||||
|
const parentOptions = categories
|
||||||
|
.filter((c) => !editingCategory || c.id !== editingCategory.id)
|
||||||
|
.map((c) => ({ label: c.name, value: c.id }));
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '分类名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (name: string) => (
|
||||||
|
<span style={{ fontWeight: 500 }}>{name}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '别名 (Slug)',
|
||||||
|
dataIndex: 'slug',
|
||||||
|
key: 'slug',
|
||||||
|
width: 180,
|
||||||
|
render: (v?: string) => v || <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>-</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '父分类',
|
||||||
|
dataIndex: 'parent_name',
|
||||||
|
key: 'parent_name',
|
||||||
|
width: 140,
|
||||||
|
render: (_v: string | undefined, record: ArticleCategory) => {
|
||||||
|
if (!record.parent_id) return <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>-</span>;
|
||||||
|
const parent = categories.find((c) => c.id === record.parent_id);
|
||||||
|
return parent?.name || record.parent_id;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '排序',
|
||||||
|
dataIndex: 'sort_order',
|
||||||
|
key: 'sort_order',
|
||||||
|
width: 80,
|
||||||
|
render: (v: number) => v ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '描述',
|
||||||
|
dataIndex: 'description',
|
||||||
|
key: 'description',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v?: string) => v || <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>-</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 120,
|
||||||
|
render: (_: unknown, record: ArticleCategory) => (
|
||||||
|
<AuthButton code="health.articles.manage">
|
||||||
|
<Space size={4}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => openEditModal(record)}
|
||||||
|
style={{ color: isDark ? '#94a3b8' : '#475569' }}
|
||||||
|
/>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除此分类?"
|
||||||
|
description="删除后不可恢复,关联文章将变为未分类"
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
>
|
||||||
|
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
</AuthButton>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 页面标题和工具栏 */}
|
||||||
|
<div className="erp-page-header">
|
||||||
|
<div>
|
||||||
|
<h4>分类管理</h4>
|
||||||
|
<div className="erp-page-subtitle">管理文章分类,支持多级分类结构</div>
|
||||||
|
</div>
|
||||||
|
<AuthButton code="health.articles.manage">
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||||
|
新建分类
|
||||||
|
</Button>
|
||||||
|
</AuthButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格容器 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={categories}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 新建/编辑分类弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={editingCategory ? '编辑分类' : '新建分类'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={closeModal}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
width={520}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
layout="vertical"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="分类名称"
|
||||||
|
rules={[{ required: true, message: '请输入分类名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入分类名称" maxLength={50} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="slug" label="别名 (Slug)">
|
||||||
|
<Input placeholder="例如: health-tips(留空则自动生成)" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="parent_id" label="父分类">
|
||||||
|
<Select
|
||||||
|
placeholder="选择父分类(可选)"
|
||||||
|
allowClear
|
||||||
|
options={parentOptions}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sort_order" label="排序" initialValue={0}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} placeholder="0" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<Input.TextArea rows={2} placeholder="请输入分类描述" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
516
apps/web/src/pages/health/ArticleEditor.tsx
Normal file
516
apps/web/src/pages/health/ArticleEditor.tsx
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { Button, Input, Select, Space, message, Spin } from 'antd';
|
||||||
|
import { ArrowLeftOutlined, SaveOutlined, SendOutlined } from '@ant-design/icons';
|
||||||
|
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
|
||||||
|
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
|
||||||
|
import {
|
||||||
|
articleApi,
|
||||||
|
articleCategoryApi,
|
||||||
|
articleTagApi,
|
||||||
|
type Article,
|
||||||
|
type ArticleTagItem,
|
||||||
|
} from '../../api/health/articles';
|
||||||
|
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||||
|
import { AuthButton } from '../../components/AuthButton';
|
||||||
|
import '@wangeditor/editor/dist/css/style.css';
|
||||||
|
|
||||||
|
export default function ArticleEditor() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
const isDark = useThemeMode();
|
||||||
|
|
||||||
|
// 表单状态
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [summary, setSummary] = useState('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [coverImage, setCoverImage] = useState('');
|
||||||
|
const [slug, setSlug] = useState('');
|
||||||
|
const [categoryId, setCategoryId] = useState<string | undefined>(undefined);
|
||||||
|
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||||
|
const [sortOrder, setSortOrder] = useState(0);
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
|
||||||
|
// 选项数据
|
||||||
|
const [categories, setCategories] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [tags, setTags] = useState<ArticleTagItem[]>([]);
|
||||||
|
|
||||||
|
// UI 状态
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [editor, setEditor] = useState<IDomEditor | null>(null);
|
||||||
|
|
||||||
|
// 加载分类和标签
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchOptions = async () => {
|
||||||
|
try {
|
||||||
|
const [cats, tagList] = await Promise.all([
|
||||||
|
articleCategoryApi.list(),
|
||||||
|
articleTagApi.list(),
|
||||||
|
]);
|
||||||
|
setCategories(cats.map((c) => ({ id: c.id, name: c.name })));
|
||||||
|
setTags(tagList);
|
||||||
|
} catch {
|
||||||
|
// 选项加载失败不阻塞
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchOptions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 编辑模式:加载现有文章
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
const fetchArticle = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const article: Article = await articleApi.get(id);
|
||||||
|
setTitle(article.title);
|
||||||
|
setSummary(article.summary || '');
|
||||||
|
setContent(article.content || '');
|
||||||
|
setCoverImage(article.cover_image || '');
|
||||||
|
setSlug(article.slug || '');
|
||||||
|
setCategoryId(article.category_id);
|
||||||
|
setSelectedTagIds(article.tags?.map((t) => t.id) || []);
|
||||||
|
setSortOrder(article.sort_order);
|
||||||
|
setVersion(article.version);
|
||||||
|
} catch {
|
||||||
|
message.error('加载文章失败');
|
||||||
|
navigate('/health/articles');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchArticle();
|
||||||
|
}, [id, navigate]);
|
||||||
|
|
||||||
|
// 编辑器配置
|
||||||
|
const toolbarConfig = useMemo<Partial<IToolbarConfig>>(
|
||||||
|
() => ({
|
||||||
|
excludeKeys: [
|
||||||
|
'group-video',
|
||||||
|
'insertLink',
|
||||||
|
'editLink',
|
||||||
|
'unLink',
|
||||||
|
'viewLink',
|
||||||
|
'codeView',
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const editorConfig = useMemo<Partial<IEditorConfig>>(
|
||||||
|
() => ({
|
||||||
|
placeholder: '请输入文章内容...',
|
||||||
|
MENU_CONF: {
|
||||||
|
uploadImage: {
|
||||||
|
// 自定义图片上传 - 预留后端接口
|
||||||
|
async customUpload(_file: File, _insertFn: (url: string, alt?: string, href?: string) => void) {
|
||||||
|
// TODO: 实现图片上传到后端
|
||||||
|
message.warning('图片上传功能待实现');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 及时销毁编辑器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (editor) {
|
||||||
|
editor.destroy();
|
||||||
|
setEditor(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
message.warning('请输入文章标题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (isEdit && id) {
|
||||||
|
await articleApi.update(id, {
|
||||||
|
title,
|
||||||
|
summary: summary || undefined,
|
||||||
|
content,
|
||||||
|
cover_image: coverImage || undefined,
|
||||||
|
slug: slug || undefined,
|
||||||
|
category_id: categoryId,
|
||||||
|
tag_ids: selectedTagIds,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
message.success('文章已保存');
|
||||||
|
// 重新加载以获取新 version
|
||||||
|
const updated = await articleApi.get(id);
|
||||||
|
setVersion(updated.version);
|
||||||
|
} else {
|
||||||
|
const created = await articleApi.create({
|
||||||
|
title,
|
||||||
|
summary: summary || undefined,
|
||||||
|
content,
|
||||||
|
cover_image: coverImage || undefined,
|
||||||
|
slug: slug || undefined,
|
||||||
|
category_id: categoryId,
|
||||||
|
tag_ids: selectedTagIds,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
});
|
||||||
|
message.success('文章已创建');
|
||||||
|
navigate(`/health/articles/${created.id}/edit`, { replace: true });
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMsg =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||||
|
'保存失败';
|
||||||
|
message.error(errorMsg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
id, isEdit, title, summary, content, coverImage, slug, categoryId,
|
||||||
|
selectedTagIds, sortOrder, version, navigate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
message.warning('请输入文章标题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// 先保存
|
||||||
|
let currentVersion = version;
|
||||||
|
if (isEdit && id) {
|
||||||
|
await articleApi.update(id, {
|
||||||
|
title,
|
||||||
|
summary: summary || undefined,
|
||||||
|
content,
|
||||||
|
cover_image: coverImage || undefined,
|
||||||
|
slug: slug || undefined,
|
||||||
|
category_id: categoryId,
|
||||||
|
tag_ids: selectedTagIds,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
const updated = await articleApi.get(id);
|
||||||
|
currentVersion = updated.version;
|
||||||
|
setVersion(updated.version);
|
||||||
|
} else {
|
||||||
|
const created = await articleApi.create({
|
||||||
|
title,
|
||||||
|
summary: summary || undefined,
|
||||||
|
content,
|
||||||
|
cover_image: coverImage || undefined,
|
||||||
|
slug: slug || undefined,
|
||||||
|
category_id: categoryId,
|
||||||
|
tag_ids: selectedTagIds,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
});
|
||||||
|
currentVersion = created.version;
|
||||||
|
setVersion(created.version);
|
||||||
|
navigate(`/health/articles/${created.id}/edit`, { replace: true });
|
||||||
|
}
|
||||||
|
// 提交审核
|
||||||
|
if (id || isEdit) {
|
||||||
|
const articleId = id!;
|
||||||
|
await articleApi.submit(articleId, currentVersion);
|
||||||
|
}
|
||||||
|
message.success('已提交审核');
|
||||||
|
navigate('/health/articles');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMsg =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||||
|
'提交审核失败';
|
||||||
|
message.error(errorMsg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
id, isEdit, title, summary, content, coverImage, slug, categoryId,
|
||||||
|
selectedTagIds, sortOrder, version, navigate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 页面标题栏 */}
|
||||||
|
<div className="erp-page-header">
|
||||||
|
<Space size={12}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate('/health/articles')}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ margin: 0 }}>{isEdit ? '编辑文章' : '新建文章'}</h4>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<Space size={8}>
|
||||||
|
<AuthButton code="health.articles.manage">
|
||||||
|
<Button
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={saving}
|
||||||
|
>
|
||||||
|
保存草稿
|
||||||
|
</Button>
|
||||||
|
</AuthButton>
|
||||||
|
<AuthButton code="health.articles.manage">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={saving}
|
||||||
|
>
|
||||||
|
提交审核
|
||||||
|
</Button>
|
||||||
|
</AuthButton>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主体布局: 左侧编辑区 + 右侧设置面板 */}
|
||||||
|
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
|
||||||
|
{/* 左侧: 富文本编辑器 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderBottom: `1px solid ${isDark ? '#1e293b' : '#f1f5f9'}`,
|
||||||
|
zIndex: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar
|
||||||
|
editor={editor}
|
||||||
|
defaultConfig={toolbarConfig}
|
||||||
|
mode="default"
|
||||||
|
style={{
|
||||||
|
background: isDark ? '#0f172a' : '#f8fafc',
|
||||||
|
borderBottom: `1px solid ${isDark ? '#1e293b' : '#e2e8f0'}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 600, overflowY: 'auto' }}>
|
||||||
|
<Editor
|
||||||
|
defaultConfig={editorConfig}
|
||||||
|
value={content}
|
||||||
|
onCreated={setEditor}
|
||||||
|
onChange={(editorInstance) => setContent(editorInstance.getHtml())}
|
||||||
|
mode="default"
|
||||||
|
style={{
|
||||||
|
minHeight: 500,
|
||||||
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
|
color: isDark ? '#e2e8f0' : '#1e293b',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧: 设置面板 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 320,
|
||||||
|
flexShrink: 0,
|
||||||
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
padding: 20,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 标题 */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 6,
|
||||||
|
color: isDark ? '#94a3b8' : '#475569',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
文章标题 <span style={{ color: '#dc2626' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="请输入文章标题"
|
||||||
|
maxLength={200}
|
||||||
|
showCount
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分类 */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 6,
|
||||||
|
color: isDark ? '#94a3b8' : '#475569',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
分类
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={categoryId}
|
||||||
|
onChange={setCategoryId}
|
||||||
|
placeholder="选择分类"
|
||||||
|
allowClear
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={categories.map((c) => ({ label: c.name, value: c.id }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标签 */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 6,
|
||||||
|
color: isDark ? '#94a3b8' : '#475569',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
标签
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
value={selectedTagIds}
|
||||||
|
onChange={setSelectedTagIds}
|
||||||
|
placeholder="选择标签"
|
||||||
|
allowClear
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={tags.map((t) => ({ label: t.name, value: t.id }))}
|
||||||
|
maxTagCount={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 摘要 */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 6,
|
||||||
|
color: isDark ? '#94a3b8' : '#475569',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
摘要
|
||||||
|
</label>
|
||||||
|
<Input.TextArea
|
||||||
|
value={summary}
|
||||||
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
|
placeholder="请输入文章摘要"
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
showCount
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 封面图 */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 6,
|
||||||
|
color: isDark ? '#94a3b8' : '#475569',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
封面图 URL
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={coverImage}
|
||||||
|
onChange={(e) => setCoverImage(e.target.value)}
|
||||||
|
placeholder="请输入封面图片 URL"
|
||||||
|
/>
|
||||||
|
{coverImage && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: `1px solid ${isDark ? '#1e293b' : '#e2e8f0'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={coverImage}
|
||||||
|
alt="封面预览"
|
||||||
|
style={{ width: '100%', height: 120, objectFit: 'cover' }}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slug */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 6,
|
||||||
|
color: isDark ? '#94a3b8' : '#475569',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
URL 别名 (Slug)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
|
placeholder="例如: health-tips-for-elderly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 排序 */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 6,
|
||||||
|
color: isDark ? '#94a3b8' : '#475569',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
排序
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={sortOrder}
|
||||||
|
onChange={(e) => setSortOrder(Number(e.target.value))}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
498
apps/web/src/pages/health/ArticleManageList.tsx
Normal file
498
apps/web/src/pages/health/ArticleManageList.tsx
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Tag,
|
||||||
|
Tabs,
|
||||||
|
Popconfirm,
|
||||||
|
message,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
CheckOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
RollbackOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
articleApi,
|
||||||
|
articleCategoryApi,
|
||||||
|
type ArticleListItem,
|
||||||
|
type ArticleStatus,
|
||||||
|
type ArticleTagItem,
|
||||||
|
} from '../../api/health/articles';
|
||||||
|
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||||
|
import { AuthButton } from '../../components/AuthButton';
|
||||||
|
|
||||||
|
const STATUS_TABS: { key: string; label: string }[] = [
|
||||||
|
{ key: '', label: '全部' },
|
||||||
|
{ key: 'draft', label: '草稿' },
|
||||||
|
{ key: 'pending_review', label: '待审核' },
|
||||||
|
{ key: 'published', label: '已发布' },
|
||||||
|
{ key: 'rejected', label: '已拒绝' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; color: string }
|
||||||
|
> = {
|
||||||
|
draft: { label: '草稿', color: 'default' },
|
||||||
|
pending_review: { label: '待审核', color: 'processing' },
|
||||||
|
published: { label: '已发布', color: 'success' },
|
||||||
|
rejected: { label: '已拒绝', color: 'error' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ArticleManageList() {
|
||||||
|
const [articles, setArticles] = useState<ArticleListItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [statusTab, setStatusTab] = useState('');
|
||||||
|
const [categoryId, setCategoryId] = useState<string | undefined>(undefined);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [categories, setCategories] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [rejectModalOpen, setRejectModalOpen] = useState(false);
|
||||||
|
const [rejectingArticle, setRejectingArticle] = useState<ArticleListItem | null>(null);
|
||||||
|
const [rejectForm] = Form.useForm();
|
||||||
|
const isDark = useThemeMode();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const fetchArticles = useCallback(
|
||||||
|
async (p = page) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await articleApi.list({
|
||||||
|
page: p,
|
||||||
|
page_size: 20,
|
||||||
|
status: (statusTab || undefined) as ArticleStatus | undefined,
|
||||||
|
category_id: categoryId,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
});
|
||||||
|
setArticles(result.data);
|
||||||
|
setTotal(result.total);
|
||||||
|
} catch {
|
||||||
|
message.error('加载文章列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[page, statusTab, categoryId, keyword],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchCategories = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const cats = await articleCategoryApi.list();
|
||||||
|
setCategories(cats.map((c) => ({ id: c.id, name: c.name })));
|
||||||
|
} catch {
|
||||||
|
// 分类列表加载失败不阻塞页面
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchArticles();
|
||||||
|
}, [fetchArticles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCategories();
|
||||||
|
}, [fetchCategories]);
|
||||||
|
|
||||||
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const debouncedSearch = useCallback((value: string) => {
|
||||||
|
setKeyword(value);
|
||||||
|
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||||
|
debounceTimer.current = setTimeout(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, 300);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await articleApi.delete(id);
|
||||||
|
message.success('文章已删除');
|
||||||
|
fetchArticles();
|
||||||
|
} catch {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (record: ArticleListItem) => {
|
||||||
|
try {
|
||||||
|
await articleApi.submit(record.id, record.version);
|
||||||
|
message.success('已提交审核');
|
||||||
|
fetchArticles();
|
||||||
|
} catch {
|
||||||
|
message.error('提交审核失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApprove = async (record: ArticleListItem) => {
|
||||||
|
try {
|
||||||
|
await articleApi.approve(record.id, record.version);
|
||||||
|
message.success('审核通过,文章已发布');
|
||||||
|
fetchArticles();
|
||||||
|
} catch {
|
||||||
|
message.error('审核操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRejectModal = (record: ArticleListItem) => {
|
||||||
|
setRejectingArticle(record);
|
||||||
|
rejectForm.resetFields();
|
||||||
|
setRejectModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async (values: { review_note: string }) => {
|
||||||
|
if (!rejectingArticle) return;
|
||||||
|
try {
|
||||||
|
await articleApi.reject(
|
||||||
|
rejectingArticle.id,
|
||||||
|
rejectingArticle.version,
|
||||||
|
values.review_note,
|
||||||
|
);
|
||||||
|
message.success('已拒绝文章');
|
||||||
|
setRejectModalOpen(false);
|
||||||
|
fetchArticles();
|
||||||
|
} catch {
|
||||||
|
message.error('拒绝操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnpublish = async (record: ArticleListItem) => {
|
||||||
|
try {
|
||||||
|
await articleApi.unpublish(record.id, record.version);
|
||||||
|
message.success('文章已撤回为草稿');
|
||||||
|
fetchArticles();
|
||||||
|
} catch {
|
||||||
|
message.error('撤回操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderActions = (record: ArticleListItem) => (
|
||||||
|
<Space size={4} wrap>
|
||||||
|
{record.status === 'draft' && (
|
||||||
|
<>
|
||||||
|
<AuthButton code="health.articles.manage">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => navigate(`/health/articles/${record.id}/edit`)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
</AuthButton>
|
||||||
|
<AuthButton code="health.articles.manage">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
onClick={() => handleSubmit(record)}
|
||||||
|
>
|
||||||
|
提交审核
|
||||||
|
</Button>
|
||||||
|
</AuthButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{record.status === 'pending_review' && (
|
||||||
|
<>
|
||||||
|
<AuthButton code="health.articles.review">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
style={{ color: '#059669' }}
|
||||||
|
onClick={() => handleApprove(record)}
|
||||||
|
>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
</AuthButton>
|
||||||
|
<AuthButton code="health.articles.review">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
danger
|
||||||
|
onClick={() => openRejectModal(record)}
|
||||||
|
>
|
||||||
|
拒绝
|
||||||
|
</Button>
|
||||||
|
</AuthButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{record.status === 'published' && (
|
||||||
|
<AuthButton code="health.articles.manage">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
icon={<RollbackOutlined />}
|
||||||
|
onClick={() => handleUnpublish(record)}
|
||||||
|
>
|
||||||
|
撤回
|
||||||
|
</Button>
|
||||||
|
</AuthButton>
|
||||||
|
)}
|
||||||
|
{(record.status === 'draft' || record.status === 'rejected') && (
|
||||||
|
<AuthButton code="health.articles.manage">
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除此文章?"
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
>
|
||||||
|
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
|
||||||
|
</Popconfirm>
|
||||||
|
</AuthButton>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '标题',
|
||||||
|
dataIndex: 'title',
|
||||||
|
key: 'title',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (title: string, record: ArticleListItem) => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
{record.cover_image && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: `url(${record.cover_image}) center/cover`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{ fontWeight: 500, fontSize: 14, cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/health/articles/${record.id}/edit`)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{record.summary && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: isDark ? '#64748b' : '#94a3b8',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
maxWidth: 300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{record.summary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '分类',
|
||||||
|
dataIndex: 'category_name',
|
||||||
|
key: 'category_name',
|
||||||
|
width: 120,
|
||||||
|
render: (v?: string) => v || <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>未分类</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '标签',
|
||||||
|
dataIndex: 'tags',
|
||||||
|
key: 'tags',
|
||||||
|
width: 180,
|
||||||
|
render: (tags?: ArticleTagItem[]) => {
|
||||||
|
if (!tags || tags.length === 0) {
|
||||||
|
return <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>-</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Space size={4} wrap>
|
||||||
|
{tags.map((t) => (
|
||||||
|
<Tag
|
||||||
|
key={t.id}
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
background: isDark ? '#0f172a' : '#f0f9ff',
|
||||||
|
border: `1px solid ${isDark ? '#1e3a5f' : '#bae6fd'}`,
|
||||||
|
color: isDark ? '#7dd3fc' : '#0369a1',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (status: string) => {
|
||||||
|
const config = STATUS_CONFIG[status] || { label: status, color: 'default' };
|
||||||
|
return <Tag color={config.color}>{config.label}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '作者',
|
||||||
|
dataIndex: 'author',
|
||||||
|
key: 'author',
|
||||||
|
width: 100,
|
||||||
|
render: (v?: string) => v || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '阅读数',
|
||||||
|
dataIndex: 'view_count',
|
||||||
|
key: 'view_count',
|
||||||
|
width: 80,
|
||||||
|
render: (v: number) => (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<EyeOutlined style={{ fontSize: 12, color: isDark ? '#64748b' : '#94a3b8' }} />
|
||||||
|
{v ?? 0}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '发布时间',
|
||||||
|
dataIndex: 'published_at',
|
||||||
|
key: 'published_at',
|
||||||
|
width: 170,
|
||||||
|
render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 200,
|
||||||
|
render: (_: unknown, record: ArticleListItem) => renderActions(record),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 页面标题和工具栏 */}
|
||||||
|
<div className="erp-page-header">
|
||||||
|
<div>
|
||||||
|
<h4>内容管理</h4>
|
||||||
|
<div className="erp-page-subtitle">管理健康科普文章、资讯和内容发布</div>
|
||||||
|
</div>
|
||||||
|
<AuthButton code="health.articles.manage">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => navigate('/health/articles/new')}
|
||||||
|
>
|
||||||
|
新建文章
|
||||||
|
</Button>
|
||||||
|
</AuthButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 筛选栏 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
padding: '12px 16px',
|
||||||
|
marginBottom: 16,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索文章标题..."
|
||||||
|
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => debouncedSearch(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 220, borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(v) => {
|
||||||
|
setCategoryId(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="选择分类"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 160, borderRadius: 8 }}
|
||||||
|
options={categories.map((c) => ({ label: c.name, value: c.id }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态标签页 + 表格 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
activeKey={statusTab}
|
||||||
|
onChange={(key) => {
|
||||||
|
setStatusTab(key);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
items={STATUS_TABS.map((tab) => ({ key: tab.key, label: tab.label }))}
|
||||||
|
style={{ padding: '0 16px', marginBottom: 0 }}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={articles}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
total,
|
||||||
|
pageSize: 20,
|
||||||
|
onChange: (p) => {
|
||||||
|
setPage(p);
|
||||||
|
fetchArticles(p);
|
||||||
|
},
|
||||||
|
showTotal: (t) => `共 ${t} 条记录`,
|
||||||
|
style: { padding: '12px 16px', margin: 0 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 拒绝理由弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title="拒绝文章"
|
||||||
|
open={rejectModalOpen}
|
||||||
|
onCancel={() => setRejectModalOpen(false)}
|
||||||
|
onOk={() => rejectForm.submit()}
|
||||||
|
okText="确认拒绝"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={rejectForm}
|
||||||
|
onFinish={handleReject}
|
||||||
|
layout="vertical"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="review_note"
|
||||||
|
label="拒绝理由"
|
||||||
|
rules={[{ required: true, message: '请输入拒绝理由' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={3} placeholder="请输入拒绝理由" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
apps/web/src/pages/health/ArticleTagManage.tsx
Normal file
220
apps/web/src/pages/health/ArticleTagManage.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Popconfirm,
|
||||||
|
Tag,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
articleTagApi,
|
||||||
|
type ArticleTagItem,
|
||||||
|
type CreateTagReq,
|
||||||
|
} from '../../api/health/articles';
|
||||||
|
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||||
|
import { AuthButton } from '../../components/AuthButton';
|
||||||
|
|
||||||
|
export default function ArticleTagManage() {
|
||||||
|
const [tags, setTags] = useState<ArticleTagItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const isDark = useThemeMode();
|
||||||
|
|
||||||
|
const fetchTags = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await articleTagApi.list();
|
||||||
|
setTags(result);
|
||||||
|
} catch {
|
||||||
|
message.error('加载标签列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTags();
|
||||||
|
}, [fetchTags]);
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
form.resetFields();
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (values: { name: string; slug?: string; color?: string }) => {
|
||||||
|
try {
|
||||||
|
const req: CreateTagReq = {
|
||||||
|
name: values.name,
|
||||||
|
slug: values.slug,
|
||||||
|
color: values.color,
|
||||||
|
};
|
||||||
|
await articleTagApi.create(req);
|
||||||
|
message.success('标签创建成功');
|
||||||
|
closeModal();
|
||||||
|
fetchTags();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMsg =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||||
|
'创建失败';
|
||||||
|
message.error(errorMsg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await articleTagApi.delete(id);
|
||||||
|
message.success('标签已删除');
|
||||||
|
fetchTags();
|
||||||
|
} catch {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '标签名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (name: string, record: ArticleTagItem) => (
|
||||||
|
<Tag
|
||||||
|
color={record.color || undefined}
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
padding: '2px 10px',
|
||||||
|
...(record.color ? {} : {
|
||||||
|
background: isDark ? '#0f172a' : '#f0f9ff',
|
||||||
|
border: `1px solid ${isDark ? '#1e3a5f' : '#bae6fd'}`,
|
||||||
|
color: isDark ? '#7dd3fc' : '#0369a1',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '别名 (Slug)',
|
||||||
|
dataIndex: 'slug',
|
||||||
|
key: 'slug',
|
||||||
|
width: 180,
|
||||||
|
render: (v?: string) => v || <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>-</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '颜色',
|
||||||
|
dataIndex: 'color',
|
||||||
|
key: 'color',
|
||||||
|
width: 100,
|
||||||
|
render: (v?: string) =>
|
||||||
|
v ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: v,
|
||||||
|
border: '1px solid rgba(0,0,0,0.1)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 12, fontFamily: 'monospace' }}>{v}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>默认</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 80,
|
||||||
|
render: (_: unknown, record: ArticleTagItem) => (
|
||||||
|
<AuthButton code="health.articles.manage">
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除此标签?"
|
||||||
|
description="删除后关联的文章将移除该标签"
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
>
|
||||||
|
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
|
||||||
|
</Popconfirm>
|
||||||
|
</AuthButton>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 页面标题和工具栏 */}
|
||||||
|
<div className="erp-page-header">
|
||||||
|
<div>
|
||||||
|
<h4>标签管理</h4>
|
||||||
|
<div className="erp-page-subtitle">管理文章标签,用于文章分类和筛选</div>
|
||||||
|
</div>
|
||||||
|
<AuthButton code="health.articles.manage">
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||||
|
新建标签
|
||||||
|
</Button>
|
||||||
|
</AuthButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格容器 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={tags}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 新建标签弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title="新建标签"
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={closeModal}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
width={440}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
onFinish={handleCreate}
|
||||||
|
layout="vertical"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="标签名称"
|
||||||
|
rules={[{ required: true, message: '请输入标签名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入标签名称" maxLength={30} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="slug" label="别名 (Slug)">
|
||||||
|
<Input placeholder="例如: diabetes(留空则自动生成)" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="color" label="颜色">
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
style={{ width: 60, height: 36, padding: 2, cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use erp_core::sanitize::{sanitize_option, sanitize_string, strip_html_tags};
|
use erp_core::sanitize::{sanitize_option, sanitize_string, strip_html_tags};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 文章 DTOs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct ArticleResp {
|
pub struct ArticleResp {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -14,6 +18,18 @@ pub struct ArticleResp {
|
|||||||
pub category: Option<String>,
|
pub category: Option<String>,
|
||||||
pub author: Option<String>,
|
pub author: Option<String>,
|
||||||
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
|
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
pub status: String,
|
||||||
|
pub slug: Option<String>,
|
||||||
|
pub content_type: String,
|
||||||
|
pub reviewed_by: Option<Uuid>,
|
||||||
|
pub reviewed_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
pub review_note: Option<String>,
|
||||||
|
pub view_count: i32,
|
||||||
|
pub sort_order: i32,
|
||||||
|
/// 文章关联的分类 ID(来自 article_category 表)
|
||||||
|
pub category_id: Option<Uuid>,
|
||||||
|
/// 文章关联的标签名称列表
|
||||||
|
pub tags: Vec<String>,
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
pub version: i32,
|
pub version: i32,
|
||||||
@@ -28,6 +44,12 @@ pub struct ArticleListItem {
|
|||||||
pub category: Option<String>,
|
pub category: Option<String>,
|
||||||
pub author: Option<String>,
|
pub author: Option<String>,
|
||||||
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
|
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
pub status: String,
|
||||||
|
pub view_count: i32,
|
||||||
|
/// 分类 ID
|
||||||
|
pub category_id: Option<Uuid>,
|
||||||
|
/// 标签名称列表
|
||||||
|
pub tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, IntoParams)]
|
#[derive(Debug, Clone, Deserialize, IntoParams)]
|
||||||
@@ -35,6 +57,14 @@ pub struct ArticleListParams {
|
|||||||
pub page: Option<u64>,
|
pub page: Option<u64>,
|
||||||
pub page_size: Option<u64>,
|
pub page_size: Option<u64>,
|
||||||
pub category: Option<String>,
|
pub category: Option<String>,
|
||||||
|
/// 按状态筛选
|
||||||
|
pub status: Option<String>,
|
||||||
|
/// 按分类 ID 筛选
|
||||||
|
pub category_id: Option<Uuid>,
|
||||||
|
/// 按标签 ID 筛选
|
||||||
|
pub tag_id: Option<Uuid>,
|
||||||
|
/// 关键词搜索(标题模糊匹配)
|
||||||
|
pub keyword: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
@@ -46,6 +76,13 @@ pub struct CreateArticleReq {
|
|||||||
pub category: Option<String>,
|
pub category: Option<String>,
|
||||||
pub author: Option<String>,
|
pub author: Option<String>,
|
||||||
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
|
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
pub slug: Option<String>,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
/// 分类 ID
|
||||||
|
pub category_id: Option<Uuid>,
|
||||||
|
/// 标签 ID 列表
|
||||||
|
#[serde(default)]
|
||||||
|
pub tag_ids: Vec<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CreateArticleReq {
|
impl CreateArticleReq {
|
||||||
@@ -55,6 +92,8 @@ impl CreateArticleReq {
|
|||||||
self.content = sanitize_option(self.content.take());
|
self.content = sanitize_option(self.content.take());
|
||||||
self.category = sanitize_option(self.category.take());
|
self.category = sanitize_option(self.category.take());
|
||||||
self.author = sanitize_option(self.author.take());
|
self.author = sanitize_option(self.author.take());
|
||||||
|
self.slug = sanitize_option(self.slug.take());
|
||||||
|
self.content_type = sanitize_option(self.content_type.take());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +106,13 @@ pub struct UpdateArticleReq {
|
|||||||
pub category: Option<String>,
|
pub category: Option<String>,
|
||||||
pub author: Option<String>,
|
pub author: Option<String>,
|
||||||
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
|
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
pub slug: Option<String>,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
/// 分类 ID
|
||||||
|
pub category_id: Option<Uuid>,
|
||||||
|
/// 标签 ID 列表(传入则整体替换)
|
||||||
|
pub tag_ids: Option<Vec<Uuid>>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
pub version: i32,
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,5 +123,98 @@ impl UpdateArticleReq {
|
|||||||
self.content = sanitize_option(self.content.take());
|
self.content = sanitize_option(self.content.take());
|
||||||
self.category = sanitize_option(self.category.take());
|
self.category = sanitize_option(self.category.take());
|
||||||
self.author = sanitize_option(self.author.take());
|
self.author = sanitize_option(self.author.take());
|
||||||
|
self.slug = sanitize_option(self.slug.take());
|
||||||
|
self.content_type = sanitize_option(self.content_type.take());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 审核文章请求
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ReviewArticleReq {
|
||||||
|
/// 审核备注
|
||||||
|
pub note: Option<String>,
|
||||||
|
/// 文章版本号(乐观锁)
|
||||||
|
pub version: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReviewArticleReq {
|
||||||
|
pub fn sanitize(&mut self) {
|
||||||
|
self.note = sanitize_option(self.note.take());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 分类 DTOs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct CategoryResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub slug: Option<String>,
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct CreateCategoryReq {
|
||||||
|
pub name: String,
|
||||||
|
pub slug: Option<String>,
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateCategoryReq {
|
||||||
|
pub fn sanitize(&mut self) {
|
||||||
|
self.name = sanitize_string(&self.name);
|
||||||
|
self.slug = sanitize_option(self.slug.take());
|
||||||
|
self.description = sanitize_option(self.description.take());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateCategoryReq {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub slug: Option<String>,
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateCategoryReq {
|
||||||
|
pub fn sanitize(&mut self) {
|
||||||
|
if let Some(ref mut v) = self.name { *v = strip_html_tags(v); }
|
||||||
|
self.slug = sanitize_option(self.slug.take());
|
||||||
|
self.description = sanitize_option(self.description.take());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 标签 DTOs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct TagResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct CreateTagReq {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateTagReq {
|
||||||
|
pub fn sanitize(&mut self) {
|
||||||
|
self.name = sanitize_string(&self.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
crates/erp-health/src/entity/article_category.rs
Normal file
32
crates/erp-health/src/entity/article_category.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "article_category")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub slug: Option<String>,
|
||||||
|
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created_by: Option<Uuid>,
|
||||||
|
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub updated_by: Option<Uuid>,
|
||||||
|
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
38
crates/erp-health/src/entity/article_revision.rs
Normal file
38
crates/erp-health/src/entity/article_revision.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 文章版本历史
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "article_revision")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub article_id: Uuid,
|
||||||
|
pub revision_number: i32,
|
||||||
|
pub title: String,
|
||||||
|
pub content: String,
|
||||||
|
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub summary: Option<String>,
|
||||||
|
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created_by: Option<Uuid>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::article::Entity",
|
||||||
|
from = "Column::ArticleId",
|
||||||
|
to = "super::article::Column::Id"
|
||||||
|
)]
|
||||||
|
Article,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::article::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Article.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
34
crates/erp-health/src/entity/article_tag.rs
Normal file
34
crates/erp-health/src/entity/article_tag.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "article_tag")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created_by: Option<Uuid>,
|
||||||
|
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub updated_by: Option<Uuid>,
|
||||||
|
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::article_article_tag::Entity")]
|
||||||
|
ArticleTagRelation,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::article_article_tag::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::ArticleTagRelation.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
pub mod appointment;
|
pub mod appointment;
|
||||||
pub mod article;
|
pub mod article;
|
||||||
|
pub mod article_article_tag;
|
||||||
|
pub mod article_category;
|
||||||
|
pub mod article_revision;
|
||||||
|
pub mod article_tag;
|
||||||
pub mod critical_value_threshold;
|
pub mod critical_value_threshold;
|
||||||
pub mod consent;
|
pub mod consent;
|
||||||
pub mod consultation_message;
|
pub mod consultation_message;
|
||||||
|
|||||||
81
crates/erp-health/src/handler/article_category_handler.rs
Normal file
81
crates/erp-health/src/handler/article_category_handler.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
//! 文章分类 Handler
|
||||||
|
|
||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, Json, Path, State};
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, TenantContext};
|
||||||
|
|
||||||
|
use crate::dto::article_dto::{CategoryResp, CreateCategoryReq, UpdateCategoryReq};
|
||||||
|
use crate::service::article_category_service;
|
||||||
|
use crate::state::HealthState;
|
||||||
|
|
||||||
|
pub async fn list_categories<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<CategoryResp>>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.articles.list")?;
|
||||||
|
let result = article_category_service::list_categories(&state, ctx.tenant_id).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_category<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
mut req: Json<CreateCategoryReq>,
|
||||||
|
) -> Result<Json<ApiResponse<CategoryResp>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.articles.manage")?;
|
||||||
|
req.sanitize();
|
||||||
|
let result = article_category_service::create_category(
|
||||||
|
&state, ctx.tenant_id, Some(ctx.user_id), req.0,
|
||||||
|
).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_category<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
mut req: Json<UpdateCategoryReq>,
|
||||||
|
) -> Result<Json<ApiResponse<CategoryResp>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.articles.manage")?;
|
||||||
|
req.sanitize();
|
||||||
|
let result = article_category_service::update_category(
|
||||||
|
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0,
|
||||||
|
).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct DeleteCategoryReq {
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_category<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
Json(req): Json<DeleteCategoryReq>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.articles.manage")?;
|
||||||
|
article_category_service::delete_category(
|
||||||
|
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
|
||||||
|
).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(())))
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ use erp_core::error::AppError;
|
|||||||
use erp_core::rbac::require_permission;
|
use erp_core::rbac::require_permission;
|
||||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||||
|
|
||||||
use crate::dto::article_dto::{ArticleListItem, ArticleListParams, ArticleResp, CreateArticleReq, UpdateArticleReq};
|
use crate::dto::article_dto::{ArticleListItem, ArticleListParams, ArticleResp, CreateArticleReq, ReviewArticleReq, UpdateArticleReq};
|
||||||
use crate::service::article_service;
|
use crate::service::article_service;
|
||||||
use crate::state::HealthState;
|
use crate::state::HealthState;
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@ where
|
|||||||
let page = params.page.unwrap_or(1);
|
let page = params.page.unwrap_or(1);
|
||||||
let page_size = params.page_size.unwrap_or(20);
|
let page_size = params.page_size.unwrap_or(20);
|
||||||
let result = article_service::list_articles(
|
let result = article_service::list_articles(
|
||||||
&state, ctx.tenant_id, page, page_size, params.category,
|
&state, ctx.tenant_id, page, page_size,
|
||||||
params.status, params.category_id, params.tag_id, params.keyword,
|
params.category, params.status, params.category_id, params.tag_id, params.keyword,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(ApiResponse::ok(result)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
@@ -97,3 +97,102 @@ where
|
|||||||
article_service::delete_article(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?;
|
article_service::delete_article(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?;
|
||||||
Ok(Json(ApiResponse::ok(())))
|
Ok(Json(ApiResponse::ok(())))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 审核工作流
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct VersionReq {
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交审核
|
||||||
|
pub async fn submit_article<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
Json(req): Json<VersionReq>,
|
||||||
|
) -> Result<Json<ApiResponse<ArticleResp>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.articles.manage")?;
|
||||||
|
let result = article_service::submit_article(
|
||||||
|
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
|
||||||
|
).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 审核通过并发布
|
||||||
|
pub async fn approve_article<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
mut req: Json<ReviewArticleReq>,
|
||||||
|
) -> Result<Json<ApiResponse<ArticleResp>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.articles.review")?;
|
||||||
|
req.sanitize();
|
||||||
|
let version = req.version.unwrap_or(0);
|
||||||
|
let result = article_service::approve_article(
|
||||||
|
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0, version,
|
||||||
|
).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 审核拒绝
|
||||||
|
pub async fn reject_article<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
mut req: Json<ReviewArticleReq>,
|
||||||
|
) -> Result<Json<ApiResponse<ArticleResp>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.articles.review")?;
|
||||||
|
req.sanitize();
|
||||||
|
let version = req.version.unwrap_or(0);
|
||||||
|
let result = article_service::reject_article(
|
||||||
|
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0, version,
|
||||||
|
).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 撤回发布
|
||||||
|
pub async fn unpublish_article<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
Json(req): Json<VersionReq>,
|
||||||
|
) -> Result<Json<ApiResponse<ArticleResp>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.articles.manage")?;
|
||||||
|
let result = article_service::unpublish_article(
|
||||||
|
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
|
||||||
|
).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 浏览计数
|
||||||
|
pub async fn view_article<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
article_service::increment_view_count(&state, ctx.tenant_id, id).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(())))
|
||||||
|
}
|
||||||
|
|||||||
63
crates/erp-health/src/handler/article_tag_handler.rs
Normal file
63
crates/erp-health/src/handler/article_tag_handler.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
//! 文章标签 Handler
|
||||||
|
|
||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, Json, Path, State};
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, TenantContext};
|
||||||
|
|
||||||
|
use crate::dto::article_dto::{CreateTagReq, TagResp};
|
||||||
|
use crate::service::article_tag_service;
|
||||||
|
use crate::state::HealthState;
|
||||||
|
|
||||||
|
pub async fn list_tags<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<TagResp>>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.articles.list")?;
|
||||||
|
let result = article_tag_service::list_tags(&state, ctx.tenant_id).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_tag<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
mut req: Json<CreateTagReq>,
|
||||||
|
) -> Result<Json<ApiResponse<TagResp>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.articles.manage")?;
|
||||||
|
req.sanitize();
|
||||||
|
let result = article_tag_service::create_tag(
|
||||||
|
&state, ctx.tenant_id, Some(ctx.user_id), req.0,
|
||||||
|
).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct DeleteTagReq {
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_tag<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
Json(req): Json<DeleteTagReq>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.articles.manage")?;
|
||||||
|
article_tag_service::delete_tag(
|
||||||
|
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
|
||||||
|
).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(())))
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod appointment_handler;
|
pub mod appointment_handler;
|
||||||
|
pub mod article_category_handler;
|
||||||
pub mod article_handler;
|
pub mod article_handler;
|
||||||
|
pub mod article_tag_handler;
|
||||||
pub mod consultation_handler;
|
pub mod consultation_handler;
|
||||||
pub mod consent_handler;
|
pub mod consent_handler;
|
||||||
pub mod critical_value_threshold_handler;
|
pub mod critical_value_threshold_handler;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use erp_core::events::EventBus;
|
|||||||
use erp_core::module::{ErpModule, PermissionDescriptor};
|
use erp_core::module::{ErpModule, PermissionDescriptor};
|
||||||
|
|
||||||
use crate::handler::{
|
use crate::handler::{
|
||||||
appointment_handler, article_handler, consultation_handler, consent_handler, critical_value_threshold_handler, daily_monitoring_handler, diagnosis_handler, dialysis_handler, doctor_handler, follow_up_handler,
|
appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_value_threshold_handler, daily_monitoring_handler, diagnosis_handler, dialysis_handler, doctor_handler, follow_up_handler,
|
||||||
health_data_handler, patient_handler, points_handler, stats_handler,
|
health_data_handler, patient_handler, points_handler, stats_handler,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -317,6 +317,48 @@ impl HealthModule {
|
|||||||
.put(article_handler::update_article)
|
.put(article_handler::update_article)
|
||||||
.delete(article_handler::delete_article),
|
.delete(article_handler::delete_article),
|
||||||
)
|
)
|
||||||
|
// 资讯审核工作流
|
||||||
|
.route(
|
||||||
|
"/health/articles/{id}/submit",
|
||||||
|
axum::routing::post(article_handler::submit_article),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/health/articles/{id}/approve",
|
||||||
|
axum::routing::post(article_handler::approve_article),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/health/articles/{id}/reject",
|
||||||
|
axum::routing::post(article_handler::reject_article),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/health/articles/{id}/unpublish",
|
||||||
|
axum::routing::post(article_handler::unpublish_article),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/health/articles/{id}/view",
|
||||||
|
axum::routing::post(article_handler::view_article),
|
||||||
|
)
|
||||||
|
// 资讯分类
|
||||||
|
.route(
|
||||||
|
"/health/article-categories",
|
||||||
|
axum::routing::get(article_category_handler::list_categories)
|
||||||
|
.post(article_category_handler::create_category),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/health/article-categories/{id}",
|
||||||
|
axum::routing::put(article_category_handler::update_category)
|
||||||
|
.delete(article_category_handler::delete_category),
|
||||||
|
)
|
||||||
|
// 资讯标签
|
||||||
|
.route(
|
||||||
|
"/health/article-tags",
|
||||||
|
axum::routing::get(article_tag_handler::list_tags)
|
||||||
|
.post(article_tag_handler::create_tag),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/health/article-tags/{id}",
|
||||||
|
axum::routing::delete(article_tag_handler::delete_tag),
|
||||||
|
)
|
||||||
// 积分商城 — 患者端
|
// 积分商城 — 患者端
|
||||||
.route(
|
.route(
|
||||||
"/health/points/account",
|
"/health/points/account",
|
||||||
@@ -630,6 +672,12 @@ impl ErpModule for HealthModule {
|
|||||||
description: "创建、编辑、删除健康资讯文章".into(),
|
description: "创建、编辑、删除健康资讯文章".into(),
|
||||||
module: "health".into(),
|
module: "health".into(),
|
||||||
},
|
},
|
||||||
|
PermissionDescriptor {
|
||||||
|
code: "health.articles.review".into(),
|
||||||
|
name: "审核资讯".into(),
|
||||||
|
description: "审核通过或拒绝资讯文章发布".into(),
|
||||||
|
module: "health".into(),
|
||||||
|
},
|
||||||
PermissionDescriptor {
|
PermissionDescriptor {
|
||||||
code: "health.points.list".into(),
|
code: "health.points.list".into(),
|
||||||
name: "查看积分".into(),
|
name: "查看积分".into(),
|
||||||
|
|||||||
148
crates/erp-health/src/service/article_category_service.rs
Normal file
148
crates/erp-health/src/service/article_category_service.rs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
//! 文章分类 Service — CRUD
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use sea_orm::{ActiveValue::Set, QueryOrder};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use erp_core::audit::AuditLog;
|
||||||
|
use erp_core::audit_service;
|
||||||
|
use erp_core::error::check_version;
|
||||||
|
|
||||||
|
use crate::dto::article_dto::{CategoryResp, CreateCategoryReq, UpdateCategoryReq};
|
||||||
|
use crate::entity::article_category;
|
||||||
|
use crate::error::{HealthError, HealthResult};
|
||||||
|
use crate::state::HealthState;
|
||||||
|
|
||||||
|
pub async fn list_categories(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
) -> HealthResult<Vec<CategoryResp>> {
|
||||||
|
let models = article_category::Entity::find()
|
||||||
|
.filter(article_category::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(article_category::Column::DeletedAt.is_null())
|
||||||
|
.order_by_asc(article_category::Column::SortOrder)
|
||||||
|
.order_by_asc(article_category::Column::Name)
|
||||||
|
.all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(models.into_iter().map(|m| CategoryResp {
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
slug: m.slug,
|
||||||
|
parent_id: m.parent_id,
|
||||||
|
description: m.description,
|
||||||
|
sort_order: m.sort_order,
|
||||||
|
created_at: m.created_at,
|
||||||
|
updated_at: m.updated_at,
|
||||||
|
version: m.version,
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_category(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Option<Uuid>,
|
||||||
|
req: CreateCategoryReq,
|
||||||
|
) -> HealthResult<CategoryResp> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let active = article_category::ActiveModel {
|
||||||
|
id: Set(Uuid::now_v7()),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
name: Set(req.name),
|
||||||
|
slug: Set(req.slug),
|
||||||
|
parent_id: Set(req.parent_id),
|
||||||
|
description: Set(req.description),
|
||||||
|
sort_order: Set(req.sort_order.unwrap_or(0)),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
let m = active.insert(&state.db).await?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, operator_id, "article_category.created", "article_category")
|
||||||
|
.with_resource_id(m.id),
|
||||||
|
&state.db,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
Ok(CategoryResp {
|
||||||
|
id: m.id, name: m.name, slug: m.slug, parent_id: m.parent_id,
|
||||||
|
description: m.description, sort_order: m.sort_order,
|
||||||
|
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_category(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
operator_id: Option<Uuid>,
|
||||||
|
req: UpdateCategoryReq,
|
||||||
|
) -> HealthResult<CategoryResp> {
|
||||||
|
let model = article_category::Entity::find()
|
||||||
|
.filter(article_category::Column::Id.eq(id))
|
||||||
|
.filter(article_category::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(article_category::Column::DeletedAt.is_null())
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(HealthError::ArticleNotFound)?;
|
||||||
|
|
||||||
|
let next_ver = check_version(req.version, model.version)
|
||||||
|
.map_err(|_| HealthError::VersionMismatch)?;
|
||||||
|
|
||||||
|
let mut active: article_category::ActiveModel = model.into();
|
||||||
|
if let Some(v) = req.name { active.name = Set(v); }
|
||||||
|
if let Some(v) = req.slug { active.slug = Set(Some(v)); }
|
||||||
|
if let Some(v) = req.parent_id { active.parent_id = Set(Some(v)); }
|
||||||
|
if let Some(v) = req.description { active.description = Set(Some(v)); }
|
||||||
|
if let Some(v) = req.sort_order { active.sort_order = Set(v); }
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
|
||||||
|
let m = active.update(&state.db).await?;
|
||||||
|
|
||||||
|
Ok(CategoryResp {
|
||||||
|
id: m.id, name: m.name, slug: m.slug, parent_id: m.parent_id,
|
||||||
|
description: m.description, sort_order: m.sort_order,
|
||||||
|
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_category(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
operator_id: Option<Uuid>,
|
||||||
|
expected_version: i32,
|
||||||
|
) -> HealthResult<()> {
|
||||||
|
let model = article_category::Entity::find()
|
||||||
|
.filter(article_category::Column::Id.eq(id))
|
||||||
|
.filter(article_category::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(article_category::Column::DeletedAt.is_null())
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(HealthError::ArticleNotFound)?;
|
||||||
|
|
||||||
|
let next_ver = check_version(expected_version, model.version)
|
||||||
|
.map_err(|_| HealthError::VersionMismatch)?;
|
||||||
|
|
||||||
|
let mut active: article_category::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
active.update(&state.db).await?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, operator_id, "article_category.deleted", "article_category")
|
||||||
|
.with_resource_id(id),
|
||||||
|
&state.db,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//! 健康资讯 Service — 文章 CRUD
|
//! 健康资讯 Service — 文章 CRUD + 审核工作流
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
@@ -10,53 +10,93 @@ use erp_core::audit_service;
|
|||||||
use erp_core::error::check_version;
|
use erp_core::error::check_version;
|
||||||
use erp_core::types::PaginatedResponse;
|
use erp_core::types::PaginatedResponse;
|
||||||
|
|
||||||
use crate::dto::article_dto::{ArticleListItem, ArticleResp, CreateArticleReq, UpdateArticleReq};
|
use crate::dto::article_dto::{ArticleListItem, ArticleResp, CreateArticleReq, ReviewArticleReq, UpdateArticleReq};
|
||||||
use crate::entity::article;
|
use crate::entity::article;
|
||||||
|
use crate::entity::article_article_tag;
|
||||||
|
use crate::entity::article_tag;
|
||||||
use crate::error::{HealthError, HealthResult};
|
use crate::error::{HealthError, HealthResult};
|
||||||
use crate::state::HealthState;
|
use crate::state::HealthState;
|
||||||
|
|
||||||
/// 文章列表(分页 + 分类筛选)
|
/// 文章列表(管理端,支持状态/分类/标签/关键词筛选)
|
||||||
pub async fn list_articles(
|
pub async fn list_articles(
|
||||||
state: &HealthState,
|
state: &HealthState,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
page: u64,
|
page: u64,
|
||||||
page_size: u64,
|
page_size: u64,
|
||||||
category: Option<String>,
|
category: Option<String>,
|
||||||
|
status: Option<String>,
|
||||||
|
category_id: Option<Uuid>,
|
||||||
|
tag_id: Option<Uuid>,
|
||||||
|
keyword: Option<String>,
|
||||||
) -> HealthResult<PaginatedResponse<ArticleListItem>> {
|
) -> HealthResult<PaginatedResponse<ArticleListItem>> {
|
||||||
let limit = page_size.min(100);
|
let limit = page_size.min(100);
|
||||||
let offset = page.saturating_sub(1) * limit;
|
let offset = page.saturating_sub(1) * limit;
|
||||||
|
|
||||||
let mut query = article::Entity::find()
|
let mut query = article::Entity::find()
|
||||||
.filter(article::Column::TenantId.eq(tenant_id))
|
.filter(article::Column::TenantId.eq(tenant_id))
|
||||||
.filter(article::Column::DeletedAt.is_null())
|
.filter(article::Column::DeletedAt.is_null());
|
||||||
.filter(article::Column::PublishedAt.is_not_null());
|
|
||||||
|
|
||||||
if let Some(ref cat) = category {
|
if let Some(ref cat) = category {
|
||||||
query = query.filter(article::Column::Category.eq(cat));
|
query = query.filter(article::Column::Category.eq(cat));
|
||||||
}
|
}
|
||||||
|
if let Some(ref s) = status {
|
||||||
|
query = query.filter(article::Column::Status.eq(s));
|
||||||
|
}
|
||||||
|
if let Some(cid) = category_id {
|
||||||
|
query = query.filter(article::Column::CategoryId.eq(cid));
|
||||||
|
}
|
||||||
|
if let Some(ref kw) = keyword {
|
||||||
|
query = query.filter(article::Column::Title.contains(kw));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按标签筛选需要子查询
|
||||||
|
if let Some(tid) = tag_id {
|
||||||
|
let article_ids: Vec<Uuid> = article_article_tag::Entity::find()
|
||||||
|
.filter(article_article_tag::Column::TagId.eq(tid))
|
||||||
|
.all(&state.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.article_id)
|
||||||
|
.collect();
|
||||||
|
if article_ids.is_empty() {
|
||||||
|
return Ok(PaginatedResponse { data: vec![], total: 0, page, page_size: limit, total_pages: 0 });
|
||||||
|
}
|
||||||
|
query = query.filter(article::Column::Id.is_in(article_ids));
|
||||||
|
}
|
||||||
|
|
||||||
let total = query.clone().count(&state.db).await?;
|
let total = query.clone().count(&state.db).await?;
|
||||||
|
|
||||||
let models = query
|
let models = query
|
||||||
.order_by_desc(article::Column::PublishedAt)
|
.order_by_desc(article::Column::SortOrder)
|
||||||
|
.order_by_desc(article::Column::CreatedAt)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.all(&state.db)
|
.all(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let total_pages = total.div_ceil(limit.max(1));
|
let total_pages = total.div_ceil(limit.max(1));
|
||||||
let data = models.into_iter().map(model_to_list_item).collect();
|
let mut data = Vec::with_capacity(models.len());
|
||||||
|
for m in models {
|
||||||
|
let tags = load_article_tags(state, m.id).await?;
|
||||||
|
data.push(ArticleListItem {
|
||||||
|
id: m.id,
|
||||||
|
title: m.title,
|
||||||
|
summary: m.summary,
|
||||||
|
cover_image: m.cover_image,
|
||||||
|
category: m.category,
|
||||||
|
author: m.author,
|
||||||
|
published_at: m.published_at,
|
||||||
|
status: m.status,
|
||||||
|
view_count: m.view_count,
|
||||||
|
category_id: m.category_id,
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Ok(PaginatedResponse {
|
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
|
||||||
data,
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
page_size: limit,
|
|
||||||
total_pages,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取文章详情
|
/// 获取文章详情(管理端,不过滤发布状态)
|
||||||
pub async fn get_article(
|
pub async fn get_article(
|
||||||
state: &HealthState,
|
state: &HealthState,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
@@ -66,58 +106,174 @@ pub async fn get_article(
|
|||||||
.filter(article::Column::Id.eq(id))
|
.filter(article::Column::Id.eq(id))
|
||||||
.filter(article::Column::TenantId.eq(tenant_id))
|
.filter(article::Column::TenantId.eq(tenant_id))
|
||||||
.filter(article::Column::DeletedAt.is_null())
|
.filter(article::Column::DeletedAt.is_null())
|
||||||
.filter(article::Column::PublishedAt.is_not_null())
|
|
||||||
.one(&state.db)
|
.one(&state.db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(HealthError::ArticleNotFound)?;
|
.ok_or(HealthError::ArticleNotFound)?;
|
||||||
|
|
||||||
Ok(model_to_resp(model))
|
let tags = load_article_tags(state, model.id).await?;
|
||||||
|
Ok(full_model_to_resp(model, tags))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 内部辅助
|
// 审核工作流
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn model_to_list_item(m: article::Model) -> ArticleListItem {
|
/// 提交审核: draft/rejected → pending_review
|
||||||
ArticleListItem {
|
pub async fn submit_article(
|
||||||
id: m.id,
|
state: &HealthState,
|
||||||
title: m.title,
|
tenant_id: Uuid,
|
||||||
summary: m.summary,
|
id: Uuid,
|
||||||
cover_image: m.cover_image,
|
operator_id: Option<Uuid>,
|
||||||
category: m.category,
|
expected_version: i32,
|
||||||
author: m.author,
|
) -> HealthResult<ArticleResp> {
|
||||||
published_at: m.published_at,
|
let model = find_article(state, tenant_id, id).await?;
|
||||||
status: m.status,
|
let next_ver = check_version(expected_version, model.version)
|
||||||
view_count: m.view_count,
|
.map_err(|_| HealthError::VersionMismatch)?;
|
||||||
category_id: None,
|
|
||||||
tags: vec![],
|
if model.status != "draft" && model.status != "rejected" {
|
||||||
|
return Err(HealthError::InvalidStatusTransition(format!("无法从 {} 提交审核", model.status)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut active: article::ActiveModel = model.into();
|
||||||
|
active.status = Set("pending_review".into());
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
let m = active.update(&state.db).await?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, operator_id, "article.submitted", "article")
|
||||||
|
.with_resource_id(m.id),
|
||||||
|
&state.db,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
let tags = load_article_tags(state, m.id).await?;
|
||||||
|
Ok(full_model_to_resp(m, tags))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn model_to_resp(m: article::Model) -> ArticleResp {
|
/// 审核通过并发布: pending_review → published
|
||||||
ArticleResp {
|
pub async fn approve_article(
|
||||||
id: m.id,
|
state: &HealthState,
|
||||||
title: m.title,
|
tenant_id: Uuid,
|
||||||
summary: m.summary,
|
id: Uuid,
|
||||||
content: Some(m.content),
|
operator_id: Option<Uuid>,
|
||||||
cover_image: m.cover_image,
|
req: ReviewArticleReq,
|
||||||
category: m.category,
|
expected_version: i32,
|
||||||
author: m.author,
|
) -> HealthResult<ArticleResp> {
|
||||||
published_at: m.published_at,
|
let model = find_article(state, tenant_id, id).await?;
|
||||||
status: m.status,
|
let next_ver = check_version(expected_version, model.version)
|
||||||
slug: m.slug,
|
.map_err(|_| HealthError::VersionMismatch)?;
|
||||||
content_type: m.content_type,
|
|
||||||
reviewed_by: m.reviewed_by,
|
if model.status != "pending_review" {
|
||||||
reviewed_at: m.reviewed_at,
|
return Err(HealthError::InvalidStatusTransition(format!("无法从 {} 审核通过", model.status)));
|
||||||
review_note: m.review_note,
|
|
||||||
view_count: m.view_count,
|
|
||||||
sort_order: m.sort_order,
|
|
||||||
category_id: None,
|
|
||||||
tags: vec![],
|
|
||||||
created_at: m.created_at,
|
|
||||||
updated_at: m.updated_at,
|
|
||||||
version: m.version,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut active: article::ActiveModel = model.into();
|
||||||
|
active.status = Set("published".into());
|
||||||
|
active.published_at = Set(Some(now));
|
||||||
|
active.reviewed_by = Set(operator_id);
|
||||||
|
active.reviewed_at = Set(Some(now));
|
||||||
|
active.review_note = Set(req.note);
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
let m = active.update(&state.db).await?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, operator_id, "article.published", "article")
|
||||||
|
.with_resource_id(m.id),
|
||||||
|
&state.db,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
let tags = load_article_tags(state, m.id).await?;
|
||||||
|
Ok(full_model_to_resp(m, tags))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 审核拒绝: pending_review → rejected
|
||||||
|
pub async fn reject_article(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
operator_id: Option<Uuid>,
|
||||||
|
req: ReviewArticleReq,
|
||||||
|
expected_version: i32,
|
||||||
|
) -> HealthResult<ArticleResp> {
|
||||||
|
let model = find_article(state, tenant_id, id).await?;
|
||||||
|
let next_ver = check_version(expected_version, model.version)
|
||||||
|
.map_err(|_| HealthError::VersionMismatch)?;
|
||||||
|
|
||||||
|
if model.status != "pending_review" {
|
||||||
|
return Err(HealthError::InvalidStatusTransition(format!("无法从 {} 审核拒绝", model.status)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut active: article::ActiveModel = model.into();
|
||||||
|
active.status = Set("rejected".into());
|
||||||
|
active.reviewed_by = Set(operator_id);
|
||||||
|
active.reviewed_at = Set(Some(now));
|
||||||
|
active.review_note = Set(req.note);
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
let m = active.update(&state.db).await?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, operator_id, "article.rejected", "article")
|
||||||
|
.with_resource_id(m.id),
|
||||||
|
&state.db,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
let tags = load_article_tags(state, m.id).await?;
|
||||||
|
Ok(full_model_to_resp(m, tags))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 撤回发布: published → draft
|
||||||
|
pub async fn unpublish_article(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
operator_id: Option<Uuid>,
|
||||||
|
expected_version: i32,
|
||||||
|
) -> HealthResult<ArticleResp> {
|
||||||
|
let model = find_article(state, tenant_id, id).await?;
|
||||||
|
let next_ver = check_version(expected_version, model.version)
|
||||||
|
.map_err(|_| HealthError::VersionMismatch)?;
|
||||||
|
|
||||||
|
if model.status != "published" {
|
||||||
|
return Err(HealthError::InvalidStatusTransition(format!("无法从 {} 撤回发布", model.status)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut active: article::ActiveModel = model.into();
|
||||||
|
active.status = Set("draft".into());
|
||||||
|
active.published_at = Set(None);
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
let m = active.update(&state.db).await?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, operator_id, "article.unpublished", "article")
|
||||||
|
.with_resource_id(m.id),
|
||||||
|
&state.db,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
let tags = load_article_tags(state, m.id).await?;
|
||||||
|
Ok(full_model_to_resp(m, tags))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 增加浏览计数
|
||||||
|
pub async fn increment_view_count(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
) -> HealthResult<()> {
|
||||||
|
let model = find_article(state, tenant_id, id).await?;
|
||||||
|
let mut active: article::ActiveModel = model.into();
|
||||||
|
active.view_count = Set(active.view_count.unwrap() + 1);
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.update(&state.db).await?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -140,6 +296,7 @@ pub async fn create_article(
|
|||||||
content: Set(req.content.unwrap_or_default()),
|
content: Set(req.content.unwrap_or_default()),
|
||||||
cover_image: Set(req.cover_image),
|
cover_image: Set(req.cover_image),
|
||||||
category: Set(req.category),
|
category: Set(req.category),
|
||||||
|
category_id: Set(req.category_id),
|
||||||
author: Set(req.author),
|
author: Set(req.author),
|
||||||
published_at: Set(req.published_at),
|
published_at: Set(req.published_at),
|
||||||
status: Set("draft".into()),
|
status: Set("draft".into()),
|
||||||
@@ -159,13 +316,17 @@ pub async fn create_article(
|
|||||||
};
|
};
|
||||||
let m = active.insert(&state.db).await?;
|
let m = active.insert(&state.db).await?;
|
||||||
|
|
||||||
|
// 保存标签关联
|
||||||
|
save_article_tags(state, m.id, &req.tag_ids).await?;
|
||||||
|
|
||||||
audit_service::record(
|
audit_service::record(
|
||||||
AuditLog::new(tenant_id, operator_id, "article.created", "article")
|
AuditLog::new(tenant_id, operator_id, "article.created", "article")
|
||||||
.with_resource_id(m.id),
|
.with_resource_id(m.id),
|
||||||
&state.db,
|
&state.db,
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
Ok(model_to_resp(m))
|
let tags = load_article_tags(state, m.id).await?;
|
||||||
|
Ok(full_model_to_resp(m, tags))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_article(
|
pub async fn update_article(
|
||||||
@@ -175,38 +336,45 @@ pub async fn update_article(
|
|||||||
operator_id: Option<Uuid>,
|
operator_id: Option<Uuid>,
|
||||||
req: UpdateArticleReq,
|
req: UpdateArticleReq,
|
||||||
) -> HealthResult<ArticleResp> {
|
) -> HealthResult<ArticleResp> {
|
||||||
let model = article::Entity::find()
|
let model = find_article(state, tenant_id, id).await?;
|
||||||
.filter(article::Column::Id.eq(id))
|
|
||||||
.filter(article::Column::TenantId.eq(tenant_id))
|
|
||||||
.filter(article::Column::DeletedAt.is_null())
|
|
||||||
.one(&state.db)
|
|
||||||
.await?
|
|
||||||
.ok_or(HealthError::ArticleNotFound)?;
|
|
||||||
|
|
||||||
let next_ver = check_version(req.version, model.version)
|
let next_ver = check_version(req.version, model.version)
|
||||||
.map_err(|_| HealthError::VersionMismatch)?;
|
.map_err(|_| HealthError::VersionMismatch)?;
|
||||||
|
|
||||||
|
// 保存版本历史
|
||||||
|
save_revision(state, tenant_id, &model, operator_id).await?;
|
||||||
|
|
||||||
let mut active: article::ActiveModel = model.into();
|
let mut active: article::ActiveModel = model.into();
|
||||||
if let Some(v) = req.title { active.title = Set(v); }
|
if let Some(v) = req.title { active.title = Set(v); }
|
||||||
if let Some(v) = req.summary { active.summary = Set(Some(v)); }
|
if let Some(v) = req.summary { active.summary = Set(Some(v)); }
|
||||||
if let Some(v) = req.content { active.content = Set(v); }
|
if let Some(v) = req.content { active.content = Set(v); }
|
||||||
if let Some(v) = req.cover_image { active.cover_image = Set(Some(v)); }
|
if let Some(v) = req.cover_image { active.cover_image = Set(Some(v)); }
|
||||||
if let Some(v) = req.category { active.category = Set(Some(v)); }
|
if let Some(v) = req.category { active.category = Set(Some(v)); }
|
||||||
|
if let Some(v) = req.category_id { active.category_id = Set(Some(v)); }
|
||||||
if let Some(v) = req.author { active.author = Set(Some(v)); }
|
if let Some(v) = req.author { active.author = Set(Some(v)); }
|
||||||
if let Some(v) = req.published_at { active.published_at = Set(Some(v)); }
|
if let Some(v) = req.published_at { active.published_at = Set(Some(v)); }
|
||||||
|
if let Some(v) = req.slug { active.slug = Set(Some(v)); }
|
||||||
|
if let Some(v) = req.content_type { active.content_type = Set(v); }
|
||||||
|
if let Some(v) = req.sort_order { active.sort_order = Set(v); }
|
||||||
active.updated_at = Set(Utc::now());
|
active.updated_at = Set(Utc::now());
|
||||||
active.updated_by = Set(operator_id);
|
active.updated_by = Set(operator_id);
|
||||||
active.version = Set(next_ver);
|
active.version = Set(next_ver);
|
||||||
|
|
||||||
let m = active.update(&state.db).await?;
|
let m = active.update(&state.db).await?;
|
||||||
|
|
||||||
|
// 替换标签关联
|
||||||
|
if let Some(tag_ids) = req.tag_ids {
|
||||||
|
replace_article_tags(state, m.id, &tag_ids).await?;
|
||||||
|
}
|
||||||
|
|
||||||
audit_service::record(
|
audit_service::record(
|
||||||
AuditLog::new(tenant_id, operator_id, "article.updated", "article")
|
AuditLog::new(tenant_id, operator_id, "article.updated", "article")
|
||||||
.with_resource_id(m.id),
|
.with_resource_id(m.id),
|
||||||
&state.db,
|
&state.db,
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
Ok(model_to_resp(m))
|
let tags = load_article_tags(state, m.id).await?;
|
||||||
|
Ok(full_model_to_resp(m, tags))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_article(
|
pub async fn delete_article(
|
||||||
@@ -216,13 +384,7 @@ pub async fn delete_article(
|
|||||||
operator_id: Option<Uuid>,
|
operator_id: Option<Uuid>,
|
||||||
expected_version: i32,
|
expected_version: i32,
|
||||||
) -> HealthResult<()> {
|
) -> HealthResult<()> {
|
||||||
let model = article::Entity::find()
|
let model = find_article(state, tenant_id, id).await?;
|
||||||
.filter(article::Column::Id.eq(id))
|
|
||||||
.filter(article::Column::TenantId.eq(tenant_id))
|
|
||||||
.filter(article::Column::DeletedAt.is_null())
|
|
||||||
.one(&state.db)
|
|
||||||
.await?
|
|
||||||
.ok_or(HealthError::ArticleNotFound)?;
|
|
||||||
|
|
||||||
let next_ver = check_version(expected_version, model.version)
|
let next_ver = check_version(expected_version, model.version)
|
||||||
.map_err(|_| HealthError::VersionMismatch)?;
|
.map_err(|_| HealthError::VersionMismatch)?;
|
||||||
@@ -242,3 +404,115 @@ pub async fn delete_article(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 内部辅助
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn find_article(state: &HealthState, tenant_id: Uuid, id: Uuid) -> HealthResult<article::Model> {
|
||||||
|
article::Entity::find()
|
||||||
|
.filter(article::Column::Id.eq(id))
|
||||||
|
.filter(article::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(article::Column::DeletedAt.is_null())
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(HealthError::ArticleNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn full_model_to_resp(m: article::Model, tags: Vec<String>) -> ArticleResp {
|
||||||
|
ArticleResp {
|
||||||
|
id: m.id,
|
||||||
|
title: m.title,
|
||||||
|
summary: m.summary,
|
||||||
|
content: Some(m.content),
|
||||||
|
cover_image: m.cover_image,
|
||||||
|
category: m.category,
|
||||||
|
author: m.author,
|
||||||
|
published_at: m.published_at,
|
||||||
|
status: m.status,
|
||||||
|
slug: m.slug,
|
||||||
|
content_type: m.content_type,
|
||||||
|
reviewed_by: m.reviewed_by,
|
||||||
|
reviewed_at: m.reviewed_at,
|
||||||
|
review_note: m.review_note,
|
||||||
|
view_count: m.view_count,
|
||||||
|
sort_order: m.sort_order,
|
||||||
|
category_id: m.category_id,
|
||||||
|
tags,
|
||||||
|
created_at: m.created_at,
|
||||||
|
updated_at: m.updated_at,
|
||||||
|
version: m.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_article_tags(state: &HealthState, article_id: Uuid) -> HealthResult<Vec<String>> {
|
||||||
|
let relations = article_article_tag::Entity::find()
|
||||||
|
.filter(article_article_tag::Column::ArticleId.eq(article_id))
|
||||||
|
.all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let tag_ids: Vec<Uuid> = relations.into_iter().map(|r| r.tag_id).collect();
|
||||||
|
if tag_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tags = article_tag::Entity::find()
|
||||||
|
.filter(article_tag::Column::Id.is_in(tag_ids))
|
||||||
|
.filter(article_tag::Column::DeletedAt.is_null())
|
||||||
|
.all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(tags.into_iter().map(|t| t.name).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_article_tags(state: &HealthState, article_id: Uuid, tag_ids: &[Uuid]) -> HealthResult<()> {
|
||||||
|
for tid in tag_ids {
|
||||||
|
let active = article_article_tag::ActiveModel {
|
||||||
|
article_id: Set(article_id),
|
||||||
|
tag_id: Set(*tid),
|
||||||
|
};
|
||||||
|
active.insert(&state.db).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn replace_article_tags(state: &HealthState, article_id: Uuid, tag_ids: &[Uuid]) -> HealthResult<()> {
|
||||||
|
article_article_tag::Entity::delete_many()
|
||||||
|
.filter(article_article_tag::Column::ArticleId.eq(article_id))
|
||||||
|
.exec(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
save_article_tags(state, article_id, tag_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_revision(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
model: &article::Model,
|
||||||
|
operator_id: Option<Uuid>,
|
||||||
|
) -> HealthResult<()> {
|
||||||
|
use crate::entity::article_revision;
|
||||||
|
|
||||||
|
// 获取当前最大版本号
|
||||||
|
let max_rev = article_revision::Entity::find()
|
||||||
|
.filter(article_revision::Column::ArticleId.eq(model.id))
|
||||||
|
.order_by_desc(article_revision::Column::RevisionNumber)
|
||||||
|
.one(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let next_rev = max_rev.map(|r| r.revision_number + 1).unwrap_or(1);
|
||||||
|
|
||||||
|
let active = article_revision::ActiveModel {
|
||||||
|
id: Set(Uuid::now_v7()),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
article_id: Set(model.id),
|
||||||
|
revision_number: Set(next_rev),
|
||||||
|
title: Set(model.title.clone()),
|
||||||
|
content: Set(model.content.clone()),
|
||||||
|
summary: Set(model.summary.clone()),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
created_at: Set(Utc::now()),
|
||||||
|
};
|
||||||
|
active.insert(&state.db).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
108
crates/erp-health/src/service/article_tag_service.rs
Normal file
108
crates/erp-health/src/service/article_tag_service.rs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
//! 文章标签 Service — CRUD
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use sea_orm::{ActiveValue::Set, QueryOrder};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use erp_core::audit::AuditLog;
|
||||||
|
use erp_core::audit_service;
|
||||||
|
use erp_core::error::check_version;
|
||||||
|
|
||||||
|
use crate::dto::article_dto::{CreateTagReq, TagResp};
|
||||||
|
use crate::entity::article_tag;
|
||||||
|
use crate::error::{HealthError, HealthResult};
|
||||||
|
use crate::state::HealthState;
|
||||||
|
|
||||||
|
pub async fn list_tags(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
) -> HealthResult<Vec<TagResp>> {
|
||||||
|
let models = article_tag::Entity::find()
|
||||||
|
.filter(article_tag::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(article_tag::Column::DeletedAt.is_null())
|
||||||
|
.order_by_asc(article_tag::Column::Name)
|
||||||
|
.all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(models.into_iter().map(|m| TagResp {
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
created_at: m.created_at,
|
||||||
|
updated_at: m.updated_at,
|
||||||
|
version: m.version,
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_tag(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Option<Uuid>,
|
||||||
|
req: CreateTagReq,
|
||||||
|
) -> HealthResult<TagResp> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let active = article_tag::ActiveModel {
|
||||||
|
id: Set(Uuid::now_v7()),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
name: Set(req.name),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
let m = active.insert(&state.db).await?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, operator_id, "article_tag.created", "article_tag")
|
||||||
|
.with_resource_id(m.id),
|
||||||
|
&state.db,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
Ok(TagResp {
|
||||||
|
id: m.id, name: m.name,
|
||||||
|
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_tag(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
operator_id: Option<Uuid>,
|
||||||
|
expected_version: i32,
|
||||||
|
) -> HealthResult<()> {
|
||||||
|
let model = article_tag::Entity::find()
|
||||||
|
.filter(article_tag::Column::Id.eq(id))
|
||||||
|
.filter(article_tag::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(article_tag::Column::DeletedAt.is_null())
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(HealthError::ArticleNotFound)?;
|
||||||
|
|
||||||
|
let next_ver = check_version(expected_version, model.version)
|
||||||
|
.map_err(|_| HealthError::VersionMismatch)?;
|
||||||
|
|
||||||
|
let mut active: article_tag::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
active.update(&state.db).await?;
|
||||||
|
|
||||||
|
// 清理关联
|
||||||
|
use crate::entity::article_article_tag;
|
||||||
|
article_article_tag::Entity::delete_many()
|
||||||
|
.filter(article_article_tag::Column::TagId.eq(id))
|
||||||
|
.exec(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, operator_id, "article_tag.deleted", "article_tag")
|
||||||
|
.with_resource_id(id),
|
||||||
|
&state.db,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod appointment_service;
|
pub mod appointment_service;
|
||||||
|
pub mod article_category_service;
|
||||||
pub mod article_service;
|
pub mod article_service;
|
||||||
|
pub mod article_tag_service;
|
||||||
pub mod consultation_service;
|
pub mod consultation_service;
|
||||||
pub mod consent_service;
|
pub mod consent_service;
|
||||||
pub mod critical_value_threshold_service;
|
pub mod critical_value_threshold_service;
|
||||||
|
|||||||
39
docs/discussions/2026-04-26-content-management-brainstorm.md
Normal file
39
docs/discussions/2026-04-26-content-management-brainstorm.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 内容管理模块设计讨论
|
||||||
|
|
||||||
|
> 日期: 2026-04-26 | 参与者: 用户 + AI
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
HMS 项目已有文章(article)基础 CRUD 实现(后端 API + 小程序列表/详情页),但缺少 Web 管理后台、富文本编辑、审核流程、分类管理等核心内容管理能力。用户希望围绕内容管理功能展开发散式讨论,确定设计方向。
|
||||||
|
|
||||||
|
## 讨论要点
|
||||||
|
|
||||||
|
### 需求确认
|
||||||
|
|
||||||
|
- **定位**:综合内容平台 — 健康科普 + 医院公告 + 活动通知 + 科室介绍等
|
||||||
|
- **编辑体验**:富文本可视化编辑(非 Markdown)
|
||||||
|
- **发布流程**:需要审核流程(作者提交 → 审核员审批 → 发布)
|
||||||
|
- **角色**:内容作者、审核员、患者读者(不需要独立的内容管理员角色)
|
||||||
|
- **媒体**:先做图片上传,视频/附件后续再加
|
||||||
|
|
||||||
|
### 方案选择
|
||||||
|
|
||||||
|
讨论了两个方案:
|
||||||
|
|
||||||
|
1. **方案 A:在 erp-health 内扩展** — 改动最小,复用现有 CRUD + 权限体系
|
||||||
|
2. **方案 B:拆分为独立 erp-content crate** — 关注点分离但工作量大
|
||||||
|
|
||||||
|
最终选择:**方案 A + 预留拆分接口**。内容管理代码放在 `content/` 子目录,通过事件总线与 health 核心通信,未来可拆分。
|
||||||
|
|
||||||
|
### 设计决策
|
||||||
|
|
||||||
|
- 数据模型:articles 表 ALTER 增加 status/slug/reviewed_by 等字段 + 新增 4 张表(category/tag/relation/revision)
|
||||||
|
- 审核状态机:draft → pending_review → published,支持 reject 和 unpublish
|
||||||
|
- 富文本编辑器:Wangeditor v5(MIT、轻量、中文优先)
|
||||||
|
- 图片存储:本地文件系统,后续可迁移至 OSS
|
||||||
|
- 新增权限码:`health.articles.review`
|
||||||
|
- 新增事件:article.submitted/approved/rejected/published
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
设计规格已写入 `docs/superpowers/specs/2026-04-26-content-management-design.md`,估算工作量 7-10 天(后端 3-4 天 + 前端 3-4 天 + 小程序 1-2 天)。
|
||||||
328
docs/superpowers/specs/2026-04-26-content-management-design.md
Normal file
328
docs/superpowers/specs/2026-04-26-content-management-design.md
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
# HMS 内容管理模块设计规格
|
||||||
|
|
||||||
|
> 日期: 2026-04-26 | 状态: Draft | 依赖: erp-health (现有 article 基础)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
### 1.1 目标
|
||||||
|
|
||||||
|
将现有 article 基础 CRUD 扩展为综合内容管理平台,支持:
|
||||||
|
|
||||||
|
- 健康科普、医院公告、活动通知、科室介绍等多种内容类型
|
||||||
|
- 富文本可视化编辑(Wangeditor)
|
||||||
|
- 作者 → 审核员 → 发布的审核工作流
|
||||||
|
- 受控分类 + 多对多标签
|
||||||
|
- 图片上传
|
||||||
|
- 阅读统计
|
||||||
|
|
||||||
|
### 1.2 方案选择
|
||||||
|
|
||||||
|
**在 erp-health 内扩展 + 预留拆分接口。**
|
||||||
|
|
||||||
|
理由:内容管理与健康管理强关联(科普、体检解读),现有后端 CRUD 完整只需增强。所有 article 相关代码放在 `crates/erp-health/src/content/` 子目录,通过事件总线与 health 核心通信,未来可拆分为独立 crate。
|
||||||
|
|
||||||
|
### 1.3 角色定义
|
||||||
|
|
||||||
|
| 角色 | 职责 | 权限 |
|
||||||
|
|------|------|------|
|
||||||
|
| 内容作者 | 创建/编辑/提交文章 | `health.articles.manage` |
|
||||||
|
| 审核员 | 审核/批准/拒绝文章 | `health.articles.review` |
|
||||||
|
| 患者读者 | 小程序阅读已发布内容 | 无需权限(公开端点) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 数据模型
|
||||||
|
|
||||||
|
### 2.1 articles 表改造
|
||||||
|
|
||||||
|
新增字段(在现有表基础上 ALTER):
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `status` | String(20) | `draft` / `pending_review` / `approved` / `rejected` / `published`,默认 `draft` |
|
||||||
|
| `slug` | String(可选) | URL 友好标识 |
|
||||||
|
| `content_type` | String(20) | `rich_text`(默认) / `markdown`,预留扩展 |
|
||||||
|
| `reviewed_by` | UUID(可选) | 审核人 ID |
|
||||||
|
| `reviewed_at` | DateTime(可选) | 审核时间 |
|
||||||
|
| `review_note` | Text(可选) | 审核备注/拒绝原因 |
|
||||||
|
| `view_count` | i32 | 阅读次数,默认 0 |
|
||||||
|
| `sort_order` | i32 | 置顶/排序权重,默认 0 |
|
||||||
|
|
||||||
|
**发布语义变更:** 原来通过 `published_at IS NOT NULL` 判断发布状态,改为通过 `status = 'published'` 控制。`published_at` 保留作为实际发布时间。
|
||||||
|
|
||||||
|
**现有字段保留:** id, tenant_id, title, summary, content, cover_image, category(过渡期保留,迁移完成后废弃), author, published_at, 标准字段。
|
||||||
|
|
||||||
|
### 2.2 新增 article_category 表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE article_category (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
slug VARCHAR(100),
|
||||||
|
parent_id UUID REFERENCES article_category(id),
|
||||||
|
description TEXT,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_by UUID,
|
||||||
|
updated_by UUID,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
version INT NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 新增 article_tag 表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE article_tag (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_by UUID,
|
||||||
|
updated_by UUID,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
version INT NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 新增 article_article_tag 关联表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE article_article_tag (
|
||||||
|
article_id UUID NOT NULL REFERENCES articles(id),
|
||||||
|
tag_id UUID NOT NULL REFERENCES article_tag(id),
|
||||||
|
PRIMARY KEY (article_id, tag_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 新增 article_revision 表(预留版本历史)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE article_revision (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
article_id UUID NOT NULL REFERENCES articles(id),
|
||||||
|
revision_number INT NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
created_by UUID,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 审核工作流
|
||||||
|
|
||||||
|
### 3.1 状态机
|
||||||
|
|
||||||
|
```
|
||||||
|
draft ──submit()──→ pending_review ──approve()──→ published
|
||||||
|
↑ │
|
||||||
|
│ reject()
|
||||||
|
│ ↓
|
||||||
|
└─────rejected ←──────┘
|
||||||
|
(修改后重新 submit → pending_review)
|
||||||
|
|
||||||
|
published ──unpublish()──→ draft
|
||||||
|
```
|
||||||
|
|
||||||
|
| 状态 | 可见性 | 允许操作 |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `draft` | 仅作者 | 编辑、提交审核、删除 |
|
||||||
|
| `pending_review` | 作者 + 审核员 | 审核员:通过/拒绝;作者:撤回 |
|
||||||
|
| `approved` | 过渡态,自动发布 | — |
|
||||||
|
| `rejected` | 作者 | 编辑后重新提交 |
|
||||||
|
| `published` | 所有人(含小程序) | 撤回、删除 |
|
||||||
|
|
||||||
|
### 3.2 事件
|
||||||
|
|
||||||
|
| 事件 | 触发时机 | 消费者 |
|
||||||
|
|------|---------|--------|
|
||||||
|
| `article.submitted` | draft → pending_review | 通知审核员 |
|
||||||
|
| `article.approved` | 审核通过 | 记录日志 |
|
||||||
|
| `article.rejected` | 审核拒绝 | 通知作者 |
|
||||||
|
| `article.published` | 正式发布 | 记录日志 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 设计
|
||||||
|
|
||||||
|
### 4.1 文章 CRUD(增强已有)
|
||||||
|
|
||||||
|
| Method | Path | 说明 | 权限 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| GET | `/api/v1/health/articles` | 文章列表(管理端,支持 status/category/tag 筛选) | `health.articles.list` |
|
||||||
|
| GET | `/api/v1/health/articles/{id}` | 文章详情 | `health.articles.list` |
|
||||||
|
| POST | `/api/v1/health/articles` | 创建文章(默认 draft) | `health.articles.manage` |
|
||||||
|
| PUT | `/api/v1/health/articles/{id}` | 更新文章 | `health.articles.manage` |
|
||||||
|
| DELETE | `/api/v1/health/articles/{id}` | 软删除 | `health.articles.manage` |
|
||||||
|
|
||||||
|
**列表查询参数**:`page`, `page_size`, `status`, `category_id`, `tag_id`, `keyword`(标题搜索)
|
||||||
|
|
||||||
|
### 4.2 审核流程
|
||||||
|
|
||||||
|
| Method | Path | 说明 | 权限 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| POST | `/api/v1/health/articles/{id}/submit` | 提交审核 | `health.articles.manage` |
|
||||||
|
| POST | `/api/v1/health/articles/{id}/approve` | 审核通过并发布 | `health.articles.review` |
|
||||||
|
| POST | `/api/v1/health/articles/{id}/reject` | 审核拒绝(body: `{ "note": "..." }`) | `health.articles.review` |
|
||||||
|
| POST | `/api/v1/health/articles/{id}/unpublish` | 撤回发布 | `health.articles.manage` |
|
||||||
|
|
||||||
|
### 4.3 分类管理
|
||||||
|
|
||||||
|
| Method | Path | 说明 | 权限 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| GET | `/api/v1/health/article-categories` | 分类列表(树形) | `health.articles.list` |
|
||||||
|
| POST | `/api/v1/health/article-categories` | 创建分类 | `health.articles.manage` |
|
||||||
|
| PUT | `/api/v1/health/article-categories/{id}` | 更新分类 | `health.articles.manage` |
|
||||||
|
| DELETE | `/api/v1/health/article-categories/{id}` | 删除分类 | `health.articles.manage` |
|
||||||
|
|
||||||
|
### 4.4 标签管理
|
||||||
|
|
||||||
|
| Method | Path | 说明 | 权限 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| GET | `/api/v1/health/article-tags` | 标签列表 | `health.articles.list` |
|
||||||
|
| POST | `/api/v1/health/article-tags` | 创建标签 | `health.articles.manage` |
|
||||||
|
| DELETE | `/api/v1/health/article-tags/{id}` | 删除标签 | `health.articles.manage` |
|
||||||
|
|
||||||
|
### 4.5 图片上传
|
||||||
|
|
||||||
|
| Method | Path | 说明 | 权限 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| POST | `/api/v1/health/articles/upload-image` | 上传图片,返回 `{ url }` | `health.articles.manage` |
|
||||||
|
|
||||||
|
图片存储:本地文件系统 `uploads/articles/{tenant_id}/{yyyy-MM}/{uuid}.{ext}`,后续可迁移至 OSS。
|
||||||
|
|
||||||
|
### 4.6 权限变更
|
||||||
|
|
||||||
|
新增权限码:
|
||||||
|
|
||||||
|
| 权限码 | 名称 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `health.articles.review` | 审核资讯 | 新增 |
|
||||||
|
|
||||||
|
保留已有:
|
||||||
|
|
||||||
|
| 权限码 | 名称 |
|
||||||
|
|--------|------|
|
||||||
|
| `health.articles.list` | 查看资讯 |
|
||||||
|
| `health.articles.manage` | 管理资讯 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Web 前端
|
||||||
|
|
||||||
|
### 5.1 新增页面
|
||||||
|
|
||||||
|
| 页面 | 路由 | 组件 |
|
||||||
|
|------|------|------|
|
||||||
|
| 文章列表 | `/health/articles` | `ArticleManageList` |
|
||||||
|
| 创建文章 | `/health/articles/new` | `ArticleEditor` |
|
||||||
|
| 编辑文章 | `/health/articles/:id/edit` | `ArticleEditor` |
|
||||||
|
| 文章详情/预览 | `/health/articles/:id` | `ArticlePreview` |
|
||||||
|
| 分类管理 | `/health/article-categories` | `ArticleCategoryManage` |
|
||||||
|
| 标签管理 | `/health/article-tags` | `ArticleTagManage` |
|
||||||
|
|
||||||
|
### 5.2 文章列表页
|
||||||
|
|
||||||
|
- 状态 Tab 筛选:全部 | 草稿 | 待审核 | 已发布 | 已拒绝
|
||||||
|
- 分类下拉筛选
|
||||||
|
- 标题关键词搜索
|
||||||
|
- 表格列:标题、分类、标签、状态(色块)、作者、阅读数、发布时间、操作
|
||||||
|
- 操作按钮根据状态和权限动态显示:
|
||||||
|
- 草稿:编辑、删除、提交审核
|
||||||
|
- 待审核:查看(审核员可看到审核/拒绝)
|
||||||
|
- 已发布:查看、撤回
|
||||||
|
- 已拒绝:编辑、重新提交
|
||||||
|
|
||||||
|
### 5.3 富文本编辑器
|
||||||
|
|
||||||
|
**选型:Wangeditor v5**
|
||||||
|
|
||||||
|
- MIT 协议,轻量(~200KB)
|
||||||
|
- 中文优先,React 集成简单(`@wangeditor/editor-for-react`)
|
||||||
|
- 支持图片上传 hook(对接后端 upload-image API)
|
||||||
|
- 支持标题/列表/链接/表格等常用格式
|
||||||
|
|
||||||
|
编辑器页面布局:
|
||||||
|
- 左侧:富文本编辑区域
|
||||||
|
- 右侧:标题、分类选择、标签选择、摘要、封面图上传、slug
|
||||||
|
- 底部:保存草稿 / 提交审核 按钮
|
||||||
|
|
||||||
|
### 5.4 侧边栏菜单
|
||||||
|
|
||||||
|
在健康管理分组下新增「内容管理」菜单项,图标使用 `FileTextOutlined`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 小程序端增强
|
||||||
|
|
||||||
|
### 6.1 文章列表页改造
|
||||||
|
|
||||||
|
- 增加顶部分类 Tab(从 article-category API 获取)
|
||||||
|
- 下拉刷新 + 无限滚动
|
||||||
|
- 搜索框(标题关键词)
|
||||||
|
|
||||||
|
### 6.2 文章详情页改造
|
||||||
|
|
||||||
|
- 增加 `view_count` 展示
|
||||||
|
- 进入详情页时调用阅读计数 API(POST 或 PUT 递增)
|
||||||
|
|
||||||
|
### 6.3 新增 API 调用
|
||||||
|
|
||||||
|
- `GET /api/v1/health/articles` — 增加 `category_id`、`keyword` 参数
|
||||||
|
- `POST /api/v1/health/articles/{id}/view` — 阅读计数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 实施步骤
|
||||||
|
|
||||||
|
### Phase 1:后端增强(3-4 天)
|
||||||
|
|
||||||
|
1. 数据库迁移:articles 表 ALTER + article_category/article_tag/article_article_tag/article_revision 四张新表
|
||||||
|
2. Entity 定义:修改 Article entity + 新增 ArticleCategory、ArticleTag、ArticleArticleTag、ArticleRevision
|
||||||
|
3. Service 层:ArticleService 增强(状态机、审核、搜索)+ CategoryService + TagService + RevisionService
|
||||||
|
4. Handler 层:新增审核/分类/标签/上传端点
|
||||||
|
5. 权限注册:新增 `health.articles.review`
|
||||||
|
6. 事件发布:article.submitted/approved/rejected/published
|
||||||
|
7. 图片上传:文件存储端点
|
||||||
|
|
||||||
|
### Phase 2:Web 前端(3-4 天)
|
||||||
|
|
||||||
|
1. 安装 Wangeditor 依赖
|
||||||
|
2. 文章列表页(筛选、状态 Tab、表格)
|
||||||
|
3. 文章编辑页(富文本编辑器 + 元数据表单)
|
||||||
|
4. 文章预览/审核页
|
||||||
|
5. 分类管理页
|
||||||
|
6. 标签管理页
|
||||||
|
7. 路由注册 + 侧边栏菜单
|
||||||
|
|
||||||
|
### Phase 3:小程序增强(1-2 天)
|
||||||
|
|
||||||
|
1. 文章列表分类 Tab
|
||||||
|
2. 搜索功能
|
||||||
|
3. 阅读计数
|
||||||
|
4. API 调用适配
|
||||||
|
|
||||||
|
### 总估算:7-10 天
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 验收标准
|
||||||
|
|
||||||
|
- [ ] 作者可创建/编辑文章(富文本),保存为草稿
|
||||||
|
- [ ] 作者可提交审核
|
||||||
|
- [ ] 审核员可看到待审列表,通过或拒绝
|
||||||
|
- [ ] 已发布文章在小程序端可见
|
||||||
|
- [ ] 分类和标签可管理
|
||||||
|
- [ ] 图片可上传并在编辑器中插入
|
||||||
|
- [ ] 阅读计数正确递增
|
||||||
|
- [ ] `cargo check` + `cargo test` 通过
|
||||||
|
- [ ] 前端 `pnpm build` 通过
|
||||||
Reference in New Issue
Block a user