This commit is contained in:
2025-10-20 05:45:19 +08:00
parent d3174a73f3
commit 15af405d02
37 changed files with 3570 additions and 5 deletions

148
pnpm-lock.yaml generated
View File

@@ -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

View 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()

View File

@@ -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",

View File

@@ -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'));

View File

@@ -15,3 +15,5 @@ app.listen(4005, () => {
})
app.onServerRequest(proxyRoute);
export { app }

View File

@@ -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)

View File

@@ -0,0 +1,5 @@
import { Query } from '@kevisual/query'
export const query = new Query({
url: 'http://localhost:4005/api/router',
})

View 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
View File

@@ -0,0 +1,6 @@
# PocketBase配置
VITE_POCKETBASE_URL=http://localhost:8090
# 可选:其他配置
# VITE_APP_NAME=Light Code Center
# VITE_DEBUG=true

View File

@@ -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",

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

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

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

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

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

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

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

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

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

View 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. **国际化**:添加多语言支持
这个表格组件提供了现代化的数据管理界面,具备企业级应用所需的核心功能。

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

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

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

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

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

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

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

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

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

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

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