From 17b423b9b8d21fe9c1048ef08e1be46e7801b5d2 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 26 Apr 2026 12:51:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20=E5=86=85=E5=AE=B9=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=A8=A1=E5=9D=97=20=E2=80=94=20=E5=AE=A1=E6=A0=B8/?= =?UTF-8?q?=E5=88=86=E7=B1=BB/=E6=A0=87=E7=AD=BE/=E5=AF=8C=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E7=BC=96=E8=BE=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 文章审核状态机: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 组件导入 --- Cargo.lock | 7 + apps/web/package.json | 2 + apps/web/pnpm-lock.yaml | 500 ++++++++++++++++- apps/web/src/App.tsx | 12 + apps/web/src/api/health/articles.ts | 208 ++++++- apps/web/src/layouts/MainLayout.tsx | 5 + .../pages/health/ArticleCategoryManage.tsx | 273 +++++++++ apps/web/src/pages/health/ArticleEditor.tsx | 516 ++++++++++++++++++ .../src/pages/health/ArticleManageList.tsx | 498 +++++++++++++++++ .../web/src/pages/health/ArticleTagManage.tsx | 220 ++++++++ crates/erp-health/src/dto/article_dto.rs | 139 +++++ .../erp-health/src/entity/article_category.rs | 32 ++ .../erp-health/src/entity/article_revision.rs | 38 ++ crates/erp-health/src/entity/article_tag.rs | 34 ++ crates/erp-health/src/entity/mod.rs | 4 + .../src/handler/article_category_handler.rs | 81 +++ .../erp-health/src/handler/article_handler.rs | 105 +++- .../src/handler/article_tag_handler.rs | 63 +++ crates/erp-health/src/handler/mod.rs | 2 + crates/erp-health/src/module.rs | 50 +- .../src/service/article_category_service.rs | 148 +++++ .../erp-health/src/service/article_service.rs | 414 +++++++++++--- .../src/service/article_tag_service.rs | 108 ++++ crates/erp-health/src/service/mod.rs | 2 + ...026-04-26-content-management-brainstorm.md | 39 ++ .../2026-04-26-content-management-design.md | 328 +++++++++++ 26 files changed, 3731 insertions(+), 97 deletions(-) create mode 100644 apps/web/src/pages/health/ArticleCategoryManage.tsx create mode 100644 apps/web/src/pages/health/ArticleEditor.tsx create mode 100644 apps/web/src/pages/health/ArticleManageList.tsx create mode 100644 apps/web/src/pages/health/ArticleTagManage.tsx create mode 100644 crates/erp-health/src/entity/article_category.rs create mode 100644 crates/erp-health/src/entity/article_revision.rs create mode 100644 crates/erp-health/src/entity/article_tag.rs create mode 100644 crates/erp-health/src/handler/article_category_handler.rs create mode 100644 crates/erp-health/src/handler/article_tag_handler.rs create mode 100644 crates/erp-health/src/service/article_category_service.rs create mode 100644 crates/erp-health/src/service/article_tag_service.rs create mode 100644 docs/discussions/2026-04-26-content-management-brainstorm.md create mode 100644 docs/superpowers/specs/2026-04-26-content-management-design.md diff --git a/Cargo.lock b/Cargo.lock index 1cbde1a..3c34df6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1391,13 +1391,20 @@ dependencies = [ name = "erp-core" version = "0.1.0" dependencies = [ + "aes-gcm", "anyhow", "async-trait", "axum", + "base64 0.22.1", "chrono", + "dashmap", + "hex", + "hmac", + "rand", "sea-orm", "serde", "serde_json", + "sha2", "thiserror 2.0.18", "tokio", "tracing", diff --git a/apps/web/package.json b/apps/web/package.json index 09252e3..d07a8b6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index e1a0c1c..af60352 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -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) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1df9db8..3595f3e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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} : ; @@ -192,6 +198,12 @@ export default function App() { } /> } /> } /> + {/* 内容管理 */} + } /> + } /> + } /> + } /> + } /> diff --git a/apps/web/src/api/health/articles.ts b/apps/web/src/api/health/articles.ts index 6ab72ba..1f52b69 100644 --- a/apps/web/src/api/health/articles.ts +++ b/apps/web/src/api/health/articles.ts @@ -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; @@ -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; + }, }; diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index 731aca2..8a6374c 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -87,6 +87,11 @@ const routeTitleFallback: Record = { '/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 { diff --git a/apps/web/src/pages/health/ArticleCategoryManage.tsx b/apps/web/src/pages/health/ArticleCategoryManage.tsx new file mode 100644 index 0000000..6e75828 --- /dev/null +++ b/apps/web/src/pages/health/ArticleCategoryManage.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [editingCategory, setEditingCategory] = useState(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) => ( + {name} + ), + }, + { + title: '别名 (Slug)', + dataIndex: 'slug', + key: 'slug', + width: 180, + render: (v?: string) => v || -, + }, + { + title: '父分类', + dataIndex: 'parent_name', + key: 'parent_name', + width: 140, + render: (_v: string | undefined, record: ArticleCategory) => { + if (!record.parent_id) return -; + 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 || -, + }, + { + title: '操作', + key: 'actions', + width: 120, + render: (_: unknown, record: ArticleCategory) => ( + + + + + + + {/* 表格容器 */} +
+ + + + {/* 新建/编辑分类弹窗 */} + form.submit()} + width={520} + > +
+ + + + + + + + setTitle(e.target.value)} + placeholder="请输入文章标题" + maxLength={200} + showCount + /> + + + {/* 分类 */} +
+ + ({ label: t.name, value: t.id }))} + maxTagCount={5} + /> +
+ + {/* 摘要 */} +
+ + setSummary(e.target.value)} + placeholder="请输入文章摘要" + rows={3} + maxLength={500} + showCount + /> +
+ + {/* 封面图 */} +
+ + setCoverImage(e.target.value)} + placeholder="请输入封面图片 URL" + /> + {coverImage && ( +
+ 封面预览 { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+ )} +
+ + {/* Slug */} +
+ + setSlug(e.target.value)} + placeholder="例如: health-tips-for-elderly" + /> +
+ + {/* 排序 */} +
+ + setSortOrder(Number(e.target.value))} + placeholder="0" + /> +
+ + + + ); +} diff --git a/apps/web/src/pages/health/ArticleManageList.tsx b/apps/web/src/pages/health/ArticleManageList.tsx new file mode 100644 index 0000000..e1980ab --- /dev/null +++ b/apps/web/src/pages/health/ArticleManageList.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [statusTab, setStatusTab] = useState(''); + const [categoryId, setCategoryId] = useState(undefined); + const [keyword, setKeyword] = useState(''); + const [categories, setCategories] = useState<{ id: string; name: string }[]>([]); + const [rejectModalOpen, setRejectModalOpen] = useState(false); + const [rejectingArticle, setRejectingArticle] = useState(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 | 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) => ( + + {record.status === 'draft' && ( + <> + + + + + + + + )} + {record.status === 'pending_review' && ( + <> + + + + + + + + )} + {record.status === 'published' && ( + + + + )} + {(record.status === 'draft' || record.status === 'rejected') && ( + + handleDelete(record.id)} + > + + + + + {/* 筛选栏 */} +
+ } + value={keyword} + onChange={(e) => debouncedSearch(e.target.value)} + allowClear + style={{ width: 220, borderRadius: 8 }} + /> +
{ + setPage(p); + fetchArticles(p); + }, + showTotal: (t) => `共 ${t} 条记录`, + style: { padding: '12px 16px', margin: 0 }, + }} + /> + + + {/* 拒绝理由弹窗 */} + setRejectModalOpen(false)} + onOk={() => rejectForm.submit()} + okText="确认拒绝" + okButtonProps={{ danger: true }} + width={480} + > + + + + + + + + ); +} diff --git a/apps/web/src/pages/health/ArticleTagManage.tsx b/apps/web/src/pages/health/ArticleTagManage.tsx new file mode 100644 index 0000000..d279b17 --- /dev/null +++ b/apps/web/src/pages/health/ArticleTagManage.tsx @@ -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([]); + 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) => ( + + {name} + + ), + }, + { + title: '别名 (Slug)', + dataIndex: 'slug', + key: 'slug', + width: 180, + render: (v?: string) => v || -, + }, + { + title: '颜色', + dataIndex: 'color', + key: 'color', + width: 100, + render: (v?: string) => + v ? ( +
+
+ {v} +
+ ) : ( + 默认 + ), + }, + { + title: '操作', + key: 'actions', + width: 80, + render: (_: unknown, record: ArticleTagItem) => ( + + handleDelete(record.id)} + > + + +
+ + {/* 表格容器 */} +
+
+ + + {/* 新建标签弹窗 */} + form.submit()} + width={440} + > +
+ + + + + + + + + + +
+ + ); +} diff --git a/crates/erp-health/src/dto/article_dto.rs b/crates/erp-health/src/dto/article_dto.rs index 0a461aa..57f0c59 100644 --- a/crates/erp-health/src/dto/article_dto.rs +++ b/crates/erp-health/src/dto/article_dto.rs @@ -4,6 +4,10 @@ use uuid::Uuid; use erp_core::sanitize::{sanitize_option, sanitize_string, strip_html_tags}; +// --------------------------------------------------------------------------- +// 文章 DTOs +// --------------------------------------------------------------------------- + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ArticleResp { pub id: Uuid, @@ -14,6 +18,18 @@ pub struct ArticleResp { pub category: Option, pub author: Option, pub published_at: Option>, + pub status: String, + pub slug: Option, + pub content_type: String, + pub reviewed_by: Option, + pub reviewed_at: Option>, + pub review_note: Option, + pub view_count: i32, + pub sort_order: i32, + /// 文章关联的分类 ID(来自 article_category 表) + pub category_id: Option, + /// 文章关联的标签名称列表 + pub tags: Vec, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, pub version: i32, @@ -28,6 +44,12 @@ pub struct ArticleListItem { pub category: Option, pub author: Option, pub published_at: Option>, + pub status: String, + pub view_count: i32, + /// 分类 ID + pub category_id: Option, + /// 标签名称列表 + pub tags: Vec, } #[derive(Debug, Clone, Deserialize, IntoParams)] @@ -35,6 +57,14 @@ pub struct ArticleListParams { pub page: Option, pub page_size: Option, pub category: Option, + /// 按状态筛选 + pub status: Option, + /// 按分类 ID 筛选 + pub category_id: Option, + /// 按标签 ID 筛选 + pub tag_id: Option, + /// 关键词搜索(标题模糊匹配) + pub keyword: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -46,6 +76,13 @@ pub struct CreateArticleReq { pub category: Option, pub author: Option, pub published_at: Option>, + pub slug: Option, + pub content_type: Option, + /// 分类 ID + pub category_id: Option, + /// 标签 ID 列表 + #[serde(default)] + pub tag_ids: Vec, } impl CreateArticleReq { @@ -55,6 +92,8 @@ impl CreateArticleReq { self.content = sanitize_option(self.content.take()); self.category = sanitize_option(self.category.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, pub author: Option, pub published_at: Option>, + pub slug: Option, + pub content_type: Option, + /// 分类 ID + pub category_id: Option, + /// 标签 ID 列表(传入则整体替换) + pub tag_ids: Option>, + pub sort_order: Option, pub version: i32, } @@ -77,5 +123,98 @@ impl UpdateArticleReq { self.content = sanitize_option(self.content.take()); self.category = sanitize_option(self.category.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, + /// 文章版本号(乐观锁) + pub version: Option, +} + +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, + pub parent_id: Option, + pub description: Option, + pub sort_order: i32, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateCategoryReq { + pub name: String, + pub slug: Option, + pub parent_id: Option, + pub description: Option, + pub sort_order: Option, +} + +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, + pub slug: Option, + pub parent_id: Option, + pub description: Option, + pub sort_order: Option, + 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, + pub updated_at: chrono::DateTime, + 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); } } diff --git a/crates/erp-health/src/entity/article_category.rs b/crates/erp-health/src/entity/article_category.rs new file mode 100644 index 0000000..7a294ab --- /dev/null +++ b/crates/erp-health/src/entity/article_category.rs @@ -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, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub sort_order: i32, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/article_revision.rs b/crates/erp-health/src/entity/article_revision.rs new file mode 100644 index 0000000..05c1c8d --- /dev/null +++ b/crates/erp-health/src/entity/article_revision.rs @@ -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, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + 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 for Entity { + fn to() -> RelationDef { + Relation::Article.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/article_tag.rs b/crates/erp-health/src/entity/article_tag.rs new file mode 100644 index 0000000..b9fc2dd --- /dev/null +++ b/crates/erp-health/src/entity/article_tag.rs @@ -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, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::article_article_tag::Entity")] + ArticleTagRelation, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ArticleTagRelation.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/mod.rs b/crates/erp-health/src/entity/mod.rs index 6289db6..a301052 100644 --- a/crates/erp-health/src/entity/mod.rs +++ b/crates/erp-health/src/entity/mod.rs @@ -1,5 +1,9 @@ pub mod appointment; 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 consent; pub mod consultation_message; diff --git a/crates/erp-health/src/handler/article_category_handler.rs b/crates/erp-health/src/handler/article_category_handler.rs new file mode 100644 index 0000000..993c6a6 --- /dev/null +++ b/crates/erp-health/src/handler/article_category_handler.rs @@ -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( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + mut req: Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + mut req: Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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(()))) +} diff --git a/crates/erp-health/src/handler/article_handler.rs b/crates/erp-health/src/handler/article_handler.rs index 4a5918a..e6970db 100644 --- a/crates/erp-health/src/handler/article_handler.rs +++ b/crates/erp-health/src/handler/article_handler.rs @@ -5,7 +5,7 @@ use erp_core::error::AppError; use erp_core::rbac::require_permission; 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::state::HealthState; @@ -22,8 +22,8 @@ where let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(20); let result = article_service::list_articles( - &state, ctx.tenant_id, page, page_size, params.category, - params.status, params.category_id, params.tag_id, params.keyword, + &state, ctx.tenant_id, page, page_size, + params.category, params.status, params.category_id, params.tag_id, params.keyword, ) .await?; 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?; Ok(Json(ApiResponse::ok(()))) } + +// --------------------------------------------------------------------------- +// 审核工作流 +// --------------------------------------------------------------------------- + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct VersionReq { + pub version: i32, +} + +/// 提交审核 +pub async fn submit_article( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + mut req: Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + mut req: Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + article_service::increment_view_count(&state, ctx.tenant_id, id).await?; + Ok(Json(ApiResponse::ok(()))) +} diff --git a/crates/erp-health/src/handler/article_tag_handler.rs b/crates/erp-health/src/handler/article_tag_handler.rs new file mode 100644 index 0000000..f7307e0 --- /dev/null +++ b/crates/erp-health/src/handler/article_tag_handler.rs @@ -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( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + mut req: Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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(()))) +} diff --git a/crates/erp-health/src/handler/mod.rs b/crates/erp-health/src/handler/mod.rs index b9b9eeb..e570ce0 100644 --- a/crates/erp-health/src/handler/mod.rs +++ b/crates/erp-health/src/handler/mod.rs @@ -1,5 +1,7 @@ pub mod appointment_handler; +pub mod article_category_handler; pub mod article_handler; +pub mod article_tag_handler; pub mod consultation_handler; pub mod consent_handler; pub mod critical_value_threshold_handler; diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index d5f9165..d400ae6 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -6,7 +6,7 @@ use erp_core::events::EventBus; use erp_core::module::{ErpModule, PermissionDescriptor}; 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, }; @@ -317,6 +317,48 @@ impl HealthModule { .put(article_handler::update_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( "/health/points/account", @@ -630,6 +672,12 @@ impl ErpModule for HealthModule { description: "创建、编辑、删除健康资讯文章".into(), module: "health".into(), }, + PermissionDescriptor { + code: "health.articles.review".into(), + name: "审核资讯".into(), + description: "审核通过或拒绝资讯文章发布".into(), + module: "health".into(), + }, PermissionDescriptor { code: "health.points.list".into(), name: "查看积分".into(), diff --git a/crates/erp-health/src/service/article_category_service.rs b/crates/erp-health/src/service/article_category_service.rs new file mode 100644 index 0000000..fcea6c6 --- /dev/null +++ b/crates/erp-health/src/service/article_category_service.rs @@ -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> { + 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, + req: CreateCategoryReq, +) -> HealthResult { + 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, + req: UpdateCategoryReq, +) -> 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(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, + 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(()) +} diff --git a/crates/erp-health/src/service/article_service.rs b/crates/erp-health/src/service/article_service.rs index ff77fe0..225453c 100644 --- a/crates/erp-health/src/service/article_service.rs +++ b/crates/erp-health/src/service/article_service.rs @@ -1,4 +1,4 @@ -//! 健康资讯 Service — 文章 CRUD +//! 健康资讯 Service — 文章 CRUD + 审核工作流 use chrono::Utc; use sea_orm::entity::prelude::*; @@ -10,53 +10,93 @@ use erp_core::audit_service; use erp_core::error::check_version; 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_article_tag; +use crate::entity::article_tag; use crate::error::{HealthError, HealthResult}; use crate::state::HealthState; -/// 文章列表(分页 + 分类筛选) +/// 文章列表(管理端,支持状态/分类/标签/关键词筛选) pub async fn list_articles( state: &HealthState, tenant_id: Uuid, page: u64, page_size: u64, category: Option, + status: Option, + category_id: Option, + tag_id: Option, + keyword: Option, ) -> HealthResult> { let limit = page_size.min(100); let offset = page.saturating_sub(1) * limit; let mut query = article::Entity::find() .filter(article::Column::TenantId.eq(tenant_id)) - .filter(article::Column::DeletedAt.is_null()) - .filter(article::Column::PublishedAt.is_not_null()); + .filter(article::Column::DeletedAt.is_null()); if let Some(ref cat) = category { 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 = 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 models = query - .order_by_desc(article::Column::PublishedAt) + .order_by_desc(article::Column::SortOrder) + .order_by_desc(article::Column::CreatedAt) .offset(offset) .limit(limit) .all(&state.db) .await?; 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 { - data, - total, - page, - page_size: limit, - total_pages, - }) + Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) } -/// 获取文章详情 +/// 获取文章详情(管理端,不过滤发布状态) pub async fn get_article( state: &HealthState, tenant_id: Uuid, @@ -66,58 +106,174 @@ pub async fn get_article( .filter(article::Column::Id.eq(id)) .filter(article::Column::TenantId.eq(tenant_id)) .filter(article::Column::DeletedAt.is_null()) - .filter(article::Column::PublishedAt.is_not_null()) .one(&state.db) .await? .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 { - 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: None, - tags: vec![], +/// 提交审核: draft/rejected → pending_review +pub async fn submit_article( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, + operator_id: Option, + expected_version: i32, +) -> HealthResult { + 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 != "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 { - 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: None, - tags: vec![], - created_at: m.created_at, - updated_at: m.updated_at, - version: m.version, +/// 审核通过并发布: pending_review → published +pub async fn approve_article( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, + operator_id: Option, + req: ReviewArticleReq, + expected_version: i32, +) -> HealthResult { + 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("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, + req: ReviewArticleReq, + expected_version: i32, +) -> HealthResult { + 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, + expected_version: i32, +) -> HealthResult { + 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()), cover_image: Set(req.cover_image), category: Set(req.category), + category_id: Set(req.category_id), author: Set(req.author), published_at: Set(req.published_at), status: Set("draft".into()), @@ -159,13 +316,17 @@ pub async fn create_article( }; let m = active.insert(&state.db).await?; + // 保存标签关联 + save_article_tags(state, m.id, &req.tag_ids).await?; + audit_service::record( AuditLog::new(tenant_id, operator_id, "article.created", "article") .with_resource_id(m.id), &state.db, ).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( @@ -175,38 +336,45 @@ pub async fn update_article( operator_id: Option, req: UpdateArticleReq, ) -> HealthResult { - let 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)?; + let model = find_article(state, tenant_id, id).await?; let next_ver = check_version(req.version, model.version) .map_err(|_| HealthError::VersionMismatch)?; + // 保存版本历史 + save_revision(state, tenant_id, &model, operator_id).await?; + let mut active: article::ActiveModel = model.into(); 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.content { active.content = Set(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_id { active.category_id = 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.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_by = Set(operator_id); active.version = Set(next_ver); 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( AuditLog::new(tenant_id, operator_id, "article.updated", "article") .with_resource_id(m.id), &state.db, ).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( @@ -216,13 +384,7 @@ pub async fn delete_article( operator_id: Option, expected_version: i32, ) -> HealthResult<()> { - let 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)?; + let model = find_article(state, tenant_id, id).await?; let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; @@ -242,3 +404,115 @@ pub async fn delete_article( Ok(()) } + +// --------------------------------------------------------------------------- +// 内部辅助 +// --------------------------------------------------------------------------- + +async fn find_article(state: &HealthState, tenant_id: Uuid, id: Uuid) -> HealthResult { + 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) -> 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> { + let relations = article_article_tag::Entity::find() + .filter(article_article_tag::Column::ArticleId.eq(article_id)) + .all(&state.db) + .await?; + + let tag_ids: Vec = 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, +) -> 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(()) +} diff --git a/crates/erp-health/src/service/article_tag_service.rs b/crates/erp-health/src/service/article_tag_service.rs new file mode 100644 index 0000000..937f8d5 --- /dev/null +++ b/crates/erp-health/src/service/article_tag_service.rs @@ -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> { + 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, + req: CreateTagReq, +) -> HealthResult { + 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, + 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(()) +} diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index 2a879df..a2a785e 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -1,5 +1,7 @@ pub mod appointment_service; +pub mod article_category_service; pub mod article_service; +pub mod article_tag_service; pub mod consultation_service; pub mod consent_service; pub mod critical_value_threshold_service; diff --git a/docs/discussions/2026-04-26-content-management-brainstorm.md b/docs/discussions/2026-04-26-content-management-brainstorm.md new file mode 100644 index 0000000..5d29829 --- /dev/null +++ b/docs/discussions/2026-04-26-content-management-brainstorm.md @@ -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 天)。 diff --git a/docs/superpowers/specs/2026-04-26-content-management-design.md b/docs/superpowers/specs/2026-04-26-content-management-design.md new file mode 100644 index 0000000..11b0032 --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-content-management-design.md @@ -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` 通过