feat(health): 内容管理模块 — 审核/分类/标签/富文本编辑器
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

后端:
- 文章审核状态机: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:
iven
2026-04-26 12:51:30 +08:00
parent 49b8300fdc
commit 17b423b9b8
26 changed files with 3731 additions and 97 deletions

View File

@@ -17,6 +17,8 @@
"@ant-design/icons": "^6.1.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-react": "^1.0.6",
"@xyflow/react": "^12.10.2",
"antd": "^6.3.5",
"axios": "^1.15.0",

500
apps/web/pnpm-lock.yaml generated
View File

@@ -20,9 +20,15 @@ importers:
'@dnd-kit/sortable':
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)
'@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':
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:
specifier: ^6.3.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)
zustand:
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:
'@eslint/js':
specifier: ^9.39.4
@@ -1018,6 +1024,9 @@ packages:
'@types/react-dom':
optional: true
'@transloadit/prettier-bytes@0.0.7':
resolution: {integrity: sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==}
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -1102,6 +1111,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/event-emitter@0.3.5':
resolution: {integrity: sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
@@ -1178,6 +1190,23 @@ packages:
resolution: {integrity: sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==}
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':
resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1220,6 +1249,95 @@ packages:
'@vitest/utils@4.1.5':
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':
resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==}
peerDependencies:
@@ -1361,6 +1479,9 @@ packages:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
compute-scroll-into-view@1.0.20:
resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
compute-scroll-into-view@3.1.1:
resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==}
@@ -1508,6 +1629,10 @@ packages:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
d@1.0.2:
resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==}
engines: {node: '>=0.12'}
dagre@0.8.5:
resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==}
@@ -1551,6 +1676,9 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
dom7@3.0.0:
resolution: {integrity: sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -1585,6 +1713,17 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
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:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -1630,6 +1769,10 @@ packages:
jiti:
optional: true
esniff@2.0.1:
resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==}
engines: {node: '>=0.10'}
espree@10.4.0:
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1653,6 +1796,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
event-emitter@0.3.5:
resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
@@ -1660,6 +1806,9 @@ packages:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
ext@1.7.0:
resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1789,10 +1938,16 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
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:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
i18next@20.6.1:
resolution: {integrity: sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@@ -1805,6 +1960,9 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immer@9.0.21:
resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -1835,12 +1993,22 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-hotkey@0.2.0:
resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==}
is-mobile@5.0.0:
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:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
is-url@1.2.4:
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -1971,9 +2139,31 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
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:
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:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
@@ -2002,6 +2192,9 @@ packages:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-match@1.0.2:
resolution: {integrity: sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
@@ -2032,6 +2225,9 @@ packages:
ms@2.1.3:
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:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -2040,6 +2236,9 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
next-tick@1.1.0:
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
node-releases@2.0.37:
resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==}
@@ -2100,6 +2299,9 @@ packages:
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
engines: {node: ^10 || ^12 || >=14}
preact@10.29.1:
resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -2108,6 +2310,10 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
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:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
@@ -2178,6 +2384,9 @@ packages:
scheduler@0.27.0:
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:
resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==}
@@ -2207,10 +2416,25 @@ packages:
simple-swizzle@0.2.4:
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:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
ssr-window@3.0.0:
resolution: {integrity: sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -2271,6 +2495,9 @@ packages:
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
engines: {node: '>=12.22'}
tiny-warning@1.0.3:
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2314,6 +2541,9 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
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:
resolution: {integrity: sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -2460,6 +2690,9 @@ packages:
engines: {node: '>=8'}
hasBin: true
wildcard@1.1.2:
resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==}
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@@ -3617,6 +3850,8 @@ snapshots:
'@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':
dependencies:
tslib: 2.8.1
@@ -3696,6 +3931,8 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/event-emitter@0.3.5': {}
'@types/geojson@7946.0.16': {}
'@types/json-schema@7.0.15': {}
@@ -3803,6 +4040,35 @@ snapshots:
'@typescript-eslint/types': 8.58.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))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
@@ -3849,13 +4115,123 @@ snapshots:
convert-source-map: 2.0.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:
'@xyflow/system': 0.0.76
classcat: 5.0.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:
- '@types/react'
- immer
@@ -4040,6 +4416,8 @@ snapshots:
commander@7.2.0: {}
compute-scroll-into-view@1.0.20: {}
compute-scroll-into-view@3.1.1: {}
concat-map@0.0.1: {}
@@ -4182,6 +4560,11 @@ snapshots:
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:
dependencies:
graphlib: 2.1.8
@@ -4214,6 +4597,10 @@ snapshots:
dom-accessibility-api@0.6.3: {}
dom7@3.0.0:
dependencies:
ssr-window: 3.0.0
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -4246,6 +4633,24 @@ snapshots:
has-tostringtag: 1.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: {}
escape-string-regexp@4.0.0: {}
@@ -4317,6 +4722,13 @@ snapshots:
transitivePeerDependencies:
- 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:
dependencies:
acorn: 8.16.0
@@ -4339,10 +4751,19 @@ snapshots:
esutils@2.0.3: {}
event-emitter@0.3.5:
dependencies:
d: 1.0.2
es5-ext: 0.10.64
eventemitter3@5.0.4: {}
expect-type@1.3.0: {}
ext@1.7.0:
dependencies:
type: 2.7.3
fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {}
@@ -4453,11 +4874,17 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
html-void-elements@2.0.1: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
i18next@20.6.1:
dependencies:
'@babel/runtime': 7.29.2
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
@@ -4466,6 +4893,8 @@ snapshots:
ignore@7.0.5: {}
immer@9.0.21: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -4487,10 +4916,16 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-hotkey@0.2.0: {}
is-mobile@5.0.0: {}
is-plain-object@5.0.0: {}
is-potential-custom-element-name@1.0.1: {}
is-url@1.2.4: {}
isexe@2.0.0: {}
jiti@2.6.1: {}
@@ -4603,8 +5038,22 @@ snapshots:
dependencies:
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.throttle@4.1.1: {}
lodash.toarray@4.4.0: {}
lodash@4.18.1: {}
lru-cache@11.3.5: {}
@@ -4625,6 +5074,10 @@ snapshots:
mime-db@1.52.0: {}
mime-match@1.0.2:
dependencies:
wildcard: 1.1.2
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
@@ -4660,10 +5113,14 @@ snapshots:
ms@2.1.3: {}
namespace-emitter@2.0.1: {}
nanoid@3.3.11: {}
natural-compare@1.4.0: {}
next-tick@1.1.0: {}
node-releases@2.0.37: {}
obug@2.1.1: {}
@@ -4719,6 +5176,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
preact@10.29.1: {}
prelude-ls@1.2.1: {}
pretty-format@27.5.1:
@@ -4727,6 +5186,8 @@ snapshots:
ansi-styles: 5.2.0
react-is: 17.0.2
prismjs@1.30.0: {}
proxy-from-env@2.1.0: {}
punycode@2.3.1: {}
@@ -4796,6 +5257,10 @@ snapshots:
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:
dependencies:
compute-scroll-into-view: 3.1.1
@@ -4818,8 +5283,23 @@ snapshots:
dependencies:
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: {}
ssr-window@3.0.0: {}
stackback@0.0.2: {}
std-env@4.1.0: {}
@@ -4861,6 +5341,8 @@ snapshots:
throttle-debounce@5.0.2: {}
tiny-warning@1.0.3: {}
tinybench@2.9.0: {}
tinyexec@1.1.1: {}
@@ -4896,6 +5378,8 @@ snapshots:
dependencies:
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):
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)
@@ -4996,6 +5480,8 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
wildcard@1.1.2: {}
word-wrap@1.2.5: {}
xml-name-validator@5.0.0: {}
@@ -5012,15 +5498,17 @@ snapshots:
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:
use-sync-external-store: 1.6.0(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
immer: 9.0.21
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:
'@types/react': 19.2.14
immer: 9.0.21
react: 19.2.5
use-sync-external-store: 1.6.0(react@19.2.5)

View File

@@ -44,6 +44,12 @@ const AiPromptList = lazy(() => import('./pages/health/AiPromptList'));
const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList'));
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 }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
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-analysis" element={<AiAnalysisList />} />
<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>
</Suspense>
</ErrorBoundary>

View File

@@ -1,52 +1,122 @@
import client from '../client';
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 {
id: string;
title: string;
summary?: string;
cover_image?: string;
category?: string;
content_type: ArticleContentType;
status: ArticleStatus;
slug?: string;
category_id?: string;
category_name?: string;
tags?: ArticleTagItem[];
author?: string;
reviewed_by?: string;
reviewed_at?: string;
review_note?: string;
view_count: number;
sort_order: number;
published_at?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface Article extends ArticleListItem {
content?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateArticleReq {
title: string;
summary?: string;
content?: string;
content_type?: ArticleContentType;
cover_image?: string;
category?: string;
author?: string;
published_at?: string;
slug?: string;
category_id?: string;
tag_ids?: string[];
sort_order?: number;
}
export interface UpdateArticleReq {
title?: string;
summary?: string;
content?: string;
content_type?: ArticleContentType;
cover_image?: string;
category?: string;
author?: string;
published_at?: string;
slug?: string;
category_id?: string;
tag_ids?: string[];
sort_order?: 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 = {
list: async (params: {
page?: number;
page_size?: number;
category?: string;
}) => {
list: async (params: ArticleListParams) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<ArticleListItem>;
@@ -85,4 +155,108 @@ export const articleApi = {
}>(`/health/articles/${id}`);
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;
},
};

View File

@@ -87,6 +87,11 @@ const routeTitleFallback: Record<string, string> = {
'/health/consultations/:id': '咨询详情',
'/health/points-rules': '积分规则管理',
'/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 {

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}