完成Work组件的界面和一些小修改
> 1. 修改优化路由守卫
> 2. 去除拦截器中的调试信息
> 3. 修改头部导航条下拉菜单的样式增加图标。
> 4. work组件现在使用mock数据
Change-Id: Ic602a35bb02e645a0d5253c5cbd12a68d70bfb33
diff --git a/package.json b/package.json
index 8c00989..ec69ade 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"less": "^4.3.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+ "react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router": "^7.6.0"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 56f73c4..810efc5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,6 +35,9 @@
react-dom:
specifier: ^19.1.0
version: 19.1.0(react@19.1.0)
+ react-markdown:
+ specifier: ^10.1.0
+ version: 10.1.0(@types/react@19.1.4)(react@19.1.0)
react-redux:
specifier: ^9.2.0
version: 9.2.0(@types/react@19.1.4)(react@19.1.0)(redux@5.0.1)
@@ -86,7 +89,7 @@
version: 6.3.5(@types/node@22.15.30)(less@4.3.0)
vitest:
specifier: ^3.1.4
- version: 3.1.4(@types/node@22.15.30)(jsdom@26.1.0)(less@4.3.0)
+ version: 3.1.4(@types/debug@4.1.12)(@types/node@22.15.30)(jsdom@26.1.0)(less@4.3.0)
packages:
@@ -742,9 +745,18 @@
'@types/babel__traverse@7.20.7':
resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==}
+ '@types/debug@4.1.12':
+ resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
+
+ '@types/estree-jsx@1.0.5':
+ resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
+
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
+ '@types/hast@3.0.4':
+ resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
+
'@types/istanbul-lib-coverage@2.0.6':
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
@@ -760,6 +772,12 @@
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+ '@types/mdast@4.0.4':
+ resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+
+ '@types/ms@2.1.0':
+ resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
'@types/node@22.15.30':
resolution: {integrity: sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA==}
@@ -774,6 +792,12 @@
'@types/stack-utils@2.0.3':
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
+ '@types/unist@2.0.11':
+ resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
+
+ '@types/unist@3.0.3':
+ resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
@@ -830,6 +854,9 @@
resolution: {integrity: sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@ungap/structured-clone@1.3.0':
+ resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+
'@vitejs/plugin-react@4.4.1':
resolution: {integrity: sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -920,6 +947,9 @@
axios@1.9.0:
resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==}
+ bail@2.0.2:
+ resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
+
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -953,6 +983,9 @@
caniuse-lite@1.0.30001718:
resolution: {integrity: sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==}
+ ccount@2.0.1:
+ resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+
chai@5.2.0:
resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==}
engines: {node: '>=12'}
@@ -965,6 +998,18 @@
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
+ character-entities-html4@2.1.0:
+ resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
+
+ character-entities-legacy@3.0.0:
+ resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
+
+ character-entities@2.0.2:
+ resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
+
+ character-reference-invalid@2.0.1:
+ resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
+
check-error@2.1.1:
resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
engines: {node: '>= 16'}
@@ -987,6 +1032,9 @@
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
+ comma-separated-tokens@2.0.3:
+ resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
+
compute-scroll-into-view@3.1.1:
resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==}
@@ -1039,6 +1087,9 @@
decimal.js@10.5.0:
resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==}
+ decode-named-character-reference@1.1.0:
+ resolution: {integrity: sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==}
+
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
@@ -1054,6 +1105,9 @@
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
+ devlop@1.1.0:
+ resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+
diff-sequences@29.6.3:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -1164,6 +1218,9 @@
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
+ estree-util-is-identifier-name@3.0.0:
+ resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
+
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
@@ -1179,6 +1236,9 @@
resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+ extend@3.0.2:
+ resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1301,10 +1361,19 @@
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
+ hast-util-to-jsx-runtime@2.3.6:
+ resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
+
+ hast-util-whitespace@3.0.0:
+ resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
+
html-encoding-sniffer@4.0.0:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
+ html-url-attributes@3.0.1:
+ resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
+
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
@@ -1345,6 +1414,18 @@
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
+ inline-style-parser@0.2.4:
+ resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==}
+
+ is-alphabetical@2.0.1:
+ resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
+
+ is-alphanumerical@2.0.1:
+ resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
+
+ is-decimal@2.0.1:
+ resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -1353,10 +1434,17 @@
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
+ is-hexadecimal@2.0.1:
+ resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
+
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
+ is-plain-obj@4.1.0:
+ resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
+ engines: {node: '>=12'}
+
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
@@ -1446,6 +1534,9 @@
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
+ longest-streak@3.1.0:
+ resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+
loupe@3.1.3:
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
@@ -1470,10 +1561,97 @@
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ mdast-util-from-markdown@2.0.2:
+ resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==}
+
+ mdast-util-mdx-expression@2.0.1:
+ resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
+
+ mdast-util-mdx-jsx@3.2.0:
+ resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
+
+ mdast-util-mdxjs-esm@2.0.1:
+ resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
+
+ mdast-util-phrasing@4.1.0:
+ resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
+
+ mdast-util-to-hast@13.2.0:
+ resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==}
+
+ mdast-util-to-markdown@2.1.2:
+ resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
+
+ mdast-util-to-string@4.0.0:
+ resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
+ micromark-core-commonmark@2.0.3:
+ resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
+
+ micromark-factory-destination@2.0.1:
+ resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
+
+ micromark-factory-label@2.0.1:
+ resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
+
+ micromark-factory-space@2.0.1:
+ resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==}
+
+ micromark-factory-title@2.0.1:
+ resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
+
+ micromark-factory-whitespace@2.0.1:
+ resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
+
+ micromark-util-character@2.1.1:
+ resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
+
+ micromark-util-chunked@2.0.1:
+ resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
+
+ micromark-util-classify-character@2.0.1:
+ resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
+
+ micromark-util-combine-extensions@2.0.1:
+ resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
+
+ micromark-util-decode-string@2.0.1:
+ resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==}
+
+ micromark-util-encode@2.0.1:
+ resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
+
+ micromark-util-html-tag-name@2.0.1:
+ resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
+
+ micromark-util-normalize-identifier@2.0.1:
+ resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
+
+ micromark-util-resolve-all@2.0.1:
+ resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
+
+ micromark-util-sanitize-uri@2.0.1:
+ resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
+
+ micromark-util-subtokenize@2.1.0:
+ resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
+
+ micromark-util-symbol@2.0.1:
+ resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
+
+ micromark-util-types@2.0.2:
+ resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
+
+ micromark@4.0.2:
+ resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
+
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
@@ -1540,6 +1718,9 @@
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
+ parse-entities@4.0.2:
+ resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
+
parse-node-version@1.0.1:
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
engines: {node: '>= 0.10'}
@@ -1593,6 +1774,9 @@
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+ property-information@7.1.0:
+ resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
+
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
@@ -1845,6 +2029,12 @@
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
+ react-markdown@10.1.0:
+ resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
+ peerDependencies:
+ '@types/react': '>=18'
+ react: '>=18'
+
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
@@ -1887,6 +2077,12 @@
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
+ remark-parse@11.0.0:
+ resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
+
+ remark-rehype@11.1.2:
+ resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
+
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
@@ -1967,6 +2163,9 @@
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
+ space-separated-tokens@2.0.2:
+ resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+
stack-utils@2.0.6:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'}
@@ -1980,6 +2179,9 @@
string-convert@0.2.1:
resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==}
+ stringify-entities@4.0.4:
+ resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
+
strip-indent@3.0.0:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'}
@@ -1988,6 +2190,12 @@
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
+ style-to-js@1.1.16:
+ resolution: {integrity: sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==}
+
+ style-to-object@1.0.8:
+ resolution: {integrity: sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==}
+
stylis@4.3.6:
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
@@ -2046,6 +2254,12 @@
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
engines: {node: '>=18'}
+ trim-lines@3.0.1:
+ resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
+
+ trough@2.2.0:
+ resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
+
ts-api-utils@2.1.0:
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
engines: {node: '>=18.12'}
@@ -2074,6 +2288,24 @@
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ unified@11.0.5:
+ resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
+
+ unist-util-is@6.0.0:
+ resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==}
+
+ unist-util-position@5.0.0:
+ resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
+
+ unist-util-stringify-position@4.0.0:
+ resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
+
+ unist-util-visit-parents@6.0.1:
+ resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==}
+
+ unist-util-visit@5.0.0:
+ resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==}
+
update-browserslist-db@1.1.3:
resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
hasBin: true
@@ -2088,6 +2320,12 @@
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ vfile-message@4.0.2:
+ resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==}
+
+ vfile@6.0.3:
+ resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+
vite-node@3.1.4:
resolution: {integrity: sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -2221,6 +2459,9 @@
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
+ zwitch@2.0.4:
+ resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+
snapshots:
'@adobe/css-tools@4.4.3': {}
@@ -2829,8 +3070,20 @@
dependencies:
'@babel/types': 7.27.1
+ '@types/debug@4.1.12':
+ dependencies:
+ '@types/ms': 2.1.0
+
+ '@types/estree-jsx@1.0.5':
+ dependencies:
+ '@types/estree': 1.0.7
+
'@types/estree@1.0.7': {}
+ '@types/hast@3.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
'@types/istanbul-lib-coverage@2.0.6': {}
'@types/istanbul-lib-report@3.0.3':
@@ -2848,6 +3101,12 @@
'@types/json-schema@7.0.15': {}
+ '@types/mdast@4.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
+ '@types/ms@2.1.0': {}
+
'@types/node@22.15.30':
dependencies:
undici-types: 6.21.0
@@ -2862,6 +3121,10 @@
'@types/stack-utils@2.0.3': {}
+ '@types/unist@2.0.11': {}
+
+ '@types/unist@3.0.3': {}
+
'@types/use-sync-external-store@0.0.6': {}
'@types/yargs-parser@21.0.3': {}
@@ -2947,6 +3210,8 @@
'@typescript-eslint/types': 8.32.1
eslint-visitor-keys: 4.2.0
+ '@ungap/structured-clone@1.3.0': {}
+
'@vitejs/plugin-react@4.4.1(vite@6.3.5(@types/node@22.15.30)(less@4.3.0))':
dependencies:
'@babel/core': 7.27.1
@@ -3099,6 +3364,8 @@
transitivePeerDependencies:
- debug
+ bail@2.0.2: {}
+
balanced-match@1.0.2: {}
brace-expansion@1.1.11:
@@ -3132,6 +3399,8 @@
caniuse-lite@1.0.30001718: {}
+ ccount@2.0.1: {}
+
chai@5.2.0:
dependencies:
assertion-error: 2.0.1
@@ -3150,6 +3419,14 @@
ansi-styles: 4.3.0
supports-color: 7.2.0
+ character-entities-html4@2.1.0: {}
+
+ character-entities-legacy@3.0.0: {}
+
+ character-entities@2.0.2: {}
+
+ character-reference-invalid@2.0.1: {}
+
check-error@2.1.1: {}
ci-info@3.9.0: {}
@@ -3166,6 +3443,8 @@
dependencies:
delayed-stream: 1.0.0
+ comma-separated-tokens@2.0.3: {}
+
compute-scroll-into-view@3.1.1: {}
concat-map@0.0.1: {}
@@ -3210,6 +3489,10 @@
decimal.js@10.5.0: {}
+ decode-named-character-reference@1.1.0:
+ dependencies:
+ character-entities: 2.0.2
+
deep-eql@5.0.2: {}
deep-is@0.1.4: {}
@@ -3218,6 +3501,10 @@
dequal@2.0.3: {}
+ devlop@1.1.0:
+ dependencies:
+ dequal: 2.0.3
+
diff-sequences@29.6.3: {}
dom-accessibility-api@0.5.16: {}
@@ -3363,6 +3650,8 @@
estraverse@5.3.0: {}
+ estree-util-is-identifier-name@3.0.0: {}
+
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.7
@@ -3379,6 +3668,8 @@
jest-message-util: 29.7.0
jest-util: 29.7.0
+ extend@3.0.2: {}
+
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@@ -3487,10 +3778,36 @@
dependencies:
function-bind: 1.1.2
+ hast-util-to-jsx-runtime@2.3.6:
+ dependencies:
+ '@types/estree': 1.0.7
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ comma-separated-tokens: 2.0.3
+ devlop: 1.1.0
+ estree-util-is-identifier-name: 3.0.0
+ hast-util-whitespace: 3.0.0
+ mdast-util-mdx-expression: 2.0.1
+ mdast-util-mdx-jsx: 3.2.0
+ mdast-util-mdxjs-esm: 2.0.1
+ property-information: 7.1.0
+ space-separated-tokens: 2.0.2
+ style-to-js: 1.1.16
+ unist-util-position: 5.0.0
+ vfile-message: 4.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ hast-util-whitespace@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
html-encoding-sniffer@4.0.0:
dependencies:
whatwg-encoding: 3.1.1
+ html-url-attributes@3.0.1: {}
+
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.3
@@ -3527,14 +3844,29 @@
indent-string@4.0.0: {}
+ inline-style-parser@0.2.4: {}
+
+ is-alphabetical@2.0.1: {}
+
+ is-alphanumerical@2.0.1:
+ dependencies:
+ is-alphabetical: 2.0.1
+ is-decimal: 2.0.1
+
+ is-decimal@2.0.1: {}
+
is-extglob@2.1.1: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
+ is-hexadecimal@2.0.1: {}
+
is-number@7.0.0: {}
+ is-plain-obj@4.1.0: {}
+
is-potential-custom-element-name@1.0.1: {}
is-what@3.14.1: {}
@@ -3656,6 +3988,8 @@
lodash@4.17.21: {}
+ longest-streak@3.1.0: {}
+
loupe@3.1.3: {}
lru-cache@10.4.3: {}
@@ -3678,8 +4012,230 @@
math-intrinsics@1.1.0: {}
+ mdast-util-from-markdown@2.0.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ decode-named-character-reference: 1.1.0
+ devlop: 1.1.0
+ mdast-util-to-string: 4.0.0
+ micromark: 4.0.2
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-decode-string: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ unist-util-stringify-position: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-expression@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-jsx@3.2.0:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ parse-entities: 4.0.2
+ stringify-entities: 4.0.4
+ unist-util-stringify-position: 4.0.0
+ vfile-message: 4.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdxjs-esm@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-phrasing@4.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ unist-util-is: 6.0.0
+
+ mdast-util-to-hast@13.2.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@ungap/structured-clone': 1.3.0
+ devlop: 1.1.0
+ micromark-util-sanitize-uri: 2.0.1
+ trim-lines: 3.0.1
+ unist-util-position: 5.0.0
+ unist-util-visit: 5.0.0
+ vfile: 6.0.3
+
+ mdast-util-to-markdown@2.1.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ longest-streak: 3.1.0
+ mdast-util-phrasing: 4.1.0
+ mdast-util-to-string: 4.0.0
+ micromark-util-classify-character: 2.0.1
+ micromark-util-decode-string: 2.0.1
+ unist-util-visit: 5.0.0
+ zwitch: 2.0.4
+
+ mdast-util-to-string@4.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+
merge2@1.4.1: {}
+ micromark-core-commonmark@2.0.3:
+ dependencies:
+ decode-named-character-reference: 1.1.0
+ devlop: 1.1.0
+ micromark-factory-destination: 2.0.1
+ micromark-factory-label: 2.0.1
+ micromark-factory-space: 2.0.1
+ micromark-factory-title: 2.0.1
+ micromark-factory-whitespace: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-html-tag-name: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-destination@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-label@2.0.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-space@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-title@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-whitespace@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-character@2.1.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-chunked@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-classify-character@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-combine-extensions@2.0.1:
+ dependencies:
+ micromark-util-chunked: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-decode-string@2.0.1:
+ dependencies:
+ decode-named-character-reference: 1.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-encode@2.0.1: {}
+
+ micromark-util-html-tag-name@2.0.1: {}
+
+ micromark-util-normalize-identifier@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-resolve-all@2.0.1:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-util-sanitize-uri@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-encode: 2.0.1
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-subtokenize@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-symbol@2.0.1: {}
+
+ micromark-util-types@2.0.2: {}
+
+ micromark@4.0.2:
+ dependencies:
+ '@types/debug': 4.1.12
+ debug: 4.4.1
+ decode-named-character-reference: 1.1.0
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-encode: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
micromatch@4.0.8:
dependencies:
braces: 3.0.3
@@ -3741,6 +4297,16 @@
dependencies:
callsites: 3.1.0
+ parse-entities@4.0.2:
+ dependencies:
+ '@types/unist': 2.0.11
+ character-entities-legacy: 3.0.0
+ character-reference-invalid: 2.0.1
+ decode-named-character-reference: 1.1.0
+ is-alphanumerical: 2.0.1
+ is-decimal: 2.0.1
+ is-hexadecimal: 2.0.1
+
parse-node-version@1.0.1: {}
parse5@7.3.0:
@@ -3784,6 +4350,8 @@
ansi-styles: 5.2.0
react-is: 18.3.1
+ property-information@7.1.0: {}
+
proxy-from-env@1.1.0: {}
prr@1.0.1:
@@ -4121,6 +4689,24 @@
react-is@18.3.1: {}
+ react-markdown@10.1.0(@types/react@19.1.4)(react@19.1.0):
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@types/react': 19.1.4
+ devlop: 1.1.0
+ hast-util-to-jsx-runtime: 2.3.6
+ html-url-attributes: 3.0.1
+ mdast-util-to-hast: 13.2.0
+ react: 19.1.0
+ remark-parse: 11.0.0
+ remark-rehype: 11.1.2
+ unified: 11.0.5
+ unist-util-visit: 5.0.0
+ vfile: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
react-redux@9.2.0(@types/react@19.1.4)(react@19.1.0)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
@@ -4153,6 +4739,23 @@
redux@5.0.1: {}
+ remark-parse@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.2
+ micromark-util-types: 2.0.2
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-rehype@11.1.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ mdast-util-to-hast: 13.2.0
+ unified: 11.0.5
+ vfile: 6.0.3
+
reselect@5.1.1: {}
resize-observer-polyfill@1.5.1: {}
@@ -4232,6 +4835,8 @@
source-map@0.6.1:
optional: true
+ space-separated-tokens@2.0.2: {}
+
stack-utils@2.0.6:
dependencies:
escape-string-regexp: 2.0.0
@@ -4242,12 +4847,25 @@
string-convert@0.2.1: {}
+ stringify-entities@4.0.4:
+ dependencies:
+ character-entities-html4: 2.1.0
+ character-entities-legacy: 3.0.0
+
strip-indent@3.0.0:
dependencies:
min-indent: 1.0.1
strip-json-comments@3.1.1: {}
+ style-to-js@1.1.16:
+ dependencies:
+ style-to-object: 1.0.8
+
+ style-to-object@1.0.8:
+ dependencies:
+ inline-style-parser: 0.2.4
+
stylis@4.3.6: {}
supports-color@7.2.0:
@@ -4293,6 +4911,10 @@
dependencies:
punycode: 2.3.1
+ trim-lines@3.0.1: {}
+
+ trough@2.2.0: {}
+
ts-api-utils@2.1.0(typescript@5.8.3):
dependencies:
typescript: 5.8.3
@@ -4317,6 +4939,39 @@
undici-types@6.21.0: {}
+ unified@11.0.5:
+ dependencies:
+ '@types/unist': 3.0.3
+ bail: 2.0.2
+ devlop: 1.1.0
+ extend: 3.0.2
+ is-plain-obj: 4.1.0
+ trough: 2.2.0
+ vfile: 6.0.3
+
+ unist-util-is@6.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-position@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-stringify-position@4.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-visit-parents@6.0.1:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.0
+
+ unist-util-visit@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.0
+ unist-util-visit-parents: 6.0.1
+
update-browserslist-db@1.1.3(browserslist@4.24.5):
dependencies:
browserslist: 4.24.5
@@ -4331,6 +4986,16 @@
dependencies:
react: 19.1.0
+ vfile-message@4.0.2:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-stringify-position: 4.0.0
+
+ vfile@6.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ vfile-message: 4.0.2
+
vite-node@3.1.4(@types/node@22.15.30)(less@4.3.0):
dependencies:
cac: 6.7.14
@@ -4365,7 +5030,7 @@
fsevents: 2.3.3
less: 4.3.0
- vitest@3.1.4(@types/node@22.15.30)(jsdom@26.1.0)(less@4.3.0):
+ vitest@3.1.4(@types/debug@4.1.12)(@types/node@22.15.30)(jsdom@26.1.0)(less@4.3.0):
dependencies:
'@vitest/expect': 3.1.4
'@vitest/mocker': 3.1.4(vite@6.3.5(@types/node@22.15.30)(less@4.3.0))
@@ -4389,6 +5054,7 @@
vite-node: 3.1.4(@types/node@22.15.30)(less@4.3.0)
why-is-node-running: 2.3.0
optionalDependencies:
+ '@types/debug': 4.1.12
'@types/node': 22.15.30
jsdom: 26.1.0
transitivePeerDependencies:
@@ -4442,3 +5108,5 @@
yallist@3.1.1: {}
yocto-queue@0.1.0: {}
+
+ zwitch@2.0.4: {}
diff --git a/src/AppLayout.tsx b/src/AppLayout.tsx
index 99765e6..ba15c74 100644
--- a/src/AppLayout.tsx
+++ b/src/AppLayout.tsx
@@ -1,26 +1,43 @@
+// src/AppLayout.tsx
+
+import React, { useEffect, useMemo } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router';
-import { Layout, Menu, Dropdown, Button, Flex } from 'antd';
-import { HomeOutlined, AppstoreOutlined, DownOutlined } from '@ant-design/icons';
-import { useEffect, useMemo } from 'react';
+import { Layout, Menu, Dropdown, Button, Flex, Space } from 'antd';
+import {
+ HomeOutlined,
+ AppstoreOutlined,
+ DownOutlined,
+ UserOutlined,
+ SettingOutlined,
+ LogoutOutlined
+} from '@ant-design/icons';
import logo from "./assets/logo.png";
import { useAppDispatch, useAppSelector } from './store/hooks';
import { getUserInfo } from './feature/user/userSlice';
import { logout } from './feature/auth/authSlice';
-const { Header } = Layout;
-const AppLayout = () => {
+const { Header, Content, Footer } = Layout;
+
+const AppLayout: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
- const userState = useAppSelector(state => state.user);
const dispatch = useAppDispatch();
- useEffect(() => {
- dispatch(getUserInfo())
- }, [dispatch])
+ // Redux状态
+ const { isAuth } = useAppSelector(state => state.auth);
+ const userState = useAppSelector(state => state.user);
- // 判断是否在登录、注册或找回密码页面
+ // 获取用户信息
+ useEffect(() => {
+ if (isAuth) {
+ dispatch(getUserInfo());
+ }
+ }, [dispatch, isAuth]);
+
+ // 判断是否在认证页面
const isAuthPage = useMemo(() => {
- return ['/login', '/register', '/forget'].includes(location.pathname);
+ const authPaths = ['/login', '/register', '/forget'];
+ return authPaths.includes(location.pathname);
}, [location.pathname]);
// 导航项配置
@@ -32,17 +49,24 @@
path: '/',
},
{
- key: 'tasks',
- label: '任务清单',
+ key: 'works',
+ label: '作品',
icon: <AppstoreOutlined />,
- path: '/tasks',
+ path: '/works',
},
];
+ // 获取当前选中的菜单项
+ const selectedKey = useMemo(() => {
+ if (location.pathname === '/') return 'home';
+ if (location.pathname.startsWith('/works')) return 'works';
+ return '';
+ }, [location.pathname]);
+
// 处理登出逻辑
const handleLogout = () => {
- dispatch(logout())
- navigate('/login'); // 重定向到登录页
+ dispatch(logout());
+ navigate('/login', { replace: true });
};
// 下拉菜单内容
@@ -50,66 +74,137 @@
{
key: 'profile',
label: '个人中心',
+ icon: <UserOutlined />,
onClick: () => navigate('/profile'),
},
{
+ key: 'settings',
+ label: '设置',
+ icon: <SettingOutlined />,
+ onClick: () => navigate('/settings'),
+ },
+ {
+ type: 'divider' as const,
+ },
+ {
key: 'logout',
label: '登出',
+ icon: <LogoutOutlined />,
onClick: handleLogout,
+ danger: true,
},
];
+ // 处理菜单点击
+ const handleMenuClick = (path: string) => {
+ navigate(path);
+ };
+
return (
<Layout style={{ minHeight: '100vh', width: '100%' }}>
- <Header className="header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
- {/* logo */}
- <Flex justify='center' align='center'>
- <img src={logo} alt="Logo" height='48px' />
- <div style={{ color: 'white', marginLeft: '10px' }}>
+ <Header
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ padding: '0 24px',
+ background: '#001529'
+ }}
+ >
+ {/* Logo区域 */}
+ <Flex
+ align="center"
+ style={{ cursor: 'pointer' }}
+ onClick={() => navigate('/')}
+ >
+ <img
+ src={logo}
+ alt="创驿Logo"
+ style={{ height: 48 }}
+ />
+ <div style={{
+ color: 'white',
+ marginLeft: 12,
+ fontSize: 18,
+ fontWeight: 'bold'
+ }}>
创驿
</div>
</Flex>
- <div style={{
- height: '30px',
- width: '1px',
- backgroundColor: 'white',
- margin: '0 20px',
- }}></div>
-
- {/* 中间导航菜单 */}
- <Menu
- mode="horizontal"
- theme="dark"
- selectedKeys={[location.pathname === '/' ? 'home' : location.pathname.slice(1)]}
- items={menuItems.map(item => ({
- ...item,
- onClick: () => navigate(item.path),
- }))}
- style={{ flex: 1 }}
- />
-
- {/* 右侧用户名和下拉菜单 */}
+ {/* 分割线 */}
{!isAuthPage && (
- <div style={{ display: 'flex', alignItems: 'center', color: 'white' }}>
- <span>{userState.username}</span>
+ <div style={{
+ height: 30,
+ width: 1,
+ backgroundColor: 'rgba(255, 255, 255, 0.3)',
+ margin: '0 20px',
+ }} />
+ )}
+
+ {/* 导航菜单 */}
+ {!isAuthPage && (
+ <Menu
+ mode="horizontal"
+ theme="dark"
+ selectedKeys={selectedKey ? [selectedKey] : []}
+ items={menuItems.map(item => ({
+ ...item,
+ onClick: () => handleMenuClick(item.path),
+ }))}
+ style={{
+ flex: 1,
+ minWidth: 0,
+ backgroundColor: 'transparent',
+ borderBottom: 'none'
+ }}
+ />
+ )}
+
+ {/* 用户信息区域 */}
+ {!isAuthPage && isAuth && (
+ <Space size="middle">
+ <span style={{ color: 'white' }}>
+ 欢迎,{userState.username || '用户'}
+ </span>
<Dropdown
- trigger={['click']}
menu={{ items: dropdownMenuItems }}
+ trigger={['click']}
+ placement="bottomRight"
>
- <Button type="text" icon={<DownOutlined />} style={{ marginLeft: '10px', color: 'white' }} />
+ <Button
+ type="text"
+ style={{
+ color: 'white',
+ display: 'flex',
+ alignItems: 'center'
+ }}
+ >
+ <DownOutlined />
+ </Button>
</Dropdown>
- </div>
+ </Space>
)}
</Header>
- <Layout.Content style={{ padding: '24px' }}>
+
+ <Content style={{
+ padding: isAuthPage ? 0 : 24,
+ minHeight: 'calc(100vh - 64px - 70px)' // 减去header和footer高度
+ }}>
<Outlet />
- </Layout.Content>
- <Layout.Footer style={{ textAlign: 'center' }}>
- © 2025 创驿 - 愿做你创作路上的同行者
- </Layout.Footer>
+ </Content>
+
+ {!isAuthPage && (
+ <Footer style={{
+ textAlign: 'center',
+ background: '#f0f2f5',
+ padding: '24px 50px'
+ }}>
+ © 2025 创驿 - 愿做你创作路上的同行者
+ </Footer>
+ )}
</Layout>
);
};
-export default AppLayout;
+export default AppLayout;
\ No newline at end of file
diff --git a/src/api/interceptors.ts b/src/api/interceptors.ts
index bc2e566..3c0863b 100644
--- a/src/api/interceptors.ts
+++ b/src/api/interceptors.ts
@@ -7,7 +7,6 @@
config.url = requestUrl.replace("/auth/","/");
} else {
const token = localStorage.getItem('token');
- console.log(token);
config.headers['token'] = `${token}`;
}
return config;
diff --git a/src/feature/auth/authSlice.ts b/src/feature/auth/authSlice.ts
index 607a6fd..289ce16 100644
--- a/src/feature/auth/authSlice.ts
+++ b/src/feature/auth/authSlice.ts
@@ -1,5 +1,7 @@
+// src/feature/auth/authSlice.ts
+
import { createAsyncThunk, createSlice, type PayloadAction } from "@reduxjs/toolkit";
-import type { AuthState } from "../../store/types";
+import type { AuthState } from "./types";
import type { LoginRequest } from "../../api/Auth/type";
import AuthAPI from "../../api/Auth/AuthApi";
@@ -9,9 +11,9 @@
const initialState: AuthState = {
token: storedToken || '',
loading: false,
- isAuth: false,
- error: ''
-}
+ isAuth: !!storedToken, // 如果有token就设为true
+ error: '',
+};
export const loginUser = createAsyncThunk<
{token: string},
@@ -33,17 +35,47 @@
}
);
+// 新增:从localStorage恢复登录状态
+export const loginFromLocalStorage = createAsyncThunk<
+ {token: string} | {empty: true},
+ void,
+ { rejectValue: string }
+>(
+ 'auth/loginFromLocalStorage',
+ async (_, { rejectWithValue, dispatch }) => {
+ try {
+ const token = localStorage.getItem('token');
+ if (!token) {
+ // 返回空状态而不是错误
+ return { empty: true };
+ }
+
+ // 直接使用refreshToken来验证token有效性
+ const result = await dispatch(refreshToken(token));
+ if (refreshToken.fulfilled.match(result)) {
+ // refresh成功,返回新的token
+ return { token: result.payload.token };
+ } else {
+ // refresh失败,token无效
+ localStorage.removeItem('token');
+ return rejectWithValue('token已失效,需要重新登录');
+ }
+ } catch {
+ localStorage.removeItem('token');
+ return rejectWithValue('恢复登录状态失败');
+ }
+ }
+);
+
export const refreshToken = createAsyncThunk<
{token: string},
string,
{ rejectValue: string }
>(
-
'auth/refresh',
async (oldToken: string, { rejectWithValue }) => {
try {
const response = await AuthAPI.refreshToken(oldToken);
- console.log(response);
if(response.data.code == 0)
return {token: response.data.data};
else
@@ -61,42 +93,77 @@
logout: (state) => {
state.token = '';
state.isAuth = false;
- localStorage.clear()
- },
- },extraReducers: (builder) => {
- // 处理登录的异步操作
- builder
- .addCase(loginUser.pending, (state) => {
- state.loading = true;
- })
- .addCase(loginUser.fulfilled, (state, action: PayloadAction<{token: string}>) => {
- state.loading = false;
- state.token = action.payload.token;
- state.isAuth = true;
-
- localStorage.setItem('token', state.token);
- })
- .addCase(loginUser.rejected, (state, action) => {
- state.loading = false;
- state.error = action.payload ? action.payload : '' // 错误处理
- })
-
- // 处理刷新 token 的异步操作
- .addCase(refreshToken.pending, (state) => {
- state.loading = true;
- })
- .addCase(refreshToken.fulfilled, (state, action) => {
- state.loading = false;
- state.token = action.payload.token;
- localStorage.setItem('token', state.token);
- })
- .addCase(refreshToken.rejected, (state, action) => {
- state.loading = false;
- state.error = action.payload ? action.payload : ''
- });
+ localStorage.clear();
},
+ // 清除错误信息
+ clearError: (state) => {
+ state.error = '';
+ },
+ },
+ extraReducers: (builder) => {
+ // 处理普通登录的异步操作
+ builder
+ .addCase(loginUser.pending, (state) => {
+ state.loading = true;
+ state.error = '';
+ })
+ .addCase(loginUser.fulfilled, (state, action: PayloadAction<{token: string}>) => {
+ state.loading = false;
+ state.token = action.payload.token;
+ state.isAuth = true;
+ localStorage.setItem('token', state.token);
+ })
+ .addCase(loginUser.rejected, (state, action) => {
+ state.loading = false;
+ state.error = action.payload ? action.payload : '';
+ });
+
+ // 处理从localStorage恢复登录状态
+ builder
+ .addCase(loginFromLocalStorage.pending, (state) => {
+ state.loading = true;
+ state.error = '';
+ })
+ .addCase(loginFromLocalStorage.fulfilled, (state, action) => {
+ state.loading = false;
+ if ('token' in action.payload) {
+ // 有token的情况
+ state.token = action.payload.token;
+ state.isAuth = true;
+ } else {
+ // 空token的情况
+ state.token = '';
+ state.isAuth = false;
+ }
+ })
+ .addCase(loginFromLocalStorage.rejected, (state, action) => {
+ state.loading = false;
+ state.token = '';
+ state.isAuth = false;
+ state.error = action.payload ? action.payload : '';
+ localStorage.removeItem('token');
+ });
+
+ // 处理刷新 token 的异步操作
+ builder
+ .addCase(refreshToken.pending, (state) => {
+ state.loading = true;
+ state.error = '';
+ })
+ .addCase(refreshToken.fulfilled, (state, action) => {
+ state.loading = false;
+ state.token = action.payload.token;
+ state.isAuth = true;
+ localStorage.setItem('token', state.token);
+ })
+ .addCase(refreshToken.rejected, (state, action) => {
+ state.loading = false;
+ state.error = action.payload ? action.payload : '';
+ state.isAuth = false;
+ });
+ },
});
-export const { logout } = authSlice.actions;
+export const { logout, clearError } = authSlice.actions;
export default authSlice.reducer;
\ No newline at end of file
diff --git a/src/feature/auth/types.ts b/src/feature/auth/types.ts
new file mode 100644
index 0000000..6d91e01
--- /dev/null
+++ b/src/feature/auth/types.ts
@@ -0,0 +1,7 @@
+// 定义认证状态
+export interface AuthState {
+ token: string;
+ loading: boolean;
+ isAuth: boolean;
+ error: string
+}
\ No newline at end of file
diff --git a/src/feature/Home.tsx b/src/feature/home/Home.tsx
similarity index 100%
rename from src/feature/Home.tsx
rename to src/feature/home/Home.tsx
diff --git a/src/feature/user/userSlice.ts b/src/feature/user/userSlice.ts
index d6200af..c3f7e44 100644
--- a/src/feature/user/userSlice.ts
+++ b/src/feature/user/userSlice.ts
@@ -32,12 +32,8 @@
async (_, { rejectWithValue }) => {
const response = await UserAPi.getMe();
if (response.data.code == 0) {
- console.log("xixi")
- console.log(response)
return response.data.data;
} else {
- console.log("buxixi")
- console.log(response)
return rejectWithValue(response.data.message);
}
}
diff --git a/src/feature/work/CreatWorkComponents.tsx b/src/feature/work/CreatWorkComponents.tsx
new file mode 100644
index 0000000..12766ee
--- /dev/null
+++ b/src/feature/work/CreatWorkComponents.tsx
@@ -0,0 +1,545 @@
+import React, { useState, useCallback } from 'react';
+import {
+ Form, Input, Select, Upload, Button, Card, message, Space, Modal,
+ Divider, Row, Col, Typography, Alert, List, Tag,
+ type FormInstance
+} from 'antd';
+import {
+ PlusOutlined, InboxOutlined, DeleteOutlined, EditOutlined,
+ CheckCircleOutlined, SendOutlined, FileTextOutlined
+} from '@ant-design/icons';
+import ReactMarkdown from 'react-markdown';
+import type {
+ UploadFile, UploadProps, UploadChangeParam, RcFile,
+} from 'antd/es/upload';
+import { type StepFormProps, type BasicInfo, ARTWORK_CATEGORIES, type VersionFormData } from './types';
+import { getBase64 } from './utils';
+
+const { TextArea } = Input;
+const { Dragger } = Upload;
+const { Title, Text, Paragraph } = Typography;
+
+// ==================== 描述组件 ====================
+interface DescriptionsProps {
+ bordered?: boolean;
+ column?: number;
+ children: React.ReactNode;
+}
+
+interface DescriptionsItemProps {
+ label: string;
+ children: React.ReactNode;
+}
+
+export const Descriptions: React.FC<DescriptionsProps> & {
+ Item: React.FC<DescriptionsItemProps>;
+} = ({ children }) => {
+ return <div style={{ marginBottom: 16 }}>{children}</div>;
+};
+
+Descriptions.Item = ({ label, children }: DescriptionsItemProps) => {
+ return (
+ <div style={{ marginBottom: 12 }}>
+ <Text strong style={{ marginRight: 16 }}>{label}:</Text>
+ <span>{children}</span>
+ </div>
+ );
+};
+
+// ==================== 步骤1:基础信息 ====================
+export const BasicInfoStep: React.FC<StepFormProps> = ({ data, onUpdate, onNext }) => {
+ const [form]: [FormInstance<BasicInfo>] = Form.useForm<BasicInfo>();
+ const [previewMode, setPreviewMode] = useState<boolean>(false);
+
+ const handleFinish = useCallback((values: BasicInfo) => {
+ onUpdate('basicInfo', values);
+ onNext?.();
+ }, [onUpdate, onNext]);
+
+ const handlePreview = useCallback(() => {
+ form.validateFields().then((values) => {
+ onUpdate('basicInfo', values);
+ setPreviewMode(true);
+ }).catch(() => {
+ message.error('请先填写完整信息');
+ });
+ }, [form, onUpdate]);
+
+ return (
+ <>
+ <Card>
+ <Form form={form} layout="vertical" initialValues={data.basicInfo} onFinish={handleFinish} autoComplete="off">
+ <Form.Item
+ label="作品名称"
+ name="artworkName"
+ rules={[
+ { required: true, message: '请输入作品名称' },
+ { min: 2, message: '作品名称至少2个字符' },
+ { max: 50, message: '作品名称最多50个字符' },
+ ]}
+ >
+ <Input placeholder="请输入作品名称,例如:未来城市概念设计" size="large" showCount maxLength={50} />
+ </Form.Item>
+
+ <Form.Item label="作品分类" name="artworkCategory" rules={[{ required: true, message: '请选择作品分类' }]}>
+ <Select placeholder="请选择作品分类" size="large" options={ARTWORK_CATEGORIES} />
+ </Form.Item>
+
+ <Form.Item
+ label="作品描述"
+ name="artworkDescription"
+ rules={[
+ { required: true, message: '请输入作品描述' },
+ { min: 20, message: '作品描述至少20个字符' },
+ { max: 2000, message: '作品描述最多2000个字符' },
+ ]}
+ extra="支持 Markdown 格式,可以使用 # 标题、**粗体**、*斜体* 等格式"
+ >
+ <TextArea
+ placeholder="请详细描述你的作品,包括创作理念、技术特点、使用说明等"
+ rows={10}
+ showCount
+ maxLength={2000}
+ />
+ </Form.Item>
+
+ <Form.Item>
+ <Space size="middle">
+ <Button type="primary" htmlType="submit" size="large">下一步</Button>
+ <Button onClick={handlePreview} size="large">预览描述</Button>
+ </Space>
+ </Form.Item>
+ </Form>
+ </Card>
+
+ <Modal title="作品描述预览" open={previewMode} onCancel={() => setPreviewMode(false)} footer={null} width={800}>
+ <div style={{ maxHeight: '60vh', overflow: 'auto' }}>
+ <ReactMarkdown>{form.getFieldValue('artworkDescription') || ''}</ReactMarkdown>
+ </div>
+ </Modal>
+ </>
+ );
+};
+
+// ==================== 步骤2:封面上传 ====================
+export const CoverUploadStep: React.FC<StepFormProps> = ({ data, onUpdate, onNext, onPrev }) => {
+ const [fileList, setFileList] = useState<UploadFile[]>(data.coverInfo.coverFile ? [data.coverInfo.coverFile] : []);
+ const [previewImage, setPreviewImage] = useState<string>('');
+ const [previewOpen, setPreviewOpen] = useState<boolean>(false);
+
+ const handleChange: UploadProps['onChange'] = useCallback((info: UploadChangeParam<UploadFile>) => {
+ const { fileList: newFileList } = info;
+ setFileList(newFileList);
+ onUpdate('coverInfo', { coverFile: newFileList[0] });
+ }, [onUpdate]);
+
+ const handlePreview = useCallback(async (file: UploadFile): Promise<void> => {
+ if (!file.url && !file.preview) {
+ file.preview = await getBase64(file.originFileObj as RcFile);
+ }
+ setPreviewImage(file.url || (file.preview as string));
+ setPreviewOpen(true);
+ }, []);
+
+ const beforeUpload = useCallback((file: RcFile): boolean => {
+ const isImage = file.type.startsWith('image/');
+ if (!isImage) {
+ message.error('只能上传图片文件!');
+ return false;
+ }
+ const isLt5M = file.size / 1024 / 1024 < 5;
+ if (!isLt5M) {
+ message.error('图片大小不能超过 5MB!');
+ return false;
+ }
+ return false;
+ }, []);
+
+ const handleNext = useCallback((): void => {
+ if (fileList.length === 0) {
+ message.error('请上传作品封面');
+ return;
+ }
+ onNext?.();
+ }, [fileList, onNext]);
+
+ return (
+ <Card>
+ <Alert
+ message="封面图片要求"
+ description={
+ <ul style={{ marginBottom: 0, paddingLeft: 20 }}>
+ <li>图片格式:JPG、PNG、GIF</li>
+ <li>图片大小:不超过 5MB</li>
+ <li>建议尺寸:宽高比 3:4,最小分辨率 600x800</li>
+ <li>内容要求:清晰展示作品特色,避免模糊或像素化</li>
+ </ul>
+ }
+ type="info"
+ showIcon
+ style={{ marginBottom: 24 }}
+ />
+
+ <Upload
+ listType="picture-card"
+ fileList={fileList}
+ onChange={handleChange}
+ onPreview={handlePreview}
+ beforeUpload={beforeUpload}
+ maxCount={1}
+ accept="image/*"
+ >
+ {fileList.length === 0 && (
+ <div>
+ <PlusOutlined />
+ <div style={{ marginTop: 8 }}>上传封面</div>
+ </div>
+ )}
+ </Upload>
+
+ <Modal open={previewOpen} title="封面预览" footer={null} onCancel={() => setPreviewOpen(false)}>
+ <img alt="封面预览" style={{ width: '100%' }} src={previewImage} />
+ </Modal>
+
+ <Divider />
+ <Space size="middle">
+ <Button onClick={onPrev} size="large">上一步</Button>
+ <Button type="primary" onClick={handleNext} size="large">下一步</Button>
+ </Space>
+ </Card>
+ );
+};
+
+// ==================== 版本管理相关组件 ====================
+const VersionItem: React.FC<{
+ version: VersionFormData;
+ index: number;
+ onEdit: (index: number) => void;
+ onDelete: (index: number) => void;
+}> = ({ version, index, onEdit, onDelete }) => {
+ return (
+ <Row align="middle">
+ <Col span={20}>
+ <Space direction="vertical" style={{ width: '100%' }}>
+ <Space>
+ <Tag color="blue">v{version.version}</Tag>
+ <Text strong>{version.versionDescription}</Text>
+ </Space>
+ {version.seedFile && (
+ <Space>
+ <FileTextOutlined />
+ <Text type="secondary">{version.seedFile.name}</Text>
+ </Space>
+ )}
+ </Space>
+ </Col>
+ <Col span={4} style={{ textAlign: 'right' }}>
+ <Space>
+ <Button type="text" icon={<EditOutlined />} onClick={() => onEdit(index)} />
+ <Button type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(index)} />
+ </Space>
+ </Col>
+ </Row>
+ );
+};
+
+const VersionEditForm: React.FC<{
+ form: FormInstance<VersionFormData>;
+ version: VersionFormData;
+ onSave: () => void;
+ onCancel: () => void;
+ onFileChange: (file: UploadFile | undefined) => void;
+}> = ({ form, version, onSave, onCancel, onFileChange }) => {
+ const beforeUpload = useCallback((file: RcFile): boolean => {
+ const isLt100M = file.size / 1024 / 1024 < 100;
+ if (!isLt100M) {
+ message.error('种子文件大小不能超过 100MB!');
+ return false;
+ }
+ return false;
+ }, []);
+
+ return (
+ <Form form={form} layout="vertical" initialValues={version}>
+ <Row gutter={16}>
+ <Col span={6}>
+ <Form.Item label="版本号" name="version" rules={[{ required: true, message: '请输入版本号' }]}>
+ <Input placeholder="例如:1.0" />
+ </Form.Item>
+ </Col>
+ <Col span={18}>
+ <Form.Item
+ label="版本描述"
+ name="versionDescription"
+ rules={[
+ { required: true, message: '请输入版本描述' },
+ { min: 10, message: '版本描述至少10个字符' },
+ ]}
+ >
+ <TextArea placeholder="描述此版本的更新内容、新增功能等" rows={3} showCount maxLength={500} />
+ </Form.Item>
+ </Col>
+ </Row>
+
+ <Form.Item label="种子文件">
+ <Dragger
+ maxCount={1}
+ beforeUpload={beforeUpload}
+ fileList={version.seedFile ? [version.seedFile] : []}
+ onChange={({ fileList }) => onFileChange(fileList[0])}
+ onRemove={() => onFileChange(undefined)}
+ >
+ <p className="ant-upload-drag-icon"><InboxOutlined /></p>
+ <p className="ant-upload-text">点击或拖拽文件到此区域上传</p>
+ <p className="ant-upload-hint">支持单个文件上传,文件大小不超过 100MB</p>
+ </Dragger>
+ </Form.Item>
+
+ <Space>
+ <Button type="primary" onClick={onSave}>保存</Button>
+ <Button onClick={onCancel}>取消</Button>
+ </Space>
+ </Form>
+ );
+};
+
+// ==================== 步骤3:版本管理 ====================
+export const VersionManagementStep: React.FC<StepFormProps> = ({ data, onUpdate, onNext, onPrev }) => {
+ const [versions, setVersions] = useState<VersionFormData[]>(
+ data.versions.length > 0 ? data.versions : [{ version: '1.0', versionDescription: '', seedFile: undefined }]
+ );
+ const [editingIndex, setEditingIndex] = useState<number | null>(null);
+ const [form]: [FormInstance<VersionFormData>] = Form.useForm<VersionFormData>();
+
+ const handleAddVersion = useCallback(() => {
+ const newVersion: VersionFormData = {
+ version: `${versions.length + 1}.0`,
+ versionDescription: '',
+ seedFile: undefined,
+ };
+ setVersions([...versions, newVersion]);
+ setEditingIndex(versions.length);
+ }, [versions]);
+
+ const handleSaveVersion = useCallback((index: number) => {
+ form.validateFields().then((values) => {
+ const newVersions = [...versions];
+ newVersions[index] = { ...newVersions[index], ...values };
+ setVersions(newVersions);
+ setEditingIndex(null);
+ form.resetFields();
+ message.success('版本信息已保存');
+ }).catch(() => {
+ message.error('请完整填写版本信息');
+ });
+ }, [form, versions]);
+
+ const handleDeleteVersion = useCallback((index: number) => {
+ Modal.confirm({
+ title: '确认删除',
+ content: '确定要删除这个版本吗?',
+ onOk: () => {
+ const newVersions = versions.filter((_, i) => i !== index);
+ setVersions(newVersions);
+ if (editingIndex === index) {
+ setEditingIndex(null);
+ }
+ },
+ });
+ }, [versions, editingIndex]);
+
+ const handleFileChange = useCallback((index: number, file: UploadFile | undefined) => {
+ const newVersions = [...versions];
+ newVersions[index].seedFile = file;
+ setVersions(newVersions);
+ }, [versions]);
+
+ const handleNext = useCallback(() => {
+ if (versions.length === 0) {
+ message.error('至少需要添加一个版本');
+ return;
+ }
+
+ const incompleteVersion = versions.find((v, index) =>
+ !v.version || !v.versionDescription || !v.seedFile || index === editingIndex
+ );
+
+ if (incompleteVersion) {
+ message.error('请完成所有版本的信息填写');
+ return;
+ }
+
+ onUpdate('versions', versions);
+ onNext?.();
+ }, [versions, editingIndex, onUpdate, onNext]);
+
+ return (
+ <Card>
+ <div style={{ marginBottom: 16 }}>
+ <Title level={4}>版本列表</Title>
+ <Paragraph type="secondary">每个版本需要包含版本号、版本描述和种子文件</Paragraph>
+ </div>
+
+ <List
+ dataSource={versions}
+ renderItem={(version, index) => (
+ <List.Item
+ key={index}
+ style={{
+ background: editingIndex === index ? '#fafafa' : 'transparent',
+ padding: 16,
+ marginBottom: 16,
+ border: '1px solid #f0f0f0',
+ borderRadius: 8,
+ }}
+ >
+ {editingIndex === index ? (
+ <VersionEditForm
+ form={form}
+ version={version}
+ onSave={() => handleSaveVersion(index)}
+ onCancel={() => setEditingIndex(null)}
+ onFileChange={(file) => handleFileChange(index, file)}
+ />
+ ) : (
+ <VersionItem
+ version={version}
+ index={index}
+ onEdit={(idx) => {
+ setEditingIndex(idx);
+ form.setFieldsValue(version);
+ }}
+ onDelete={handleDeleteVersion}
+ />
+ )}
+ </List.Item>
+ )}
+ />
+
+ {editingIndex === null && (
+ <Button
+ type="dashed"
+ onClick={handleAddVersion}
+ style={{ width: '100%', marginBottom: 24 }}
+ icon={<PlusOutlined />}
+ >
+ 添加版本
+ </Button>
+ )}
+
+ <Divider />
+ <Space size="middle">
+ <Button onClick={onPrev} size="large">上一步</Button>
+ <Button type="primary" onClick={handleNext} size="large">下一步</Button>
+ </Space>
+ </Card>
+ );
+};
+
+// ==================== 步骤4:确认发布 ====================
+export const ConfirmPublishStep: React.FC<StepFormProps & { onPublish: () => void }> = ({ data, onPrev, onPublish }) => {
+ const [publishing, setPublishing] = useState<boolean>(false);
+
+ const handlePublish = useCallback(async () => {
+ setPublishing(true);
+ try {
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ message.success('作品发布成功!');
+ onPublish();
+ } catch {
+ message.error('发布失败,请重试');
+ } finally {
+ setPublishing(false);
+ }
+ }, [onPublish]);
+
+ return (
+ <Card>
+ <Title level={3} style={{ marginBottom: 24 }}>
+ <CheckCircleOutlined style={{ color: '#52c41a', marginRight: 8 }} />
+ 确认发布信息
+ </Title>
+
+ <div style={{ marginBottom: 32 }}>
+ <Title level={4}>基础信息</Title>
+ <Descriptions bordered column={1}>
+ <Descriptions.Item label="作品名称">{data.basicInfo.artworkName}</Descriptions.Item>
+ <Descriptions.Item label="作品分类">
+ <Tag color="blue">{data.basicInfo.artworkCategory}</Tag>
+ </Descriptions.Item>
+ <Descriptions.Item label="作品描述">
+ <div style={{ maxHeight: 200, overflow: 'auto' }}>
+ <ReactMarkdown>{data.basicInfo.artworkDescription}</ReactMarkdown>
+ </div>
+ </Descriptions.Item>
+ </Descriptions>
+ </div>
+
+ <div style={{ marginBottom: 32 }}>
+ <Title level={4}>作品封面</Title>
+ {data.coverInfo.coverFile && (
+ <img
+ src={data.coverInfo.coverFile.thumbUrl || data.coverInfo.coverFile.url}
+ alt="作品封面"
+ style={{ maxWidth: 300, maxHeight: 400, objectFit: 'cover' }}
+ />
+ )}
+ </div>
+
+ <div style={{ marginBottom: 32 }}>
+ <Title level={4}>版本信息</Title>
+ <List
+ dataSource={data.versions}
+ renderItem={(version) => (
+ <List.Item>
+ <List.Item.Meta
+ title={
+ <Space>
+ <Tag color="green">v{version.version}</Tag>
+ <Text>{version.versionDescription}</Text>
+ </Space>
+ }
+ description={
+ version.seedFile && (
+ <Space>
+ <FileTextOutlined />
+ <Text type="secondary">{version.seedFile.name}</Text>
+ </Space>
+ )
+ }
+ />
+ </List.Item>
+ )}
+ />
+ </div>
+
+ <Alert
+ message="发布须知"
+ description={
+ <ul style={{ marginBottom: 0, paddingLeft: 20 }}>
+ <li>发布后的作品将公开展示,所有用户都可以查看和下载</li>
+ <li>请确保作品内容符合社区规范,不包含违法违规内容</li>
+ <li>发布后您仍可以编辑作品信息和添加新版本</li>
+ <li>请尊重他人知识产权,确保作品为原创或已获得授权</li>
+ </ul>
+ }
+ type="warning"
+ showIcon
+ style={{ marginBottom: 24 }}
+ />
+
+ <Space size="middle">
+ <Button onClick={onPrev} size="large">上一步</Button>
+ <Button
+ type="primary"
+ onClick={handlePublish}
+ loading={publishing}
+ icon={<SendOutlined />}
+ size="large"
+ >
+ 确认发布
+ </Button>
+ </Space>
+ </Card>
+ );
+};
\ No newline at end of file
diff --git a/src/feature/work/CreateWork.tsx b/src/feature/work/CreateWork.tsx
new file mode 100644
index 0000000..5e97433
--- /dev/null
+++ b/src/feature/work/CreateWork.tsx
@@ -0,0 +1,53 @@
+import React, { useMemo } from 'react';
+import { Layout, Steps, Card, Typography } from 'antd';
+import { FileTextOutlined, FileImageOutlined, SaveOutlined, SendOutlined } from '@ant-design/icons';
+import { BasicInfoStep, CoverUploadStep, VersionManagementStep, ConfirmPublishStep } from './CreatWorkComponents';
+import { useCreateWorkForm } from './hooks';
+
+
+const { Content } = Layout;
+const { Title, Paragraph } = Typography;
+
+const CreateWork: React.FC = () => {
+ const { currentStep, formData, handleUpdateFormData, handleNext, handlePrev, handlePublish } = useCreateWorkForm();
+
+ const steps = useMemo(() => [
+ { title: '基础信息', description: '填写作品基本信息', icon: <FileTextOutlined /> },
+ { title: '上传封面', description: '上传作品封面图片', icon: <FileImageOutlined /> },
+ { title: '版本管理', description: '添加作品版本和文件', icon: <SaveOutlined /> },
+ { title: '确认发布', description: '检查并发布作品', icon: <SendOutlined /> },
+ ], []);
+
+ const renderStepContent = useMemo(() => {
+ const commonProps = { data: formData, onUpdate: handleUpdateFormData, onNext: handleNext, onPrev: handlePrev };
+
+ switch (currentStep) {
+ case 0: return <BasicInfoStep {...commonProps} />;
+ case 1: return <CoverUploadStep {...commonProps} />;
+ case 2: return <VersionManagementStep {...commonProps} />;
+ case 3: return <ConfirmPublishStep {...commonProps} onPublish={handlePublish} />;
+ default: return null;
+ }
+ }, [currentStep, formData, handleUpdateFormData, handleNext, handlePrev, handlePublish]);
+
+ return (
+ <Layout style={{ minHeight: '100vh', backgroundColor: '#f5f5f5' }}>
+ <Content style={{ padding: '20px' }}>
+ <div style={{ maxWidth: 1000, margin: '0 auto' }}>
+ <Card style={{ marginBottom: 24 }}>
+ <Title level={2} style={{ marginBottom: 8 }}>创建新作品</Title>
+ <Paragraph type="secondary">通过以下步骤发布您的创意作品,与社区分享您的才华</Paragraph>
+ </Card>
+
+ <Card style={{ marginBottom: 24 }}>
+ <Steps current={currentStep} items={steps} />
+ </Card>
+
+ {renderStepContent}
+ </div>
+ </Content>
+ </Layout>
+ );
+};
+
+export default CreateWork;
\ No newline at end of file
diff --git a/src/feature/work/EditWork.tsx b/src/feature/work/EditWork.tsx
new file mode 100644
index 0000000..f56d42a
--- /dev/null
+++ b/src/feature/work/EditWork.tsx
@@ -0,0 +1,753 @@
+import React, { useState, useCallback } from 'react';
+import {
+ Modal, Form, Input, Upload, Button, message, Space, Card,
+ Typography, Alert, List, Tag, Popconfirm, Row, Col,
+ type FormInstance
+} from 'antd';
+import {
+ EditOutlined, DeleteOutlined, PlusOutlined, InboxOutlined,
+ SaveOutlined, FileTextOutlined, UserOutlined
+} from '@ant-design/icons';
+import ReactMarkdown from 'react-markdown';
+import type {
+ UploadFile, UploadProps, UploadChangeParam, RcFile,
+} from 'antd/es/upload';
+import type {
+ ArtworkData, VersionFormData, Comment,
+} from './types';
+
+const { TextArea } = Input;
+const { Dragger } = Upload;
+const { Text } = Typography;
+
+// ==================== 工具函数 ====================
+const getBase64 = (file: RcFile): Promise<string> =>
+ new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = (): void => resolve(reader.result as string);
+ reader.onerror = (error): void => reject(error);
+ });
+
+// ==================== 类型定义 ====================
+interface EditCoverProps {
+ visible: boolean;
+ currentCover: string;
+ onCancel: () => void;
+ onSave: (coverUrl: string) => Promise<void>;
+}
+
+interface EditDescriptionProps {
+ visible: boolean;
+ currentDescription: string;
+ onCancel: () => void;
+ onSave: (description: string) => Promise<void>;
+}
+
+interface EditVersionsProps {
+ visible: boolean;
+ versions: VersionFormData[];
+ onCancel: () => void;
+ onSave: (versions: VersionFormData[]) => Promise<void>;
+}
+
+interface CommentItemProps {
+ comment: Comment;
+ isAuthor: boolean;
+ onDelete: (commentId: string) => Promise<void>;
+ level?: number;
+}
+
+interface EditWorkControlsProps {
+ artwork: ArtworkData;
+ isAuthor: boolean;
+ onUpdate: (updatedArtwork: Partial<ArtworkData>) => Promise<void>;
+ onDeleteComment: (commentId: string) => Promise<void>;
+}
+
+interface VersionItemProps {
+ version: VersionFormData;
+ index: number;
+ onEdit: (index: number) => void;
+ onDelete: (index: number) => void;
+}
+
+interface VersionEditFormProps {
+ form: FormInstance<VersionFormData>;
+ version: VersionFormData;
+ onSave: () => void;
+ onCancel: () => void;
+ onFileChange: (file: UploadFile | undefined) => void;
+}
+
+// ==================== 封面编辑组件 ====================
+export const EditWorkCover: React.FC<EditCoverProps> = ({
+ visible,
+ currentCover,
+ onCancel,
+ onSave
+}) => {
+ const [fileList, setFileList] = useState<UploadFile[]>([]);
+ const [previewImage, setPreviewImage] = useState<string>('');
+ const [previewOpen, setPreviewOpen] = useState<boolean>(false);
+ const [uploading, setUploading] = useState<boolean>(false);
+
+ const handleChange: UploadProps['onChange'] = useCallback((info: UploadChangeParam<UploadFile>): void => {
+ setFileList(info.fileList);
+ }, []);
+
+ const handlePreview = useCallback(async (file: UploadFile): Promise<void> => {
+ if (!file.url && !file.preview) {
+ file.preview = await getBase64(file.originFileObj as RcFile);
+ }
+ setPreviewImage(file.url || (file.preview as string));
+ setPreviewOpen(true);
+ }, []);
+
+ const beforeUpload = useCallback((file: RcFile): boolean => {
+ const isImage = file.type.startsWith('image/');
+ if (!isImage) {
+ message.error('只能上传图片文件!');
+ return false;
+ }
+ const isLt5M = file.size / 1024 / 1024 < 5;
+ if (!isLt5M) {
+ message.error('图片大小不能超过 5MB!');
+ return false;
+ }
+ return false; // 阻止自动上传
+ }, []);
+
+ const handleSave = useCallback(async (): Promise<void> => {
+ if (fileList.length === 0) {
+ message.error('请选择要上传的封面图片');
+ return;
+ }
+
+ setUploading(true);
+ try {
+ // 模拟上传过程
+ const file = fileList[0];
+ const coverUrl = file.preview || URL.createObjectURL(file.originFileObj as RcFile);
+
+ await onSave(coverUrl);
+ message.success('封面更新成功!');
+ onCancel();
+ } catch {
+ message.error('封面更新失败,请重试');
+ } finally {
+ setUploading(false);
+ }
+ }, [fileList, onSave, onCancel]);
+
+ return (
+ <>
+ <Modal
+ title="编辑作品封面"
+ open={visible}
+ onCancel={onCancel}
+ footer={[
+ <Button key="cancel" onClick={onCancel}>
+ 取消
+ </Button>,
+ <Button
+ key="save"
+ type="primary"
+ loading={uploading}
+ onClick={handleSave}
+ icon={<SaveOutlined />}
+ >
+ 保存
+ </Button>,
+ ]}
+ width={600}
+ >
+ <div style={{ marginBottom: 16 }}>
+ <Text strong>当前封面:</Text>
+ <div style={{ marginTop: 8 }}>
+ <img
+ src={currentCover}
+ alt="当前封面"
+ style={{ maxWidth: '100%', maxHeight: 200, objectFit: 'cover' }}
+ />
+ </div>
+ </div>
+
+ <Alert
+ message="新封面要求"
+ description="图片格式:JPG、PNG、GIF;大小不超过 5MB;建议尺寸:宽高比 3:4"
+ type="info"
+ showIcon
+ style={{ marginBottom: 16 }}
+ />
+
+ <Upload
+ listType="picture-card"
+ fileList={fileList}
+ onChange={handleChange}
+ onPreview={handlePreview}
+ beforeUpload={beforeUpload}
+ maxCount={1}
+ accept="image/*"
+ >
+ {fileList.length === 0 && (
+ <div>
+ <PlusOutlined />
+ <div style={{ marginTop: 8 }}>选择新封面</div>
+ </div>
+ )}
+ </Upload>
+ </Modal>
+
+ <Modal
+ open={previewOpen}
+ title="图片预览"
+ footer={null}
+ onCancel={(): void => setPreviewOpen(false)}
+ >
+ <img alt="预览" style={{ width: '100%' }} src={previewImage} />
+ </Modal>
+ </>
+ );
+};
+
+// ==================== 作品描述编辑组件 ====================
+export const EditWorkDescription: React.FC<EditDescriptionProps> = ({
+ visible,
+ currentDescription,
+ onCancel,
+ onSave
+}) => {
+ const [form] = Form.useForm<{ description: string }>();
+ const [previewMode, setPreviewMode] = useState<boolean>(false);
+ const [saving, setSaving] = useState<boolean>(false);
+
+ const handleSave = useCallback(async (): Promise<void> => {
+ try {
+ const values = await form.validateFields();
+ setSaving(true);
+ await onSave(values.description);
+ message.success('作品描述更新成功!');
+ onCancel();
+ } catch (error) {
+ if (error && typeof error === 'object' && 'errorFields' in error) {
+ message.error('请检查输入内容');
+ } else {
+ message.error('更新失败,请重试');
+ }
+ } finally {
+ setSaving(false);
+ }
+ }, [form, onSave, onCancel]);
+
+ const handlePreview = useCallback((): void => {
+ form.validateFields().then(() => {
+ setPreviewMode(true);
+ }).catch(() => {
+ message.error('请先填写完整信息');
+ });
+ }, [form]);
+
+ return (
+ <>
+ <Modal
+ title="编辑作品描述"
+ open={visible}
+ onCancel={onCancel}
+ footer={[
+ <Button key="preview" onClick={handlePreview}>
+ 预览
+ </Button>,
+ <Button key="cancel" onClick={onCancel}>
+ 取消
+ </Button>,
+ <Button
+ key="save"
+ type="primary"
+ loading={saving}
+ onClick={handleSave}
+ icon={<SaveOutlined />}
+ >
+ 保存
+ </Button>,
+ ]}
+ width={800}
+ >
+ <Form
+ form={form}
+ layout="vertical"
+ initialValues={{ description: currentDescription }}
+ >
+ <Form.Item
+ label="作品描述"
+ name="description"
+ rules={[
+ { required: true, message: '请输入作品描述' },
+ { min: 20, message: '作品描述至少20个字符' },
+ { max: 2000, message: '作品描述最多2000个字符' },
+ ]}
+ extra="支持 Markdown 格式,可以使用 # 标题、**粗体**、*斜体* 等格式"
+ >
+ <TextArea
+ placeholder="请详细描述你的作品,包括创作理念、技术特点、使用说明等"
+ rows={12}
+ showCount
+ maxLength={2000}
+ />
+ </Form.Item>
+ </Form>
+ </Modal>
+
+ <Modal
+ title="作品描述预览"
+ open={previewMode}
+ onCancel={(): void => setPreviewMode(false)}
+ footer={null}
+ width={800}
+ >
+ <div style={{ maxHeight: '60vh', overflow: 'auto' }}>
+ <ReactMarkdown>{form.getFieldValue('description') || ''}</ReactMarkdown>
+ </div>
+ </Modal>
+ </>
+ );
+};
+
+// ==================== 版本编辑相关组件 ====================
+const VersionItem: React.FC<VersionItemProps> = ({
+ version,
+ index,
+ onEdit,
+ onDelete
+}) => {
+ return (
+ <Row align="middle" style={{ width: '100%' }}>
+ <Col span={20}>
+ <Space direction="vertical" style={{ width: '100%' }}>
+ <Space>
+ <Tag color="blue">v{version.version}</Tag>
+ <Text strong>{version.versionDescription}</Text>
+ </Space>
+ {version.seedFile && (
+ <Space>
+ <FileTextOutlined />
+ <Text type="secondary">{version.seedFile.name}</Text>
+ </Space>
+ )}
+ </Space>
+ </Col>
+ <Col span={4} style={{ textAlign: 'right' }}>
+ <Space>
+ <Button
+ type="text"
+ icon={<EditOutlined />}
+ onClick={(): void => onEdit(index)}
+ />
+ <Popconfirm
+ title="确定要删除这个版本吗?"
+ onConfirm={(): void => onDelete(index)}
+ okText="确定"
+ cancelText="取消"
+ >
+ <Button type="text" danger icon={<DeleteOutlined />} />
+ </Popconfirm>
+ </Space>
+ </Col>
+ </Row>
+ );
+};
+
+const VersionEditForm: React.FC<VersionEditFormProps> = ({
+ form,
+ version,
+ onSave,
+ onCancel,
+ onFileChange
+}) => {
+ const beforeUpload = useCallback((file: RcFile): boolean => {
+ const isLt100M = file.size / 1024 / 1024 < 100;
+ if (!isLt100M) {
+ message.error('种子文件大小不能超过 100MB!');
+ return false;
+ }
+ return false;
+ }, []);
+
+ return (
+ <div style={{ width: '100%' }}>
+ <Form form={form} layout="vertical" initialValues={version}>
+ <Row gutter={16}>
+ <Col span={6}>
+ <Form.Item
+ label="版本号"
+ name="version"
+ rules={[{ required: true, message: '请输入版本号' }]}
+ >
+ <Input placeholder="例如:1.0" />
+ </Form.Item>
+ </Col>
+ <Col span={18}>
+ <Form.Item
+ label="版本描述"
+ name="versionDescription"
+ rules={[
+ { required: true, message: '请输入版本描述' },
+ { min: 10, message: '版本描述至少10个字符' },
+ ]}
+ >
+ <TextArea
+ placeholder="描述此版本的更新内容、新增功能等"
+ rows={3}
+ showCount
+ maxLength={500}
+ />
+ </Form.Item>
+ </Col>
+ </Row>
+
+ <Form.Item label="种子文件">
+ <Dragger
+ maxCount={1}
+ beforeUpload={beforeUpload}
+ fileList={version.seedFile ? [version.seedFile] : []}
+ onChange={({ fileList }): void => onFileChange(fileList[0])}
+ onRemove={(): void => onFileChange(undefined)}
+ >
+ <p className="ant-upload-drag-icon"><InboxOutlined /></p>
+ <p className="ant-upload-text">点击或拖拽文件到此区域上传</p>
+ <p className="ant-upload-hint">支持单个文件上传,文件大小不超过 100MB</p>
+ </Dragger>
+ </Form.Item>
+
+ <Space>
+ <Button type="primary" onClick={onSave}>
+ 保存
+ </Button>
+ <Button onClick={onCancel}>
+ 取消
+ </Button>
+ </Space>
+ </Form>
+ </div>
+ );
+};
+
+// ==================== 版本管理组件 ====================
+export const EditWorkVersions: React.FC<EditVersionsProps> = ({
+ visible,
+ versions,
+ onCancel,
+ onSave
+}) => {
+ const [localVersions, setLocalVersions] = useState<VersionFormData[]>(versions);
+ const [editingIndex, setEditingIndex] = useState<number | null>(null);
+ const [form] = Form.useForm<VersionFormData>();
+ const [saving, setSaving] = useState<boolean>(false);
+
+ const handleAddVersion = useCallback((): void => {
+ const newVersion: VersionFormData = {
+ version: `${localVersions.length + 1}.0`,
+ versionDescription: '',
+ seedFile: undefined,
+ };
+ setLocalVersions([...localVersions, newVersion]);
+ setEditingIndex(localVersions.length);
+ }, [localVersions]);
+
+ const handleSaveVersion = useCallback((index: number): void => {
+ form.validateFields().then((values) => {
+ const newVersions = [...localVersions];
+ newVersions[index] = { ...newVersions[index], ...values };
+ setLocalVersions(newVersions);
+ setEditingIndex(null);
+ form.resetFields();
+ message.success('版本信息已保存');
+ }).catch(() => {
+ message.error('请完整填写版本信息');
+ });
+ }, [form, localVersions]);
+
+ const handleDeleteVersion = useCallback((index: number): void => {
+ const newVersions = localVersions.filter((_, i) => i !== index);
+ setLocalVersions(newVersions);
+ if (editingIndex === index) {
+ setEditingIndex(null);
+ }
+ }, [localVersions, editingIndex]);
+
+ const handleFileChange = useCallback((index: number, file: UploadFile | undefined): void => {
+ const newVersions = [...localVersions];
+ newVersions[index].seedFile = file;
+ setLocalVersions(newVersions);
+ }, [localVersions]);
+
+ const handleSave = useCallback(async (): Promise<void> => {
+ if (localVersions.length === 0) {
+ message.error('至少需要保留一个版本');
+ return;
+ }
+
+ const incompleteVersion = localVersions.find((v, index) =>
+ !v.version || !v.versionDescription || !v.seedFile || index === editingIndex
+ );
+
+ if (incompleteVersion) {
+ message.error('请完成所有版本的信息填写');
+ return;
+ }
+
+ setSaving(true);
+ try {
+ await onSave(localVersions);
+ message.success('版本信息更新成功!');
+ onCancel();
+ } catch {
+ message.error('更新失败,请重试');
+ } finally {
+ setSaving(false);
+ }
+ }, [localVersions, editingIndex, onSave, onCancel]);
+
+ return (
+ <Modal
+ title="编辑版本信息"
+ open={visible}
+ onCancel={onCancel}
+ footer={[
+ <Button key="cancel" onClick={onCancel}>
+ 取消
+ </Button>,
+ <Button
+ key="save"
+ type="primary"
+ loading={saving}
+ onClick={handleSave}
+ icon={<SaveOutlined />}
+ >
+ 保存所有更改
+ </Button>,
+ ]}
+ width={900}
+ >
+ <div style={{ maxHeight: '60vh', overflow: 'auto' }}>
+ <List
+ dataSource={localVersions}
+ renderItem={(version, index): React.ReactElement => (
+ <List.Item
+ key={index}
+ style={{
+ background: editingIndex === index ? '#fafafa' : 'transparent',
+ padding: 16,
+ marginBottom: 16,
+ border: '1px solid #f0f0f0',
+ borderRadius: 8,
+ }}
+ >
+ {editingIndex === index ? (
+ <VersionEditForm
+ form={form}
+ version={version}
+ onSave={(): void => handleSaveVersion(index)}
+ onCancel={(): void => setEditingIndex(null)}
+ onFileChange={(file): void => handleFileChange(index, file)}
+ />
+ ) : (
+ <VersionItem
+ version={version}
+ index={index}
+ onEdit={(idx): void => {
+ setEditingIndex(idx);
+ form.setFieldsValue(version);
+ }}
+ onDelete={handleDeleteVersion}
+ />
+ )}
+ </List.Item>
+ )}
+ />
+
+ {editingIndex === null && (
+ <Button
+ type="dashed"
+ onClick={handleAddVersion}
+ style={{ width: '100%', marginTop: 16 }}
+ icon={<PlusOutlined />}
+ >
+ 添加新版本
+ </Button>
+ )}
+ </div>
+ </Modal>
+ );
+};
+
+// ==================== 评论管理组件 ====================
+export const EditWorkComment: React.FC<CommentItemProps> = ({
+ comment,
+ isAuthor,
+ onDelete,
+ level = 0
+}) => {
+ const [deleting, setDeleting] = useState<boolean>(false);
+
+ const handleDelete = useCallback(async (): Promise<void> => {
+ if (!comment.id) return;
+
+ setDeleting(true);
+ try {
+ await onDelete(comment.id);
+ message.success('评论删除成功');
+ } catch {
+ message.error('删除失败,请重试');
+ } finally {
+ setDeleting(false);
+ }
+ }, [comment.id, onDelete]);
+
+ return (
+ <div style={{ marginLeft: level * 24 }}>
+ <div
+ style={{
+ background: '#fafafa',
+ padding: 12,
+ borderRadius: 8,
+ marginBottom: 8,
+ }}
+ >
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
+ <div style={{ flex: 1 }}>
+ <Space style={{ marginBottom: 8 }}>
+ <UserOutlined />
+ <Text strong>{comment.author}</Text>
+ {comment.createdAt && (
+ <Text type="secondary" style={{ fontSize: 12 }}>
+ {new Date(comment.createdAt).toLocaleString()}
+ </Text>
+ )}
+ </Space>
+ <div>
+ <Text>{comment.content}</Text>
+ </div>
+ </div>
+
+ {isAuthor && comment.id && (
+ <Popconfirm
+ title="确定要删除这条评论吗?"
+ onConfirm={handleDelete}
+ okText="确定"
+ cancelText="取消"
+ >
+ <Button
+ type="text"
+ danger
+ size="small"
+ icon={<DeleteOutlined />}
+ loading={deleting}
+ />
+ </Popconfirm>
+ )}
+ </div>
+ </div>
+
+ {/* 递归渲染子评论 */}
+ {comment.child && comment.child.length > 0 && (
+ <div>
+ {comment.child.map((childComment, index) => (
+ <EditWorkComment
+ key={childComment.id || index}
+ comment={childComment}
+ isAuthor={isAuthor}
+ onDelete={onDelete}
+ level={level + 1}
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ );
+};
+
+// ==================== 主编辑控制器组件 ====================
+export const EditWorkControls: React.FC<EditWorkControlsProps> = ({
+ artwork,
+ isAuthor,
+ onUpdate,
+}) => {
+ const [editCoverVisible, setEditCoverVisible] = useState<boolean>(false);
+ const [editDescriptionVisible, setEditDescriptionVisible] = useState<boolean>(false);
+ const [editVersionsVisible, setEditVersionsVisible] = useState<boolean>(false);
+
+ const handleUpdateCover = useCallback(async (coverUrl: string): Promise<void> => {
+ await onUpdate({ artworkCover: coverUrl });
+ }, [onUpdate]);
+
+ const handleUpdateDescription = useCallback(async (description: string): Promise<void> => {
+ await onUpdate({ artworkDescription: description });
+ }, [onUpdate]);
+
+ const handleUpdateVersions = useCallback(async (versions: VersionFormData[]): Promise<void> => {
+ // 转换为展示用的版本格式
+ const versionList = versions.map(v => ({
+ version: v.version,
+ versionDescription: v.versionDescription,
+ seedFile: v.seedFile?.name || '',
+ }));
+ await onUpdate({ versionList });
+ }, [onUpdate]);
+
+ if (!isAuthor) {
+ return null;
+ }
+
+ return (
+ <>
+ <Card title="作者管理" style={{ marginBottom: 24 }}>
+ <Space wrap>
+ <Button
+ icon={<EditOutlined />}
+ onClick={(): void => setEditCoverVisible(true)}
+ >
+ 编辑封面
+ </Button>
+ <Button
+ icon={<EditOutlined />}
+ onClick={(): void => setEditDescriptionVisible(true)}
+ >
+ 编辑描述
+ </Button>
+ <Button
+ icon={<EditOutlined />}
+ onClick={(): void => setEditVersionsVisible(true)}
+ >
+ 管理版本
+ </Button>
+ </Space>
+ </Card>
+
+ <EditWorkCover
+ visible={editCoverVisible}
+ currentCover={artwork.artworkCover}
+ onCancel={(): void => setEditCoverVisible(false)}
+ onSave={handleUpdateCover}
+ />
+
+ <EditWorkDescription
+ visible={editDescriptionVisible}
+ currentDescription={artwork.artworkDescription}
+ onCancel={(): void => setEditDescriptionVisible(false)}
+ onSave={handleUpdateDescription}
+ />
+
+ <EditWorkVersions
+ visible={editVersionsVisible}
+ versions={artwork.versionList.map(v => ({
+ version: v.version,
+ versionDescription: v.versionDescription,
+ seedFile: { name: v.seedFile } as UploadFile,
+ }))}
+ onCancel={(): void => setEditVersionsVisible(false)}
+ onSave={handleUpdateVersions}
+ />
+ </>
+ );
+};
\ No newline at end of file
diff --git a/src/feature/work/Work.tsx b/src/feature/work/Work.tsx
new file mode 100644
index 0000000..56939d1
--- /dev/null
+++ b/src/feature/work/Work.tsx
@@ -0,0 +1,288 @@
+// src/feature/work/Work.tsx
+
+import React, { useEffect, useState, useCallback } from 'react';
+import { Layout, Flex, Spin, Alert, message } from 'antd';
+import { useParams } from 'react-router';
+import { useAppDispatch, useAppSelector } from '../../store/hooks';
+import {
+ fetchArtworkDetail, fetchComments, addComment, selectCurrentArtwork,
+ selectWorkLoading, selectWorkError, selectComments, setCommentsPage, clearCurrentArtwork,
+ updateArtwork, deleteComment
+} from './workSlice';
+import { Sidebar, MainContent } from './WorkComponents';
+import { EditWorkControls } from './EditWork';
+import type { ArtworkData, Comment } from './types';
+
+const { Content } = Layout;
+
+interface UserState {
+ userid?: string | number;
+ username?: string;
+}
+
+interface RootState {
+ user: UserState;
+ work: {
+ currentArtwork: ArtworkData | null;
+ loading: {
+ artwork: boolean;
+ comments: boolean;
+ addComment: boolean;
+ updateArtwork?: boolean;
+ deleteComment?: boolean;
+ };
+ error: {
+ artwork: string | null;
+ comments: string | null;
+ addComment: string | null;
+ updateArtwork?: string | null;
+ deleteComment?: string | null;
+ };
+ comments: {
+ list: Comment[];
+ total: number;
+ current: number;
+ pageSize: number;
+ };
+ };
+}
+
+const Work: React.FC = () => {
+ const dispatch = useAppDispatch();
+ const { work_id } = useParams<{ work_id: string }>();
+
+ // Redux state
+ const currentArtwork = useAppSelector(selectCurrentArtwork);
+ const loading = useAppSelector(selectWorkLoading);
+ const error = useAppSelector(selectWorkError);
+ const comments = useAppSelector(selectComments);
+ const currentUser = useAppSelector((state: RootState) => state.user);
+
+ // Local state for edit functionality
+ const [showEditControls, setShowEditControls] = useState<boolean>(false);
+
+ // 初始化数据
+ useEffect(() => {
+ if (work_id) {
+ dispatch(clearCurrentArtwork());
+ dispatch(fetchArtworkDetail(work_id));
+ dispatch(fetchComments({ workId: work_id, page: 1, pageSize: 5 }));
+ }
+ }, [work_id, dispatch]);
+
+ // 权限判断
+ const isAuthor: boolean = Boolean(
+ currentUser?.userid && currentArtwork?.authorId &&
+ String(currentUser.userid) === String(currentArtwork.authorId)
+ );
+
+ // 显示编辑控件
+ useEffect(() => {
+ setShowEditControls(isAuthor);
+ }, [isAuthor]);
+
+ // 评论分页处理
+ const handleCommentsPageChange = useCallback((page: number, pageSize: number): void => {
+ dispatch(setCommentsPage({ current: page, pageSize }));
+ if (work_id) {
+ dispatch(fetchComments({ workId: work_id, page, pageSize }));
+ }
+ }, [work_id, dispatch]);
+
+ // 添加评论
+ const handleAddComment = useCallback(async (content: string, parentId?: string): Promise<void> => {
+ if (!work_id) return;
+ try {
+ await dispatch(addComment({ workId: work_id, content, parentId })).unwrap();
+ message.success(parentId ? '回复发表成功!' : '评论发表成功!');
+ } catch {
+ message.error('评论发表失败,请重试');
+ }
+ }, [work_id, dispatch]);
+
+ // ==================== EditWork 集成功能 ====================
+
+ // 更新作品信息
+ const handleUpdateArtwork = useCallback(async (updates: Partial<ArtworkData>): Promise<void> => {
+ if (!work_id || !currentArtwork) return;
+
+ try {
+ // 检查 updateArtwork action 是否存在
+ if (updateArtwork) {
+ await dispatch(updateArtwork({
+ workId: work_id,
+ updates
+ })).unwrap();
+ message.success('作品信息更新成功!');
+ } else {
+ // 临时处理:直接更新本地状态
+ console.log('updateArtwork action not available, using local update');
+ message.success('作品信息更新成功!(本地更新)');
+ }
+ } catch (error) {
+ console.error('更新作品失败:', error);
+ message.error('更新失败,请重试');
+ throw error;
+ }
+ }, [work_id, currentArtwork, dispatch]);
+
+ // 删除评论
+ const handleDeleteComment = useCallback(async (commentId: string): Promise<void> => {
+ if (!work_id) return;
+
+ try {
+ // 检查 deleteComment action 是否存在
+ if (deleteComment) {
+ await dispatch(deleteComment({
+ workId: work_id,
+ commentId
+ })).unwrap();
+ message.success('评论删除成功!');
+
+ // 重新加载评论列表
+ dispatch(fetchComments({
+ workId: work_id,
+ page: comments.current,
+ pageSize: comments.pageSize
+ }));
+ } else {
+ // 临时处理
+ console.log('deleteComment action not available');
+ message.success('评论删除成功!(本地处理)');
+ }
+ } catch (error) {
+ console.error('删除评论失败:', error);
+ message.error('删除评论失败,请重试');
+ throw error;
+ }
+ }, [work_id, dispatch, comments.current, comments.pageSize]);
+
+ // 兼容旧的编辑处理器
+ const handleEditArtwork = useCallback((): void => {
+ if (isAuthor) {
+ setShowEditControls(true);
+ message.info('请使用上方的编辑控件来修改作品信息');
+ } else {
+ message.warning('您没有编辑此作品的权限');
+ }
+ }, [isAuthor]);
+
+ // ==================== 渲染逻辑 ====================
+
+ // 加载状态
+ if (loading.artwork) {
+ return (
+ <Layout style={{ minHeight: '100vh', backgroundColor: '#f5f5f5' }}>
+ <Content style={{ padding: '20px' }}>
+ <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '50vh' }}>
+ <Spin size="large" tip="加载作品详情中..." />
+ </div>
+ </Content>
+ </Layout>
+ );
+ }
+
+ // 错误状态
+ if (error.artwork) {
+ return (
+ <Layout style={{ minHeight: '100vh', backgroundColor: '#f5f5f5' }}>
+ <Content style={{ padding: '20px' }}>
+ <Flex justify="center" style={{ width: '100%' }}>
+ <div style={{ width: '90%', maxWidth: 1200 }}>
+ <Alert
+ message="加载失败"
+ description={error.artwork}
+ type="error"
+ showIcon
+ />
+ </div>
+ </Flex>
+ </Content>
+ </Layout>
+ );
+ }
+
+ // 作品不存在
+ if (!currentArtwork) {
+ return (
+ <Layout style={{ minHeight: '100vh', backgroundColor: '#f5f5f5' }}>
+ <Content style={{ padding: '20px' }}>
+ <Flex justify="center" align="center" style={{ height: '50vh' }}>
+ <Alert
+ message="作品不存在"
+ description="未找到对应的作品信息"
+ type="warning"
+ showIcon
+ />
+ </Flex>
+ </Content>
+ </Layout>
+ );
+ }
+
+ // 确保数据完整性,添加默认值
+ const safeArtwork = {
+ ...currentArtwork,
+ usersSeedingCurrently: currentArtwork.usersSeedingCurrently || [],
+ usersSeedingHistory: currentArtwork.usersSeedingHistory || [],
+ versionList: currentArtwork.versionList || [],
+ comments: comments.list || []
+ };
+
+ const safeComments = {
+ ...comments,
+ list: comments.list || []
+ };
+
+ // 主要内容渲染
+ return (
+ <Layout style={{ minHeight: '100vh', backgroundColor: '#f5f5f5' }}>
+ <Content style={{ padding: '20px' }}>
+ <Flex justify="center" style={{ width: '100%' }}>
+ <div style={{ width: '90%', maxWidth: 1200 }}>
+ {/* EditWork 编辑控件 - 仅作者可见 */}
+ {showEditControls && (
+ <div style={{ marginBottom: 20 }}>
+ <EditWorkControls
+ artwork={safeArtwork}
+ isAuthor={isAuthor}
+ onUpdate={handleUpdateArtwork}
+ onDeleteComment={handleDeleteComment}
+ />
+ </div>
+ )}
+
+ {/* 原有的作品展示布局 */}
+ <Flex gap={20}>
+ <Sidebar
+ coverUrl={safeArtwork.artworkCover}
+ currentUsers={safeArtwork.usersSeedingCurrently}
+ historyUsers={safeArtwork.usersSeedingHistory}
+ />
+ <MainContent
+ artworkName={safeArtwork.artworkName || safeArtwork.artworkCategory}
+ author={safeArtwork.author}
+ category={safeArtwork.artworkCategory}
+ description={safeArtwork.artworkDescription}
+ versions={safeArtwork.versionList}
+ comments={safeComments.list}
+ commentsTotal={safeComments.total}
+ commentsLoading={loading.comments}
+ commentsError={error.comments}
+ addCommentLoading={loading.addComment}
+ onCommentsPageChange={handleCommentsPageChange}
+ onAddComment={handleAddComment}
+ currentPage={safeComments.current}
+ pageSize={safeComments.pageSize}
+ isAuthor={isAuthor}
+ onEditArtwork={handleEditArtwork}
+ />
+ </Flex>
+ </div>
+ </Flex>
+ </Content>
+ </Layout>
+ );
+};
+
+export default Work;
\ No newline at end of file
diff --git a/src/feature/work/WorkComponents.tsx b/src/feature/work/WorkComponents.tsx
new file mode 100644
index 0000000..64ef37a
--- /dev/null
+++ b/src/feature/work/WorkComponents.tsx
@@ -0,0 +1,368 @@
+import React, { useState } from 'react';
+import { Card, Typography, Tag, Flex, Table, Collapse, List, Spin, Alert, Button, Input, Form, message } from 'antd';
+import { EditOutlined, SendOutlined } from '@ant-design/icons';
+import ReactMarkdown from 'react-markdown';
+import type { ColumnsType } from 'antd/es/table';
+import type { PaginationConfig } from 'antd/es/pagination';
+import type { FormInstance } from 'antd/es/form';
+import type { Comment, Version, User, HistoryUser } from './types';
+import { parseUploadSize } from './types';
+
+const { Title, Paragraph } = Typography;
+const { Panel } = Collapse;
+const { TextArea } = Input;
+
+// 作品封面组件
+export const ArtworkCover: React.FC<{ coverUrl: string }> = ({ coverUrl }) => (
+ <Card cover={<img alt="作品封面" src={coverUrl} style={{ height: 250, objectFit: 'cover' }} />} />
+);
+
+// 当前做种用户组件
+export const CurrentSeedingUsers: React.FC<{ users: User[] }> = ({ users }) => (
+ <Card>
+ <Title level={4} style={{ marginBottom: 12 }}>当前做种用户</Title>
+ <Flex wrap="wrap" gap={8}>
+ {users.map((user) => (
+ <Tag key={user.userId} color="green">{user.username}</Tag>
+ ))}
+ </Flex>
+ </Card>
+);
+
+// 历史做种用户组件
+export const HistorySeedingUsers: React.FC<{ users: HistoryUser[] }> = ({ users }) => {
+ const sortedUsers = [...users].sort((a, b) => parseUploadSize(b.uploadTotal) - parseUploadSize(a.uploadTotal));
+
+ const columns: ColumnsType<HistoryUser> = [
+ { title: '用户名', dataIndex: 'username', key: 'username' },
+ {
+ title: '上传总量',
+ dataIndex: 'uploadTotal',
+ key: 'uploadTotal',
+ sorter: (a: HistoryUser, b: HistoryUser) => parseUploadSize(a.uploadTotal) - parseUploadSize(b.uploadTotal),
+ },
+ ];
+
+ return (
+ <Card>
+ <Title level={4} style={{ marginBottom: 12 }}>历史做种用户</Title>
+ <Table columns={columns} dataSource={sortedUsers} rowKey="username" pagination={false} size="small" />
+ </Card>
+ );
+};
+
+// 作品描述组件
+export const ArtworkDescription: React.FC<{
+ name: string;
+ author: string;
+ category: string;
+ description: string;
+ isAuthor?: boolean;
+ onEdit?: () => void;
+}> = ({ name, author, category, description, isAuthor = false, onEdit }) => (
+ <Card style={{ marginBottom: 20 }}>
+ <Flex justify="space-between" align="flex-start">
+ <div style={{ flex: 1 }}>
+ <Title level={2} style={{ marginBottom: 8 }}>{name}</Title>
+ <Paragraph style={{ marginBottom: 8, fontSize: 16 }}>
+ <strong>作者:</strong>{author}
+ </Paragraph>
+ <div style={{ marginBottom: 16 }}>
+ <Tag color="blue">{category}</Tag>
+ </div>
+ <div style={{ lineHeight: 1.6 }}>
+ <ReactMarkdown>{description}</ReactMarkdown>
+ </div>
+ </div>
+ {isAuthor && (
+ <Button type="primary" icon={<EditOutlined />} onClick={onEdit} style={{ marginLeft: 16 }}>
+ 编辑作品
+ </Button>
+ )}
+ </Flex>
+ </Card>
+);
+
+// 版本历史组件
+export const VersionHistory: React.FC<{ versions: Version[] }> = ({ versions }) => (
+ <Card title="版本历史" style={{ marginBottom: 20 }}>
+ <Collapse>
+ {versions.map((version, index) => (
+ <Panel
+ header={`版本 ${version.version}`}
+ key={`version-${index}`}
+ extra={<Tag color="blue">v{version.version}</Tag>}
+ >
+ <div style={{ marginBottom: 16 }}>
+ <strong>版本描述:</strong>
+ <div style={{ marginTop: 8, lineHeight: 1.6 }}>
+ <ReactMarkdown>{version.versionDescription}</ReactMarkdown>
+ </div>
+ </div>
+ <div>
+ <strong>种子文件:</strong>
+ <a href={version.seedFile} target="_blank" rel="noopener noreferrer" style={{ marginLeft: 8 }}>
+ 下载链接
+ </a>
+ </div>
+ </Panel>
+ ))}
+ </Collapse>
+ </Card>
+);
+
+// 评论项组件(递归)
+export const CommentItem: React.FC<{
+ comment: Comment;
+ level?: number;
+ onReply?: (parentId: string, parentAuthor: string) => void;
+}> = ({ comment, level = 0, onReply }) => (
+ <div style={{ marginLeft: level * 20 }}>
+ <div style={{ marginBottom: 8 }}>
+ <Paragraph style={{ marginBottom: 4 }}>
+ <strong>{comment.author}:</strong>{comment.content}
+ </Paragraph>
+ <div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>
+ {comment.createdAt && <span style={{ marginRight: 16 }}>{comment.createdAt}</span>}
+ {onReply && (
+ <Button
+ type="link"
+ size="small"
+ style={{ padding: 0, height: 'auto', fontSize: 12 }}
+ onClick={() => onReply(comment.id || comment.author, comment.author)}
+ >
+ 回复
+ </Button>
+ )}
+ </div>
+ </div>
+ {comment.child && comment.child.length > 0 && (
+ <div style={{
+ borderLeft: level === 0 ? '2px solid #f0f0f0' : 'none',
+ paddingLeft: level === 0 ? 12 : 0
+ }}>
+ {comment.child.map((child, index) => (
+ <CommentItem
+ key={child.id || `child-${level}-${index}`}
+ comment={child}
+ level={level + 1}
+ onReply={onReply}
+ />
+ ))}
+ </div>
+ )}
+ </div>
+);
+
+// 发表评论组件
+export const CommentForm: React.FC<{
+ loading?: boolean;
+ onSubmit: (content: string, parentId?: string) => void;
+ replyTo?: { id: string; author: string } | null;
+ onCancelReply?: () => void;
+}> = ({ loading = false, onSubmit, replyTo, onCancelReply }) => {
+ const [form]: [FormInstance] = Form.useForm();
+ const [content, setContent] = useState('');
+
+ const handleSubmit = (): void => {
+ if (!content.trim()) {
+ message.warning('请输入评论内容');
+ return;
+ }
+ onSubmit(content.trim(), replyTo?.id);
+ setContent('');
+ form.resetFields();
+ };
+
+ const placeholder = replyTo ? `回复 @${replyTo.author}:` : "发表你的看法...";
+
+ return (
+ <Card
+ size="small"
+ style={{ marginBottom: 16 }}
+ title={replyTo ? (
+ <div style={{ fontSize: 14 }}>
+ <span>回复 @{replyTo.author}</span>
+ <Button type="link" size="small" onClick={onCancelReply} style={{ padding: '0 0 0 8px', fontSize: 12 }}>
+ 取消
+ </Button>
+ </div>
+ ) : undefined}
+ >
+ <Form form={form} layout="vertical">
+ <Form.Item>
+ <TextArea
+ value={content}
+ onChange={(e) => setContent(e.target.value)}
+ placeholder={placeholder}
+ rows={3}
+ maxLength={500}
+ showCount
+ />
+ </Form.Item>
+ <Form.Item style={{ marginBottom: 0 }}>
+ <Flex justify="flex-end" gap={8}>
+ {replyTo && <Button onClick={onCancelReply}>取消</Button>}
+ <Button
+ type="primary"
+ icon={<SendOutlined />}
+ loading={loading}
+ onClick={handleSubmit}
+ disabled={!content.trim()}
+ >
+ {replyTo ? '发表回复' : '发表评论'}
+ </Button>
+ </Flex>
+ </Form.Item>
+ </Form>
+ </Card>
+ );
+};
+
+// 评论区组件
+export const CommentSection: React.FC<{
+ comments: Comment[];
+ total: number;
+ loading?: boolean;
+ error?: string | null;
+ addCommentLoading?: boolean;
+ onPageChange: (page: number, pageSize: number) => void;
+ onAddComment: (content: string, parentId?: string) => void;
+ currentPage: number;
+ pageSize: number;
+}> = ({ comments, total, loading, error, addCommentLoading, onPageChange, onAddComment, currentPage, pageSize }) => {
+ const [replyTo, setReplyTo] = useState<{ id: string; author: string } | null>(null);
+
+ const handleReply = (parentId: string, parentAuthor: string): void => {
+ setReplyTo({ id: parentId, author: parentAuthor });
+ };
+
+ const handleCancelReply = (): void => {
+ setReplyTo(null);
+ };
+
+ const handleSubmitComment = (content: string, parentId?: string): void => {
+ onAddComment(content, parentId);
+ setReplyTo(null);
+ };
+
+ const paginationConfig: PaginationConfig = {
+ current: currentPage,
+ pageSize,
+ total,
+ showSizeChanger: true,
+ showQuickJumper: true,
+ showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条评论`,
+ pageSizeOptions: ['5', '10', '20'],
+ onChange: onPageChange,
+ onShowSizeChange: onPageChange,
+ };
+
+ return (
+ <Card title={`评论 (${total})`} style={{ marginBottom: 20 }}>
+ <CommentForm
+ loading={addCommentLoading}
+ onSubmit={handleSubmitComment}
+ replyTo={replyTo}
+ onCancelReply={handleCancelReply}
+ />
+
+ {error ? (
+ <Alert message="加载评论失败" description={error} type="error" showIcon />
+ ) : loading ? (
+ <Flex justify="center" align="center" style={{ minHeight: 200 }}>
+ <Spin size="large" />
+ </Flex>
+ ) : comments.length > 0 ? (
+ <List
+ dataSource={comments}
+ pagination={paginationConfig}
+ renderItem={(comment, index) => (
+ <List.Item
+ key={comment.id || `comment-${index}`}
+ style={{ border: 'none', padding: '16px 0', borderBottom: '1px solid #f0f0f0' }}
+ >
+ <CommentItem comment={comment} onReply={handleReply} />
+ </List.Item>
+ )}
+ />
+ ) : (
+ <Paragraph style={{ textAlign: 'center', color: '#999', margin: '20px 0' }}>
+ 暂无评论,来发表第一条评论吧!
+ </Paragraph>
+ )}
+ </Card>
+ );
+};
+
+// 侧边栏组合组件
+export const Sidebar: React.FC<{
+ coverUrl: string;
+ currentUsers: User[];
+ historyUsers: HistoryUser[];
+ loading?: boolean;
+ error?: string | null;
+}> = ({ coverUrl, currentUsers, historyUsers, loading, error }) => (
+ <Flex flex={1} vertical gap={20}>
+ <ArtworkCover coverUrl={coverUrl} />
+ {loading ? (
+ <Flex justify="center" align="center" style={{ minHeight: 200 }}>
+ <Spin size="large" />
+ </Flex>
+ ) : error ? (
+ <Alert message="加载用户信息失败" description={error} type="error" showIcon />
+ ) : (
+ <>
+ <CurrentSeedingUsers users={currentUsers} />
+ <HistorySeedingUsers users={historyUsers} />
+ </>
+ )}
+ </Flex>
+);
+
+// 主内容区组合组件
+export const MainContent: React.FC<{
+ artworkName: string;
+ author: string;
+ category: string;
+ description: string;
+ versions: Version[];
+ comments: Comment[];
+ commentsTotal: number;
+ commentsLoading?: boolean;
+ commentsError?: string | null;
+ addCommentLoading?: boolean;
+ onCommentsPageChange: (page: number, pageSize: number) => void;
+ onAddComment: (content: string, parentId?: string) => void;
+ currentPage: number;
+ pageSize: number;
+ isAuthor?: boolean;
+ onEditArtwork?: () => void;
+}> = ({
+ artworkName, author, category, description, versions, comments, commentsTotal,
+ commentsLoading, commentsError, addCommentLoading, onCommentsPageChange, onAddComment,
+ currentPage, pageSize, isAuthor, onEditArtwork
+}) => (
+ <Flex flex={4} vertical>
+ <ArtworkDescription
+ name={artworkName}
+ author={author}
+ category={category}
+ description={description}
+ isAuthor={isAuthor}
+ onEdit={onEditArtwork}
+ />
+ <VersionHistory versions={versions} />
+ <CommentSection
+ comments={comments}
+ total={commentsTotal}
+ loading={commentsLoading}
+ error={commentsError}
+ addCommentLoading={addCommentLoading}
+ onPageChange={onCommentsPageChange}
+ onAddComment={onAddComment}
+ currentPage={currentPage}
+ pageSize={pageSize}
+ />
+ </Flex>
+ );
\ No newline at end of file
diff --git a/src/feature/work/hooks.ts b/src/feature/work/hooks.ts
new file mode 100644
index 0000000..f07189e
--- /dev/null
+++ b/src/feature/work/hooks.ts
@@ -0,0 +1,34 @@
+import { useCallback, useState } from "react";
+import { useNavigate } from "react-router";
+import type {
+ WorkFormData,
+ BasicInfo,
+ CoverInfo,
+ VersionFormData,
+ ArtworkCategory
+} from './types';
+
+// ==================== Hook ====================
+export const useCreateWorkForm = () => {
+ const navigate = useNavigate();
+ const [currentStep, setCurrentStep] = useState<number>(0);
+ const [formData, setFormData] = useState<WorkFormData>({
+ basicInfo: {
+ artworkName: '',
+ artworkCategory: '概念设计' as ArtworkCategory,
+ artworkDescription: ''
+ },
+ coverInfo: {},
+ versions: [],
+ });
+
+ const handleUpdateFormData = useCallback((field: keyof WorkFormData, value: BasicInfo | CoverInfo | VersionFormData[]) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ }, []);
+
+ const handleNext = useCallback(() => setCurrentStep(prev => Math.min(prev + 1, 3)), []);
+ const handlePrev = useCallback(() => setCurrentStep(prev => Math.max(prev - 1, 0)), []);
+ const handlePublish = useCallback(() => navigate('/work/new-work-id'), [navigate]);
+
+ return { currentStep, formData, handleUpdateFormData, handleNext, handlePrev, handlePublish };
+};
\ No newline at end of file
diff --git a/src/feature/work/mockData.ts b/src/feature/work/mockData.ts
new file mode 100644
index 0000000..8470e61
--- /dev/null
+++ b/src/feature/work/mockData.ts
@@ -0,0 +1,405 @@
+import type { ArtworkData, Comment } from './types';
+
+// 生成随机时间的工具函数
+const generateRandomDate = (daysAgo: number): string => {
+ const date = new Date();
+ date.setDate(date.getDate() - Math.floor(Math.random() * daysAgo));
+ return date.toLocaleString('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+};
+
+// 通用评论数据
+const baseComments: Comment[] = [
+ {
+ id: 'comment_1',
+ content: '这个作品真的很棒!设计思路很新颖,学到了很多东西。',
+ author: '设计爱好者小王',
+ authorId: 'user_wang_001',
+ createdAt: generateRandomDate(3),
+ child: [
+ {
+ id: 'comment_1_1',
+ content: '同感!特别是色彩搭配的部分,很有启发性。',
+ author: '视觉设计师李雷',
+ authorId: 'user_lilei_002',
+ createdAt: generateRandomDate(2),
+ child: [],
+ },
+ {
+ id: 'comment_1_2',
+ content: '确实,作者的审美很在线,期待更多作品!',
+ author: '创意总监韩梅梅',
+ authorId: 'user_hanmeimei_003',
+ createdAt: generateRandomDate(2),
+ child: [
+ {
+ id: 'comment_1_2_1',
+ content: '@创意总监韩梅梅 您说得对,这种风格确实很难得',
+ author: '设计爱好者小王',
+ authorId: 'user_wang_001',
+ createdAt: generateRandomDate(1),
+ child: [],
+ }
+ ],
+ },
+ ],
+ },
+ {
+ id: 'comment_2',
+ content: '技术实现方面有什么特别的难点吗?想了解一下制作过程。',
+ author: '技术控阿明',
+ authorId: 'user_aming_004',
+ createdAt: generateRandomDate(5),
+ child: [],
+ },
+ {
+ id: 'comment_3',
+ content: '看起来很专业,请问这是用什么软件制作的?',
+ author: '新手小白',
+ authorId: 'user_xiaobai_005',
+ createdAt: generateRandomDate(4),
+ child: [
+ {
+ id: 'comment_3_1',
+ content: '从效果来看应该是用Adobe全家桶,主要是PS和AI',
+ author: '软件专家老刘',
+ authorId: 'user_laoliu_006',
+ createdAt: generateRandomDate(3),
+ child: [],
+ }
+ ],
+ },
+ {
+ id: 'comment_4',
+ content: '色彩运用得很巧妙,给人一种很舒服的视觉感受。',
+ author: '色彩搭配师小张',
+ authorId: 'user_xiaozhang_007',
+ createdAt: generateRandomDate(6),
+ child: [],
+ },
+ {
+ id: 'comment_5',
+ content: '作品质量很高,但是感觉在某些细节上还可以再优化一下。',
+ author: '完美主义者',
+ authorId: 'user_perfect_008',
+ createdAt: generateRandomDate(7),
+ child: [
+ {
+ id: 'comment_5_1',
+ content: '能具体说说哪些地方可以优化吗?我也想学习学习',
+ author: '求知者小陈',
+ authorId: 'user_xiaochen_009',
+ createdAt: generateRandomDate(6),
+ child: [],
+ }
+ ],
+ },
+ {
+ id: 'comment_6',
+ content: '已经下载收藏了,感谢分享!🎉',
+ author: '收藏家大佬',
+ authorId: 'user_collector_010',
+ createdAt: generateRandomDate(8),
+ child: [],
+ },
+ {
+ id: 'comment_7',
+ content: '这个风格很适合我正在做的项目,能不能商用呢?',
+ author: '商务合作方',
+ authorId: 'user_business_011',
+ createdAt: generateRandomDate(9),
+ child: [],
+ },
+ {
+ id: 'comment_8',
+ content: '教程什么时候出?期待大神的分享!',
+ author: '学习狂人',
+ authorId: 'user_learner_012',
+ createdAt: generateRandomDate(10),
+ child: [],
+ }
+];
+
+// 作品数据集合
+export const mockArtworks: Record<string, ArtworkData> = {
+ '12345': {
+ artworkId: '12345',
+ artworkCover: 'https://picsum.photos/300/400?random=1',
+ author: '视觉设计师张三',
+ authorId: 'author_zhangsan_001',
+ artworkName: '未来城市概念设计',
+ artworkCategory: '概念设计',
+ comments: [],
+ artworkDescription: `# 未来城市概念设计
+
+这是一个关于2050年智慧城市的概念设计作品。整个设计融合了**可持续发展**、**人工智能**和**绿色科技**的理念。
+
+## 设计理念
+
+- 🌱 **生态友好**:建筑与自然和谐共生
+- 🤖 **智能化**:AI驱动的城市管理系统
+- 🚗 **零排放**:全面电动化的交通系统
+- 🏗️ **模块化**:可扩展的建筑结构
+
+## 技术特点
+
+采用了最新的**参数化设计**方法,通过算法生成建筑形态,确保每个结构都能最大化利用自然光和风能。
+
+*希望这个设计能为未来城市规划提供一些启发。*`,
+ versionList: [
+ {
+ version: '1.0',
+ seedFile: 'magnet:?xt=urn:btih:future_city_v1_0&dn=未来城市v1.0.zip',
+ versionDescription: `## 初始版本 v1.0
+
+### 包含内容
+- 🎨 **高分辨率效果图** (4K)
+- 📐 **CAD源文件** (.dwg)
+- 🖼️ **PSD分层文件**
+- 📄 **设计说明文档**
+
+### 更新说明
+首次发布,包含完整的设计方案和素材文件。`,
+ },
+ {
+ version: '1.1',
+ seedFile: 'magnet:?xt=urn:btih:future_city_v1_1&dn=未来城市v1.1.zip',
+ versionDescription: `## 更新版本 v1.1
+
+### 新增内容
+- 🌃 **夜景渲染图**
+- 🎬 **动画演示视频** (1080p)
+- 🔧 **Blender源文件**
+
+### 修复内容
+- 修复了部分贴图丢失问题
+- 优化了文件结构
+- 添加了英文版说明文档`,
+ },
+ ],
+ usersSeedingCurrently: [
+ { username: '设计师小李', userId: 'user_xiaoli_101' },
+ { username: '建筑师王工', userId: 'user_wanggong_102' },
+ { username: '学生小赵', userId: 'user_xiaozhao_103' },
+ { username: '创意总监', userId: 'user_director_104' },
+ ],
+ usersSeedingHistory: [
+ {
+ username: '资深下载者', uploadTotal: '156.8GB',
+ userId: ''
+ },
+ {
+ username: '设计素材库', uploadTotal: '89.2GB',
+ userId: ''
+ },
+ {
+ username: '创意工作室', uploadTotal: '67.5GB',
+ userId: ''
+ },
+ {
+ username: '学院资源组', uploadTotal: '45.3GB',
+ userId: ''
+ },
+ {
+ username: '独立设计师', uploadTotal: '23.7GB',
+ userId: ''
+ },
+ ],
+ },
+
+ '23456': {
+ artworkId: '23456',
+ artworkCover: 'https://picsum.photos/300/400?random=2',
+ author: 'UI设计师李四',
+ authorId: 'author_lisi_002',
+ artworkName: '移动应用界面设计套件',
+ artworkCategory: 'UI/UX设计',
+ comments: [],
+ artworkDescription: `# 移动应用界面设计套件
+
+一套完整的移动端UI设计规范和组件库,包含**100+个精美界面**和**500+个设计组件**。
+
+## 包含内容
+
+### 📱 界面设计
+- 登录注册页面
+- 主页和导航
+- 商品展示页面
+- 个人中心
+- 设置页面
+
+### 🎨 设计系统
+- **颜色规范**:主色调、辅助色、状态色
+- **字体系统**:标题、正文、注释文字
+- **图标库**:线性图标、填充图标
+- **组件库**:按钮、输入框、卡片等`,
+ versionList: [
+ {
+ version: '1.0',
+ seedFile: 'magnet:?xt=urn:btih:ui_kit_v1_0&dn=UI设计套件v1.0.zip',
+ versionDescription: `## 基础版本 v1.0
+
+### 核心功能
+- 📱 **50个界面模板**
+- 🎨 **基础组件库**
+- 📋 **设计规范文档**
+- 🎯 **Sketch源文件**`,
+ },
+ ],
+ usersSeedingCurrently: [
+ { username: 'UI设计新手', userId: 'user_ui_newbie_201' },
+ { username: '产品经理小王', userId: 'user_pm_wang_202' },
+ ],
+ usersSeedingHistory: [
+ {
+ username: 'UI设计公司', uploadTotal: '78.9GB',
+ userId: ''
+ },
+ {
+ username: '设计师联盟', uploadTotal: '45.6GB',
+ userId: ''
+ },
+ {
+ username: '学习小组', uploadTotal: '23.4GB',
+ userId: ''
+ },
+ ],
+ },
+
+ '67890': {
+ artworkId: '67890',
+ artworkCover: 'https://picsum.photos/300/400?random=6',
+ author: '刘松林',
+ authorId: '2', // 用户ID为2
+ artworkName: 'React组件库开发指南',
+ artworkCategory: '前端开发',
+ comments: [],
+ artworkDescription: `# React组件库开发指南
+
+一套完整的**企业级React组件库**开发教程和源码,包含从设计到发布的完整流程。
+
+## 项目特色
+
+### 🚀 技术栈
+- **React 18** + **TypeScript**
+- **Styled-components** 样式解决方案
+- **Storybook** 组件文档
+- **Jest** + **Testing Library** 测试
+
+### 📦 组件覆盖
+- **基础组件**: Button, Input, Select, Modal等
+- **布局组件**: Grid, Layout, Container等
+- **数据展示**: Table, List, Card, Timeline等`,
+ versionList: [
+ {
+ version: '1.0',
+ seedFile: 'magnet:?xt=urn:btih:react_components_v1_0&dn=React组件库v1.0.zip',
+ versionDescription: `## 基础版本 v1.0
+
+### 核心内容
+- 📚 **完整教程文档** (50+页)
+- 💻 **基础组件源码** (20+个组件)
+- 🧪 **单元测试示例**
+- 📖 **Storybook配置**`,
+ },
+ ],
+ usersSeedingCurrently: [
+ { username: '前端新手小李', userId: 'user_frontend_newbie_601' },
+ { username: 'React爱好者', userId: 'user_react_fan_602' },
+ ],
+ usersSeedingHistory: [
+ {
+ username: '大厂前端团队', uploadTotal: '567.8GB',
+ userId: ''
+ },
+ {
+ username: '开源社区', uploadTotal: '234.5GB',
+ userId: ''
+ },
+ {
+ username: '技术培训机构', uploadTotal: '189.7GB',
+ userId: ''
+ },
+ ],
+ },
+};
+
+// 获取指定作品的评论数据
+export const getCommentsForArtwork = (artworkId: string): Comment[] => {
+ // 为不同作品生成不同的评论
+ const commentVariations: Record<string, Comment[]> = {
+ '12345': baseComments,
+ '23456': baseComments.slice(0, 5).map(comment => ({
+ ...comment,
+ id: `ui_${comment.id}`,
+ content: comment.content.replace('作品', 'UI套件').replace('设计', '界面设计'),
+ })),
+ '67890': [
+ {
+ id: 'dev_comment_1',
+ content: '这个组件库的设计思路很棒!TypeScript类型定义特别完善。',
+ author: '前端架构师张工',
+ authorId: 'user_architect_zhang',
+ createdAt: generateRandomDate(2),
+ child: [
+ {
+ id: 'dev_comment_1_1',
+ content: '同感!特别是组件API的设计,很符合React的设计理念。',
+ author: 'React核心开发者',
+ authorId: 'user_react_core',
+ createdAt: generateRandomDate(1),
+ child: [],
+ }
+ ],
+ },
+ {
+ id: 'dev_comment_2',
+ content: '构建配置写得很详细,我们团队已经参考这个搭建了自己的组件库。',
+ author: '技术负责人小刘',
+ authorId: 'user_tech_liu',
+ createdAt: generateRandomDate(3),
+ child: [],
+ },
+ ],
+ };
+
+ return commentVariations[artworkId] || baseComments;
+};
+
+// 根据作品ID获取作品数据
+export const getArtworkById = (artworkId: string): ArtworkData | null => {
+ const artwork = mockArtworks[artworkId];
+ if (artwork) {
+ return {
+ ...artwork,
+ comments: getCommentsForArtwork(artworkId)
+ };
+ }
+ return null;
+};
+
+// 获取所有作品列表
+export const getAllArtworks = (): ArtworkData[] => {
+ return Object.values(mockArtworks);
+};
+
+// 按分类获取作品
+export const getArtworksByCategory = (category: string): ArtworkData[] => {
+ return Object.values(mockArtworks).filter(artwork => artwork.artworkCategory === category);
+};
+
+// 搜索作品
+export const searchArtworks = (keyword: string): ArtworkData[] => {
+ const lowerKeyword = keyword.toLowerCase();
+ return Object.values(mockArtworks).filter(artwork =>
+ artwork.artworkName.toLowerCase().includes(lowerKeyword) ||
+ artwork.artworkDescription.toLowerCase().includes(lowerKeyword) ||
+ artwork.author.toLowerCase().includes(lowerKeyword) ||
+ artwork.artworkCategory.toLowerCase().includes(lowerKeyword)
+ );
+};
\ No newline at end of file
diff --git a/src/feature/work/types.ts b/src/feature/work/types.ts
new file mode 100644
index 0000000..2d7dbd7
--- /dev/null
+++ b/src/feature/work/types.ts
@@ -0,0 +1,283 @@
+import type { UploadFile } from 'antd/es/upload';
+
+// ==================== 基础类型 ====================
+
+export interface User {
+ userId: string;
+ username: string;
+}
+
+export interface HistoryUser extends User {
+ uploadTotal: string;
+}
+
+// ==================== 评论相关 ====================
+
+export interface Comment {
+ id?: string;
+ content: string;
+ author: string;
+ authorId?: string;
+ createdAt?: string;
+ child: Comment[];
+}
+
+// ==================== 版本管理相关 ====================
+
+// 用于表单创建的版本信息
+export interface VersionFormData {
+ version: string;
+ versionDescription: string;
+ seedFile?: UploadFile; // 表单中的文件对象
+}
+
+// 用于展示的版本信息(后端返回)
+export interface Version {
+ version: string;
+ versionDescription: string;
+ seedFile: string; // 文件URL或路径
+ uploadedAt?: string;
+ fileSize?: number;
+}
+
+// ==================== 作品相关 ====================
+
+// 基础信息(表单用)
+export interface BasicInfo {
+ artworkName: string;
+ artworkCategory: ArtworkCategory;
+ artworkDescription: string;
+}
+
+// 封面信息(表单用)
+export interface CoverInfo {
+ coverFile?: UploadFile;
+}
+
+// 表单数据结构
+export interface WorkFormData {
+ basicInfo: BasicInfo;
+ coverInfo: CoverInfo;
+ versions: VersionFormData[];
+}
+
+// 完整的作品数据(后端返回/展示用)
+export interface ArtworkData {
+ artworkId: string;
+ artworkCover: string;
+ author: string;
+ authorId: string;
+ artworkName: string;
+ artworkCategory: ArtworkCategory;
+ artworkDescription: string;
+ versionList: Version[];
+ comments: Comment[];
+ usersSeedingCurrently: User[];
+ usersSeedingHistory: HistoryUser[];
+ createdAt?: string;
+ updatedAt?: string;
+ downloadCount?: number;
+ likeCount?: number;
+}
+
+// ==================== 表单相关 ====================
+
+export interface StepFormProps {
+ data: WorkFormData;
+ onUpdate: (field: keyof WorkFormData, value: BasicInfo | CoverInfo | VersionFormData[]) => void;
+ onNext?: () => void;
+ onPrev?: () => void;
+}
+
+// ==================== 常量定义 ====================
+
+export const ARTWORK_CATEGORIES = [
+ { label: '概念设计', value: '概念设计' },
+ { label: 'UI/UX设计', value: 'UI/UX设计' },
+ { label: '3D建模', value: '3D建模' },
+ { label: '摄影作品', value: '摄影作品' },
+ { label: '音乐制作', value: '音乐制作' },
+ { label: '前端开发', value: '前端开发' },
+ { label: '插画艺术', value: '插画艺术' },
+ { label: '动画制作', value: '动画制作' },
+ { label: '视频剪辑', value: '视频剪辑' },
+ { label: '其他', value: '其他' },
+];
+
+// 提取分类值的联合类型
+export type ArtworkCategory = '概念设计' | 'UI/UX设计' | '3D建模' | '摄影作品' | '音乐制作' | '前端开发' | '插画艺术' | '动画制作' | '视频剪辑' | '其他';
+
+// 分类选项类型(用于 Select 组件)
+export interface ArtworkCategoryOption {
+ label: string;
+ value: ArtworkCategory;
+}
+
+// ==================== 工具函数 ====================
+
+/**
+ * 解析上传大小字符串为MB数值
+ * @param uploadStr 上传大小字符串,如 "1.5GB", "500MB"
+ * @returns 大小值(以MB为单位)
+ */
+export const parseUploadSize = (uploadStr: string): number => {
+ const match = uploadStr.match(/(\d+(?:\.\d+)?)\s*(GB|MB|KB|TB)?/i);
+ if (!match) return 0;
+
+ const size = parseFloat(match[1]);
+ const unit = (match[2] || '').toUpperCase();
+
+ const multipliers: Record<string, number> = {
+ 'TB': 1024 * 1024,
+ 'GB': 1024,
+ 'MB': 1,
+ 'KB': 1 / 1024,
+ '': 1
+ };
+
+ return size * (multipliers[unit] || 1);
+};
+
+/**
+ * 格式化文件大小为可读字符串
+ * @param sizeInMB 文件大小(MB)
+ * @returns 格式化后的字符串
+ */
+export const formatFileSize = (sizeInMB: number): string => {
+ if (sizeInMB >= 1024) {
+ return `${(sizeInMB / 1024).toFixed(1)}GB`;
+ } else if (sizeInMB >= 1) {
+ return `${sizeInMB.toFixed(1)}MB`;
+ } else {
+ return `${(sizeInMB * 1024).toFixed(0)}KB`;
+ }
+};
+
+/**
+ * 格式化日期
+ * @param dateString 日期字符串
+ * @returns 格式化后的日期字符串
+ */
+export const formatDate = (dateString?: string): string => {
+ if (!dateString) return '';
+
+ const date = new Date(dateString);
+ const now = new Date();
+ const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60);
+
+ if (diffInHours < 1) {
+ return '刚刚';
+ } else if (diffInHours < 24) {
+ return `${Math.floor(diffInHours)}小时前`;
+ } else if (diffInHours < 24 * 7) {
+ return `${Math.floor(diffInHours / 24)}天前`;
+ } else {
+ return date.toLocaleDateString('zh-CN');
+ }
+};
+
+/**
+ * 验证文件大小是否符合要求
+ * @param fileSize 文件大小(字节)
+ * @param maxSizeMB 最大允许大小(MB)
+ * @returns 是否符合要求
+ */
+export const isValidFileSize = (fileSize: number, maxSizeMB: number): boolean => {
+ const fileSizeMB = fileSize / (1024 * 1024);
+ return fileSizeMB <= maxSizeMB;
+};
+
+/**
+ * 验证文件类型是否符合要求
+ * @param fileName 文件名
+ * @param allowedExtensions 允许的扩展名数组
+ * @returns 是否符合要求
+ */
+export const isValidFileType = (fileName: string, allowedExtensions: string[]): boolean => {
+ if (!fileName) return false;
+ const extension = fileName.toLowerCase().split('.').pop();
+ return extension ? allowedExtensions.includes(extension) : false;
+};
+
+/**
+ * 验证图片文件类型
+ * @param fileName 文件名
+ * @returns 是否为有效图片
+ */
+export const isValidImageFile = (fileName: string): boolean => {
+ return isValidFileType(fileName, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']);
+};
+
+/**
+ * 安全地解析JSON字符串
+ * @param jsonString JSON字符串
+ * @returns 解析结果或null
+ */
+export const safeJsonParse = <T = unknown>(jsonString: string): T | null => {
+ try {
+ return JSON.parse(jsonString) as T;
+ } catch {
+ return null;
+ }
+};
+
+// ==================== 类型守卫 ====================
+
+export const isValidArtworkCategory = (category: string): category is ArtworkCategory => {
+ return ARTWORK_CATEGORIES.some(cat => cat.value === category);
+};
+
+export const isComment = (obj: unknown): obj is Comment => {
+ return typeof obj === 'object' &&
+ obj !== null &&
+ 'content' in obj &&
+ 'author' in obj &&
+ 'child' in obj &&
+ typeof (obj as Record<string, unknown>).content === 'string' &&
+ typeof (obj as Record<string, unknown>).author === 'string' &&
+ Array.isArray((obj as Record<string, unknown>).child);
+};
+
+export const isUser = (obj: unknown): obj is User => {
+ return typeof obj === 'object' &&
+ obj !== null &&
+ 'userId' in obj &&
+ 'username' in obj &&
+ typeof (obj as Record<string, unknown>).userId === 'string' &&
+ typeof (obj as Record<string, unknown>).username === 'string';
+};
+
+export const isHistoryUser = (obj: unknown): obj is HistoryUser => {
+ return isUser(obj) &&
+ 'uploadTotal' in obj &&
+ typeof (obj as Record<string, unknown>).uploadTotal === 'string';
+};
+
+export const isVersion = (obj: unknown): obj is Version => {
+ return typeof obj === 'object' &&
+ obj !== null &&
+ 'version' in obj &&
+ 'versionDescription' in obj &&
+ 'seedFile' in obj &&
+ typeof (obj as Record<string, unknown>).version === 'string' &&
+ typeof (obj as Record<string, unknown>).versionDescription === 'string' &&
+ typeof (obj as Record<string, unknown>).seedFile === 'string';
+};
+
+export const isArtworkData = (obj: unknown): obj is ArtworkData => {
+ if (typeof obj !== 'object' || obj === null) return false;
+
+ const artwork = obj as Record<string, unknown>;
+
+ return typeof artwork.artworkId === 'string' &&
+ typeof artwork.artworkCover === 'string' &&
+ typeof artwork.author === 'string' &&
+ typeof artwork.authorId === 'string' &&
+ typeof artwork.artworkName === 'string' &&
+ typeof artwork.artworkCategory === 'string' &&
+ typeof artwork.artworkDescription === 'string' &&
+ Array.isArray(artwork.versionList) &&
+ Array.isArray(artwork.comments) &&
+ Array.isArray(artwork.usersSeedingCurrently) &&
+ Array.isArray(artwork.usersSeedingHistory);
+};
\ No newline at end of file
diff --git a/src/feature/work/utils.ts b/src/feature/work/utils.ts
new file mode 100644
index 0000000..a10d95f
--- /dev/null
+++ b/src/feature/work/utils.ts
@@ -0,0 +1,155 @@
+import type { RcFile } from 'antd/es/upload';
+
+// 将文件转换为Base64
+export const getBase64 = (file: RcFile): Promise<string> =>
+ new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = (error) => reject(error);
+ });
+
+// 验证图片文件
+interface ValidationResult {
+ valid: boolean;
+ message?: string;
+}
+
+export const validateImageFile = (file: RcFile): ValidationResult => {
+ const isImage = file.type.startsWith('image/');
+ if (!isImage) {
+ return { valid: false, message: '只能上传图片文件!' };
+ }
+
+ const isLt5M = file.size / 1024 / 1024 < 5;
+ if (!isLt5M) {
+ return { valid: false, message: '图片大小不能超过 5MB!' };
+ }
+
+ return { valid: true };
+};
+
+// 验证种子文件
+export const validateSeedFile = (file: RcFile): ValidationResult => {
+ const isLt100M = file.size / 1024 / 1024 < 100;
+ if (!isLt100M) {
+ return { valid: false, message: '种子文件大小不能超过 100MB!' };
+ }
+
+ return { valid: true };
+};
+
+// 格式化文件大小
+export const formatFileSize = (bytes: number): string => {
+ if (bytes === 0) return '0 Bytes';
+
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+};
+
+// 防抖函数 - 修复版本
+export const debounce = <T extends (...args: unknown[]) => unknown>(
+ func: T,
+ wait: number
+): ((...args: Parameters<T>) => void) => {
+ let timeout: ReturnType<typeof setTimeout>;
+
+ return (...args: Parameters<T>) => {
+ clearTimeout(timeout);
+ timeout = setTimeout(() => func(...args), wait);
+ };
+};
+
+// 节流函数 - 修复版本
+export const throttle = <T extends (...args: unknown[]) => unknown>(
+ func: T,
+ limit: number
+): ((...args: Parameters<T>) => void) => {
+ let inThrottle: boolean;
+
+ return (...args: Parameters<T>) => {
+ if (!inThrottle) {
+ func(...args);
+ inThrottle = true;
+ setTimeout(() => inThrottle = false, limit);
+ }
+ };
+};
+// 生成唯一ID
+export const generateId = (): string => {
+ return Date.now().toString(36) + Math.random().toString(36).substr(2);
+};
+
+// 安全的JSON解析
+export const safeJsonParse = <T>(str: string, defaultValue: T): T => {
+ try {
+ return JSON.parse(str) as T;
+ } catch {
+ return defaultValue;
+ }
+};
+
+// 检查是否为空值
+export const isEmpty = (value: unknown): boolean => {
+ if (value === null || value === undefined) return true;
+ if (typeof value === 'string') return value.trim() === '';
+ if (Array.isArray(value)) return value.length === 0;
+ if (typeof value === 'object') return Object.keys(value).length === 0;
+ return false;
+};
+
+// URL参数解析
+export const parseUrlParams = (url: string): Record<string, string> => {
+ const params: Record<string, string> = {};
+ const urlObj = new URL(url);
+
+ urlObj.searchParams.forEach((value, key) => {
+ params[key] = value;
+ });
+
+ return params;
+};
+
+// 时间格式化
+export const formatDate = (date: Date | string | number, format: string = 'YYYY-MM-DD HH:mm:ss'): string => {
+ const d = new Date(date);
+
+ const year = d.getFullYear();
+ const month = String(d.getMonth() + 1).padStart(2, '0');
+ const day = String(d.getDate()).padStart(2, '0');
+ const hours = String(d.getHours()).padStart(2, '0');
+ const minutes = String(d.getMinutes()).padStart(2, '0');
+ const seconds = String(d.getSeconds()).padStart(2, '0');
+
+ return format
+ .replace('YYYY', year.toString())
+ .replace('MM', month)
+ .replace('DD', day)
+ .replace('HH', hours)
+ .replace('mm', minutes)
+ .replace('ss', seconds);
+};
+
+// 相对时间格式化(例如:2小时前)
+export const formatRelativeTime = (date: Date | string | number): string => {
+ const now = new Date();
+ const target = new Date(date);
+ const diff = now.getTime() - target.getTime();
+
+ const seconds = Math.floor(diff / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+ const months = Math.floor(days / 30);
+ const years = Math.floor(months / 12);
+
+ if (years > 0) return `${years}年前`;
+ if (months > 0) return `${months}个月前`;
+ if (days > 0) return `${days}天前`;
+ if (hours > 0) return `${hours}小时前`;
+ if (minutes > 0) return `${minutes}分钟前`;
+ return '刚刚';
+};
\ No newline at end of file
diff --git a/src/feature/work/workSlice.ts b/src/feature/work/workSlice.ts
new file mode 100644
index 0000000..6b2c12c
--- /dev/null
+++ b/src/feature/work/workSlice.ts
@@ -0,0 +1,469 @@
+import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit';
+import type { ArtworkData, Comment } from './types';
+import type { RootState } from '../../store/store';
+import { getArtworkById, getCommentsForArtwork } from './mockData';
+
+// ==================== 类型定义 ====================
+interface WorkState {
+ currentArtwork: ArtworkData | null;
+ loading: {
+ artwork: boolean;
+ comments: boolean;
+ addComment: boolean;
+ updateArtwork: boolean;
+ deleteComment: boolean;
+ };
+ error: {
+ artwork: string | null;
+ comments: string | null;
+ addComment: string | null;
+ updateArtwork: string | null;
+ deleteComment: string | null;
+ };
+ comments: {
+ list: Comment[];
+ total: number;
+ current: number;
+ pageSize: number;
+ };
+}
+
+interface FetchCommentsParams {
+ workId: string;
+ page: number;
+ pageSize: number;
+}
+
+interface AddCommentParams {
+ workId: string;
+ content: string;
+ parentId?: string;
+}
+
+interface UpdateArtworkParams {
+ workId: string;
+ updates: Partial<ArtworkData>;
+}
+
+interface DeleteCommentParams {
+ workId: string;
+ commentId: string;
+}
+
+interface SetCommentsPageParams {
+ current: number;
+ pageSize: number;
+}
+
+// ==================== 初始状态 ====================
+const initialState: WorkState = {
+ currentArtwork: null,
+ loading: {
+ artwork: false,
+ comments: false,
+ addComment: false,
+ updateArtwork: false,
+ deleteComment: false,
+ },
+ error: {
+ artwork: null,
+ comments: null,
+ addComment: null,
+ updateArtwork: null,
+ deleteComment: null,
+ },
+ comments: {
+ list: [],
+ total: 0,
+ current: 1,
+ pageSize: 5,
+ },
+};
+
+// ==================== Mock 工具函数 ====================
+
+// 模拟网络延迟
+const mockDelay = (ms: number = 800): Promise<void> =>
+ new Promise(resolve => setTimeout(resolve, ms));
+
+// 生成新评论ID
+const generateCommentId = (): string => {
+ return `comment_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+};
+
+// 生成新评论数据
+const createNewComment = (content: string): Comment => {
+ return {
+ id: generateCommentId(),
+ content,
+ author: '当前用户', // 实际应用中从用户状态获取
+ authorId: 'current_user_id',
+ createdAt: new Date().toLocaleString('zh-CN'),
+ child: [],
+ };
+};
+
+// 递归删除评论
+const removeCommentById = (comments: Comment[], targetId: string): Comment[] => {
+ return comments.filter(comment => {
+ if (comment.id === targetId) {
+ return false;
+ }
+ if (comment.child.length > 0) {
+ comment.child = removeCommentById(comment.child, targetId);
+ }
+ return true;
+ });
+};
+
+// 递归添加回复评论
+const addReplyToComment = (comments: Comment[], parentId: string, newComment: Comment): Comment[] => {
+ return comments.map(comment => {
+ if (comment.id === parentId) {
+ return {
+ ...comment,
+ child: [...comment.child, newComment]
+ };
+ }
+ if (comment.child.length > 0) {
+ return {
+ ...comment,
+ child: addReplyToComment(comment.child, parentId, newComment)
+ };
+ }
+ return comment;
+ });
+};
+
+// 分页处理评论
+const paginateComments = (comments: Comment[], page: number, pageSize: number): Comment[] => {
+ const startIndex = (page - 1) * pageSize;
+ const endIndex = startIndex + pageSize;
+ return comments.slice(startIndex, endIndex);
+};
+
+// ==================== 异步 Actions ====================
+
+// 获取作品详情
+export const fetchArtworkDetail = createAsyncThunk<
+ ArtworkData,
+ string,
+ { rejectValue: string }
+>(
+ 'work/fetchArtworkDetail',
+ async (workId: string, { rejectWithValue }) => {
+ try {
+ await mockDelay(600); // 模拟网络延迟
+
+ const artwork = getArtworkById(workId);
+
+ if (!artwork) {
+ throw new Error(`作品 ${workId} 不存在`);
+ }
+
+ return artwork;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '获取作品详情失败';
+ return rejectWithValue(message);
+ }
+ }
+);
+
+// 获取评论列表
+export const fetchComments = createAsyncThunk<
+ { comments: Comment[]; total: number },
+ FetchCommentsParams,
+ { rejectValue: string }
+>(
+ 'work/fetchComments',
+ async ({ workId, page, pageSize }, { rejectWithValue }) => {
+ try {
+ await mockDelay(400); // 模拟网络延迟
+
+ const allComments = getCommentsForArtwork(workId);
+ const paginatedComments = paginateComments(allComments, page, pageSize);
+
+ return {
+ comments: paginatedComments,
+ total: allComments.length
+ };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '获取评论失败';
+ return rejectWithValue(message);
+ }
+ }
+);
+
+// 添加评论
+export const addComment = createAsyncThunk<
+ Comment,
+ AddCommentParams,
+ { rejectValue: string }
+>(
+ 'work/addComment',
+ async ({ workId, content }, { rejectWithValue }) => {
+ try {
+ await mockDelay(500); // 模拟网络延迟
+
+ // 验证作品是否存在
+ const artwork = getArtworkById(workId);
+ if (!artwork) {
+ throw new Error('作品不存在');
+ }
+
+ // 创建新评论
+ const newComment = createNewComment(content);
+
+ // 模拟服务器返回完整的评论数据
+ return newComment;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '添加评论失败';
+ return rejectWithValue(message);
+ }
+ }
+);
+
+// 更新作品信息
+export const updateArtwork = createAsyncThunk<
+ ArtworkData,
+ UpdateArtworkParams,
+ { rejectValue: string }
+>(
+ 'work/updateArtwork',
+ async ({ workId, updates }, { rejectWithValue }) => {
+ try {
+ await mockDelay(1000); // 模拟网络延迟
+
+ const currentArtwork = getArtworkById(workId);
+ if (!currentArtwork) {
+ throw new Error('作品不存在');
+ }
+
+ // 模拟文件上传处理
+ const processedUpdates = { ...updates };
+
+ // 如果包含 blob URL,模拟转换为正式URL
+ if (updates.artworkCover && updates.artworkCover.startsWith('blob:')) {
+ // 模拟上传成功,生成新的图片URL
+ processedUpdates.artworkCover = `https://picsum.photos/300/400?random=${Date.now()}`;
+ }
+
+ // 处理版本文件上传
+ if (updates.versionList) {
+ processedUpdates.versionList = updates.versionList.map(version => ({
+ ...version,
+ seedFile: version.seedFile.startsWith?.('blob:')
+ ? `magnet:?xt=urn:btih:updated_${Date.now()}&dn=${version.version}.zip`
+ : version.seedFile
+ }));
+ }
+
+ // 合并更新后的数据
+ const updatedArtwork: ArtworkData = {
+ ...currentArtwork,
+ ...processedUpdates,
+ updatedAt: new Date().toISOString(),
+ };
+
+ return updatedArtwork;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '更新作品失败';
+ return rejectWithValue(message);
+ }
+ }
+);
+
+// 删除评论
+export const deleteComment = createAsyncThunk<
+ string,
+ DeleteCommentParams,
+ { rejectValue: string }
+>(
+ 'work/deleteComment',
+ async ({ workId, commentId }, { rejectWithValue }) => {
+ try {
+ await mockDelay(300); // 模拟网络延迟
+
+ // 验证作品是否存在
+ const artwork = getArtworkById(workId);
+ if (!artwork) {
+ throw new Error('作品不存在');
+ }
+
+ // 模拟删除成功
+ return commentId;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '删除评论失败';
+ return rejectWithValue(message);
+ }
+ }
+);
+
+// ==================== Slice 定义 ====================
+const workSlice = createSlice({
+ name: 'work',
+ initialState,
+ reducers: {
+ // 清除当前作品
+ clearCurrentArtwork: (state) => {
+ state.currentArtwork = null;
+ state.comments.list = [];
+ state.comments.total = 0;
+ state.comments.current = 1;
+ // 清除所有错误状态
+ Object.keys(state.error).forEach(key => {
+ state.error[key as keyof typeof state.error] = null;
+ });
+ },
+
+ // 设置评论分页
+ setCommentsPage: (state, action: PayloadAction<SetCommentsPageParams>) => {
+ state.comments.current = action.payload.current;
+ state.comments.pageSize = action.payload.pageSize;
+ },
+
+ // 清除特定错误
+ clearError: (state, action: PayloadAction<keyof WorkState['error']>) => {
+ state.error[action.payload] = null;
+ },
+
+ // 清除所有错误
+ clearAllErrors: (state) => {
+ Object.keys(state.error).forEach(key => {
+ state.error[key as keyof typeof state.error] = null;
+ });
+ },
+ },
+ extraReducers: (builder) => {
+ // 获取作品详情
+ builder
+ .addCase(fetchArtworkDetail.pending, (state) => {
+ state.loading.artwork = true;
+ state.error.artwork = null;
+ })
+ .addCase(fetchArtworkDetail.fulfilled, (state, action) => {
+ state.loading.artwork = false;
+ state.currentArtwork = action.payload;
+ state.error.artwork = null;
+ })
+ .addCase(fetchArtworkDetail.rejected, (state, action) => {
+ state.loading.artwork = false;
+ state.error.artwork = action.payload || '获取作品详情失败';
+ });
+
+ // 获取评论列表
+ builder
+ .addCase(fetchComments.pending, (state) => {
+ state.loading.comments = true;
+ state.error.comments = null;
+ })
+ .addCase(fetchComments.fulfilled, (state, action) => {
+ state.loading.comments = false;
+ state.comments.list = action.payload.comments;
+ state.comments.total = action.payload.total;
+ state.error.comments = null;
+ })
+ .addCase(fetchComments.rejected, (state, action) => {
+ state.loading.comments = false;
+ state.error.comments = action.payload || '获取评论失败';
+ });
+
+ // 添加评论
+ builder
+ .addCase(addComment.pending, (state) => {
+ state.loading.addComment = true;
+ state.error.addComment = null;
+ })
+ .addCase(addComment.fulfilled, (state, action) => {
+ state.loading.addComment = false;
+
+ const newComment = action.payload;
+ const { parentId } = action.meta.arg;
+
+ if (parentId) {
+ // 添加回复评论
+ state.comments.list = addReplyToComment(state.comments.list, parentId, newComment);
+ } else {
+ // 添加顶级评论
+ state.comments.list.unshift(newComment);
+ state.comments.total += 1;
+ }
+
+ state.error.addComment = null;
+ })
+ .addCase(addComment.rejected, (state, action) => {
+ state.loading.addComment = false;
+ state.error.addComment = action.payload || '添加评论失败';
+ });
+
+ // 更新作品信息
+ builder
+ .addCase(updateArtwork.pending, (state) => {
+ state.loading.updateArtwork = true;
+ state.error.updateArtwork = null;
+ })
+ .addCase(updateArtwork.fulfilled, (state, action) => {
+ state.loading.updateArtwork = false;
+ state.currentArtwork = action.payload;
+ state.error.updateArtwork = null;
+ })
+ .addCase(updateArtwork.rejected, (state, action) => {
+ state.loading.updateArtwork = false;
+ state.error.updateArtwork = action.payload || '更新作品失败';
+ });
+
+ // 删除评论
+ builder
+ .addCase(deleteComment.pending, (state) => {
+ state.loading.deleteComment = true;
+ state.error.deleteComment = null;
+ })
+ .addCase(deleteComment.fulfilled, (state, action) => {
+ state.loading.deleteComment = false;
+
+ // 从评论列表中移除已删除的评论
+ state.comments.list = removeCommentById(state.comments.list, action.payload);
+ state.comments.total = Math.max(0, state.comments.total - 1);
+ state.error.deleteComment = null;
+ })
+ .addCase(deleteComment.rejected, (state, action) => {
+ state.loading.deleteComment = false;
+ state.error.deleteComment = action.payload || '删除评论失败';
+ });
+ },
+});
+
+// ==================== Actions 导出 ====================
+export const {
+ clearCurrentArtwork,
+ setCommentsPage,
+ clearError,
+ clearAllErrors
+} = workSlice.actions;
+
+// ==================== Selectors 导出 ====================
+export const selectCurrentArtwork = (state: RootState): ArtworkData | null =>
+ state.work.currentArtwork;
+
+export const selectWorkLoading = (state: RootState): WorkState['loading'] =>
+ state.work.loading;
+
+export const selectWorkError = (state: RootState): WorkState['error'] =>
+ state.work.error;
+
+export const selectComments = (state: RootState): WorkState['comments'] =>
+ state.work.comments;
+
+export const selectIsAuthor = (state: RootState): boolean => {
+ const currentUser = state.user;
+ const currentArtwork = state.work.currentArtwork;
+
+ return Boolean(
+ currentUser?.userid &&
+ currentArtwork?.authorId &&
+ String(currentUser.userid) === String(currentArtwork.authorId)
+ );
+};
+
+// ==================== Reducer 导出 ====================
+export default workSlice.reducer;
\ No newline at end of file
diff --git a/src/routes/ProtectedRoute.tsx b/src/routes/ProtectedRoute.tsx
index 238fdf4..2764f5a 100644
--- a/src/routes/ProtectedRoute.tsx
+++ b/src/routes/ProtectedRoute.tsx
@@ -1,7 +1,10 @@
-import React, { useEffect } from 'react';
-import { useAppSelector, useAppDispatch } from '../store/hooks'; // 导入 hooks
+// src/routes/ProtectedRoute.tsx
+
+import React, { useEffect, useState } from 'react';
+import { useAppSelector, useAppDispatch } from '../store/hooks';
import { useNavigate } from 'react-router';
-import { refreshToken } from '../feature/auth/authSlice'; // 导入刷新 token 的 thunk
+import { refreshToken } from '../feature/auth/authSlice';
+import { Spin } from 'antd';
interface ProtectedRouteProps {
children: React.ReactNode;
@@ -11,26 +14,68 @@
const { isAuth, token } = useAppSelector((state) => state.auth);
const navigate = useNavigate();
const dispatch = useAppDispatch();
+ const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const tryRefreshToken = async () => {
+ // 如果没有token,直接跳转到登录页
if (!token) {
- navigate('/login'); // 如果没有 token,跳转到登录页
+ navigate('/login', { replace: true });
+ return;
}
+
+ // 如果有token但未认证,尝试刷新token
if (!isAuth && token) {
+ setIsLoading(true);
try {
- await dispatch(refreshToken(token)).unwrap(); // 尝试刷新 token
- } catch {
- navigate('/login');
+ await dispatch(refreshToken(token)).unwrap();
+ } catch (error) {
+ console.error('Token refresh failed:', error);
+ navigate('/login', { replace: true });
+ } finally {
+ setIsLoading(false);
}
}
};
- tryRefreshToken(); // 执行 token 刷新
+ tryRefreshToken();
}, [isAuth, token, dispatch, navigate]);
- // 如果已认证则渲染 children,否则返回 null
- return isAuth ? <>{children}</> : null;
+ // 显示加载状态
+ if (isLoading) {
+ return (
+ <div style={{
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ height: '100vh'
+ }}>
+ <Spin size="large" tip="验证身份中..." />
+ </div>
+ );
+ }
+
+ // 如果没有token,返回null(会触发导航)
+ if (!token) {
+ return null;
+ }
+
+ // 如果已认证,渲染children
+ if (isAuth) {
+ return <>{children}</>;
+ }
+
+ // 如果有token但还在验证中,显示加载状态
+ return (
+ <div style={{
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ height: '100vh'
+ }}>
+ <Spin size="large" tip="验证身份中..." />
+ </div>
+ );
};
-export default ProtectedRoute;
+export default ProtectedRoute;
\ No newline at end of file
diff --git a/src/routes/routes.ts b/src/routes/routes.ts
index 585b1f6..3bb3135 100644
--- a/src/routes/routes.ts
+++ b/src/routes/routes.ts
@@ -1,29 +1,45 @@
+// src/routes/routes.ts
+
import { createBrowserRouter } from "react-router";
-import Home from "../feature/Home";
+import Home from "../feature/home/Home";
import AuthLayout from "../feature/auth/AuthLayout";
import Login from "../feature/auth/Login";
import Register from "../feature/auth/Register";
import Forget from "../feature/auth/Forget";
import AppLayout from "../AppLayout";
import withProtect from "./withProtect";
+import Work from "../feature/work/Work";
+import CreateWork from "../feature/work/CreateWork";
+// 创建受保护的组件
+const ProtectedHome = withProtect(Home);
+const ProtectedWork = withProtect(Work);
+const ProtectedCreatWork = withProtect(CreateWork)
export default createBrowserRouter([
{
+ path: "/",
Component: AppLayout,
children: [
{
path: "/",
- // 使用 ProtectedRoute 来包裹需要保护的页面
- element: withProtect(Home),
+ Component: ProtectedHome,
},
{
Component: AuthLayout,
children: [
- { path: "login", Component: Login },
- { path: "register", Component: Register },
- { path: "forget", Component: Forget },
+ { path: "/login", Component: Login },
+ { path: "/register", Component: Register },
+ { path: "/forget", Component: Forget },
],
},
+ {
+ path: "/work/:work_id",
+ Component: ProtectedWork,
+ },
+ {
+ path:"/work/creat",
+ Component: ProtectedCreatWork
+ }
],
},
-]);
+]);
\ No newline at end of file
diff --git a/src/routes/withProtect.ts b/src/routes/withProtect.ts
index bcde943..f8ea125 100644
--- a/src/routes/withProtect.ts
+++ b/src/routes/withProtect.ts
@@ -1,10 +1,14 @@
-import React from 'react';
-import ProtectedRoute from './ProtectedRoute';
+// src/routes/withProtect.ts
-const withProtect = (Component: React.ComponentType) => {
- return React.createElement(ProtectedRoute, {
- children: React.createElement(Component)
- });
+import React from 'react';
+import ProtectedRoute from './ProtectedRoute';
+
+const withProtect = <P extends object>(Component: React.ComponentType<P>) => {
+ return function ProtectedComponent(props: P) {
+ return React.createElement(ProtectedRoute, {
+ children: React.createElement(Component, props)
+ });
+ };
};
export default withProtect;
\ No newline at end of file
diff --git a/src/store/store.ts b/src/store/store.ts
index 4921218..cb5b975 100644
--- a/src/store/store.ts
+++ b/src/store/store.ts
@@ -1,10 +1,13 @@
import { configureStore } from '@reduxjs/toolkit'
import authReducer from "../feature/auth/authSlice"
import userReducer from "../feature/user/userSlice"
+import workReducer from "../feature/work/workSlice"
+
export const store = configureStore({
reducer: {
auth: authReducer,
user: userReducer,
+ work: workReducer,
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(),
})
diff --git a/src/store/types.ts b/src/store/types.ts
deleted file mode 100644
index 3e2d796..0000000
--- a/src/store/types.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-// 定义用户基本信息
-export interface User {
- id: number;
- username: string;
- email: string;
- role: string;
- token: string;
-}
-
-// 定义认证状态
-export interface AuthState {
- token: string;
- loading: boolean;
- isAuth: boolean;
- error: string
-}
\ No newline at end of file