Compare commits
2 Commits
da0ebde816
...
15af405d02
| Author | SHA1 | Date | |
|---|---|---|---|
| 15af405d02 | |||
| d3174a73f3 |
159
pnpm-lock.yaml
generated
159
pnpm-lock.yaml
generated
@@ -17,12 +17,21 @@ importers:
|
|||||||
'@kevisual/noco':
|
'@kevisual/noco':
|
||||||
specifier: ^0.0.1
|
specifier: ^0.0.1
|
||||||
version: 0.0.1
|
version: 0.0.1
|
||||||
|
'@kevisual/query':
|
||||||
|
specifier: ^0.0.29
|
||||||
|
version: 0.0.29(zod@3.25.76)
|
||||||
'@kevisual/router':
|
'@kevisual/router':
|
||||||
specifier: ^0.0.29
|
specifier: ^0.0.29
|
||||||
version: 0.0.29
|
version: 0.0.29
|
||||||
fast-glob:
|
fast-glob:
|
||||||
specifier: ^3.3.3
|
specifier: ^3.3.3
|
||||||
version: 3.3.3
|
version: 3.3.3
|
||||||
|
pocketbase:
|
||||||
|
specifier: ^0.26.2
|
||||||
|
version: 0.26.2
|
||||||
|
unstorage:
|
||||||
|
specifier: ^1.17.1
|
||||||
|
version: 1.17.1(idb-keyval@6.2.2)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@kevisual/local-proxy':
|
'@kevisual/local-proxy':
|
||||||
specifier: ^0.0.6
|
specifier: ^0.0.6
|
||||||
@@ -48,6 +57,9 @@ importers:
|
|||||||
'@astrojs/sitemap':
|
'@astrojs/sitemap':
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
|
'@faker-js/faker':
|
||||||
|
specifier: ^10.1.0
|
||||||
|
version: 10.1.0
|
||||||
'@kevisual/noco':
|
'@kevisual/noco':
|
||||||
specifier: ^0.0.1
|
specifier: ^0.0.1
|
||||||
version: 0.0.1
|
version: 0.0.1
|
||||||
@@ -60,6 +72,9 @@ importers:
|
|||||||
'@kevisual/registry':
|
'@kevisual/registry':
|
||||||
specifier: ^0.0.1
|
specifier: ^0.0.1
|
||||||
version: 0.0.1(typescript@5.9.3)
|
version: 0.0.1(typescript@5.9.3)
|
||||||
|
'@ricky0123/vad-web':
|
||||||
|
specifier: ^0.0.28
|
||||||
|
version: 0.0.28
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.1.14
|
specifier: ^4.1.14
|
||||||
version: 4.1.14(vite@6.3.7(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1))
|
version: 4.1.14(vite@6.3.7(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1))
|
||||||
@@ -90,12 +105,18 @@ importers:
|
|||||||
nanoid:
|
nanoid:
|
||||||
specifier: ^5.1.6
|
specifier: ^5.1.6
|
||||||
version: 5.1.6
|
version: 5.1.6
|
||||||
|
pocketbase:
|
||||||
|
specifier: ^0.26.2
|
||||||
|
version: 0.26.2
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.0
|
version: 19.2.0
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.0(react@19.2.0)
|
version: 19.2.0(react@19.2.0)
|
||||||
|
react-resizable-panels:
|
||||||
|
specifier: ^3.0.6
|
||||||
|
version: 3.0.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
react-toastify:
|
react-toastify:
|
||||||
specifier: ^11.0.5
|
specifier: ^11.0.5
|
||||||
version: 11.0.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 11.0.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
@@ -427,6 +448,10 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@faker-js/faker@10.1.0':
|
||||||
|
resolution: {integrity: sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==}
|
||||||
|
engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
|
||||||
|
|
||||||
'@img/colour@1.0.0':
|
'@img/colour@1.0.0':
|
||||||
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
|
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -639,6 +664,39 @@ packages:
|
|||||||
'@oslojs/encoding@1.1.0':
|
'@oslojs/encoding@1.1.0':
|
||||||
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
|
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
|
||||||
|
|
||||||
|
'@protobufjs/aspromise@1.1.2':
|
||||||
|
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
|
||||||
|
|
||||||
|
'@protobufjs/base64@1.1.2':
|
||||||
|
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
|
||||||
|
|
||||||
|
'@protobufjs/codegen@2.0.4':
|
||||||
|
resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
|
||||||
|
|
||||||
|
'@protobufjs/eventemitter@1.1.0':
|
||||||
|
resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
|
||||||
|
|
||||||
|
'@protobufjs/fetch@1.1.0':
|
||||||
|
resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==}
|
||||||
|
|
||||||
|
'@protobufjs/float@1.0.2':
|
||||||
|
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
|
||||||
|
|
||||||
|
'@protobufjs/inquire@1.1.0':
|
||||||
|
resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
|
||||||
|
|
||||||
|
'@protobufjs/path@1.1.2':
|
||||||
|
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
|
||||||
|
|
||||||
|
'@protobufjs/pool@1.1.0':
|
||||||
|
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
|
||||||
|
|
||||||
|
'@protobufjs/utf8@1.1.0':
|
||||||
|
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
|
||||||
|
|
||||||
|
'@ricky0123/vad-web@0.0.28':
|
||||||
|
resolution: {integrity: sha512-Hvw8jN3r1SBxmjJa89HITxRcwlT6dc7CQPVtVQLrqfY8EeQcx41QeqKUol4lw8ZCeAIHKwYndHnB1K/4SAQJgQ==}
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.27':
|
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||||
|
|
||||||
@@ -1382,6 +1440,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
flatbuffers@25.9.23:
|
||||||
|
resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==}
|
||||||
|
|
||||||
flattie@1.1.1:
|
flattie@1.1.1:
|
||||||
resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==}
|
resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1422,6 +1483,9 @@ packages:
|
|||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
|
guid-typescript@1.0.9:
|
||||||
|
resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==}
|
||||||
|
|
||||||
h3@1.15.4:
|
h3@1.15.4:
|
||||||
resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==}
|
resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==}
|
||||||
|
|
||||||
@@ -1670,6 +1734,9 @@ packages:
|
|||||||
lodash-es@4.17.21:
|
lodash-es@4.17.21:
|
||||||
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
||||||
|
|
||||||
|
long@5.3.2:
|
||||||
|
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
||||||
|
|
||||||
longest-streak@3.1.0:
|
longest-streak@3.1.0:
|
||||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||||
|
|
||||||
@@ -1954,6 +2021,12 @@ packages:
|
|||||||
oniguruma-to-es@4.3.3:
|
oniguruma-to-es@4.3.3:
|
||||||
resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==}
|
resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==}
|
||||||
|
|
||||||
|
onnxruntime-common@1.23.0:
|
||||||
|
resolution: {integrity: sha512-Auz8S9D7vpF8ok7fzTobvD1XdQDftRf/S7pHmjeCr3Xdymi4z1C7zx4vnT6nnUjbpelZdGwda0BmWHCCTMKUTg==}
|
||||||
|
|
||||||
|
onnxruntime-web@1.23.0:
|
||||||
|
resolution: {integrity: sha512-w0bvC2RwDxphOUFF8jFGZ/dYw+duaX20jM6V4BIZJPCfK4QuCpB/pVREV+hjYbT3x4hyfa2ZbTaWx4e1Vot0fQ==}
|
||||||
|
|
||||||
openai@5.23.2:
|
openai@5.23.2:
|
||||||
resolution: {integrity: sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==}
|
resolution: {integrity: sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2010,6 +2083,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
platform@1.3.6:
|
||||||
|
resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==}
|
||||||
|
|
||||||
|
pocketbase@0.26.2:
|
||||||
|
resolution: {integrity: sha512-WA8EOBc3QnSJh8rJ3iYoi9DmmPOMFIgVfAmIGux7wwruUEIzXgvrO4u0W2htfQjGIcyezJkdZOy5Xmh7SxAftw==}
|
||||||
|
|
||||||
postcss@8.5.6:
|
postcss@8.5.6:
|
||||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
@@ -2028,6 +2107,10 @@ packages:
|
|||||||
property-information@7.1.0:
|
property-information@7.1.0:
|
||||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||||
|
|
||||||
|
protobufjs@7.5.4:
|
||||||
|
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
@@ -2063,6 +2146,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
react-resizable-panels@3.0.6:
|
||||||
|
resolution: {integrity: sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
react-toastify@11.0.5:
|
react-toastify@11.0.5:
|
||||||
resolution: {integrity: sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==}
|
resolution: {integrity: sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2939,6 +3028,8 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.25.11':
|
'@esbuild/win32-x64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@faker-js/faker@10.1.0': {}
|
||||||
|
|
||||||
'@img/colour@1.0.0':
|
'@img/colour@1.0.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -3165,6 +3256,33 @@ snapshots:
|
|||||||
|
|
||||||
'@oslojs/encoding@1.1.0': {}
|
'@oslojs/encoding@1.1.0': {}
|
||||||
|
|
||||||
|
'@protobufjs/aspromise@1.1.2': {}
|
||||||
|
|
||||||
|
'@protobufjs/base64@1.1.2': {}
|
||||||
|
|
||||||
|
'@protobufjs/codegen@2.0.4': {}
|
||||||
|
|
||||||
|
'@protobufjs/eventemitter@1.1.0': {}
|
||||||
|
|
||||||
|
'@protobufjs/fetch@1.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@protobufjs/aspromise': 1.1.2
|
||||||
|
'@protobufjs/inquire': 1.1.0
|
||||||
|
|
||||||
|
'@protobufjs/float@1.0.2': {}
|
||||||
|
|
||||||
|
'@protobufjs/inquire@1.1.0': {}
|
||||||
|
|
||||||
|
'@protobufjs/path@1.1.2': {}
|
||||||
|
|
||||||
|
'@protobufjs/pool@1.1.0': {}
|
||||||
|
|
||||||
|
'@protobufjs/utf8@1.1.0': {}
|
||||||
|
|
||||||
|
'@ricky0123/vad-web@0.0.28':
|
||||||
|
dependencies:
|
||||||
|
onnxruntime-web: 1.23.0
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||||
|
|
||||||
'@rollup/plugin-commonjs@28.0.7(rollup@4.52.4)':
|
'@rollup/plugin-commonjs@28.0.7(rollup@4.52.4)':
|
||||||
@@ -3917,6 +4035,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
|
|
||||||
|
flatbuffers@25.9.23: {}
|
||||||
|
|
||||||
flattie@1.1.1: {}
|
flattie@1.1.1: {}
|
||||||
|
|
||||||
fontace@0.3.1:
|
fontace@0.3.1:
|
||||||
@@ -3955,6 +4075,8 @@ snapshots:
|
|||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
|
guid-typescript@1.0.9: {}
|
||||||
|
|
||||||
h3@1.15.4:
|
h3@1.15.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
cookie-es: 1.2.2
|
cookie-es: 1.2.2
|
||||||
@@ -4251,6 +4373,8 @@ snapshots:
|
|||||||
|
|
||||||
lodash-es@4.17.21: {}
|
lodash-es@4.17.21: {}
|
||||||
|
|
||||||
|
long@5.3.2: {}
|
||||||
|
|
||||||
longest-streak@3.1.0: {}
|
longest-streak@3.1.0: {}
|
||||||
|
|
||||||
lru-cache@10.4.3: {}
|
lru-cache@10.4.3: {}
|
||||||
@@ -4785,6 +4909,17 @@ snapshots:
|
|||||||
regex: 6.0.1
|
regex: 6.0.1
|
||||||
regex-recursion: 6.0.2
|
regex-recursion: 6.0.2
|
||||||
|
|
||||||
|
onnxruntime-common@1.23.0: {}
|
||||||
|
|
||||||
|
onnxruntime-web@1.23.0:
|
||||||
|
dependencies:
|
||||||
|
flatbuffers: 25.9.23
|
||||||
|
guid-typescript: 1.0.9
|
||||||
|
long: 5.3.2
|
||||||
|
onnxruntime-common: 1.23.0
|
||||||
|
platform: 1.3.6
|
||||||
|
protobufjs: 7.5.4
|
||||||
|
|
||||||
openai@5.23.2(zod@3.25.76):
|
openai@5.23.2(zod@3.25.76):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
@@ -4837,6 +4972,10 @@ snapshots:
|
|||||||
|
|
||||||
picomatch@4.0.3: {}
|
picomatch@4.0.3: {}
|
||||||
|
|
||||||
|
platform@1.3.6: {}
|
||||||
|
|
||||||
|
pocketbase@0.26.2: {}
|
||||||
|
|
||||||
postcss@8.5.6:
|
postcss@8.5.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.11
|
nanoid: 3.3.11
|
||||||
@@ -4854,6 +4993,21 @@ snapshots:
|
|||||||
|
|
||||||
property-information@7.1.0: {}
|
property-information@7.1.0: {}
|
||||||
|
|
||||||
|
protobufjs@7.5.4:
|
||||||
|
dependencies:
|
||||||
|
'@protobufjs/aspromise': 1.1.2
|
||||||
|
'@protobufjs/base64': 1.1.2
|
||||||
|
'@protobufjs/codegen': 2.0.4
|
||||||
|
'@protobufjs/eventemitter': 1.1.0
|
||||||
|
'@protobufjs/fetch': 1.1.0
|
||||||
|
'@protobufjs/float': 1.0.2
|
||||||
|
'@protobufjs/inquire': 1.1.0
|
||||||
|
'@protobufjs/path': 1.1.2
|
||||||
|
'@protobufjs/pool': 1.1.0
|
||||||
|
'@protobufjs/utf8': 1.1.0
|
||||||
|
'@types/node': 24.7.2
|
||||||
|
long: 5.3.2
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
radix3@1.1.2: {}
|
radix3@1.1.2: {}
|
||||||
@@ -4877,6 +5031,11 @@ snapshots:
|
|||||||
|
|
||||||
react-refresh@0.17.0: {}
|
react-refresh@0.17.0: {}
|
||||||
|
|
||||||
|
react-resizable-panels@3.0.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
|
||||||
react-toastify@11.0.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
react-toastify@11.0.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
|
|||||||
14
server/code/test/demo/main.ts
Normal file
14
server/code/test/demo/main.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { QueryRouterServer } from "@kevisual/router";
|
||||||
|
|
||||||
|
const app = new QueryRouterServer();
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
path: 'main'
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
ctx.body = {
|
||||||
|
message: 'this is main. filename: test/demo/main.ts',
|
||||||
|
params: ctx.query
|
||||||
|
}
|
||||||
|
}).addTo(app)
|
||||||
|
|
||||||
|
app.wait()
|
||||||
@@ -22,7 +22,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kevisual/noco": "^0.0.1",
|
"@kevisual/noco": "^0.0.1",
|
||||||
|
"@kevisual/query": "^0.0.29",
|
||||||
"@kevisual/router": "^0.0.29",
|
"@kevisual/router": "^0.0.29",
|
||||||
"fast-glob": "^3.3.3"
|
"fast-glob": "^3.3.3",
|
||||||
|
"pocketbase": "^0.26.2",
|
||||||
|
"unstorage": "^1.17.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
23
server/src/cache/index.ts
vendored
Normal file
23
server/src/cache/index.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { createStorage } from 'unstorage'
|
||||||
|
import fsLiteDriver from "unstorage/drivers/fs-lite";
|
||||||
|
import { codeRoot } from '@/modules/config.ts';
|
||||||
|
import memoryDriver from "unstorage/drivers/memory";
|
||||||
|
|
||||||
|
export const storage = createStorage({
|
||||||
|
// @ts-ignore
|
||||||
|
driver: memoryDriver(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const codeStorage = createStorage({
|
||||||
|
// @ts-ignore
|
||||||
|
driver: fsLiteDriver({
|
||||||
|
base: codeRoot
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// storage.setItem('test-ke/test-key.json', 'test-value');
|
||||||
|
// console.log('Cache test-key:', await storage.getItem('test-key'));
|
||||||
|
|
||||||
|
// codeStorage.setItem('root/light-code-demo/main.ts', 'test-value2');
|
||||||
|
console.log('Cache test-key:', await codeStorage.getItem('root/light-code-demo/main.ts'));
|
||||||
|
console.log('has', await codeStorage.hasItem('root/light-code-demo/main.ts'));
|
||||||
52
server/src/db/collections/project.ts
Normal file
52
server/src/db/collections/project.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
|
||||||
|
import { Field } from '../types/index.ts';
|
||||||
|
|
||||||
|
export const projectStatus = ['提交', '审核中', '审核通过']; // 提交,审核中,审核通过
|
||||||
|
export type projectStatus = typeof projectStatus[number];
|
||||||
|
|
||||||
|
export const projectFields: Field[] = [
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
type: 'text'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'key',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'owner',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'data',
|
||||||
|
type: 'json'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'files',
|
||||||
|
type: 'json'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "createdAt",
|
||||||
|
onCreate: true,
|
||||||
|
onUpdate: false,
|
||||||
|
type: "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "updatedAt",
|
||||||
|
onCreate: true,
|
||||||
|
onUpdate: true,
|
||||||
|
type: "autodate"
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const name = 'xx_projects';
|
||||||
|
|
||||||
|
export const type = 'base';
|
||||||
37
server/src/db/init.ts
Normal file
37
server/src/db/init.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { pb, db } from '../modules/db.ts'
|
||||||
|
import * as projects from './collections/project.ts'
|
||||||
|
// 要求
|
||||||
|
// 1. collection只做新增,不做修改
|
||||||
|
// 2. collection不存在就创建
|
||||||
|
// 3. 每一个collection的定义在文档中需要有
|
||||||
|
|
||||||
|
|
||||||
|
export const main = async () => {
|
||||||
|
try {
|
||||||
|
await db.ensureLogin().catch(() => { throw new Error('Login failed'); });
|
||||||
|
// 程序第一次运行的时候执行,如果已经初始化过则跳过
|
||||||
|
const collections = await db.pb.collections.getFullList({
|
||||||
|
filter: 'name ~ "xx_%"',
|
||||||
|
})
|
||||||
|
console.log('Existing collections:', collections.map(c => c.name));
|
||||||
|
const dbs = [projects]
|
||||||
|
for (const coll of dbs) {
|
||||||
|
const exists = collections.find(c => c.name === coll.name)
|
||||||
|
if (exists) {
|
||||||
|
console.log(`Collection ${coll.name} already exists, skipping creation.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 第一步,获取那个叉叉开头的 Collection。第二步,获取它的版本。
|
||||||
|
const createdCollection = await db.pb.collections.create({
|
||||||
|
name: coll.name,
|
||||||
|
type: coll?.type || 'base',
|
||||||
|
fields: coll?.projectFields,
|
||||||
|
})
|
||||||
|
console.log('Created collection:', createdCollection);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during DB initialization:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main()
|
||||||
26
server/src/db/types/collection.ts
Normal file
26
server/src/db/types/collection.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export type Field = {
|
||||||
|
/**
|
||||||
|
* The unique identifier for the field
|
||||||
|
*/
|
||||||
|
id?: string;
|
||||||
|
/**
|
||||||
|
* The name of the field
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
type?: 'text' | 'json' | 'autodate' | 'boolean' | 'number' | 'email' | 'url' | 'file' | 'relation';
|
||||||
|
/**
|
||||||
|
* Indicates whether the field is required
|
||||||
|
*/
|
||||||
|
required?: boolean;
|
||||||
|
/**
|
||||||
|
* Only for 'autodate' type
|
||||||
|
* Indicates whether to set the date on record creation or update
|
||||||
|
*/
|
||||||
|
onCreate?: boolean;
|
||||||
|
/** Only for 'autodate' type
|
||||||
|
* Indicates whether to set the date on record update
|
||||||
|
*/
|
||||||
|
onUpdate?: boolean;
|
||||||
|
options?: Record<string, any>;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
1
server/src/db/types/index.ts
Normal file
1
server/src/db/types/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './collection.ts';
|
||||||
@@ -15,3 +15,5 @@ app.listen(4005, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.onServerRequest(proxyRoute);
|
app.onServerRequest(proxyRoute);
|
||||||
|
|
||||||
|
export { app }
|
||||||
7
server/src/modules/config.ts
Normal file
7
server/src/modules/config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { useConfig } from '@kevisual/use-config'
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export const config = useConfig()
|
||||||
|
|
||||||
|
export const codeRoot = path.join(process.cwd(), 'code');
|
||||||
24
server/src/modules/db.ts
Normal file
24
server/src/modules/db.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import PocketBase from 'pocketbase';
|
||||||
|
|
||||||
|
const POCKETBASE_URL = 'https://pocketbase.pro.xiongxiao.me/';
|
||||||
|
|
||||||
|
export const pb = new PocketBase(POCKETBASE_URL);
|
||||||
|
|
||||||
|
export class DB {
|
||||||
|
pb: PocketBase
|
||||||
|
constructor(pb: PocketBase) {
|
||||||
|
this.pb = pb
|
||||||
|
}
|
||||||
|
async ensureLogin() {
|
||||||
|
const pb = this.pb;
|
||||||
|
if (!pb.authStore.isValid) {
|
||||||
|
await pb.collection("_superusers").authWithPassword('xiongxiao@xiongxiao.me', '123456xx');
|
||||||
|
}
|
||||||
|
return pb.authStore.record;
|
||||||
|
}
|
||||||
|
async getCollection(name: string) {
|
||||||
|
await this.ensureLogin();
|
||||||
|
return this.pb.collection(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const db = new DB(pb);
|
||||||
8
server/src/routes/auth.ts
Normal file
8
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { app } from '../app.ts'
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
path: 'auth',
|
||||||
|
id: 'auth'
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
// Authentication logic here
|
||||||
|
}).addTo(app);
|
||||||
@@ -2,9 +2,10 @@ import { app } from '@/app.ts';
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import glob from 'fast-glob';
|
import glob from 'fast-glob';
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
|
import { codeRoot } from '@/modules/config.ts';
|
||||||
const list = async () => {
|
const list = async () => {
|
||||||
const root = path.join(process.cwd(), 'code');
|
|
||||||
const files = await glob('**/*.ts', { cwd: root });
|
const files = await glob('**/*.ts', { cwd: codeRoot });
|
||||||
type FileContent = {
|
type FileContent = {
|
||||||
path: string;
|
path: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -12,7 +13,7 @@ const list = async () => {
|
|||||||
const filesContent: FileContent[] = [];
|
const filesContent: FileContent[] = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.startsWith('node_modules') || file.startsWith('dist') || file.startsWith('.git')) continue;
|
if (file.startsWith('node_modules') || file.startsWith('dist') || file.startsWith('.git')) continue;
|
||||||
const fullPath = path.join(root, file);
|
const fullPath = path.join(codeRoot, file);
|
||||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||||
if (content) {
|
if (content) {
|
||||||
filesContent.push({ path: file, content: content });
|
filesContent.push({ path: file, content: content });
|
||||||
@@ -20,9 +21,48 @@ const list = async () => {
|
|||||||
}
|
}
|
||||||
return filesContent;
|
return filesContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.route({
|
app.route({
|
||||||
path: 'file-code'
|
path: 'file-code'
|
||||||
}).define(async (ctx) => {
|
}).define(async (ctx) => {
|
||||||
const files = await list();
|
const files = await list();
|
||||||
ctx.body = files
|
ctx.body = files
|
||||||
}).addTo(app);
|
}).addTo(app);
|
||||||
|
|
||||||
|
type UploadProps = {
|
||||||
|
user: string;
|
||||||
|
key: string;
|
||||||
|
files: {
|
||||||
|
type: 'file' | 'base64';
|
||||||
|
filepath: string;
|
||||||
|
content: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
app.route({
|
||||||
|
path: 'file-code',
|
||||||
|
key: 'upload',
|
||||||
|
middleware: ['auth']
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const upload = ctx.query?.upload as UploadProps;
|
||||||
|
if (!upload || !upload.user || !upload.key || !upload.files) {
|
||||||
|
ctx.throw(400, 'Invalid upload data');
|
||||||
|
}
|
||||||
|
const user = upload.user;
|
||||||
|
const key = upload.key;
|
||||||
|
for (const file of upload.files) {
|
||||||
|
if (file.type === 'file') {
|
||||||
|
const fullPath = path.join(codeRoot, user, key, file.filepath);
|
||||||
|
const dir = path.dirname(fullPath);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, file.content, 'utf-8');
|
||||||
|
} else if (file.type === 'base64') {
|
||||||
|
const fullPath = path.join(codeRoot, user, key, file.filepath);
|
||||||
|
const dir = path.dirname(fullPath);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const buffer = Buffer.from(file.content, 'base64');
|
||||||
|
fs.writeFileSync(fullPath, buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.body = { success: true };
|
||||||
|
|
||||||
|
}).addTo(app)
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
import './call/index.ts';
|
import './call/index.ts';
|
||||||
|
|
||||||
import './file-code/index.ts';
|
import './file-code/index.ts';
|
||||||
|
|
||||||
|
import './auth.ts'
|
||||||
5
server/src/test/common.ts
Normal file
5
server/src/test/common.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Query } from '@kevisual/query'
|
||||||
|
|
||||||
|
export const query = new Query({
|
||||||
|
url: 'http://localhost:4005/api/router',
|
||||||
|
})
|
||||||
48
server/src/test/test-upload.ts
Normal file
48
server/src/test/test-upload.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { query } from './common.ts'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const testUpload = async () => {
|
||||||
|
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'file-code',
|
||||||
|
key: 'upload',
|
||||||
|
upload: {
|
||||||
|
user: 'test',
|
||||||
|
key: 'demo',
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
type: 'file',
|
||||||
|
filepath: 'main.ts',
|
||||||
|
content: `import { QueryRouterServer } from "@kevisual/router";
|
||||||
|
|
||||||
|
const app = new QueryRouterServer();
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
path: 'main'
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
ctx.body = {
|
||||||
|
message: 'this is main. filename: test/demo/main.ts',
|
||||||
|
params: ctx.query
|
||||||
|
}
|
||||||
|
}).addTo(app)
|
||||||
|
|
||||||
|
app.wait()`
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log('Upload response:', res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// testUpload();
|
||||||
|
|
||||||
|
const callTestDemo = async () => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'call',
|
||||||
|
filename: 'test/demo/main.ts',
|
||||||
|
})
|
||||||
|
console.log('Call response:', res);
|
||||||
|
}
|
||||||
|
|
||||||
|
callTestDemo();
|
||||||
6
web/.env.example
Normal file
6
web/.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# PocketBase配置
|
||||||
|
VITE_POCKETBASE_URL=http://localhost:8090
|
||||||
|
|
||||||
|
# 可选:其他配置
|
||||||
|
# VITE_APP_NAME=Light Code Center
|
||||||
|
# VITE_DEBUG=true
|
||||||
@@ -19,10 +19,12 @@
|
|||||||
"@astrojs/mdx": "^4.3.7",
|
"@astrojs/mdx": "^4.3.7",
|
||||||
"@astrojs/react": "^4.4.0",
|
"@astrojs/react": "^4.4.0",
|
||||||
"@astrojs/sitemap": "^3.6.0",
|
"@astrojs/sitemap": "^3.6.0",
|
||||||
|
"@faker-js/faker": "^10.1.0",
|
||||||
"@kevisual/noco": "^0.0.1",
|
"@kevisual/noco": "^0.0.1",
|
||||||
"@kevisual/query": "^0.0.29",
|
"@kevisual/query": "^0.0.29",
|
||||||
"@kevisual/query-login": "^0.0.6",
|
"@kevisual/query-login": "^0.0.6",
|
||||||
"@kevisual/registry": "^0.0.1",
|
"@kevisual/registry": "^0.0.1",
|
||||||
|
"@ricky0123/vad-web": "^0.0.28",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"astro": "^5.14.4",
|
"astro": "^5.14.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -33,8 +35,10 @@
|
|||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
|
"pocketbase": "^0.26.2",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"three": "^0.180.0",
|
"three": "^0.180.0",
|
||||||
|
|||||||
17
web/src/apps/login/AuthProvider.tsx
Normal file
17
web/src/apps/login/AuthProvider.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||||
|
const initAuth = useAuthStore(state => state.initAuth);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 在应用启动时初始化认证状态
|
||||||
|
initAuth();
|
||||||
|
}, [initAuth]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
70
web/src/apps/login/DashboardApp.tsx
Normal file
70
web/src/apps/login/DashboardApp.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { AuthProvider } from './AuthProvider';
|
||||||
|
import { ProtectedRoute } from '@/apps/login/ProtectedRoute';
|
||||||
|
import { UserInfo } from '@/apps/login/UserInfo';
|
||||||
|
import { useAuth } from '../../store/authStore';
|
||||||
|
import { UserType } from '../../lib/pocketbase';
|
||||||
|
import { ToastContainer } from 'react-toastify';
|
||||||
|
|
||||||
|
const DashboardContent: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<header className="bg-white shadow">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center py-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">用户仪表板</h1>
|
||||||
|
<UserInfo />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
<div className="px-4 py-6 sm:px-0">
|
||||||
|
<div className="border-4 border-dashed border-gray-200 rounded-lg p-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||||
|
欢迎,{user?.email || '用户'}!
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-8">
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">项目管理</h3>
|
||||||
|
<p className="text-gray-600">管理您的代码项目和文件</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">设置</h3>
|
||||||
|
<p className="text-gray-600">配置您的账户设置</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DashboardApp: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<ProtectedRoute requiredUserType={UserType.USER}>
|
||||||
|
<DashboardContent />
|
||||||
|
</ProtectedRoute>
|
||||||
|
<ToastContainer
|
||||||
|
position="top-right"
|
||||||
|
autoClose={5000}
|
||||||
|
hideProgressBar={false}
|
||||||
|
newestOnTop={false}
|
||||||
|
closeOnClick
|
||||||
|
rtl={false}
|
||||||
|
pauseOnFocusLoss
|
||||||
|
draggable
|
||||||
|
pauseOnHover
|
||||||
|
theme="light"
|
||||||
|
/>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
200
web/src/apps/login/LoginForm.tsx
Normal file
200
web/src/apps/login/LoginForm.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuthStore, useAuth, useAuthActions } from '@/store/authStore';
|
||||||
|
import { UserType } from '@/lib/pocketbase';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
interface LoginFormData {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
userType: UserType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoginForm: React.FC = () => {
|
||||||
|
const { isLoading, error } = useAuth();
|
||||||
|
const { login, clearError } = useAuthActions();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<LoginFormData>({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
userType: UserType.USER,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 清除错误信息
|
||||||
|
if (error) {
|
||||||
|
clearError();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!formData.email || !formData.password) {
|
||||||
|
toast.error('请填写邮箱和密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(formData);
|
||||||
|
toast.success('登录成功!');
|
||||||
|
|
||||||
|
// 登录成功后跳转到首页或仪表板
|
||||||
|
window.location.href = formData.userType === UserType.ADMIN ? '/admin' : '/dashboard';
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : '登录失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserTypeChange = (userType: UserType) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
userType,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
clearError();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
|
登录您的账户
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-600">
|
||||||
|
选择您的账户类型并输入登录信息
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
|
{/* 用户类型选择 */}
|
||||||
|
<div className="rounded-md shadow-sm space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-700 block mb-2">
|
||||||
|
账户类型
|
||||||
|
</label>
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleUserTypeChange(UserType.USER)}
|
||||||
|
className={`flex-1 py-2 px-4 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
formData.userType === UserType.USER
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
普通用户
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleUserTypeChange(UserType.ADMIN)}
|
||||||
|
className={`flex-1 py-2 px-4 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
formData.userType === UserType.ADMIN
|
||||||
|
? 'bg-red-600 text-white'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
管理员
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 邮箱输入 */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="text-sm font-medium text-gray-700 block mb-1">
|
||||||
|
邮箱地址
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="请输入邮箱地址"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 密码输入 */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="text-sm font-medium text-gray-700 block mb-1">
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误信息显示 */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-red-800">
|
||||||
|
{error}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 提交按钮 */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white transition-colors ${
|
||||||
|
isLoading
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: formData.userType === UserType.ADMIN
|
||||||
|
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
||||||
|
: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500'
|
||||||
|
} focus:outline-none focus:ring-2 focus:ring-offset-2`}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
登录中...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
`登录${formData.userType === UserType.ADMIN ? '管理员' : '用户'}账户`
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提示信息 */}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{formData.userType === UserType.ADMIN
|
||||||
|
? '管理员账户将使用 superuser 权限登录'
|
||||||
|
: '普通用户账户将使用标准权限登录'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
23
web/src/apps/login/LoginPage.tsx
Normal file
23
web/src/apps/login/LoginPage.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { LoginForm } from './LoginForm';
|
||||||
|
import { ToastContainer } from 'react-toastify';
|
||||||
|
|
||||||
|
export const LoginPage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LoginForm />
|
||||||
|
<ToastContainer
|
||||||
|
position="top-right"
|
||||||
|
autoClose={5000}
|
||||||
|
hideProgressBar={false}
|
||||||
|
newestOnTop={false}
|
||||||
|
closeOnClick
|
||||||
|
rtl={false}
|
||||||
|
pauseOnFocusLoss
|
||||||
|
draggable
|
||||||
|
pauseOnHover
|
||||||
|
theme="light"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
103
web/src/apps/login/ProtectedRoute.tsx
Normal file
103
web/src/apps/login/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useAuth, usePermissions } from '@/store/authStore';
|
||||||
|
import { UserType } from '@/lib/pocketbase';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
requiredUserType?: UserType;
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
redirectTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||||
|
children,
|
||||||
|
requiredUserType,
|
||||||
|
fallback,
|
||||||
|
redirectTo = '/login'
|
||||||
|
}) => {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
const { canAccess } = usePermissions();
|
||||||
|
|
||||||
|
// 加载中显示
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
<span className="text-gray-600">验证身份中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未登录
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
if (fallback) {
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重定向到登录页面
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = redirectTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">需要登录</h2>
|
||||||
|
<p className="text-gray-600 mb-6">请先登录您的账户</p>
|
||||||
|
<a
|
||||||
|
href={redirectTo}
|
||||||
|
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
前往登录
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (requiredUserType && !canAccess(requiredUserType)) {
|
||||||
|
if (fallback) {
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-red-600 mb-4">权限不足</h2>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
您需要{requiredUserType === UserType.ADMIN ? '管理员' : '用户'}权限才能访问此页面
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="bg-gray-600 text-white px-6 py-2 rounded-md hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 专门用于管理员页面的保护组件
|
||||||
|
export const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<ProtectedRoute requiredUserType={UserType.ADMIN}>
|
||||||
|
{children}
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 专门用于普通用户页面的保护组件
|
||||||
|
export const UserRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<ProtectedRoute requiredUserType={UserType.USER}>
|
||||||
|
{children}
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
};
|
||||||
110
web/src/apps/login/UserInfo.tsx
Normal file
110
web/src/apps/login/UserInfo.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useAuth, useAuthActions, usePermissions } from '@/store/authStore';
|
||||||
|
import { UserType } from '@/lib/pocketbase';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
export const UserInfo: React.FC = () => {
|
||||||
|
const { isAuthenticated, user, userType, isLoading } = useAuth();
|
||||||
|
const { logout } = useAuthActions();
|
||||||
|
const { isAdmin, isUser } = usePermissions();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
toast.success('已成功登出');
|
||||||
|
window.location.href = '/login';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated || !user) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* 用户信息 */}
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{/* 用户头像 */}
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium ${
|
||||||
|
isAdmin ? 'bg-red-600' : 'bg-blue-600'
|
||||||
|
}`}>
|
||||||
|
{user.email?.charAt(0).toUpperCase() || 'U'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 用户详情 */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
{(user as any).name || (user as any).username || user.email}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs ${
|
||||||
|
isAdmin ? 'text-red-600' : 'text-blue-600'
|
||||||
|
}`}>
|
||||||
|
{isAdmin ? '管理员' : '用户'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{/* 仪表板链接 */}
|
||||||
|
<a
|
||||||
|
href={isAdmin ? '/admin' : '/dashboard'}
|
||||||
|
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
isAdmin
|
||||||
|
? 'bg-red-100 text-red-700 hover:bg-red-200'
|
||||||
|
: 'bg-blue-100 text-blue-700 hover:bg-blue-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isAdmin ? '管理面板' : '仪表板'}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* 登出按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="px-3 py-1 rounded-md text-sm font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
登出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简化版本的用户状态显示组件
|
||||||
|
export const UserStatus: React.FC = () => {
|
||||||
|
const { isAuthenticated, user, userType } = useAuth();
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
|
|
||||||
|
if (!isAuthenticated || !user) {
|
||||||
|
return (
|
||||||
|
<span className="text-sm text-gray-500">未登录</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${
|
||||||
|
isAdmin ? 'bg-red-500' : 'bg-green-500'
|
||||||
|
}`}></div>
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
{isAdmin ? '管理员' : '用户'}: {user.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
70
web/src/apps/muse/base/index.tsx
Normal file
70
web/src/apps/muse/base/index.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Base } from "./table/index";
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
key: 'table',
|
||||||
|
title: '表格'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'graph',
|
||||||
|
title: '关系图'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'world',
|
||||||
|
title: '世界'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BaseApp = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState('table');
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'table':
|
||||||
|
return <Base />;
|
||||||
|
case 'graph':
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96 text-gray-500">
|
||||||
|
关系图模块暂未实现
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'world':
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96 text-gray-500">
|
||||||
|
世界模块暂未实现
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full">
|
||||||
|
{/* Tab 导航栏 */}
|
||||||
|
<div className="border-b border-gray-200">
|
||||||
|
<nav className="flex space-x-8">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 内容区域 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
web/src/apps/muse/base/mock/collection.ts
Normal file
183
web/src/apps/muse/base/mock/collection.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import { nanoid, customAlphabet } from 'nanoid';
|
||||||
|
|
||||||
|
export const random = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ');
|
||||||
|
|
||||||
|
// 类型定义
|
||||||
|
export type MarkDataNode = {
|
||||||
|
id?: string;
|
||||||
|
content?: string;
|
||||||
|
type?: string;
|
||||||
|
title?: string;
|
||||||
|
position?: { x: number; y: number };
|
||||||
|
size?: { width: number; height: number };
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarkFile = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
type: 'self' | 'data' | 'generate'; // generate为生成文件
|
||||||
|
query: string; // 'data.nodes[id].content';
|
||||||
|
hash: string;
|
||||||
|
fileKey: string; // 文件的名称, 唯一
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarkData = {
|
||||||
|
md?: string; // markdown
|
||||||
|
mdList?: string[]; // markdown list
|
||||||
|
type?: string; // 类型 markdown | json | html | image | video | audio | code | link | file
|
||||||
|
data?: any;
|
||||||
|
key?: string; // 文件的名称, 唯一
|
||||||
|
push?: boolean; // 是否推送到elasticsearch
|
||||||
|
pushTime?: Date; // 推送时间
|
||||||
|
summary?: string; // 摘要
|
||||||
|
nodes?: MarkDataNode[]; // 节点
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarkConfig = {
|
||||||
|
visibility?: 'public' | 'private' | 'restricted';
|
||||||
|
allowComments?: boolean;
|
||||||
|
allowDownload?: boolean;
|
||||||
|
password?: string;
|
||||||
|
expiredAt?: Date;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarkAuth = {
|
||||||
|
permissions?: string[];
|
||||||
|
roles?: string[];
|
||||||
|
userId?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Mark = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
cover: string;
|
||||||
|
thumbnail: string;
|
||||||
|
key: string;
|
||||||
|
markType: string;
|
||||||
|
link: string;
|
||||||
|
tags: string[];
|
||||||
|
summary: string;
|
||||||
|
data: MarkData;
|
||||||
|
uid: string;
|
||||||
|
puid: string;
|
||||||
|
config: MarkConfig;
|
||||||
|
fileList: MarkFile[];
|
||||||
|
uname: string;
|
||||||
|
markedAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成模拟的 MarkDataNode
|
||||||
|
const generateMarkDataNode = (): MarkDataNode => ({
|
||||||
|
id: random(12),
|
||||||
|
content: faker.lorem.paragraph(),
|
||||||
|
type: faker.helpers.arrayElement(['text', 'image', 'video', 'code', 'link']),
|
||||||
|
title: faker.lorem.sentence(),
|
||||||
|
position: {
|
||||||
|
x: faker.number.int({ min: 0, max: 1920 }),
|
||||||
|
y: faker.number.int({ min: 0, max: 1080 })
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
width: faker.number.int({ min: 100, max: 800 }),
|
||||||
|
height: faker.number.int({ min: 50, max: 600 })
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
createdBy: faker.person.fullName(),
|
||||||
|
lastModified: faker.date.recent(),
|
||||||
|
isLocked: faker.datatype.boolean()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成模拟的 MarkFile
|
||||||
|
const generateMarkFile = (): MarkFile => ({
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
name: faker.system.fileName(),
|
||||||
|
url: faker.internet.url(),
|
||||||
|
size: faker.number.int({ min: 1024, max: 50 * 1024 * 1024 }), // 1KB to 50MB
|
||||||
|
type: faker.helpers.arrayElement(['self', 'data', 'generate']),
|
||||||
|
query: `data.nodes[${random(12)}].content`,
|
||||||
|
hash: faker.git.commitSha(),
|
||||||
|
fileKey: faker.system.fileName()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成模拟的 MarkData
|
||||||
|
const generateMarkData = (): MarkData => ({
|
||||||
|
md: faker.lorem.paragraphs(3, '\n\n'),
|
||||||
|
mdList: Array.from({ length: faker.number.int({ min: 3, max: 8 }) }, () => faker.lorem.sentence()),
|
||||||
|
type: faker.helpers.arrayElement(['markdown', 'json', 'html', 'image', 'video', 'audio', 'code', 'link', 'file']),
|
||||||
|
data: {
|
||||||
|
author: faker.person.fullName(),
|
||||||
|
category: faker.helpers.arrayElement(['技术', '生活', '工作', '学习', '思考']),
|
||||||
|
priority: faker.helpers.arrayElement(['low', 'medium', 'high'])
|
||||||
|
},
|
||||||
|
key: faker.system.fileName(),
|
||||||
|
push: faker.datatype.boolean(),
|
||||||
|
pushTime: faker.date.recent(),
|
||||||
|
summary: faker.lorem.paragraph(),
|
||||||
|
nodes: Array.from({ length: faker.number.int({ min: 2, max: 6 }) }, generateMarkDataNode)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成模拟的 MarkConfig
|
||||||
|
const generateMarkConfig = (): MarkConfig => ({
|
||||||
|
visibility: faker.helpers.arrayElement(['public', 'private', 'restricted']),
|
||||||
|
allowComments: faker.datatype.boolean(),
|
||||||
|
allowDownload: faker.datatype.boolean(),
|
||||||
|
password: faker.datatype.boolean() ? faker.internet.password() : undefined,
|
||||||
|
expiredAt: faker.datatype.boolean() ? faker.date.future() : undefined,
|
||||||
|
theme: faker.helpers.arrayElement(['light', 'dark', 'auto']),
|
||||||
|
language: faker.helpers.arrayElement(['zh-CN', 'en-US', 'ja-JP'])
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成单个 Mark 记录
|
||||||
|
const generateMark = (): Mark => {
|
||||||
|
const markType = faker.helpers.arrayElement(['markdown', 'json', 'html', 'image', 'video', 'audio', 'code', 'link', 'file']);
|
||||||
|
const title = faker.lorem.sentence({ min: 3, max: 8 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
title,
|
||||||
|
description: faker.lorem.paragraph(),
|
||||||
|
cover: faker.image.url({ width: 800, height: 600 }),
|
||||||
|
thumbnail: faker.image.url({ width: 200, height: 150 }),
|
||||||
|
key: faker.system.filePath(),
|
||||||
|
markType,
|
||||||
|
link: faker.internet.url(),
|
||||||
|
tags: Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, () =>
|
||||||
|
faker.helpers.arrayElement(['技术', '前端', '后端', '设计', 'AI', '工具', '教程', '笔记'])
|
||||||
|
),
|
||||||
|
summary: faker.lorem.sentence(),
|
||||||
|
data: generateMarkData(),
|
||||||
|
uid: faker.string.uuid(),
|
||||||
|
puid: faker.string.uuid(),
|
||||||
|
config: generateMarkConfig(),
|
||||||
|
fileList: Array.from({ length: faker.number.int({ min: 0, max: 4 }) }, generateMarkFile),
|
||||||
|
uname: faker.person.fullName(),
|
||||||
|
markedAt: faker.date.past(),
|
||||||
|
createdAt: faker.date.past(),
|
||||||
|
updatedAt: faker.date.recent(),
|
||||||
|
version: faker.number.int({ min: 1, max: 10 })
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成 20 条模拟数据
|
||||||
|
export const mockMarks: Mark[] = Array.from({ length: 20 }, generateMark);
|
||||||
|
|
||||||
|
// 导出生成器函数
|
||||||
|
export {
|
||||||
|
generateMark,
|
||||||
|
generateMarkData,
|
||||||
|
generateMarkFile,
|
||||||
|
generateMarkDataNode,
|
||||||
|
generateMarkConfig
|
||||||
|
};
|
||||||
153
web/src/apps/muse/base/table/DetailModal.tsx
Normal file
153
web/src/apps/muse/base/table/DetailModal.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Mark } from '../mock/collection';
|
||||||
|
import './modal.css';
|
||||||
|
|
||||||
|
interface DetailModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
data: Mark | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DetailModal: React.FC<DetailModalProps> = ({ visible, data, onClose }) => {
|
||||||
|
if (!visible || !data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>详情信息</h3>
|
||||||
|
<button className="modal-close" onClick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="detail-section">
|
||||||
|
<h4>基本信息</h4>
|
||||||
|
<div className="detail-grid">
|
||||||
|
<div className="detail-item">
|
||||||
|
<label>标题:</label>
|
||||||
|
<span>{data.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<label>类型:</label>
|
||||||
|
<span className={`type-badge type-${data.markType}`}>{data.markType}</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<label>创建者:</label>
|
||||||
|
<span>{data.uname}</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<label>可见性:</label>
|
||||||
|
<span className={`visibility-badge visibility-${data.config.visibility}`}>
|
||||||
|
{data.config.visibility === 'public' ? '公开' :
|
||||||
|
data.config.visibility === 'private' ? '私有' : '受限'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-section">
|
||||||
|
<h4>描述</h4>
|
||||||
|
<p>{data.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-section">
|
||||||
|
<h4>标签</h4>
|
||||||
|
<div className="tags-container">
|
||||||
|
{data.tags.map((tag, index) => (
|
||||||
|
<span key={index} className="tag">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-section">
|
||||||
|
<h4>时间信息</h4>
|
||||||
|
<div className="detail-grid">
|
||||||
|
<div className="detail-item">
|
||||||
|
<label>标记时间:</label>
|
||||||
|
<span>{new Date(data.markedAt).toLocaleString('zh-CN')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<label>创建时间:</label>
|
||||||
|
<span>{new Date(data.createdAt).toLocaleString('zh-CN')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<label>更新时间:</label>
|
||||||
|
<span>{new Date(data.updatedAt).toLocaleString('zh-CN')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-item">
|
||||||
|
<label>版本:</label>
|
||||||
|
<span>v{data.version}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.fileList.length > 0 && (
|
||||||
|
<div className="detail-section">
|
||||||
|
<h4>附件文件 ({data.fileList.length})</h4>
|
||||||
|
<div className="file-list">
|
||||||
|
{data.fileList.map((file, index) => (
|
||||||
|
<div key={index} className="file-item">
|
||||||
|
<div className="file-info">
|
||||||
|
<span className="file-name">{file.name}</span>
|
||||||
|
<span className="file-size">{formatFileSize(file.size)}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`file-type file-type-${file.type}`}>{file.type}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="detail-section">
|
||||||
|
<h4>数据摘要</h4>
|
||||||
|
<p className="summary-text">{data.data.summary || data.summary}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.config.allowComments !== undefined && (
|
||||||
|
<div className="detail-section">
|
||||||
|
<h4>权限设置</h4>
|
||||||
|
<div className="permission-grid">
|
||||||
|
<div className="permission-item">
|
||||||
|
<label>允许评论:</label>
|
||||||
|
<span className={data.config.allowComments ? 'enabled' : 'disabled'}>
|
||||||
|
{data.config.allowComments ? '是' : '否'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="permission-item">
|
||||||
|
<label>允许下载:</label>
|
||||||
|
<span className={data.config.allowDownload ? 'enabled' : 'disabled'}>
|
||||||
|
{data.config.allowDownload ? '是' : '否'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{data.config.expiredAt && (
|
||||||
|
<div className="permission-item">
|
||||||
|
<label>过期时间:</label>
|
||||||
|
<span>{new Date(data.config.expiredAt).toLocaleString('zh-CN')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn btn-default" onClick={onClose}>关闭</button>
|
||||||
|
<button className="btn btn-primary" onClick={() => {
|
||||||
|
alert('编辑功能待实现');
|
||||||
|
}}>编辑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化文件大小
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
180
web/src/apps/muse/base/table/README.md
Normal file
180
web/src/apps/muse/base/table/README.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# 数据管理表格组件
|
||||||
|
|
||||||
|
这是一个功能完整的React表格组件,支持多选、排序、分页、操作等功能,并集成了Mock数据。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### ✅ 已实现功能
|
||||||
|
|
||||||
|
1. **数据展示**
|
||||||
|
- 支持多种数据类型展示(标题、类型、标签、创建者等)
|
||||||
|
- 自定义列渲染(类型徽章、标签展示等)
|
||||||
|
- 响应式设计,适配移动端
|
||||||
|
|
||||||
|
2. **多选功能**
|
||||||
|
- 支持单行选择和全选
|
||||||
|
- 批量操作(批量删除)
|
||||||
|
- 选择状态实时显示
|
||||||
|
|
||||||
|
3. **排序功能**
|
||||||
|
- 支持多列排序(标题、类型、创建者、创建时间等)
|
||||||
|
- 升序/降序/取消排序
|
||||||
|
- 排序状态可视化指示
|
||||||
|
|
||||||
|
4. **分页功能**
|
||||||
|
- 支持页码切换
|
||||||
|
- 可调整每页显示数量(10/20/50/100条)
|
||||||
|
- 显示总数和当前范围
|
||||||
|
- 快速跳转页码
|
||||||
|
|
||||||
|
5. **操作功能**
|
||||||
|
- 详情查看(弹窗形式)
|
||||||
|
- 编辑功能
|
||||||
|
- 删除功能(单个/批量)
|
||||||
|
- 删除确认对话框
|
||||||
|
|
||||||
|
6. **详情模态框**
|
||||||
|
- 完整的数据信息展示
|
||||||
|
- 分区域显示(基本信息、描述、标签、时间信息等)
|
||||||
|
- 附件文件列表
|
||||||
|
- 权限设置显示
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
base/table/
|
||||||
|
├── index.tsx # 主组件入口,集成所有功能
|
||||||
|
├── Table.tsx # 基础表格组件
|
||||||
|
├── DetailModal.tsx # 详情查看模态框
|
||||||
|
├── types.ts # TypeScript类型定义
|
||||||
|
├── table.css # 表格样式
|
||||||
|
└── modal.css # 模态框样式
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用的Mock数据
|
||||||
|
|
||||||
|
数据来源:`base/mock/collection.ts`
|
||||||
|
- 20条模拟的Mark记录
|
||||||
|
- 包含完整的用户、文件、配置等信息
|
||||||
|
- 支持各种数据类型和状态
|
||||||
|
|
||||||
|
## 组件特色
|
||||||
|
|
||||||
|
### 1. 类型安全
|
||||||
|
- 完整的TypeScript类型定义
|
||||||
|
- 严格的类型检查
|
||||||
|
- 良好的IDE支持
|
||||||
|
|
||||||
|
### 2. 用户体验
|
||||||
|
- 直观的操作界面
|
||||||
|
- 实时的状态反馈
|
||||||
|
- 响应式设计
|
||||||
|
- 加载状态和空状态处理
|
||||||
|
|
||||||
|
### 3. 数据展示
|
||||||
|
- 多种数据类型的可视化展示
|
||||||
|
- 颜色编码的类型和状态
|
||||||
|
- 格式化的时间和文件大小
|
||||||
|
|
||||||
|
### 4. 交互功能
|
||||||
|
- 丰富的操作按钮
|
||||||
|
- 确认对话框
|
||||||
|
- 详情查看弹窗
|
||||||
|
- 批量操作支持
|
||||||
|
|
||||||
|
## 技术实现
|
||||||
|
|
||||||
|
### 状态管理
|
||||||
|
- 使用React Hooks进行状态管理
|
||||||
|
- 分离的数据状态和UI状态
|
||||||
|
- 受控组件模式
|
||||||
|
|
||||||
|
### 样式设计
|
||||||
|
- 现代化的UI设计
|
||||||
|
- 一致的视觉风格
|
||||||
|
- 响应式布局
|
||||||
|
- 无障碍访问支持
|
||||||
|
|
||||||
|
### 数据处理
|
||||||
|
- 客户端排序和分页
|
||||||
|
- 嵌套数据访问
|
||||||
|
- 数据格式化和转换
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
1. 确保已安装依赖:
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 启动开发服务器:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 访问表格页面查看效果
|
||||||
|
|
||||||
|
## 自定义配置
|
||||||
|
|
||||||
|
### 添加新列
|
||||||
|
在 `index.tsx` 中的 `columns` 数组中添加新的列配置:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{
|
||||||
|
key: 'newColumn',
|
||||||
|
title: '新列',
|
||||||
|
dataIndex: 'fieldName',
|
||||||
|
width: 120,
|
||||||
|
sortable: true,
|
||||||
|
render: (value, record) => {
|
||||||
|
// 自定义渲染逻辑
|
||||||
|
return <span>{value}</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 添加新操作
|
||||||
|
在 `actions` 数组中添加新的操作按钮:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{
|
||||||
|
key: 'newAction',
|
||||||
|
label: '新操作',
|
||||||
|
type: 'primary',
|
||||||
|
icon: '🔧',
|
||||||
|
onClick: (record) => {
|
||||||
|
// 操作逻辑
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改分页配置
|
||||||
|
调整 `paginationConfig` 对象的属性:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const paginationConfig = {
|
||||||
|
current: currentPage,
|
||||||
|
pageSize: pageSize,
|
||||||
|
total: data.length,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
// 其他配置...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
1. **虚拟化**:对于大量数据,可以考虑实现虚拟滚动
|
||||||
|
2. **懒加载**:支持服务端分页和按需加载
|
||||||
|
3. **缓存**:实现数据缓存机制
|
||||||
|
4. **防抖**:搜索和过滤功能添加防抖处理
|
||||||
|
|
||||||
|
## 后续优化建议
|
||||||
|
|
||||||
|
1. **搜索功能**:添加全局搜索和列过滤
|
||||||
|
2. **导出功能**:支持数据导出为Excel/CSV
|
||||||
|
3. **列配置**:支持用户自定义显示列
|
||||||
|
4. **主题配置**:支持多主题切换
|
||||||
|
5. **国际化**:添加多语言支持
|
||||||
|
|
||||||
|
这个表格组件提供了现代化的数据管理界面,具备企业级应用所需的核心功能。
|
||||||
306
web/src/apps/muse/base/table/Table.tsx
Normal file
306
web/src/apps/muse/base/table/Table.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { Mark } from '../mock/collection';
|
||||||
|
import { TableProps, SortState } from './types';
|
||||||
|
import './table.css';
|
||||||
|
|
||||||
|
export const Table: React.FC<TableProps> = ({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
loading = false,
|
||||||
|
rowSelection,
|
||||||
|
pagination,
|
||||||
|
actions,
|
||||||
|
onSort
|
||||||
|
}) => {
|
||||||
|
const [sortState, setSortState] = useState<SortState>({ field: null, order: null });
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
// 处理排序
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
let newOrder: 'asc' | 'desc' | null = 'asc';
|
||||||
|
|
||||||
|
if (sortState.field === field) {
|
||||||
|
if (sortState.order === 'asc') {
|
||||||
|
newOrder = 'desc';
|
||||||
|
} else if (sortState.order === 'desc') {
|
||||||
|
newOrder = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSortState = { field: newOrder ? field : null, order: newOrder };
|
||||||
|
setSortState(newSortState);
|
||||||
|
onSort?.(newSortState.field!, newSortState.order!);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 排序后的数据
|
||||||
|
const sortedData = useMemo(() => {
|
||||||
|
if (!sortState.field || !sortState.order) return data;
|
||||||
|
|
||||||
|
return [...data].sort((a, b) => {
|
||||||
|
const aVal = getNestedValue(a, sortState.field!);
|
||||||
|
const bVal = getNestedValue(b, sortState.field!);
|
||||||
|
|
||||||
|
if (aVal < bVal) return sortState.order === 'asc' ? -1 : 1;
|
||||||
|
if (aVal > bVal) return sortState.order === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}, [data, sortState]);
|
||||||
|
|
||||||
|
// 分页数据
|
||||||
|
const paginatedData = useMemo(() => {
|
||||||
|
if (!pagination) return sortedData;
|
||||||
|
|
||||||
|
const start = (currentPage - 1) * pagination.pageSize;
|
||||||
|
const end = start + pagination.pageSize;
|
||||||
|
return sortedData.slice(start, end);
|
||||||
|
}, [sortedData, currentPage, pagination]);
|
||||||
|
|
||||||
|
// 处理分页变化
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
if (pagination && typeof pagination === 'object') {
|
||||||
|
pagination.onChange?.(page, pagination.pageSize);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理页大小变化
|
||||||
|
const handlePageSizeChange = (pageSize: number) => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
if (pagination && typeof pagination === 'object') {
|
||||||
|
pagination.onChange?.(1, pageSize);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 全选/取消全选
|
||||||
|
const handleSelectAll = (checked: boolean) => {
|
||||||
|
if (!rowSelection) return;
|
||||||
|
|
||||||
|
const allKeys = paginatedData.map(item => item.id);
|
||||||
|
const selectedKeys = checked ? allKeys : [];
|
||||||
|
const selectedRows = checked ? paginatedData : [];
|
||||||
|
|
||||||
|
rowSelection.onChange?.(selectedKeys, selectedRows);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 单行选择
|
||||||
|
const handleRowSelect = (record: Mark, checked: boolean) => {
|
||||||
|
if (!rowSelection) return;
|
||||||
|
|
||||||
|
const currentKeys = rowSelection.selectedRowKeys || [];
|
||||||
|
const newKeys = checked
|
||||||
|
? [...currentKeys, record.id]
|
||||||
|
: currentKeys.filter(key => key !== record.id);
|
||||||
|
|
||||||
|
const selectedRows = data.filter(item => newKeys.includes(item.id));
|
||||||
|
rowSelection.onChange?.(newKeys, selectedRows);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取嵌套值
|
||||||
|
const getNestedValue = (obj: any, path: string) => {
|
||||||
|
return path.split('.').reduce((o, p) => o?.[p], obj);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="table-loading">
|
||||||
|
<div className="loading-spinner"></div>
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedKeys = rowSelection?.selectedRowKeys || [];
|
||||||
|
const isAllSelected = paginatedData.length > 0 && paginatedData.every(item => selectedKeys.includes(item.id));
|
||||||
|
const isIndeterminate = selectedKeys.length > 0 && !isAllSelected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="table-container">
|
||||||
|
{/* 表格工具栏 */}
|
||||||
|
{rowSelection && selectedKeys.length > 0 && (
|
||||||
|
<div className="table-toolbar">
|
||||||
|
<span className="selected-info">
|
||||||
|
已选择 {selectedKeys.length} 项
|
||||||
|
</span>
|
||||||
|
<div className="bulk-actions">
|
||||||
|
<button className="btn btn-danger" onClick={() => {
|
||||||
|
// 批量删除逻辑
|
||||||
|
console.log('批量删除:', selectedKeys);
|
||||||
|
}}>
|
||||||
|
批量删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{rowSelection && (
|
||||||
|
<th className="selection-column">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isAllSelected}
|
||||||
|
ref={input => {
|
||||||
|
if (input) input.indeterminate = isIndeterminate;
|
||||||
|
}}
|
||||||
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
{columns.map(column => (
|
||||||
|
<th
|
||||||
|
key={column.key}
|
||||||
|
style={{ width: column.width }}
|
||||||
|
className={column.sortable ? 'sortable' : ''}
|
||||||
|
>
|
||||||
|
<div className="table-header">
|
||||||
|
<span>{column.title}</span>
|
||||||
|
{column.sortable && (
|
||||||
|
<div
|
||||||
|
className="sort-indicators"
|
||||||
|
onClick={() => handleSort(column.dataIndex)}
|
||||||
|
>
|
||||||
|
<span className={`sort-arrow sort-up ${
|
||||||
|
sortState.field === column.dataIndex && sortState.order === 'asc' ? 'active' : ''
|
||||||
|
}`}>▲</span>
|
||||||
|
<span className={`sort-arrow sort-down ${
|
||||||
|
sortState.field === column.dataIndex && sortState.order === 'desc' ? 'active' : ''
|
||||||
|
}`}>▼</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
{actions && actions.length > 0 && (
|
||||||
|
<th className="actions-column">操作</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paginatedData.map((record, index) => (
|
||||||
|
<tr key={record.id} className="table-row">
|
||||||
|
{rowSelection && (
|
||||||
|
<td className="selection-column">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedKeys.includes(record.id)}
|
||||||
|
onChange={(e) => handleRowSelect(record, e.target.checked)}
|
||||||
|
disabled={rowSelection.getCheckboxProps?.(record)?.disabled}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{columns.map(column => (
|
||||||
|
<td key={column.key}>
|
||||||
|
{column.render
|
||||||
|
? column.render(getNestedValue(record, column.dataIndex), record, index)
|
||||||
|
: getNestedValue(record, column.dataIndex)
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
{actions && actions.length > 0 && (
|
||||||
|
<td className="actions-column">
|
||||||
|
<div className="action-buttons">
|
||||||
|
{actions.map(action => (
|
||||||
|
<button
|
||||||
|
key={action.key}
|
||||||
|
className={`btn btn-${action.type || 'default'}`}
|
||||||
|
onClick={() => action.onClick(record)}
|
||||||
|
disabled={action.disabled?.(record)}
|
||||||
|
title={action.label}
|
||||||
|
>
|
||||||
|
{action.icon && <span className="btn-icon">{action.icon}</span>}
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{paginatedData.length === 0 && (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-icon">📭</div>
|
||||||
|
<p>暂无数据</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
{pagination && (
|
||||||
|
<div className="pagination-wrapper">
|
||||||
|
<div className="pagination-info">
|
||||||
|
{pagination.showTotal && pagination.showTotal(
|
||||||
|
pagination.total,
|
||||||
|
[
|
||||||
|
(currentPage - 1) * pagination.pageSize + 1,
|
||||||
|
Math.min(currentPage * pagination.pageSize, pagination.total)
|
||||||
|
]
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="pagination-controls">
|
||||||
|
<button
|
||||||
|
className="btn btn-default"
|
||||||
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="page-numbers">
|
||||||
|
{Array.from({ length: Math.ceil(pagination.total / pagination.pageSize) })
|
||||||
|
.map((_, i) => i + 1)
|
||||||
|
.filter(page => {
|
||||||
|
const distance = Math.abs(page - currentPage);
|
||||||
|
return distance === 0 || distance <= 2 || page === 1 || page === Math.ceil(pagination.total / pagination.pageSize);
|
||||||
|
})
|
||||||
|
.map((page, index, pages) => {
|
||||||
|
const prevPage = pages[index - 1];
|
||||||
|
const showEllipsis = prevPage && page - prevPage > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={page}>
|
||||||
|
{showEllipsis && <span className="page-ellipsis">...</span>}
|
||||||
|
<button
|
||||||
|
className={`btn page-btn ${currentPage === page ? 'active' : ''}`}
|
||||||
|
onClick={() => handlePageChange(page)}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-default"
|
||||||
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage >= Math.ceil(pagination.total / pagination.pageSize)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pagination.showSizeChanger && (
|
||||||
|
<div className="page-size-selector">
|
||||||
|
<select
|
||||||
|
value={pagination.pageSize}
|
||||||
|
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={10}>10条/页</option>
|
||||||
|
<option value={20}>20条/页</option>
|
||||||
|
<option value={50}>50条/页</option>
|
||||||
|
<option value={100}>100条/页</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
281
web/src/apps/muse/base/table/index.tsx
Normal file
281
web/src/apps/muse/base/table/index.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { Table } from './Table';
|
||||||
|
import { DetailModal } from './DetailModal';
|
||||||
|
import { mockMarks, Mark } from '../mock/collection';
|
||||||
|
import { TableColumn, ActionButton } from './types';
|
||||||
|
|
||||||
|
export const Base = () => {
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [data, setData] = useState<Mark[]>(mockMarks);
|
||||||
|
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||||
|
const [currentRecord, setCurrentRecord] = useState<Mark | null>(null);
|
||||||
|
|
||||||
|
// 表格列配置
|
||||||
|
const columns: TableColumn<Mark>[] = [
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
title: '标题',
|
||||||
|
dataIndex: 'title',
|
||||||
|
width: 300,
|
||||||
|
sortable: true,
|
||||||
|
render: (value: string, record: Mark) => (
|
||||||
|
<div>
|
||||||
|
<div className="title-text" style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||||
|
{record.description.slice(0, 60)}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'markType',
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'markType',
|
||||||
|
width: 100,
|
||||||
|
sortable: true,
|
||||||
|
render: (value: string) => (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: getTypeColor(value),
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tags',
|
||||||
|
title: '标签',
|
||||||
|
dataIndex: 'tags',
|
||||||
|
width: 200,
|
||||||
|
render: (tags: string[]) => (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||||
|
{tags.slice(0, 3).map((tag, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
padding: '2px 6px',
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
borderRadius: '2px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#666'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{tags.length > 3 && (
|
||||||
|
<span style={{ fontSize: '11px', color: '#999' }}>
|
||||||
|
+{tags.length - 3}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'uname',
|
||||||
|
title: '创建者',
|
||||||
|
dataIndex: 'uname',
|
||||||
|
width: 120,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 180,
|
||||||
|
sortable: true,
|
||||||
|
render: (value: Date) => new Date(value).toLocaleString('zh-CN')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'config.visibility',
|
||||||
|
title: '可见性',
|
||||||
|
dataIndex: 'config.visibility',
|
||||||
|
width: 100,
|
||||||
|
render: (value: string) => (
|
||||||
|
<span style={{
|
||||||
|
color: value === 'public' ? '#52c41a' : value === 'private' ? '#ff4d4f' : '#faad14'
|
||||||
|
}}>
|
||||||
|
{value === 'public' ? '公开' : value === 'private' ? '私有' : '受限'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 操作按钮配置
|
||||||
|
const actions: ActionButton[] = [
|
||||||
|
{
|
||||||
|
key: 'view',
|
||||||
|
label: '详情',
|
||||||
|
type: 'primary',
|
||||||
|
icon: '👁',
|
||||||
|
onClick: (record: Mark) => {
|
||||||
|
handleViewDetail(record);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'edit',
|
||||||
|
label: '编辑',
|
||||||
|
icon: '✏️',
|
||||||
|
onClick: (record: Mark) => {
|
||||||
|
handleEdit(record);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
label: '删除',
|
||||||
|
type: 'danger',
|
||||||
|
icon: '🗑️',
|
||||||
|
onClick: (record: Mark) => {
|
||||||
|
handleDelete(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 获取类型颜色
|
||||||
|
const getTypeColor = (type: string): string => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
markdown: '#1890ff',
|
||||||
|
json: '#52c41a',
|
||||||
|
html: '#fa8c16',
|
||||||
|
image: '#eb2f96',
|
||||||
|
video: '#722ed1',
|
||||||
|
audio: '#13c2c2',
|
||||||
|
code: '#666',
|
||||||
|
link: '#1890ff',
|
||||||
|
file: '#999'
|
||||||
|
};
|
||||||
|
return colors[type] || '#999';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理详情查看
|
||||||
|
const handleViewDetail = (record: Mark) => {
|
||||||
|
setCurrentRecord(record);
|
||||||
|
setDetailModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理编辑
|
||||||
|
const handleEdit = (record: Mark) => {
|
||||||
|
alert(`编辑: ${record.title}`);
|
||||||
|
// 这里可以打开编辑对话框或跳转到编辑页面
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理删除
|
||||||
|
const handleDelete = (record: Mark) => {
|
||||||
|
if (window.confirm(`确定要删除"${record.title}"吗?`)) {
|
||||||
|
setData(prevData => prevData.filter(item => item.id !== record.id));
|
||||||
|
// 如果当前选中的项包含被删除的项,也要从选中列表中移除
|
||||||
|
setSelectedRowKeys(prev => prev.filter(key => key !== record.id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理批量删除
|
||||||
|
const handleBatchDelete = () => {
|
||||||
|
if (selectedRowKeys.length === 0) return;
|
||||||
|
|
||||||
|
if (window.confirm(`确定要删除选中的 ${selectedRowKeys.length} 项吗?`)) {
|
||||||
|
setData(prevData => prevData.filter(item => !selectedRowKeys.includes(item.id)));
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 排序处理
|
||||||
|
const handleSort = (field: string, order: 'asc' | 'desc' | null) => {
|
||||||
|
if (!order) {
|
||||||
|
setData(mockMarks); // 重置为原始顺序
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedData = [...data].sort((a, b) => {
|
||||||
|
const getNestedValue = (obj: any, path: string) => {
|
||||||
|
return path.split('.').reduce((o, p) => o?.[p], obj);
|
||||||
|
};
|
||||||
|
|
||||||
|
const aVal = getNestedValue(a, field);
|
||||||
|
const bVal = getNestedValue(b, field);
|
||||||
|
|
||||||
|
if (aVal < bVal) return order === 'asc' ? -1 : 1;
|
||||||
|
if (aVal > bVal) return order === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
setData(sortedData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分页配置
|
||||||
|
const paginationConfig = {
|
||||||
|
current: currentPage,
|
||||||
|
pageSize: pageSize,
|
||||||
|
total: data.length,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (total: number, range: [number, number]) =>
|
||||||
|
`第 ${range[0]}-${range[1]} 条,共 ${total} 条`,
|
||||||
|
onChange: (page: number, size: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
setPageSize(size);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px' }}>
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<h2>数据管理表格</h2>
|
||||||
|
<p style={{ color: '#666', margin: '8px 0' }}>
|
||||||
|
支持多选、排序、分页等功能的数据表格示例
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRowKeys.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '16px',
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: '#e6f7ff',
|
||||||
|
borderRadius: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
<span>已选择 {selectedRowKeys.length} 项</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={handleBatchDelete}
|
||||||
|
>
|
||||||
|
批量删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table
|
||||||
|
data={data}
|
||||||
|
columns={columns}
|
||||||
|
actions={actions}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (keys, rows) => {
|
||||||
|
setSelectedRowKeys(keys);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
pagination={paginationConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailModal
|
||||||
|
visible={detailModalVisible}
|
||||||
|
data={currentRecord}
|
||||||
|
onClose={() => {
|
||||||
|
setDetailModalVisible(false);
|
||||||
|
setCurrentRecord(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
305
web/src/apps/muse/base/table/modal.css
Normal file
305
web/src/apps/muse/base/table/modal.css
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
/* 模态框样式 */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid #e8e8e8;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 详情部分 */
|
||||||
|
.detail-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section h4 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 2px solid #1890ff;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
min-width: 80px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item span {
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 类型徽章 */
|
||||||
|
.type-badge {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-markdown { background: #1890ff; }
|
||||||
|
.type-json { background: #52c41a; }
|
||||||
|
.type-html { background: #fa8c16; }
|
||||||
|
.type-image { background: #eb2f96; }
|
||||||
|
.type-video { background: #722ed1; }
|
||||||
|
.type-audio { background: #13c2c2; }
|
||||||
|
.type-code { background: #666; }
|
||||||
|
.type-link { background: #1890ff; }
|
||||||
|
.type-file { background: #999; }
|
||||||
|
|
||||||
|
/* 可见性徽章 */
|
||||||
|
.visibility-badge {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-public {
|
||||||
|
background: #f6ffed;
|
||||||
|
color: #52c41a;
|
||||||
|
border: 1px solid #b7eb8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-private {
|
||||||
|
background: #fff2f0;
|
||||||
|
color: #ff4d4f;
|
||||||
|
border: 1px solid #ffccc7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-restricted {
|
||||||
|
background: #fffbe6;
|
||||||
|
color: #faad14;
|
||||||
|
border: 1px solid #ffe58f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签容器 */
|
||||||
|
.tags-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文件列表 */
|
||||||
|
.file-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-type {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-type-self {
|
||||||
|
background: #e6f7ff;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-type-data {
|
||||||
|
background: #f6ffed;
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-type-generate {
|
||||||
|
background: #fff7e6;
|
||||||
|
color: #fa8c16;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 摘要文本 */
|
||||||
|
.summary-text {
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 4px solid #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 权限网格 */
|
||||||
|
.permission-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-item label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enabled {
|
||||||
|
color: #52c41a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
color: #ff4d4f;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.modal-content {
|
||||||
|
margin: 10px;
|
||||||
|
max-height: 95vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header,
|
||||||
|
.modal-body,
|
||||||
|
.modal-footer {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
312
web/src/apps/muse/base/table/table.css
Normal file
312
web/src/apps/muse/base/table/table.css
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
/* 表格容器 */
|
||||||
|
.table-container {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工具栏 */
|
||||||
|
.table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-info {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格主体 */
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
background: #fafafa;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表头 */
|
||||||
|
.table-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-indicators {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-arrow {
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #ccc;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-arrow.active {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-up {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选择列 */
|
||||||
|
.selection-column {
|
||||||
|
width: 48px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-column input[type="checkbox"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作列 */
|
||||||
|
.actions-column {
|
||||||
|
width: 200px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮样式 */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
color: #333;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
border-color: #40a9ff;
|
||||||
|
color: #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #1890ff;
|
||||||
|
border-color: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #40a9ff;
|
||||||
|
border-color: #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #ff4d4f;
|
||||||
|
border-color: #ff4d4f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #ff7875;
|
||||||
|
border-color: #ff7875;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 64px 16px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载状态 */
|
||||||
|
.table-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 64px 16px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid #f0f0f0;
|
||||||
|
border-top: 3px solid #1890ff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分页 */
|
||||||
|
.pagination-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid #e8e8e8;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-numbers {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn {
|
||||||
|
min-width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
background: #fff;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:hover {
|
||||||
|
border-color: #40a9ff;
|
||||||
|
color: #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn.active {
|
||||||
|
background: #1890ff;
|
||||||
|
border-color: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-ellipsis {
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-selector {
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-selector select {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.table-toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-selector {
|
||||||
|
margin-left: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-column {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
web/src/apps/muse/base/table/types.ts
Normal file
58
web/src/apps/muse/base/table/types.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Mark } from '../mock/collection';
|
||||||
|
|
||||||
|
// 表格列配置
|
||||||
|
export interface TableColumn<T = any> {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
dataIndex: string;
|
||||||
|
width?: number;
|
||||||
|
render?: (value: any, record: T, index: number) => React.ReactNode;
|
||||||
|
sortable?: boolean;
|
||||||
|
fixed?: 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格行选择配置
|
||||||
|
export interface RowSelection<T = any> {
|
||||||
|
type?: 'checkbox' | 'radio';
|
||||||
|
selectedRowKeys?: React.Key[];
|
||||||
|
onChange?: (selectedRowKeys: React.Key[], selectedRows: T[]) => void;
|
||||||
|
getCheckboxProps?: (record: T) => { disabled?: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页配置
|
||||||
|
export interface PaginationConfig {
|
||||||
|
current: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
showSizeChanger?: boolean;
|
||||||
|
showQuickJumper?: boolean;
|
||||||
|
showTotal?: (total: number, range: [number, number]) => string;
|
||||||
|
onChange?: (page: number, pageSize: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格操作按钮类型
|
||||||
|
export interface ActionButton {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type?: 'primary' | 'default' | 'danger';
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
onClick: (record: Mark) => void;
|
||||||
|
disabled?: (record: Mark) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格属性
|
||||||
|
export interface TableProps {
|
||||||
|
data: Mark[];
|
||||||
|
columns: TableColumn<Mark>[];
|
||||||
|
loading?: boolean;
|
||||||
|
rowSelection?: RowSelection<Mark>;
|
||||||
|
pagination?: PaginationConfig | false;
|
||||||
|
actions?: ActionButton[];
|
||||||
|
onSort?: (field: string, order: 'asc' | 'desc' | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序状态
|
||||||
|
export interface SortState {
|
||||||
|
field: string | null;
|
||||||
|
order: 'asc' | 'desc' | null;
|
||||||
|
}
|
||||||
126
web/src/apps/muse/index.tsx
Normal file
126
web/src/apps/muse/index.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { ToastContainer } from 'react-toastify';
|
||||||
|
import { AuthProvider } from '../login/AuthProvider';
|
||||||
|
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { VadVoice } from './videos/modules/VadVoice.tsx';
|
||||||
|
import { ChatInterface } from './prompts/index.tsx';
|
||||||
|
import { BaseApp } from './base/index.tsx';
|
||||||
|
|
||||||
|
const LeftPanel = () => {
|
||||||
|
return (
|
||||||
|
<Panel defaultSize={50} minSize={10}>
|
||||||
|
<div className="h-full border-r border-gray-200">
|
||||||
|
<BaseApp />
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CenterPanel = () => {
|
||||||
|
return (
|
||||||
|
<Panel defaultSize={25} minSize={10}>
|
||||||
|
<div className="h-full border-r border-gray-200">
|
||||||
|
<ChatInterface />
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RightPanel = ({ isVisible }: { isVisible: boolean }) => {
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel defaultSize={25} minSize={0}>
|
||||||
|
<div className="h-full bg-gray-50 p-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Right Panel</h2>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
<VadVoice />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MuseApp = () => {
|
||||||
|
const [showRightPanel, setShowRightPanel] = useState(true);
|
||||||
|
const [showLeftPanel, setShowLeftPanel] = useState(true);
|
||||||
|
const [showCenterPanel, setShowCenterPanel] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col">
|
||||||
|
{/* Panel Controls */}
|
||||||
|
<div className="bg-white border-b border-gray-200 p-2 z-10">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLeftPanel(!showLeftPanel)}
|
||||||
|
className={`px-3 py-1 rounded text-sm ${showLeftPanel
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
AI 聊天
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCenterPanel(!showCenterPanel)}
|
||||||
|
className={`px-3 py-1 rounded text-sm ${showCenterPanel
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Center Panel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRightPanel(!showRightPanel)}
|
||||||
|
className={`px-3 py-1 rounded text-sm ${showRightPanel
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Right Panel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resizable Panels */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<PanelGroup direction="horizontal">
|
||||||
|
{showLeftPanel && <LeftPanel />}
|
||||||
|
|
||||||
|
{showLeftPanel && showCenterPanel && (
|
||||||
|
<PanelResizeHandle className="w-1 bg-gray-300 hover:bg-gray-400 cursor-col-resize transition-colors" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCenterPanel && <CenterPanel />}
|
||||||
|
|
||||||
|
{showCenterPanel && showRightPanel && (
|
||||||
|
<PanelResizeHandle className="w-1 bg-gray-300 hover:bg-gray-400 cursor-col-resize transition-colors" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRightPanel && <RightPanel isVisible={showRightPanel} />}
|
||||||
|
</PanelGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const App: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<MuseApp />
|
||||||
|
<ToastContainer
|
||||||
|
position="top-right"
|
||||||
|
autoClose={5000}
|
||||||
|
hideProgressBar={false}
|
||||||
|
newestOnTop={false}
|
||||||
|
closeOnClick
|
||||||
|
rtl={false}
|
||||||
|
pauseOnFocusLoss
|
||||||
|
draggable
|
||||||
|
pauseOnHover
|
||||||
|
theme="light"
|
||||||
|
/>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
190
web/src/apps/muse/prompts/index.tsx
Normal file
190
web/src/apps/muse/prompts/index.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Send, Bot, User } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatInterface: React.FC = () => {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
content: '你好!我是AI助手,有什么可以帮助您的吗?',
|
||||||
|
role: 'assistant',
|
||||||
|
timestamp: new Date()
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// 自动滚动到最新消息
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!inputValue.trim() || isLoading) return;
|
||||||
|
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
content: inputValue.trim(),
|
||||||
|
role: 'user',
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, userMessage]);
|
||||||
|
setInputValue('');
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// 模拟AI回复
|
||||||
|
setTimeout(() => {
|
||||||
|
const aiMessage: Message = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
content: `我收到了您的消息:"${userMessage.content}"。这里是我的回复,您还有其他问题吗?`,
|
||||||
|
role: 'assistant',
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
setMessages(prev => [...prev, aiMessage]);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理键盘事件
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (date: Date) => {
|
||||||
|
return date.toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-gray-50">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="bg-white border-b border-gray-200 p-4 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center">
|
||||||
|
<Bot className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900">AI 助手</h1>
|
||||||
|
<p className="text-sm text-gray-500">在线 · 随时为您服务</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 对话列表区域 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={`flex gap-3 ${
|
||||||
|
message.role === 'user' ? 'justify-end' : 'justify-start'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.role === 'assistant' && (
|
||||||
|
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<Bot className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`max-w-[70%] rounded-lg px-4 py-2 ${
|
||||||
|
message.role === 'user'
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-white text-gray-900 shadow-sm border border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-xs mt-1 ${
|
||||||
|
message.role === 'user' ? 'text-blue-100' : 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatTime(message.timestamp)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message.role === 'user' && (
|
||||||
|
<div className="w-8 h-8 bg-gray-600 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<User className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 加载状态 */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex gap-3 justify-start">
|
||||||
|
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<Bot className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg px-4 py-2 shadow-sm border border-gray-200">
|
||||||
|
<div className="flex space-x-1">
|
||||||
|
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||||
|
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
||||||
|
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 输入框区域 */}
|
||||||
|
<div className="bg-white border-t border-gray-200 p-4">
|
||||||
|
<div className="flex gap-3 items-end">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="输入您的消息... (按 Enter 发送,Shift+Enter 换行)"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
rows={1}
|
||||||
|
style={{ minHeight: '96px', maxHeight: '180px' }}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!inputValue.trim() || isLoading}
|
||||||
|
className={`p-3 rounded-lg flex items-center justify-center transition-colors ${
|
||||||
|
!inputValue.trim() || isLoading
|
||||||
|
? 'bg-gray-300 cursor-not-allowed'
|
||||||
|
: 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提示文本 */}
|
||||||
|
<div className="mt-2 text-xs text-gray-500 text-center">
|
||||||
|
AI助手会根据您的输入生成回复,请文明使用
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
130
web/src/apps/muse/videos/modules/VadVoice.tsx
Normal file
130
web/src/apps/muse/videos/modules/VadVoice.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { MicVAD, utils } from "@ricky0123/vad-web"
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
type speakType = {
|
||||||
|
timestamp: number;
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
}
|
||||||
|
export const VadVoice = () => {
|
||||||
|
const [userList, setUserList] = useState<speakType[]>([]);
|
||||||
|
const [listen, setListen] = useState<boolean>(true);
|
||||||
|
const ref = useRef<MicVAD | null>(null);
|
||||||
|
async function main() {
|
||||||
|
const myvad = await MicVAD.new({
|
||||||
|
onSpeechEnd: (audio) => {
|
||||||
|
// do something with `audio` (Float32Array of audio samples at sample rate 16000)...
|
||||||
|
const wavBuffer = utils.encodeWAV(audio)
|
||||||
|
const base64 = utils.arrayBufferToBase64(wavBuffer)
|
||||||
|
// const url = `data:audio/wav;base64,${base64}`
|
||||||
|
const url = URL.createObjectURL(new Blob([wavBuffer], { type: 'audio/wav' }))
|
||||||
|
setUserList((prev) => [...prev, { timestamp: Date.now(), url }]);
|
||||||
|
},
|
||||||
|
onnxWASMBasePath: "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/dist/",
|
||||||
|
baseAssetPath: "https://cdn.jsdelivr.net/npm/@ricky0123/vad-web@0.0.27/dist/",
|
||||||
|
})
|
||||||
|
ref.current = myvad;
|
||||||
|
myvad.start();
|
||||||
|
return myvad;
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
main();
|
||||||
|
}, [])
|
||||||
|
const close = () => {
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.destroy();
|
||||||
|
ref.current = null;
|
||||||
|
setListen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <div className="h-full flex flex-col">
|
||||||
|
{/* Audio Recordings List */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 py-3">
|
||||||
|
{userList.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-400 text-sm py-8">
|
||||||
|
<div className="mb-2">🎤</div>
|
||||||
|
<div>No recordings yet</div>
|
||||||
|
<div className="text-xs mt-1">Start talking to record</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{userList.map((item, index) => (
|
||||||
|
<li key={index} className="bg-white rounded-lg border border-gray-200 p-2 hover:shadow-sm transition-shadow">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<audio
|
||||||
|
controls
|
||||||
|
style={{
|
||||||
|
transform: 'scale(0.85)',
|
||||||
|
transformOrigin: 'left center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<source src={item.url} type="audio/wav" />
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs text-gray-400 truncate">
|
||||||
|
{new Date(item.timestamp).toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-300">
|
||||||
|
#{userList.length - index}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Voice Control Bottom Section */}
|
||||||
|
<div className="border-t border-gray-200 p-3 bg-gray-50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="relative">
|
||||||
|
<div className={clsx(
|
||||||
|
"h-8 w-8 rounded-lg bg-gradient-to-l from-[#7928CA] to-[#008080] flex items-center justify-center",
|
||||||
|
{ "animate-pulse": listen, "low-energy-spin": listen }
|
||||||
|
)}>
|
||||||
|
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
{listen && (
|
||||||
|
<div className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{listen ? 'Listening...' : 'Paused'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{userList.length} recording{userList.length !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (listen) {
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
main();
|
||||||
|
setListen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
"px-3 py-1.5 text-xs font-medium rounded-md transition-colors",
|
||||||
|
listen
|
||||||
|
? "bg-red-100 text-red-700 hover:bg-red-200"
|
||||||
|
: "bg-green-100 text-green-700 hover:bg-green-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{listen ? 'Stop' : 'Start'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div >
|
||||||
|
}
|
||||||
5
web/src/apps/muse/videos/modules/style.css
Normal file
5
web/src/apps/muse/videos/modules/style.css
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
.low-energy-spin {
|
||||||
|
animation: 2.5s linear 0s infinite normal forwards running spin;
|
||||||
|
}
|
||||||
9
web/src/env.d.ts
vendored
Normal file
9
web/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="astro/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_POCKETBASE_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
143
web/src/lib/pocketbase.ts
Normal file
143
web/src/lib/pocketbase.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import PocketBase, { RecordModel } from 'pocketbase';
|
||||||
|
|
||||||
|
// PocketBase API URL - 你需要根据实际情况修改
|
||||||
|
export const pb = new PocketBase(
|
||||||
|
import.meta.env?.VITE_POCKETBASE_URL || 'https://pocketbase.pro.xiongxiao.me'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 用户类型
|
||||||
|
export enum UserType {
|
||||||
|
USER = 'user',
|
||||||
|
ADMIN = 'superuser'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户信息接口,扩展PocketBase的RecordModel
|
||||||
|
export interface UserInfo extends RecordModel {
|
||||||
|
email: string;
|
||||||
|
username?: string;
|
||||||
|
name?: string;
|
||||||
|
avatar?: string;
|
||||||
|
verified?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员信息接口
|
||||||
|
export interface AdminInfo {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
avatar?: string;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录响应接口
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string;
|
||||||
|
record: UserInfo | AdminInfo;
|
||||||
|
userType: UserType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录参数接口
|
||||||
|
export interface LoginParams {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
userType: UserType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 普通用户登录
|
||||||
|
*/
|
||||||
|
export async function loginUser(email: string, password: string): Promise<LoginResponse> {
|
||||||
|
try {
|
||||||
|
const authData = await pb.collection('users').authWithPassword(email, password);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: pb.authStore.token,
|
||||||
|
record: authData.record as UserInfo,
|
||||||
|
userType: UserType.USER
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('User login failed:', error);
|
||||||
|
throw new Error('用户登录失败,请检查邮箱和密码');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员登录
|
||||||
|
*/
|
||||||
|
export async function loginAdmin(email: string, password: string): Promise<LoginResponse> {
|
||||||
|
try {
|
||||||
|
const authData = await pb.admins.authWithPassword(email, password);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: pb.authStore.token,
|
||||||
|
record: authData.record as unknown as AdminInfo,
|
||||||
|
userType: UserType.ADMIN
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin login failed:', error);
|
||||||
|
throw new Error('管理员登录失败,请检查邮箱和密码');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一登录函数
|
||||||
|
*/
|
||||||
|
export async function login(params: LoginParams): Promise<LoginResponse> {
|
||||||
|
const { email, password, userType } = params;
|
||||||
|
|
||||||
|
if (userType === UserType.ADMIN) {
|
||||||
|
return await loginAdmin(email, password);
|
||||||
|
} else {
|
||||||
|
return await loginUser(email, password);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登出
|
||||||
|
*/
|
||||||
|
export function logout(): void {
|
||||||
|
pb.authStore.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否已登录
|
||||||
|
*/
|
||||||
|
export function isLoggedIn(): boolean {
|
||||||
|
return pb.authStore.isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户信息
|
||||||
|
*/
|
||||||
|
export function getCurrentUser(): UserInfo | AdminInfo | null {
|
||||||
|
if (!pb.authStore.isValid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pb.authStore.model as UserInfo | AdminInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查当前用户是否为管理员
|
||||||
|
*/
|
||||||
|
export function isAdmin(): boolean {
|
||||||
|
return pb.authStore.isValid && pb.authStore.isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新认证token
|
||||||
|
*/
|
||||||
|
export async function refreshAuth(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (pb.authStore.isValid) {
|
||||||
|
if (pb.authStore.isAdmin) {
|
||||||
|
await pb.admins.authRefresh();
|
||||||
|
} else {
|
||||||
|
await pb.collection('users').authRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth refresh failed:', error);
|
||||||
|
pb.authStore.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
85
web/src/pages/api/login.ts
Normal file
85
web/src/pages/api/login.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { login, UserType } from '@/lib/pocketbase';
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { email, password, userType } = body;
|
||||||
|
|
||||||
|
// 验证必需字段
|
||||||
|
if (!email || !password || !userType) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: '请提供邮箱、密码和用户类型'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户类型
|
||||||
|
if (!Object.values(UserType).includes(userType)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: '无效的用户类型'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行登录
|
||||||
|
const result = await login({ email, password, userType });
|
||||||
|
|
||||||
|
// 设置认证 cookie(可选)
|
||||||
|
const headers = new Headers({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果需要设置cookie来保持session
|
||||||
|
if (result.token) {
|
||||||
|
headers.set('Set-Cookie', `pb_auth=${result.token}; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: result.record,
|
||||||
|
userType: result.userType,
|
||||||
|
token: result.token,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login API error:', error);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '登录失败'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
42
web/src/pages/api/logout.ts
Normal file
42
web/src/pages/api/logout.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { logout } from '@/lib/pocketbase';
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
// 执行登出
|
||||||
|
logout();
|
||||||
|
|
||||||
|
// 清除认证 cookie
|
||||||
|
const headers = new Headers({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Set-Cookie': 'pb_auth=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0'
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: '已成功登出'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout API error:', error);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '登出失败'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
14
web/src/pages/dashboard.astro
Normal file
14
web/src/pages/dashboard.astro
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
import Html from '../components/html.astro';
|
||||||
|
import { DashboardApp } from '../apps/login/DashboardApp.tsx';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Html>
|
||||||
|
<head>
|
||||||
|
<title>仪表板 - Light Code Center</title>
|
||||||
|
<meta name='description' content='用户仪表板' />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<DashboardApp client:only />
|
||||||
|
</body>
|
||||||
|
</Html>
|
||||||
14
web/src/pages/login.astro
Normal file
14
web/src/pages/login.astro
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
import Html from '../components/html.astro';
|
||||||
|
import { LoginPage } from '@/apps/login/LoginPage.tsx';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Html>
|
||||||
|
<head>
|
||||||
|
<title>登录 - Light Code Center</title>
|
||||||
|
<meta name='description' content='登录 Light Code Center 账户' />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<LoginPage client:only />
|
||||||
|
</body>
|
||||||
|
</Html>
|
||||||
8
web/src/pages/muse.astro
Normal file
8
web/src/pages/muse.astro
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
import Html from '../components/html.astro';
|
||||||
|
import { App } from '@/apps/muse/index.tsx';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Html>
|
||||||
|
<App client:only />
|
||||||
|
</Html>
|
||||||
171
web/src/store/authStore.ts
Normal file
171
web/src/store/authStore.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
import {
|
||||||
|
UserType,
|
||||||
|
UserInfo,
|
||||||
|
AdminInfo,
|
||||||
|
LoginParams,
|
||||||
|
login,
|
||||||
|
logout as pbLogout,
|
||||||
|
isLoggedIn,
|
||||||
|
getCurrentUser,
|
||||||
|
isAdmin,
|
||||||
|
refreshAuth
|
||||||
|
} from '@/lib/pocketbase';
|
||||||
|
|
||||||
|
// 认证状态接口
|
||||||
|
interface AuthState {
|
||||||
|
// 状态
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
user: UserInfo | AdminInfo | null;
|
||||||
|
userType: UserType | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// 操作
|
||||||
|
login: (params: LoginParams) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
clearError: () => void;
|
||||||
|
refreshAuth: () => Promise<void>;
|
||||||
|
initAuth: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建认证状态store
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// 初始状态
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
userType: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// 登录操作
|
||||||
|
login: async (params: LoginParams) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await login(params);
|
||||||
|
|
||||||
|
set({
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: response.record,
|
||||||
|
userType: response.userType,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
userType: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : '登录失败',
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 登出操作
|
||||||
|
logout: () => {
|
||||||
|
pbLogout();
|
||||||
|
set({
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
userType: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清除错误
|
||||||
|
clearError: () => {
|
||||||
|
set({ error: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
// 刷新认证
|
||||||
|
refreshAuth: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshAuth();
|
||||||
|
const user = getCurrentUser();
|
||||||
|
const userType = isAdmin() ? UserType.ADMIN : UserType.USER;
|
||||||
|
|
||||||
|
set({
|
||||||
|
isAuthenticated: isLoggedIn(),
|
||||||
|
user,
|
||||||
|
userType: user ? userType : null,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
userType: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : '认证刷新失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 初始化认证状态
|
||||||
|
initAuth: () => {
|
||||||
|
const authenticated = isLoggedIn();
|
||||||
|
const user = getCurrentUser();
|
||||||
|
const userType = user ? (isAdmin() ? UserType.ADMIN : UserType.USER) : null;
|
||||||
|
|
||||||
|
set({
|
||||||
|
isAuthenticated: authenticated,
|
||||||
|
user,
|
||||||
|
userType,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
// 只持久化必要的状态,不包括 isLoading 和 error
|
||||||
|
partialize: (state) => ({
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
user: state.user,
|
||||||
|
userType: state.userType,
|
||||||
|
}),
|
||||||
|
// 从存储恢复后重新初始化
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
if (state) {
|
||||||
|
state.initAuth();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 导出一些便利的 hooks
|
||||||
|
export const useAuth = () => {
|
||||||
|
const { isAuthenticated, user, userType, isLoading, error } = useAuthStore();
|
||||||
|
return { isAuthenticated, user, userType, isLoading, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuthActions = () => {
|
||||||
|
const { login, logout, clearError, refreshAuth } = useAuthStore();
|
||||||
|
return { login, logout, clearError, refreshAuth };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查用户权限的 hook
|
||||||
|
export const usePermissions = () => {
|
||||||
|
const { user, userType } = useAuthStore();
|
||||||
|
|
||||||
|
return {
|
||||||
|
isUser: userType === UserType.USER,
|
||||||
|
isAdmin: userType === UserType.ADMIN,
|
||||||
|
canAccess: (requiredType: UserType) => {
|
||||||
|
if (requiredType === UserType.ADMIN) {
|
||||||
|
return userType === UserType.ADMIN;
|
||||||
|
}
|
||||||
|
return userType === UserType.USER || userType === UserType.ADMIN;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user