update
This commit is contained in:
		
							
								
								
									
										148
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										148
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							@@ -17,6 +17,9 @@ importers:
 | 
			
		||||
      '@kevisual/noco':
 | 
			
		||||
        specifier: ^0.0.1
 | 
			
		||||
        version: 0.0.1
 | 
			
		||||
      '@kevisual/query':
 | 
			
		||||
        specifier: ^0.0.29
 | 
			
		||||
        version: 0.0.29(zod@3.25.76)
 | 
			
		||||
      '@kevisual/router':
 | 
			
		||||
        specifier: ^0.0.29
 | 
			
		||||
        version: 0.0.29
 | 
			
		||||
@@ -54,6 +57,9 @@ importers:
 | 
			
		||||
      '@astrojs/sitemap':
 | 
			
		||||
        specifier: ^3.6.0
 | 
			
		||||
        version: 3.6.0
 | 
			
		||||
      '@faker-js/faker':
 | 
			
		||||
        specifier: ^10.1.0
 | 
			
		||||
        version: 10.1.0
 | 
			
		||||
      '@kevisual/noco':
 | 
			
		||||
        specifier: ^0.0.1
 | 
			
		||||
        version: 0.0.1
 | 
			
		||||
@@ -66,6 +72,9 @@ importers:
 | 
			
		||||
      '@kevisual/registry':
 | 
			
		||||
        specifier: ^0.0.1
 | 
			
		||||
        version: 0.0.1(typescript@5.9.3)
 | 
			
		||||
      '@ricky0123/vad-web':
 | 
			
		||||
        specifier: ^0.0.28
 | 
			
		||||
        version: 0.0.28
 | 
			
		||||
      '@tailwindcss/vite':
 | 
			
		||||
        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))
 | 
			
		||||
@@ -96,12 +105,18 @@ importers:
 | 
			
		||||
      nanoid:
 | 
			
		||||
        specifier: ^5.1.6
 | 
			
		||||
        version: 5.1.6
 | 
			
		||||
      pocketbase:
 | 
			
		||||
        specifier: ^0.26.2
 | 
			
		||||
        version: 0.26.2
 | 
			
		||||
      react:
 | 
			
		||||
        specifier: ^19.2.0
 | 
			
		||||
        version: 19.2.0
 | 
			
		||||
      react-dom:
 | 
			
		||||
        specifier: ^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:
 | 
			
		||||
        specifier: ^11.0.5
 | 
			
		||||
        version: 11.0.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
 | 
			
		||||
@@ -433,6 +448,10 @@ packages:
 | 
			
		||||
    cpu: [x64]
 | 
			
		||||
    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':
 | 
			
		||||
    resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
 | 
			
		||||
    engines: {node: '>=18'}
 | 
			
		||||
@@ -645,6 +664,39 @@ packages:
 | 
			
		||||
  '@oslojs/encoding@1.1.0':
 | 
			
		||||
    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':
 | 
			
		||||
    resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
 | 
			
		||||
 | 
			
		||||
@@ -1388,6 +1440,9 @@ packages:
 | 
			
		||||
    resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
 | 
			
		||||
    engines: {node: '>=8'}
 | 
			
		||||
 | 
			
		||||
  flatbuffers@25.9.23:
 | 
			
		||||
    resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==}
 | 
			
		||||
 | 
			
		||||
  flattie@1.1.1:
 | 
			
		||||
    resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==}
 | 
			
		||||
    engines: {node: '>=8'}
 | 
			
		||||
@@ -1428,6 +1483,9 @@ packages:
 | 
			
		||||
  graceful-fs@4.2.11:
 | 
			
		||||
    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:
 | 
			
		||||
    resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==}
 | 
			
		||||
 | 
			
		||||
@@ -1676,6 +1734,9 @@ packages:
 | 
			
		||||
  lodash-es@4.17.21:
 | 
			
		||||
    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:
 | 
			
		||||
    resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
 | 
			
		||||
 | 
			
		||||
@@ -1960,6 +2021,12 @@ packages:
 | 
			
		||||
  oniguruma-to-es@4.3.3:
 | 
			
		||||
    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:
 | 
			
		||||
    resolution: {integrity: sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==}
 | 
			
		||||
    hasBin: true
 | 
			
		||||
@@ -2016,6 +2083,9 @@ packages:
 | 
			
		||||
    resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
 | 
			
		||||
    engines: {node: '>=12'}
 | 
			
		||||
 | 
			
		||||
  platform@1.3.6:
 | 
			
		||||
    resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==}
 | 
			
		||||
 | 
			
		||||
  pocketbase@0.26.2:
 | 
			
		||||
    resolution: {integrity: sha512-WA8EOBc3QnSJh8rJ3iYoi9DmmPOMFIgVfAmIGux7wwruUEIzXgvrO4u0W2htfQjGIcyezJkdZOy5Xmh7SxAftw==}
 | 
			
		||||
 | 
			
		||||
@@ -2037,6 +2107,10 @@ packages:
 | 
			
		||||
  property-information@7.1.0:
 | 
			
		||||
    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:
 | 
			
		||||
    resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
 | 
			
		||||
 | 
			
		||||
@@ -2072,6 +2146,12 @@ packages:
 | 
			
		||||
    resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
 | 
			
		||||
    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:
 | 
			
		||||
    resolution: {integrity: sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
@@ -2948,6 +3028,8 @@ snapshots:
 | 
			
		||||
  '@esbuild/win32-x64@0.25.11':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
  '@faker-js/faker@10.1.0': {}
 | 
			
		||||
 | 
			
		||||
  '@img/colour@1.0.0':
 | 
			
		||||
    optional: true
 | 
			
		||||
 | 
			
		||||
@@ -3174,6 +3256,33 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  '@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': {}
 | 
			
		||||
 | 
			
		||||
  '@rollup/plugin-commonjs@28.0.7(rollup@4.52.4)':
 | 
			
		||||
@@ -3926,6 +4035,8 @@ snapshots:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      to-regex-range: 5.0.1
 | 
			
		||||
 | 
			
		||||
  flatbuffers@25.9.23: {}
 | 
			
		||||
 | 
			
		||||
  flattie@1.1.1: {}
 | 
			
		||||
 | 
			
		||||
  fontace@0.3.1:
 | 
			
		||||
@@ -3964,6 +4075,8 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  graceful-fs@4.2.11: {}
 | 
			
		||||
 | 
			
		||||
  guid-typescript@1.0.9: {}
 | 
			
		||||
 | 
			
		||||
  h3@1.15.4:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      cookie-es: 1.2.2
 | 
			
		||||
@@ -4260,6 +4373,8 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  lodash-es@4.17.21: {}
 | 
			
		||||
 | 
			
		||||
  long@5.3.2: {}
 | 
			
		||||
 | 
			
		||||
  longest-streak@3.1.0: {}
 | 
			
		||||
 | 
			
		||||
  lru-cache@10.4.3: {}
 | 
			
		||||
@@ -4794,6 +4909,17 @@ snapshots:
 | 
			
		||||
      regex: 6.0.1
 | 
			
		||||
      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):
 | 
			
		||||
    optionalDependencies:
 | 
			
		||||
      zod: 3.25.76
 | 
			
		||||
@@ -4846,6 +4972,8 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  picomatch@4.0.3: {}
 | 
			
		||||
 | 
			
		||||
  platform@1.3.6: {}
 | 
			
		||||
 | 
			
		||||
  pocketbase@0.26.2: {}
 | 
			
		||||
 | 
			
		||||
  postcss@8.5.6:
 | 
			
		||||
@@ -4865,6 +4993,21 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  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: {}
 | 
			
		||||
 | 
			
		||||
  radix3@1.1.2: {}
 | 
			
		||||
@@ -4888,6 +5031,11 @@ snapshots:
 | 
			
		||||
 | 
			
		||||
  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):
 | 
			
		||||
    dependencies:
 | 
			
		||||
      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,6 +22,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@kevisual/noco": "^0.0.1",
 | 
			
		||||
    "@kevisual/query": "^0.0.29",
 | 
			
		||||
    "@kevisual/router": "^0.0.29",
 | 
			
		||||
    "fast-glob": "^3.3.3",
 | 
			
		||||
    "pocketbase": "^0.26.2",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										6
									
								
								server/src/cache/index.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								server/src/cache/index.ts
									
									
									
									
										vendored
									
									
								
							@@ -11,13 +11,13 @@ export const storage = createStorage({
 | 
			
		||||
export const codeStorage = createStorage({
 | 
			
		||||
  // @ts-ignore
 | 
			
		||||
  driver: fsLiteDriver({
 | 
			
		||||
    base: './code'
 | 
			
		||||
    base: codeRoot
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
// storage.setItem('test-ke/test-key.json', 'test-value');
 | 
			
		||||
// console.log('Cache test-key:', await storage.getItem('test-key'));
 | 
			
		||||
 | 
			
		||||
storage.setItem('root/light-code-demo/main.ts', 'test-value2');
 | 
			
		||||
console.log('Cache test-key:', await storage.getItem('root/light-code-demo/main.ts'));
 | 
			
		||||
// 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'));
 | 
			
		||||
 
 | 
			
		||||
@@ -15,3 +15,5 @@ app.listen(4005, () => {
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
app.onServerRequest(proxyRoute);
 | 
			
		||||
 | 
			
		||||
export { app }
 | 
			
		||||
@@ -29,11 +29,40 @@ app.route({
 | 
			
		||||
  ctx.body = files
 | 
			
		||||
}).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)
 | 
			
		||||
							
								
								
									
										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/react": "^4.4.0",
 | 
			
		||||
    "@astrojs/sitemap": "^3.6.0",
 | 
			
		||||
    "@faker-js/faker": "^10.1.0",
 | 
			
		||||
    "@kevisual/noco": "^0.0.1",
 | 
			
		||||
    "@kevisual/query": "^0.0.29",
 | 
			
		||||
    "@kevisual/query-login": "^0.0.6",
 | 
			
		||||
    "@kevisual/registry": "^0.0.1",
 | 
			
		||||
    "@ricky0123/vad-web": "^0.0.28",
 | 
			
		||||
    "@tailwindcss/vite": "^4.1.14",
 | 
			
		||||
    "astro": "^5.14.4",
 | 
			
		||||
    "class-variance-authority": "^0.7.1",
 | 
			
		||||
@@ -33,8 +35,10 @@
 | 
			
		||||
    "lodash-es": "^4.17.21",
 | 
			
		||||
    "lucide-react": "^0.545.0",
 | 
			
		||||
    "nanoid": "^5.1.6",
 | 
			
		||||
    "pocketbase": "^0.26.2",
 | 
			
		||||
    "react": "^19.2.0",
 | 
			
		||||
    "react-dom": "^19.2.0",
 | 
			
		||||
    "react-resizable-panels": "^3.0.6",
 | 
			
		||||
    "react-toastify": "^11.0.5",
 | 
			
		||||
    "tailwind-merge": "^3.3.1",
 | 
			
		||||
    "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