Initial commit: restore project after Git corruption

This commit is contained in:
2025-10-21 18:29:15 +08:00
commit 0bb423fcca
112 changed files with 19665 additions and 0 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
node_modules
dist
.turbo
filebrowser
filebrowser.db
/data

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM oven/bun:alpine
# From bun:latest
# 创建应用目录
WORKDIR /app
COPY server/package.json ./
# 复制源码
COPY ./server ./
COPY ./server/code ./code-backup
COPY ./web/dist ./demo/root/light-code-center
RUN bun install
# 构建(可选)
# RUN bun run build
# 暴露端口
EXPOSE 4005
# 启动服务
CMD ["bun", "start"]
# 保持容器运行
# CMD ["tail", "-f", "/dev/null"]

24
compose.yml Normal file
View File

@@ -0,0 +1,24 @@
services:
light-code:
# image: 'kevisual/light-code:latest'
image: 'crpi-92z54xpbq1hzmdcz.cn-hangzhou.personal.cr.aliyuncs.com/kevisual/light-code:latest'
user: root
volumes:
- ./data/code:/app/code
ports:
- '6005:4005'
restart: always
filebrowser:
image: 'filebrowser/filebrowser'
user: root
volumes:
- ./data/code:/srv
- ./data/database:/database
- ./data/config:/config
restart: unless-stopped
ports:
- '6006:80'
environment:
- PUID=1000
- PGID=1000

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@kevisual/light-code",
"version": "0.0.1",
"description": "",
"main": "index.js",
"scripts": {
"dev": "pnpm run dev:server & pnpm run dev:web",
"dev:server": "cd server && pnpm dev",
"dev:web": "cd web && pnpm dev",
"docker:build": "docker build -t kevisual/light-code:latest .",
"docker:aliyun": "docker build -t crpi-92z54xpbq1hzmdcz.cn-hangzhou.personal.cr.aliyuncs.com/kevisual/light-code .",
"docker:dev": "docker compose up",
"build": "turbo build"
},
"keywords": [],
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
"license": "MIT",
"packageManager": "pnpm@10.18.3",
"type": "module",
"dependencies": {
"turbo": "^2.5.8"
}
}

6706
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

8
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,8 @@
packages:
- web
- server
onlyBuiltDependencies:
- '@tailwindcss/oxide'
- esbuild
- sharp

8
readme.md Normal file
View File

@@ -0,0 +1,8 @@
# light-code
[介绍](https://docmost.xiongxiao.me/share/vh4ff05fru/p/2025-10-15-light-code-HyZh9ERDN1)
## 文件管理工具
[filebrowser](https://github.com/filebrowser/filebrowser/releases)

25
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
node_modules
dist
app.config.json5
apps.config.json
deploy.tar.gz
cache-file
/apps
logs
release/*
!release/.gitkeep
.turbo
.env*
!.env.example
pack-dist
app.config.json5.envision

22
server/bun.config.ts Normal file
View File

@@ -0,0 +1,22 @@
// @ts-check
import { resolvePath } from '@kevisual/use-config';
import { execSync } from 'node:child_process';
const entry = 'src/index.ts';
const naming = 'app';
const external = ['sequelize', 'pg', 'ioredis', 'pm2'];
/**
* @type {import('bun').BuildConfig}
*/
// @ts-ignore
await Bun.build({
target: 'node',
format: 'esm',
entrypoints: [resolvePath(entry, { meta: import.meta })],
outdir: resolvePath('./dist', { meta: import.meta }),
naming: {
entry: `${naming}.js`,
},
external,
env: 'KEVISUAL_*',
});

View File

@@ -0,0 +1,24 @@
import { QueryRouterServer } from "@kevisual/router";
const app = new QueryRouterServer();
app.route({
path: 'main'
}).define(async (ctx) => {
ctx.body = {
message: 'this is main. filename: root/light-code-demo/main.ts',
params: ctx.query
}
}).addTo(app)
app.route({
path: 'main2'
}).define(async (ctx) => {
ctx.body = {
message: 'this is main2. filename: root/light-code-demo/main.ts',
params: ctx.query
}
}).addTo(app)
app.wait()

View File

@@ -0,0 +1,46 @@
import { QueryRouterServer as Mini } from "@kevisual/router";
import { NocoApi } from "@kevisual/noco";
const config = {
NOCODB_URL: process.env.NOCODB_URL || 'https://nocodb.xiongxiao.me',
NOCODB_API_KEY: process.env.NOCODB_API_KEY || 'uca1Zx3p_0pnNUBV6ot9mBP6JCPqQ0X1TF3N3R7s'
}
const table = 'mcby44q8zrayvn9'
const nocoAPi = new NocoApi({
baseURL: config.NOCODB_URL,
token: config.NOCODB_API_KEY,
table,
});
console.log('nocoAPi', await nocoAPi.record.list())
const app = new Mini();
app.route({
path: 'sign'
}).define(async (ctx) => {
const { Title, Description } = ctx.query
// 这里可以处理签到
await nocoAPi.record.create({ Title, Description })
const list = await nocoAPi.record.list({ sort: '-CreatedAt' })
ctx.body = { message: '签到成功', list }
}).addTo(app)
app.route({
path: 'sign',
key: 'list'
}).define(async (ctx) => {
// 这里可以处理签到
ctx.body = await nocoAPi.record.list()
}).addTo(app)
app.route({
path: 'sign',
key: 'delete'
}).define(async (ctx) => {
const { id } = ctx.query
// 这里可以处理签到
await nocoAPi.record.delete({ Id: id })
const list = await nocoAPi.record.list({ sort: '-CreatedAt' })
ctx.body = { message: '删除成功', list }
}).addTo(app)
app.wait()

View File

@@ -0,0 +1,56 @@
const weatherHost = 'n65khufe5n.re.qweatherapi.com';
const token = 'fdad5aeb2ba54949a8a1df2a0f3d1efb'
const xihu = '101210113'; // 西湖
export const getWeather = async (location: string = xihu) => {
const url = `https://${weatherHost}/v7/weather/3d?location=${location}`;
const headers = {
'Authorization': `Bearer ${token}`
};
const res = await fetch(url, { headers });
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const data = await res.json();
return data;
}
// getWeather().then(console.log).catch(console.error);
// https://dev.qweather.com/
class Weather {
host: string;
token: string;
constructor(opts: { host: string; token: string }) {
this.host = opts.host;
this.token = opts.token;
console.log(this.host, this.token);
}
getWeather(location: string) {
return fetch(`https://${this.host}/v7/weather/now?location=${location}`, {
headers: {
'Content-Type': 'application/json',
'X-QW-Api-Key': '<KEY>'.replace('<KEY>', this.token),
},
}).then((res) => res.json());
}
}
const newWeather = new Weather({
host: process.env?.QWEATHER_HOST || weatherHost,
token: process.env?.QWEATHER_TOKEN || token,
});
// newWeather.getWeather(xihu).then(console.log).catch(console.error);
import { QueryRouterServer as Mini } from "@kevisual/router";
const app = new Mini();
app.route({
path: 'main'
}).define(async (ctx) => {
ctx.body = await newWeather.getWeather(xihu);
}).addTo(app)
app.wait()

View File

@@ -0,0 +1,57 @@
const main = () => {
const random = Math.random().toString(36).slice(-6)
return 'hello a ' + random
}
// fs.writeFileSync('./a.txt', main() + '\n', { flag: 'a' })
console.log('pwd', process.cwd())
// const value = fs.readFileSync('./bun.config.ts', 'utf-8')
// console.log('a.txt 内容:', value)
const listen = async () => {
console.log('子进程启动,等待消息...')
const getParams = async () => {
return new Promise((resolve) => {
process.on('message', (msg) => {
console.log('子进程收到消息:', msg)
resolve(msg)
})
})
}
try {
const params = await getParams()
console.log('处理参数:', params)
// 执行主要逻辑
const result = main()
// 发送结果回主进程
const response = {
foo: 'bar',
params,
success: true,
data: { code: 200, data: result },
timestamp: new Date().toISOString()
}
process.send?.(response, (error) => {
if (error) {
console.error('发送消息失败:', error)
} else {
console.log('成功发送响应:', response)
}
process.exit(0)
})
} catch (error) {
console.error('子进程执行出错:', error)
process.send?.({
success: false,
error: error.message
})
process.exit(1)
}
}
// 启动监听
listen().catch(console.error)

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: root/listen-demo/router.ts',
params: ctx.query
}
}).addTo(app)
app.wait()

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

@@ -0,0 +1 @@
测试静态页面

31
server/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "light-code",
"version": "0.0.1",
"description": "",
"main": "index.js",
"scripts": {
"dev": "bun run --watch --hot src/index.ts",
"start": "bun run src/index.ts",
"prestart": "bun src/test/check-code.ts",
"build": "NODE_ENV=production bun bun.config.ts"
},
"keywords": [],
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
"license": "MIT",
"packageManager": "pnpm@10.16.1",
"type": "module",
"devDependencies": {
"@kevisual/local-proxy": "^0.0.6",
"@kevisual/types": "^0.0.10",
"@kevisual/use-config": "^1.0.19",
"@types/bun": "^1.3.0"
},
"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",
"unstorage": "^1.17.1"
}
}

384
server/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,384 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@kevisual/router':
specifier: ^0.0.28
version: 0.0.28
devDependencies:
'@kevisual/types':
specifier: ^0.0.10
version: 0.0.10
'@kevisual/use-config':
specifier: ^1.0.19
version: 1.0.19(dotenv@16.6.1)
'@types/bun':
specifier: ^1.3.0
version: 1.3.0(@types/react@19.2.2)
bun:
specifier: ^1.3.0
version: 1.3.0
packages:
'@kevisual/load@0.0.6':
resolution: {integrity: sha512-+3YTFehRcZ1haGel5DKYMUwmi5i6f2psyaPZlfkKU/cOXgkpwoG9/BEqPCnPjicKqqnksEpixVRkyHJ+5bjLVA==}
'@kevisual/router@0.0.28':
resolution: {integrity: sha512-MqpnRqBRt2TkM9KyDDaz/AjbBFi8L2y2/MwChu28fK6g0OL5fJ45NQQBGNpNrj2rsUVmpCA2wDr2SqjVxE3CLA==}
'@kevisual/types@0.0.10':
resolution: {integrity: sha512-Q73uzzjk9UidumnmCvOpgzqDDvQxsblz22bIFuoiioUFJWwaparx8bpd8ArRyFojicYL1YJoFDzDZ9j9NN8grA==}
'@kevisual/use-config@1.0.19':
resolution: {integrity: sha512-Q1IH4eMqUe5w6Bq8etoqOSls9FPIy0xwwD3wHf26EsQLZadhccI9qkDuFzP/rFWDa57mwFPEfwbGE5UlqWOCkw==}
peerDependencies:
dotenv: ^16.4.7
'@oven/bun-darwin-aarch64@1.3.0':
resolution: {integrity: sha512-WeXSaL29ylJEZMYHHW28QZ6rgAbxQ1KuNSZD9gvd3fPlo0s6s2PglvPArjjP07nmvIK9m4OffN0k4M98O7WmAg==}
cpu: [arm64]
os: [darwin]
'@oven/bun-darwin-x64-baseline@1.3.0':
resolution: {integrity: sha512-+FSr/ub5vA/EkD3fMhHJUzYioSf/sXd50OGxNDAntVxcDu4tXL/81Ka3R/gkZmjznpLFIzovU/1Ts+b7dlkrfw==}
cpu: [x64]
os: [darwin]
'@oven/bun-darwin-x64@1.3.0':
resolution: {integrity: sha512-CFKjoUWQH0Oz3UHYfKbdKLq0wGryrFsTJEYq839qAwHQSECvVZYAnxVVDYUDa0yQFonhO2qSHY41f6HK+b7xtw==}
cpu: [x64]
os: [darwin]
'@oven/bun-linux-aarch64-musl@1.3.0':
resolution: {integrity: sha512-HT5sr7N8NDYbQRjAnT7ISpx64y+ewZZRQozOJb0+KQObKvg4UUNXGm4Pn1xA4/WPMZDDazjO8E2vtOQw1nJlAQ==}
cpu: [arm64]
os: [linux]
'@oven/bun-linux-aarch64@1.3.0':
resolution: {integrity: sha512-WHthS/eLkCNcp9pk4W8aubRl9fIUgt2XhHyLrP0GClB1FVvmodu/zIOtG0NXNpzlzB8+gglOkGo4dPjfVf4Z+g==}
cpu: [arm64]
os: [linux]
'@oven/bun-linux-x64-baseline@1.3.0':
resolution: {integrity: sha512-OmlEH3nlxQyv7HOvTH21vyNAZGv9DIPnrTznzvKiOQxkOphhCyKvPTlF13ydw4s/i18iwaUrhHy+YG9HSSxa4Q==}
cpu: [x64]
os: [linux]
'@oven/bun-linux-x64-musl-baseline@1.3.0':
resolution: {integrity: sha512-hrr7mDvUjMX1tuJaXz448tMsgKIqGJBY8+rJqztKOw1U5+a/v2w5HuIIW1ce7ut0ZwEn+KIDvAujlPvpH33vpQ==}
cpu: [x64]
os: [linux]
'@oven/bun-linux-x64-musl@1.3.0':
resolution: {integrity: sha512-rtzUEzCynl3Rhgn/iR9DQezSFiZMcAXAbU+xfROqsweMGKwvwIA2ckyyckO08psEP8XcUZTs3LT9CH7PnaMiEA==}
cpu: [x64]
os: [linux]
'@oven/bun-linux-x64@1.3.0':
resolution: {integrity: sha512-sGEWoJQXO4GDr0x4t/yJQ/Bq1yNkOdX9tHbZZ+DBGJt3z3r7jeb4Digv8xQUk6gdTFC9vnGHuin+KW3/yD1Aww==}
cpu: [x64]
os: [linux]
'@oven/bun-windows-x64-baseline@1.3.0':
resolution: {integrity: sha512-/jVZ8eYjpYHLDFNoT86cP+AjuWvpkzFY+0R0a1bdeu0sQ6ILuy1FV6hz1hUAP390E09VCo5oP76fnx29giHTtA==}
cpu: [x64]
os: [win32]
'@oven/bun-windows-x64@1.3.0':
resolution: {integrity: sha512-xXwtpZVVP7T+vkxcF/TUVVOGRjEfkByO4mKveKYb4xnHWV4u4NnV0oNmzyMKkvmj10to5j2h0oZxA4ZVVv4gfA==}
cpu: [x64]
os: [win32]
'@types/bun@1.3.0':
resolution: {integrity: sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA==}
'@types/node@24.7.2':
resolution: {integrity: sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==}
'@types/react@19.2.2':
resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==}
bun-types@1.3.0:
resolution: {integrity: sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ==}
peerDependencies:
'@types/react': ^19
bun@1.3.0:
resolution: {integrity: sha512-YI7mFs7iWc/VsGsh2aw6eAPD2cjzn1j+LKdYVk09x1CrdTWKYIHyd+dG5iQoN9//3hCDoZj8U6vKpZzEf5UARA==}
cpu: [arm64, x64]
os: [darwin, linux, win32]
hasBin: true
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
encodeurl@2.0.0:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
http-errors@2.0.0:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
mime-types@3.0.1:
resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==}
engines: {node: '>= 0.6'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
node-forge@1.3.1:
resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==}
engines: {node: '>= 6.13.0'}
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
path-to-regexp@8.3.0:
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
selfsigned@3.0.1:
resolution: {integrity: sha512-6U6w6kSLrM9Zxo0D7mC7QdGS6ZZytMWBnj/vhF9p+dAHx6CwGezuRcO4VclTbrrI7mg7SD6zNiqXUuBHOVopNQ==}
engines: {node: '>=10'}
send@1.2.0:
resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
engines: {node: '>= 18'}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
undici-types@7.14.0:
resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==}
snapshots:
'@kevisual/load@0.0.6':
dependencies:
eventemitter3: 5.0.1
'@kevisual/router@0.0.28':
dependencies:
path-to-regexp: 8.3.0
selfsigned: 3.0.1
send: 1.2.0
transitivePeerDependencies:
- supports-color
'@kevisual/types@0.0.10': {}
'@kevisual/use-config@1.0.19(dotenv@16.6.1)':
dependencies:
'@kevisual/load': 0.0.6
dotenv: 16.6.1
'@oven/bun-darwin-aarch64@1.3.0':
optional: true
'@oven/bun-darwin-x64-baseline@1.3.0':
optional: true
'@oven/bun-darwin-x64@1.3.0':
optional: true
'@oven/bun-linux-aarch64-musl@1.3.0':
optional: true
'@oven/bun-linux-aarch64@1.3.0':
optional: true
'@oven/bun-linux-x64-baseline@1.3.0':
optional: true
'@oven/bun-linux-x64-musl-baseline@1.3.0':
optional: true
'@oven/bun-linux-x64-musl@1.3.0':
optional: true
'@oven/bun-linux-x64@1.3.0':
optional: true
'@oven/bun-windows-x64-baseline@1.3.0':
optional: true
'@oven/bun-windows-x64@1.3.0':
optional: true
'@types/bun@1.3.0(@types/react@19.2.2)':
dependencies:
bun-types: 1.3.0(@types/react@19.2.2)
transitivePeerDependencies:
- '@types/react'
'@types/node@24.7.2':
dependencies:
undici-types: 7.14.0
'@types/react@19.2.2':
dependencies:
csstype: 3.1.3
bun-types@1.3.0(@types/react@19.2.2):
dependencies:
'@types/node': 24.7.2
'@types/react': 19.2.2
bun@1.3.0:
optionalDependencies:
'@oven/bun-darwin-aarch64': 1.3.0
'@oven/bun-darwin-x64': 1.3.0
'@oven/bun-darwin-x64-baseline': 1.3.0
'@oven/bun-linux-aarch64': 1.3.0
'@oven/bun-linux-aarch64-musl': 1.3.0
'@oven/bun-linux-x64': 1.3.0
'@oven/bun-linux-x64-baseline': 1.3.0
'@oven/bun-linux-x64-musl': 1.3.0
'@oven/bun-linux-x64-musl-baseline': 1.3.0
'@oven/bun-windows-x64': 1.3.0
'@oven/bun-windows-x64-baseline': 1.3.0
csstype@3.1.3: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
depd@2.0.0: {}
dotenv@16.6.1: {}
ee-first@1.1.1: {}
encodeurl@2.0.0: {}
escape-html@1.0.3: {}
etag@1.8.1: {}
eventemitter3@5.0.1: {}
fresh@2.0.0: {}
http-errors@2.0.0:
dependencies:
depd: 2.0.0
inherits: 2.0.4
setprototypeof: 1.2.0
statuses: 2.0.1
toidentifier: 1.0.1
inherits@2.0.4: {}
mime-db@1.54.0: {}
mime-types@3.0.1:
dependencies:
mime-db: 1.54.0
ms@2.1.3: {}
node-forge@1.3.1: {}
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
path-to-regexp@8.3.0: {}
range-parser@1.2.1: {}
selfsigned@3.0.1:
dependencies:
node-forge: 1.3.1
send@1.2.0:
dependencies:
debug: 4.4.3
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
fresh: 2.0.0
http-errors: 2.0.0
mime-types: 3.0.1
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.2.1
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
setprototypeof@1.2.0: {}
statuses@2.0.1: {}
statuses@2.0.2: {}
toidentifier@1.0.1: {}
undici-types@7.14.0: {}

3
server/src/app.ts Normal file
View File

@@ -0,0 +1,3 @@
import { App } from '@kevisual/router'
export const app = new App()

23
server/src/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
import { createStorage } from 'unstorage'
import fsLiteDriver from "unstorage/drivers/fs-lite";
import { codeRoot } from '@/modules/config.ts';
import memoryDriver from "unstorage/drivers/memory";
export const storage = createStorage({
// @ts-ignore
driver: memoryDriver(),
});
export const codeStorage = createStorage({
// @ts-ignore
driver: fsLiteDriver({
base: codeRoot
})
})
// storage.setItem('test-ke/test-key.json', 'test-value');
// console.log('Cache test-key:', await storage.getItem('test-key'));
// codeStorage.setItem('root/light-code-demo/main.ts', 'test-value2');
console.log('Cache test-key:', await codeStorage.getItem('root/light-code-demo/main.ts'));
console.log('has', await codeStorage.hasItem('root/light-code-demo/main.ts'));

View File

@@ -0,0 +1,52 @@
import { Field } from '../types/index.ts';
export const projectStatus = ['提交', '审核中', '审核通过']; // 提交,审核中,审核通过
export type projectStatus = typeof projectStatus[number];
export const projectFields: Field[] = [
{
name: 'title',
type: 'text',
},
{
name: 'description',
type: 'text',
},
{
name: 'status',
type: 'text'
},
{
name: 'key',
type: 'text',
},
{
name: 'owner',
type: 'text',
},
{
name: 'data',
type: 'json'
},
{
name: 'files',
type: 'json'
},
{
name: "createdAt",
onCreate: true,
onUpdate: false,
type: "autodate"
},
{
name: "updatedAt",
onCreate: true,
onUpdate: true,
type: "autodate"
},
];
export const name = 'xx_projects';
export const type = 'base';

37
server/src/db/init.ts Normal file
View File

@@ -0,0 +1,37 @@
import { pb, db } from '../modules/db.ts'
import * as projects from './collections/project.ts'
// 要求
// 1. collection只做新增不做修改
// 2. collection不存在就创建
// 3. 每一个collection的定义在文档中需要有
export const main = async () => {
try {
await db.ensureLogin().catch(() => { throw new Error('Login failed'); });
// 程序第一次运行的时候执行,如果已经初始化过则跳过
const collections = await db.pb.collections.getFullList({
filter: 'name ~ "xx_%"',
})
console.log('Existing collections:', collections.map(c => c.name));
const dbs = [projects]
for (const coll of dbs) {
const exists = collections.find(c => c.name === coll.name)
if (exists) {
console.log(`Collection ${coll.name} already exists, skipping creation.`);
continue;
}
// 第一步,获取那个叉叉开头的 Collection。第二步获取它的版本。
const createdCollection = await db.pb.collections.create({
name: coll.name,
type: coll?.type || 'base',
fields: coll?.projectFields,
})
console.log('Created collection:', createdCollection);
}
} catch (error) {
console.error('Error during DB initialization:', error);
}
}
main()

View File

@@ -0,0 +1,26 @@
export type Field = {
/**
* The unique identifier for the field
*/
id?: string;
/**
* The name of the field
*/
name: string;
type?: 'text' | 'json' | 'autodate' | 'boolean' | 'number' | 'email' | 'url' | 'file' | 'relation';
/**
* Indicates whether the field is required
*/
required?: boolean;
/**
* Only for 'autodate' type
* Indicates whether to set the date on record creation or update
*/
onCreate?: boolean;
/** Only for 'autodate' type
* Indicates whether to set the date on record update
*/
onUpdate?: boolean;
options?: Record<string, any>;
[key: string]: any;
}

View File

@@ -0,0 +1 @@
export * from './collection.ts';

19
server/src/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import { proxyRoute, initProxy } from '@kevisual/local-proxy/proxy.ts';
// http://localhost:4005/test/a/index.html
initProxy({
pagesDir: './demo',
watch: true,
home: '/root/light-code-center',
});
import { app } from './app.ts'
import './routes/index.ts'
app.listen(4005, () => {
console.log('Server is running on http://localhost:4005')
})
app.onServerRequest(proxyRoute);
export { app }

View File

@@ -0,0 +1,7 @@
import { useConfig } from '@kevisual/use-config'
import path from 'path';
export const config = useConfig()
export const codeRoot = path.join(process.cwd(), 'code');

24
server/src/modules/db.ts Normal file
View File

@@ -0,0 +1,24 @@
import PocketBase from 'pocketbase';
const POCKETBASE_URL = 'https://pocketbase.pro.xiongxiao.me/';
export const pb = new PocketBase(POCKETBASE_URL);
export class DB {
pb: PocketBase
constructor(pb: PocketBase) {
this.pb = pb
}
async ensureLogin() {
const pb = this.pb;
if (!pb.authStore.isValid) {
await pb.collection("_superusers").authWithPassword('xiongxiao@xiongxiao.me', '123456xx');
}
return pb.authStore.record;
}
async getCollection(name: string) {
await this.ensureLogin();
return this.pb.collection(name);
}
}
export const db = new DB(pb);

View File

@@ -0,0 +1,50 @@
import { fork } from 'child_process'
export type RunCodeParams = {
path?: string;
key?: string;
payload?: string;
[key: string]: any
}
type RunCode = {
// 调用进程的功能
success?: boolean
data?: {
// 调用router的结果
code?: number
data?: any
message?: string
[key: string]: any
};
error?: any
timestamp?: string
[key: string]: any
}
export const runCode = async (tsPath: string, params: RunCodeParams = {}): Promise<RunCode> => {
return new Promise((resolve, reject) => {
// 使用 Bun 的 fork 模式启动子进程
const child = fork(tsPath)
// 监听来自子进程的消息
child.on('message', (msg: RunCode) => {
resolve(msg)
})
// child.on('exit', (code, signal) => {
// console.log('子进程已退出,退出码:', code, '信号:', signal)
// })
// child.on('close', (code, signal) => {
// console.log('子进程已关闭,退出码:', code, '信号:', signal)
// })
child.on('error', (error) => {
resolve({
success: false, error: error?.message
})
})
// 向子进程发送消息
child.send(params)
});
}

View File

@@ -0,0 +1,8 @@
import { app } from '../app.ts'
app.route({
path: 'auth',
id: 'auth'
}).define(async (ctx) => {
// Authentication logic here
}).addTo(app);

View File

@@ -0,0 +1,24 @@
import { app } from '../../app.ts'
import path from 'path'
import { runCode } from '../../modules/run-code/run.ts'
// http://localhost:4005/api/router?path=call
app.route({
path: 'call'
}).define(async (ctx) => {
const filename = ctx.query?.filename || 'root/listen-demo/router.ts'
const data = ctx.query?.data || {}
const pwd = process.cwd()
const testA = path.join(pwd, 'code', filename)
const resulst = await runCode(testA, data)
if (resulst.success) {
const callResult = resulst.data;
if (callResult.code === 200) ctx.body = callResult.data
else {
const callError = `调用程序错误: ${callResult.message}`
ctx.throw(callResult.code, callError)
}
} else {
ctx.body = `执行脚本错误: ${resulst.error}`
}
}).addTo(app)

View File

@@ -0,0 +1,68 @@
import { app } from '@/app.ts';
import path from 'node:path'
import glob from 'fast-glob';
import fs from 'node:fs'
import { codeRoot } from '@/modules/config.ts';
const list = async () => {
const files = await glob('**/*.ts', { cwd: codeRoot });
type FileContent = {
path: string;
content: string;
}
const filesContent: FileContent[] = [];
for (const file of files) {
if (file.startsWith('node_modules') || file.startsWith('dist') || file.startsWith('.git')) continue;
const fullPath = path.join(codeRoot, file);
const content = fs.readFileSync(fullPath, 'utf-8');
if (content) {
filesContent.push({ path: file, content: content });
}
}
return filesContent;
}
app.route({
path: 'file-code'
}).define(async (ctx) => {
const files = await list();
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 './call/index.ts';
import './file-code/index.ts';
import './auth.ts'

View File

@@ -0,0 +1,37 @@
import path from 'node:path'
import fs from 'node:fs'
const main = async () => {
const root = path.join(process.cwd(), 'code');
const buckupRoot = path.join(process.cwd(), 'code-backup');
// 如果 code 文件夹不存在或文件夹列表长度等于0则从 code-backup 复制
let shouldCopy = false;
if (!fs.existsSync(root)) {
console.log('code 文件夹不存在');
shouldCopy = true;
} else {
// 检查 code 文件夹下的文件夹列表
const items = await fs.promises.readdir(root, { withFileTypes: true });
const folders = items.filter(item => item.isDirectory());
if (folders.length === 0) {
console.log('code 文件夹存在但为空(无子文件夹)');
shouldCopy = true;
} else {
console.log(`code 文件夹已存在且包含 ${folders.length} 个子文件夹`);
}
}
if (shouldCopy) {
if (fs.existsSync(buckupRoot)) {
console.log('正在从 code-backup 复制...');
await fs.promises.cp(buckupRoot, root, { recursive: true });
console.log('复制完成!');
} else {
console.log('code-backup 文件夹不存在,无法复制');
}
}
}
main().catch(console.error)

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

19
server/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "@kevisual/types/json/backend.json",
"compilerOptions": {
"baseUrl": ".",
"module": "NodeNext",
"target": "esnext",
"typeRoots": [
"./node_modules/@types"
],
"paths": {
"@/*": [
"src/*"
]
},
},
"include": [
"src/**/*", "code/**/*",
],
}

18
turbo.json Normal file
View File

@@ -0,0 +1,18 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"outputs": [
"dist/**"
]
},
"dev": {
"cache": false
},
"build:app": {
"outputs": [
"dist/**"
]
}
}
}

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

6
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.DS_Store
.astro
dist

36
web/astro.config.mjs Normal file
View File

@@ -0,0 +1,36 @@
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import react from '@astrojs/react';
import sitemap from '@astrojs/sitemap';
import pkgs from './package.json';
import tailwindcss from '@tailwindcss/vite';
const isDev = process.env.NODE_ENV === 'development';
let target = process.env.VITE_API_URL || 'http://localhost:4005';
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
let proxy = {
'/root/': {
target: `${target}/root/`,
},
'/api': apiProxy,
};
export default defineConfig({
base: isDev ? undefined : pkgs.basename,
integrations: [
mdx(),
react(), //
// sitemap(), // sitemap must be site has a domain
],
vite: {
plugins: [tailwindcss()],
server: {
port: 7008,
host: '0.0.0.0',
allowedHosts: true,
proxy,
},
},
});

22
web/components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

82
web/package.json Normal file
View File

@@ -0,0 +1,82 @@
{
"name": "@kevisual/light-code-center",
"version": "0.0.1",
"description": "",
"main": "index.js",
"basename": "/root/light-code-center",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"pub": "envision deploy ./dist -k light-code-center -v 0.0.1 -u",
"sn": "pnpm dlx shadcn@latest add "
},
"keywords": [],
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
"license": "MIT",
"type": "module",
"dependencies": {
"@astrojs/mdx": "^4.3.7",
"@astrojs/react": "^4.4.0",
"@astrojs/sitemap": "^3.6.0",
"@faker-js/faker": "^10.1.0",
"@floating-ui/react": "^0.27.16",
"@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",
"@szhsin/react-menu": "^4.5.0",
"@tailwindcss/vite": "^4.1.14",
"astro": "^5.14.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.18",
"es-toolkit": "^1.40.0",
"events": "^3.3.0",
"graphology": "^0.26.0",
"highlight.js": "^11.11.1",
"lodash-es": "^4.17.21",
"lucide-react": "^0.545.0",
"marked": "^16.4.1",
"nanoid": "^5.1.6",
"pocketbase": "^0.26.2",
"pouchdb-adapter-memory": "^9.0.0",
"pouchdb-browser": "^9.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-modal": "^3.16.3",
"react-resizable-panels": "^3.0.6",
"react-toastify": "^11.0.5",
"react-virtualized": "^9.22.6",
"sigma": "^3.0.2",
"tailwind-merge": "^3.3.1",
"three": "^0.180.0",
"wavesurfer.js": "^7.11.0",
"zustand": "^5.0.8"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@kevisual/types": "^0.0.10",
"@types/pouchdb": "^6.4.2",
"@types/pouchdb-browser": "^6.1.5",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@types/react-modal": "^3.16.3",
"@types/react-virtualized": "^9.22.3",
"@types/three": "^0.180.0",
"@vitejs/plugin-basic-ssl": "^2.1.0",
"dotenv": "^17.2.3",
"pouchdb": "^9.0.0",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0"
},
"packageManager": "pnpm@10.18.3",
"onlyBuiltDependencies": [
"@tailwindcss/oxide",
"esbuild",
"sharp"
]
}

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,46 @@
/* MarkDetailList 组件样式 */
/* 虚拟化列表容器 */
.ReactVirtualized__List {
outline: none;
}
/* 列表行容器 */
.ReactVirtualized__List .ReactVirtualized__Grid__innerScrollContainer > div {
box-sizing: border-box;
}
/* 防止内容溢出 */
.mark-detail-row {
overflow: hidden;
box-sizing: border-box;
}
/* 卡片样式优化 */
.mark-detail-card {
margin-bottom: 8px;
box-sizing: border-box;
position: relative;
z-index: 1;
}
/* 确保图片不会影响布局 */
.mark-detail-card img {
flex-shrink: 0;
}
/* 代码块样式优化 */
.mark-detail-card pre {
word-wrap: break-word;
white-space: pre-wrap;
}
/* 链接样式 */
.mark-detail-card a {
word-break: break-all;
}
/* 标签容器 */
.mark-detail-tags {
min-height: 24px;
}

View File

@@ -0,0 +1,364 @@
import React, { useState, useMemo } from 'react';
import { AutoSizer, List } from 'react-virtualized';
import './MarkDetailList.css';
export type MarkShow = {
id: string;
title?: string;
description?: string;
tags?: string[];
markType?: string;
cover?: string;
link?: string;
summary?: string;
key?: string;
data: any;
createdAt?: string;
updatedAt?: string;
markedAt?: Date;
}
export type SimpleMarkShow = {
id: string;
title?: string;
description?: string;
tags?: string[];
cover?: string;
link?: string;
summary?: string;
}
interface MarkDetailProps {
data: MarkShow[];
}
export const MarkDetailList: React.FC<MarkDetailProps> = ({ data = [] }) => {
const [showAll, setShowAll] = useState(false);
// 根据显示模式过滤数据
const displayData = useMemo(() => {
if (showAll) {
// 显示所有字段
return data;
} else {
// 仅显示 SimpleMarkShow 字段
return data.map(item => ({
id: item.id,
title: item.title,
description: item.description,
tags: item.tags,
cover: item.cover,
link: item.link,
summary: item.summary
} as SimpleMarkShow));
}
}, [data, showAll]);
// 计算行高 - 根据视图模式计算固定高度
const getRowHeight = () => {
return showAll ? 400 : 240;
};
// 渲染单个简化项目
const renderSimpleItem = (item: SimpleMarkShow) => {
return (
<div className="mark-detail-card border border-gray-200 rounded-lg p-4 bg-white shadow-sm">
<div className="space-y-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-gray-600">ID:</label>
<p className="text-sm text-gray-800 mt-1">{item.id}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<p className="text-sm text-gray-800 mt-1">{item.title || '-'}</p>
</div>
</div>
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<p className="text-sm text-gray-800 mt-1">{item.description || '-'}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<div className="mt-1 mark-detail-tags">{renderTags(item.tags)}</div>
</div>
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<p className="text-sm text-gray-800 mt-1">{item.summary || '-'}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<div className="mt-1">
{item.cover ? (
<img
src={item.cover}
alt="封面"
className="w-16 h-16 object-cover rounded-md"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<span className="text-sm text-gray-500">-</span>
)}
</div>
</div>
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<div className="mt-1">
{item.link ? (
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-800 underline"
>
{item.link}
</a>
) : (
<span className="text-sm text-gray-500">-</span>
)}
</div>
</div>
</div>
</div>
</div>
);
};
// 渲染单个完整项目
const renderFullItem = (item: MarkShow) => {
return (
<div className="mark-detail-card border border-gray-200 rounded-lg p-4 bg-white shadow-sm">
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium text-gray-600">ID:</label>
<p className="text-sm text-gray-800 mt-1">{item.id}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<p className="text-sm text-gray-800 mt-1">{item.title || '-'}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<div className="mt-1">{renderMarkType(item.markType)}</div>
</div>
</div>
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<p className="text-sm text-gray-800 mt-1">{item.description || '-'}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<div className="mt-1 mark-detail-tags">{renderTags(item.tags)}</div>
</div>
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<p className="text-sm text-gray-800 mt-1">{item.summary || '-'}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<p className="text-sm text-gray-800 mt-1">{item.key || '-'}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<p className="text-sm text-gray-800 mt-1">{formatDate(item.createdAt)}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<p className="text-sm text-gray-800 mt-1">{formatDate(item.updatedAt)}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<p className="text-sm text-gray-800 mt-1">{formatDate(item.markedAt)}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<div className="mt-1">
{item.cover ? (
<img
src={item.cover}
alt="封面"
className="w-16 h-16 object-cover rounded-md"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<span className="text-sm text-gray-500">-</span>
)}
</div>
</div>
</div>
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<div className="mt-1">
{item.link ? (
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-800 underline"
>
{item.link}
</a>
) : (
<span className="text-sm text-gray-500">-</span>
)}
</div>
</div>
<div>
<label className="text-sm font-medium text-gray-600">:</label>
<div className="mt-1">
<pre className="text-xs bg-gray-50 p-2 rounded-md overflow-x-auto max-h-32">
{JSON.stringify(item.data, null, 2)}
</pre>
</div>
</div>
</div>
</div>
);
};
// 行渲染函数
const rowRenderer = ({ index, key, style }: any) => {
const item = displayData[index];
return (
<div key={key} style={{ ...style, overflow: 'hidden' }} className="mark-detail-row">
<div className="px-4 py-2">
{showAll ? renderFullItem(item as MarkShow) : renderSimpleItem(item as SimpleMarkShow)}
</div>
</div>
);
};
// 格式化日期显示
const formatDate = (date: string | Date | undefined) => {
if (!date) return '-';
return new Date(date).toLocaleString('zh-CN');
};
// 渲染标签
const renderTags = (tags?: string[]) => {
if (!tags || tags.length === 0) return '-';
return (
<div className="flex flex-wrap gap-1">
{tags.map((tag, index) => (
<span
key={index}
className="inline-block px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-md"
>
{tag}
</span>
))}
</div>
);
};
// 渲染类型徽章
const renderMarkType = (markType?: string) => {
if (!markType) return '-';
const typeColors: Record<string, string> = {
markdown: 'bg-green-100 text-green-800',
json: 'bg-yellow-100 text-yellow-800',
html: 'bg-orange-100 text-orange-800',
image: 'bg-purple-100 text-purple-800',
video: 'bg-red-100 text-red-800',
audio: 'bg-pink-100 text-pink-800',
code: 'bg-gray-100 text-gray-800',
link: 'bg-blue-100 text-blue-800',
file: 'bg-indigo-100 text-indigo-800',
};
const colorClass = typeColors[markType] || 'bg-gray-100 text-gray-800';
return (
<span className={`inline-block px-2 py-1 text-xs rounded-md ${colorClass}`}>
{markType}
</span>
);
};
// 渲染虚拟滚动列表
const renderVirtualizedList = () => {
return (
<AutoSizer>
{({ height, width }) => (
<List
height={height}
width={width}
rowCount={displayData.length}
rowHeight={getRowHeight()}
rowRenderer={rowRenderer}
/>
)}
</AutoSizer>
);
};
return (
<div className="h-full flex flex-col">
{/* 头部控制区域 */}
<div className="flex-shrink-0 bg-white border-b border-gray-200 p-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
SimpleMark
</h2>
<div className="flex items-center space-x-3">
<span className="text-sm text-gray-600">
:
</span>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={showAll}
onChange={(e) => setShowAll(e.target.checked)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
/>
<span className="text-sm text-gray-700">
{showAll ? '显示全部字段' : '仅显示基础字段'}
</span>
</label>
<div className="text-sm text-gray-500">
{data.length}
</div>
</div>
</div>
</div>
{/* 数据显示区域 */}
<div className="flex-1 overflow-y-auto p-4 bg-gray-50">
{data.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="text-4xl text-gray-400 mb-4">📝</div>
<p className="text-gray-500"></p>
</div>
</div>
) : (
renderVirtualizedList()
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,191 @@
# 文档组件 (DocsComponent)
一个现代化的左侧导航右侧内容的文档显示组件,支持多种内容类型和优雅的样式设计。
## 特性
- 🎨 **现代化设计**: 清新的UI设计优雅的色彩搭配
- 📱 **响应式布局**: 支持桌面和移动端适配
- 📝 **Markdown支持**: 使用 marked 库,支持 GitHub Flavored Markdown
- 🔄 **多内容类型**: 支持Markdown、代码、JSON、图片等多种类型
-**流畅交互**: 带加载状态和平滑过渡动画
- 🏷️ **标签系统**: 支持文档标签和分类
- 🔍 **清晰导航**: 左侧树形导航,快速定位文档
-**丰富语法**: 支持表格、任务列表、代码高亮等 GFM 特性
## 使用方法
### 基础用法
```tsx
import React from 'react';
import { DocsComponent } from './docs';
import { mockMarks } from './mock/collection';
function App() {
return (
<div style={{ height: '100vh' }}>
<DocsComponent dataSource={mockMarks} />
</div>
);
}
```
### 自定义数据
```tsx
import { DocsComponent, Mark } from './docs';
const customDocs: Mark[] = [
{
id: '1',
title: '快速开始',
description: '了解如何快速开始使用我们的产品',
tags: ['入门', '指南'],
markType: 'markdown',
data: {
content: `# 快速开始
这里是文档内容...`
},
createdAt: new Date(),
updatedAt: new Date()
}
];
function App() {
return <DocsComponent dataSource={customDocs} />;
}
```
## 数据结构
组件接受一个 `Mark[]` 类型的数据源每个Mark对象包含
```typescript
type Mark = {
id: string; // 唯一标识
title?: string; // 文档标题
description?: string; // 文档描述
tags?: string[]; // 标签数组
markType?: string; // 内容类型
data: any; // 内容数据
createdAt: Date; // 创建时间
updatedAt: Date; // 更新时间
// ... 其他字段
}
```
## 支持的内容类型
### Markdown
```typescript
{
markType: 'markdown',
data: {
content: '# 标题\n\n这是Markdown内容...'
}
}
```
### 代码
```typescript
{
markType: 'code',
data: {
code: 'const hello = "world";',
language: 'javascript'
}
}
```
### JSON数据
```typescript
{
markType: 'json',
data: {
// 任何JSON数据
}
}
```
### 图片
```typescript
{
markType: 'image',
data: {
src: 'https://example.com/image.jpg',
alt: '图片描述'
}
}
```
## 样式定制
组件使用CSS类名你可以通过覆盖这些类名来定制样式
```css
/* 主容器 */
.docs-container { }
/* 导航区域 */
.docs-nav { }
.docs-nav-item { }
.docs-nav-link { }
/* 内容区域 */
.docs-content { }
.docs-content-header { }
.docs-content-body { }
/* Markdown内容 */
.docs-markdown { }
```
## 组件API
### Props
| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| dataSource | Mark[] | [] | 文档数据源 |
### 导出组件
- `DocsComponent`: 主要的文档组件
- `App`: DocsComponent的别名保持向后兼容
## 示例
查看 `example.tsx` 文件获取完整的使用示例。
## 开发
```bash
# 安装依赖
npm install marked
# 或
pnpm install marked
```
组件使用 `marked` 库进行 Markdown 渲染,支持:
- ✅ GitHub Flavored Markdown (GFM)
- ✅ 表格语法
- ✅ 任务列表
- ✅ 代码块语法高亮
- ✅ 自动链接识别
- ✅ 删除线语法
## 注意事项
1. 确保容器有足够的高度(建议设置为 `100vh`
2. 组件会自动选中第一个文档项
3. 支持键盘导航和无障碍访问
4. 在移动端会自动调整为上下布局
## 更新日志
- v1.0.0: 初始版本,支持基础的文档显示功能
- 支持Markdown、代码、JSON、图片等内容类型
- 响应式设计和现代化UI

View File

@@ -0,0 +1,542 @@
/* 文档组件样式 */
.docs-container {
display: flex;
height: 100%;
background: #f8fafc;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* 左侧导航区域 */
.docs-nav {
width: 280px;
background: #ffffff;
border-right: 1px solid #e2e8f0;
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.docs-nav-header {
padding: 20px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
position: sticky;
top: 0;
z-index: 10;
flex-shrink: 0;
}
.docs-nav-title {
font-size: 18px;
font-weight: 600;
color: #1e293b;
margin: 0;
}
.docs-nav-list {
list-style: none;
padding: 0;
margin: 0;
flex: 1;
overflow-y: auto;
}
.docs-nav-item {
border-bottom: 1px solid #f1f5f9;
transition: all 0.2s ease;
}
.docs-nav-item:last-child {
border-bottom: none;
}
.docs-nav-link {
display: block;
padding: 16px 20px;
color: #64748b;
text-decoration: none;
transition: all 0.2s ease;
cursor: pointer;
border-left: 3px solid transparent;
}
.docs-nav-link:hover {
background: #f8fafc;
color: #334155;
border-left-color: #e2e8f0;
}
.docs-nav-link.active {
background: #eff6ff;
color: #2563eb;
border-left-color: #2563eb;
font-weight: 500;
}
.docs-nav-link-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
line-height: 1.4;
}
.docs-nav-link-desc {
font-size: 12px;
color: #94a3b8;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.docs-nav-link.active .docs-nav-link-desc {
color: #60a5fa;
}
/* 右侧内容区域 */
.docs-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.docs-content-header {
padding: 20px 32px;
background: #ffffff;
border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
}
.docs-content-title {
font-size: 24px;
font-weight: 700;
color: #1e293b;
margin: 0 0 8px 0;
line-height: 1.3;
}
.docs-content-meta {
display: flex;
gap: 16px;
align-items: center;
margin-top: 8px;
}
.docs-content-tag {
display: inline-flex;
align-items: center;
padding: 4px 8px;
background: #f1f5f9;
color: #475569;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
.docs-content-date {
font-size: 12px;
color: #94a3b8;
}
.docs-content-body {
flex: 1;
padding: 32px;
overflow-y: auto;
background: #ffffff;
}
.docs-content-body.empty {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
color: #94a3b8;
}
.docs-empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.docs-empty-text {
font-size: 16px;
text-align: center;
line-height: 1.5;
}
/* Markdown内容样式 */
.docs-markdown {
max-width: none;
color: #374151;
line-height: 1.7;
}
.docs-markdown h1,
.docs-markdown h2,
.docs-markdown h3,
.docs-markdown h4,
.docs-markdown h5,
.docs-markdown h6 {
color: #111827;
font-weight: 600;
margin: 24px 0 16px 0;
line-height: 1.25;
}
.docs-markdown h1 {
font-size: 32px;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 12px;
}
.docs-markdown h2 {
font-size: 24px;
border-bottom: 1px solid #f3f4f6;
padding-bottom: 8px;
}
.docs-markdown h3 {
font-size: 20px;
}
.docs-markdown h4 {
font-size: 16px;
}
.docs-markdown p {
margin: 16px 0;
line-height: 1.7;
}
.docs-markdown ul,
.docs-markdown ol {
margin: 16px 0;
padding-left: 24px;
}
.docs-markdown li {
margin: 8px 0;
line-height: 1.6;
}
.docs-markdown blockquote {
margin: 16px 0;
padding: 16px 20px;
background: #f9fafb;
border-left: 4px solid #d1d5db;
color: #6b7280;
font-style: italic;
}
.docs-markdown code {
background: #f3f4f6;
color: #e11d48;
padding: 2px 6px;
border-radius: 4px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
font-size: 0.875em;
}
.docs-markdown pre {
background: #1f2937;
color: #f9fafb;
padding: 20px;
border-radius: 8px;
overflow-x: auto;
margin: 16px 0;
}
.docs-markdown pre code {
background: none;
color: inherit;
padding: 0;
border-radius: 0;
}
.docs-markdown a {
color: #2563eb;
text-decoration: none;
}
.docs-markdown a:hover {
text-decoration: underline;
}
.docs-markdown img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 16px 0;
}
.docs-markdown table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
.docs-markdown th,
.docs-markdown td {
border: 1px solid #e5e7eb;
padding: 12px;
text-align: left;
}
.docs-markdown th {
background: #f9fafb;
font-weight: 600;
}
/* 响应式设计 */
@media (max-width: 768px) {
.docs-container {
flex-direction: column;
height: auto;
}
.docs-nav {
width: 100%;
max-height: 300px;
display: flex;
flex-direction: column;
}
.docs-nav-header {
position: sticky;
top: 0;
z-index: 10;
}
.docs-nav-list {
flex: 1;
overflow-y: auto;
}
.docs-content-header {
padding: 16px 20px;
}
.docs-content-body {
padding: 20px;
}
.docs-content-title {
font-size: 20px;
}
}
/* 滚动条样式 */
.docs-nav-list::-webkit-scrollbar,
.docs-content-body::-webkit-scrollbar {
width: 6px;
}
.docs-nav-list::-webkit-scrollbar-track,
.docs-content-body::-webkit-scrollbar-track {
background: #f1f5f9;
}
.docs-nav-list::-webkit-scrollbar-thumb,
.docs-content-body::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.docs-nav-list::-webkit-scrollbar-thumb:hover,
.docs-content-body::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* 不同内容类型的样式 */
.docs-json-content {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 20px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
font-size: 14px;
line-height: 1.5;
color: #374151;
overflow-x: auto;
}
.docs-code-content {
background: #1e293b;
color: #f1f5f9;
border-radius: 8px;
padding: 20px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
font-size: 14px;
line-height: 1.6;
overflow-x: auto;
margin: 16px 0;
}
.docs-code-content code {
background: none;
color: inherit;
padding: 0;
border-radius: 0;
}
.docs-image-content {
text-align: center;
margin: 20px 0;
}
.docs-image-content img {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.docs-default-content {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
}
.docs-default-content pre {
background: none;
color: #374151;
padding: 0;
margin: 0;
font-size: 14px;
line-height: 1.5;
}
/* 内容区域优化 */
.docs-content-body .docs-markdown h1:first-child {
margin-top: 0;
}
.docs-content-body .docs-markdown h1:last-child,
.docs-content-body .docs-markdown h2:last-child,
.docs-content-body .docs-markdown h3:last-child,
.docs-content-body .docs-markdown p:last-child {
margin-bottom: 0;
}
/* 导航项类型标识 */
.docs-nav-item::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 0;
background: #2563eb;
transition: height 0.2s ease;
}
.docs-nav-item {
position: relative;
}
.docs-nav-link.active ~ ::before,
.docs-nav-item:has(.docs-nav-link.active)::before {
height: 100%;
}
/* 标签优化 */
.docs-content-tag.type-markdown {
background: #dbeafe;
color: #1e40af;
}
.docs-content-tag.type-code {
background: #f3e8ff;
color: #7c3aed;
}
.docs-content-tag.type-json {
background: #ecfdf5;
color: #059669;
}
.docs-content-tag.type-image {
background: #fef3c7;
color: #d97706;
}
/* 加载状态 */
.docs-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
color: #94a3b8;
}
.docs-loading-spinner {
width: 20px;
height: 20px;
border: 2px solid #e2e8f0;
border-top: 2px solid #2563eb;
border-radius: 50%;
animation: docs-spin 1s linear infinite;
margin-right: 12px;
}
@keyframes docs-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 搜索和过滤功能样式 */
.docs-nav-search {
padding: 16px 20px;
border-bottom: 1px solid #e2e8f0;
background: #ffffff;
position: sticky;
top: 0;
z-index: 10;
flex-shrink: 0;
}
.docs-nav-search-input {
width: 100%;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s ease;
}
.docs-nav-search-input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.docs-nav-search-input::placeholder {
color: #9ca3af;
}
/* 内容区域的打印样式 */
@media print {
.docs-nav {
display: none;
}
.docs-content {
width: 100%;
}
.docs-content-body {
padding: 0;
}
.docs-markdown {
color: #000;
}
}

View File

@@ -0,0 +1,222 @@
import React from 'react';
import { DocsComponent } from './index';
import { mockMarks, generateMarkWithType } from '../mock/collection';
// 创建一些示例文档数据
const createSampleDocs = () => {
const markdownDoc = generateMarkWithType('markdown');
markdownDoc.title = '项目介绍';
markdownDoc.description = '了解我们的项目背景和目标';
markdownDoc.tags = ['介绍', '项目'];
markdownDoc.data = {
content: `# 欢迎使用文档系统
这是一个现代化的文档展示系统,现在使用 **marked** 库进行 Markdown 渲染。
## 主要功能
- **响应式设计**: 适配各种屏幕尺寸
- **左侧导航**: 清晰的文档结构
- **Markdown支持**: 使用 marked 库,支持 GitHub Flavored Markdown
- **多种内容类型**: 支持文本、代码、图片等多种内容
## 技术特性
### 样式设计
使用了现代化的CSS设计包括
1. 清新的配色方案
2. 优雅的阴影和边框
3. 流畅的过渡动画
### 功能特性
> **提示**: 这是一个引用块,展示了 marked 的渲染能力
- [x] 点击导航自动切换内容
- [x] 加载状态提示
- [x] 标签和日期显示
- [ ] 响应式布局
## 代码示例
### TypeScript 代码
\`\`\`typescript
import { DocsComponent } from './docs';
import { mockMarks } from './mock/collection';
const App: React.FC = () => {
return <DocsComponent dataSource={mockMarks} />;
};
\`\`\`
### 内联代码
使用 \`marked\` 库可以更好地处理 Markdown 语法。
## 表格支持
| 功能 | 状态 | 描述 |
|------|------|------|
| Markdown | ✅ | 完整支持 |
| 代码高亮 | ✅ | 语法高亮 |
| 表格 | ✅ | GFM 表格 |
| 任务列表 | ✅ | 支持复选框 |
## 链接和图片
- 外部链接: [GitHub](https://github.com)
- 图片支持: ![示例](https://via.placeholder.com/150)
---
希望你喜欢这个使用 **marked** 的文档系统!🎉`
};
const apiDoc = generateMarkWithType('markdown');
apiDoc.title = 'API 文档';
apiDoc.description = 'API接口使用说明和示例';
apiDoc.tags = ['API', '开发'];
apiDoc.data = {
content: `# API 文档
## 🔐 用户认证
### 登录接口
**请求地址**: \`POST /api/auth/login\`
**请求参数**:
| 参数 | 类型 | 必填 | 描述 |
|------|------|------|------|
| username | string | ✅ | 用户名 |
| password | string | ✅ | 密码 |
**响应示例**:
\`\`\`json
{
"code": 200,
"message": "success",
"data": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": 1,
"username": "admin",
"email": "admin@example.com"
}
}
}
\`\`\`
## 📊 数据操作
### 获取列表
**请求地址**: \`GET /api/data/list\`
**查询参数**:
- \`page\`: 页码 (默认: 1)
- \`size\`: 每页数量 (默认: 10)
- \`keyword\`: 搜索关键词
### 创建数据
**请求地址**: \`POST /api/data/create\`
**请求体**:
\`\`\`json
{
"title": "标题",
"content": "内容",
"tags": ["tag1", "tag2"]
}
\`\`\`
## ⚠️ 错误码
| 错误码 | 描述 | 解决方案 |
|--------|------|----------|
| 400 | 请求参数错误 | 检查请求参数格式 |
| 401 | 未授权 | 重新登录获取 token |
| 403 | 禁止访问 | 检查用户权限 |
| 500 | 服务器错误 | 联系技术支持 |
> **注意**: 所有 API 请求都需要在 Header 中包含 \`Authorization: Bearer <token>\`
更多 API 详情请参考 [完整文档](https://docs.example.com) 📖`
};
const codeDoc = generateMarkWithType('code');
codeDoc.title = '代码示例';
codeDoc.description = '常用的代码片段和最佳实践';
codeDoc.tags = ['代码', '示例'];
codeDoc.data = {
code: `// React Hook 示例
import { useState, useEffect, useCallback } from 'react';
const useDocuments = (initialData = []) => {
const [docs, setDocs] = useState(initialData);
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// 获取文档列表
const fetchDocs = useCallback(async () => {
setLoading(true);
try {
const response = await fetch('/api/docs');
const data = await response.json();
setDocs(data);
} catch (error) {
console.error('Failed to fetch docs:', error);
} finally {
setLoading(false);
}
}, []);
// 选择文档
const selectDoc = useCallback((id) => {
setSelectedId(id);
}, []);
useEffect(() => {
fetchDocs();
}, [fetchDocs]);
return {
docs,
loading,
selectedId,
selectDoc,
refetch: fetchDocs
};
};
export default useDocuments;`,
language: 'typescript'
};
const configDoc = generateMarkWithType('json');
configDoc.title = '配置说明';
configDoc.description = '系统配置项说明和默认值';
configDoc.tags = ['配置', '设置'];
return [markdownDoc, apiDoc, codeDoc, configDoc];
};
// 示例组件
export const DocsExample: React.FC = () => {
const sampleDocs = createSampleDocs();
return (
<div style={{ height: '100vh' }}>
<DocsComponent dataSource={sampleDocs} />
</div>
);
};
export default DocsExample;

View File

@@ -0,0 +1,222 @@
import React, { useState, useEffect, useMemo } from 'react';
import { marked } from 'marked';
import dayjs from 'dayjs';
import { Mark } from '../mock/collection';
import './docs.css';
type Props = {
dataSource?: Mark[];
}
// 配置 marked 选项
marked.setOptions({
breaks: true,
gfm: true,
});
// Markdown渲染组件
const MarkdownRenderer: React.FC<{ content: string }> = ({ content }) => {
const [htmlContent, setHtmlContent] = useState<string>('');
useEffect(() => {
const renderMarkdown = async () => {
try {
const html = await marked(content);
setHtmlContent(html);
} catch (error) {
console.error('Markdown rendering error:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
setHtmlContent(`<p>渲染错误: ${errorMessage}</p>`);
}
};
renderMarkdown();
}, [content]);
return (
<div
className="docs-markdown"
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
);
};
// 内容渲染组件
const ContentRenderer: React.FC<{ mark: Mark }> = ({ mark }) => {
const renderContent = () => {
if (!mark.data) {
return <div className="docs-empty-text"></div>;
}
if (mark.description) {
return <MarkdownRenderer content={mark.description} />;
}
// 根据markType渲染不同类型的内容
switch (mark.markType) {
case 'markdown':
if (mark.data.content) {
return <MarkdownRenderer content={mark.data.content} />;
}
break;
case 'json':
return (
<pre className="docs-json-content">
{JSON.stringify(mark.data, null, 2)}
</pre>
);
case 'code':
return (
<pre className="docs-code-content">
<code>{mark.data.code || JSON.stringify(mark.data, null, 2)}</code>
</pre>
);
case 'image':
if (mark.data.src) {
return (
<div className="docs-image-content">
<img src={mark.data.src} alt={mark.data.alt || mark.title} />
</div>
);
}
break;
default:
// 对于其他类型,尝试显示内容字段
if (mark.data.content) {
return <MarkdownRenderer content={mark.data.content} />;
}
// 如果没有内容字段显示JSON格式
return (
<div className="docs-default-content">
<pre>{JSON.stringify(mark.data, null, 2)}</pre>
</div>
);
}
return <div className="docs-empty-text"></div>;
};
return <>{renderContent()}</>;
};
// 主要的Docs组件
export const DocsComponent: React.FC<Props> = ({ dataSource = [] }) => {
const [selectedMarkId, setSelectedMarkId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
// 过滤和处理数据源
const validMarks = useMemo(() => {
return dataSource.filter(mark => mark.title || mark.description);
}, [dataSource]);
// 获取当前选中的Mark
const selectedMark = useMemo(() => {
return validMarks.find(mark => mark.id === selectedMarkId) || null;
}, [validMarks, selectedMarkId]);
// 默认选中第一项
useEffect(() => {
if (validMarks.length > 0 && !selectedMarkId) {
setSelectedMarkId(validMarks[0].id);
}
}, [validMarks, selectedMarkId]);
// 处理导航项点击
const handleNavItemClick = (markId: string) => {
if (markId !== selectedMarkId) {
setIsLoading(true);
setSelectedMarkId(markId);
// 模拟加载时间
setTimeout(() => {
setIsLoading(false);
}, 200);
}
};
// 格式化日期
const formatDate = (date: Date) => {
return dayjs(date).format('YYYY年MM月DD日');
};
return (
<div className="docs-container">
{/* 左侧导航 */}
<nav className="docs-nav">
<div className="docs-nav-header">
<h2 className="docs-nav-title"></h2>
</div>
<ul className="docs-nav-list">
{validMarks.map((mark) => (
<li key={mark.id} className="docs-nav-item">
<a
className={`docs-nav-link ${selectedMarkId === mark.id ? 'active' : ''}`}
onClick={() => handleNavItemClick(mark.id)}
>
<div className="docs-nav-link-title">
{mark.title || '未命名文档'}
</div>
{mark.description && (
<div className="docs-nav-link-desc">
{mark.description}
</div>
)}
</a>
</li>
))}
</ul>
{validMarks.length === 0 && (
<div className="docs-loading">
<div className="docs-empty-text"></div>
</div>
)}
</nav>
{/* 右侧内容 */}
<main className="docs-content">
{selectedMark ? (
<>
{/* 内容标题栏 */}
<header className="docs-content-header">
<h1 className="docs-content-title">
{selectedMark.title || '未命名文档'}
</h1>
<div className="docs-content-meta">
{selectedMark.tags && selectedMark.tags.map((tag, index) => (
<span key={index} className="docs-content-tag">
{tag}
</span>
))}
<span className="docs-content-date">
{formatDate(selectedMark.updatedAt)}
</span>
</div>
</header>
{/* 内容主体 */}
<div className="docs-content-body">
{isLoading ? (
<div className="docs-loading">
<div className="docs-loading-spinner"></div>
<span>...</span>
</div>
) : (
<ContentRenderer mark={selectedMark} />
)}
</div>
</>
) : (
<div className="docs-content-body empty">
<div className="docs-empty-icon">📄</div>
<div className="docs-empty-text">
{validMarks.length === 0 ? '暂无文档可显示' : '请选择一个文档查看'}
</div>
</div>
)}
</main>
</div>
);
};
// 兼容性导出
export const App = DocsComponent;

View File

@@ -0,0 +1,127 @@
import React, { useEffect, useRef, useState } from 'react';
import Graph from 'graphology';
import Sigma from 'sigma';
import { Mark } from '../../../modules/mark';
type Props = {
dataSource?: Mark[];
}
export const SigmaGraph = (props: Props) => {
const { dataSource = [] } = props;
const containerRef = useRef<HTMLDivElement>(null);
const sigmaRef = useRef<Sigma | null>(null);
const graphRef = useRef<Graph | null>(null);
useEffect(() => {
if (!containerRef.current) return;
// 创建图实例
const graph = new Graph();
graphRef.current = graph;
// 创建 Sigma 实例
const sigma = new Sigma(graph, containerRef.current, {
renderLabels: true,
labelRenderedSizeThreshold: 8,
labelDensity: 8,
});
sigmaRef.current = sigma;
return () => {
sigma.kill();
};
}, []);
useEffect(() => {
if (!graphRef.current || !dataSource.length) return;
const graph = graphRef.current;
// 清空现有图数据
graph.clear();
// 添加节点
dataSource.forEach((mark, index) => {
graph.addNode(`node-${index}`, {
x: Math.random() * 800,
y: Math.random() * 600,
size: Math.random() * 20 + 5,
label: mark.title || `Mark ${index}`,
color: `#${Math.floor(Math.random() * 16777215).toString(16)}`,
});
});
// // 添加一些随机边(基于数据关系或随机生成)
// for (let i = 0; i < Math.min(dataSource.length - 1, 20); i++) {
// const sourceIndex = Math.floor(Math.random() * dataSource.length);
// const targetIndex = Math.floor(Math.random() * dataSource.length);
// if (sourceIndex !== targetIndex) {
// try {
// graph.addEdge(`node-${sourceIndex}`, `node-${targetIndex}`, {
// size: Math.random() * 5 + 1,
// color: '#999',
// });
// } catch (error) {
// // 边已存在,忽略错误
// }
// }
// }
// 刷新 Sigma 渲染
if (sigmaRef.current) {
sigmaRef.current.refresh();
}
}, [dataSource]);
// 添加交互事件处理
useEffect(() => {
if (!sigmaRef.current) return;
const sigma = sigmaRef.current;
// 节点点击事件
const handleNodeClick = (event: any) => {
console.log('Node clicked:', event.node);
};
// 节点悬停事件
const handleNodeHover = (event: any) => {
console.log('Node hovered:', event.node);
};
sigma.on('clickNode', handleNodeClick);
sigma.on('enterNode', handleNodeHover);
return () => {
sigma.off('clickNode', handleNodeClick);
sigma.off('enterNode', handleNodeHover);
};
}, []);
return (
<div style={{ width: '100%', height: '100vh', position: 'relative' }}>
<div
ref={containerRef}
style={{
width: '100%',
height: '100%',
background: '#f5f5f5'
}}
/>
<div style={{
position: 'absolute',
top: 10,
left: 10,
background: 'rgba(255,255,255,0.8)',
padding: '10px',
borderRadius: '4px'
}}>
<p>: {dataSource.length}</p>
<p>使</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import { Base } from "./table/index";
import { markService } from "../modules/mark-service";
import { SigmaGraph } from "./graph/sigma/index";
import { DocsComponent } from "./docs";
import { MarkDetailList } from "./card/MarkDetailList";
const tabs = [
{
key: 'table',
title: '表格'
},
{
key: 'card',
title: '卡片'
},
{
key: 'graph',
title: '关系图'
},
{
key: 'docs',
title: '文档'
},
{
key: 'world',
title: '世界'
}
];
export const BaseApp = () => {
const [activeTab, setActiveTab] = useState('table');
const [dataSource, setDataSource] = useState<any[]>([]);
useEffect(() => {
getMarks();
}, []);
const getMarks = async () => {
const marks = await markService.getAllMarks();
setDataSource(marks);
}
const renderContent = () => {
switch (activeTab) {
case 'table':
return <Base dataSource={dataSource} />;
case 'graph':
return (
<div className="w-full h-96">
<SigmaGraph dataSource={dataSource} />
</div>
);
case 'card':
return <MarkDetailList data={dataSource} />;
case 'docs':
return <DocsComponent dataSource={dataSource} />;
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-4 border-b-2 font-medium text-sm transition-all duration-200 ease-in-out transform cursor-pointer ${activeTab === tab.key
? 'border-blue-500 text-blue-600 bg-blue-50'
: 'border-transparent text-gray-500'
}`}
>
{tab.title}
</button>
))}
</nav>
</div>
{/* Tab 内容区域 */}
<div className="flex-1 h-full">
{renderContent()}
</div>
</div>
);
}

View File

@@ -0,0 +1,326 @@
import { faker } from '@faker-js/faker';
import { nanoid, customAlphabet } from 'nanoid';
export const random = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ');
// 确保类型定义
const ensureType = ['markdown', 'json', 'html', 'image', 'video', 'audio', 'code', 'link', 'file'];
export type MarkEnsureType = typeof ensureType[number];
// 根据新的类型定义
export type Mark<T = any> = {
/**
* 标记ID
*/
id: string;
/**
* 标题
*/
title?: string;
/**
* 描述
*/
description?: string;
/**
* 标签
*/
tags?: string[];
/**
* 标记类型
*/
markType?: string;
/**
* 封面
*/
cover?: string;
/**
* 链接
*/
link?: string;
/**
* 摘要
*/
summary?: string;
/**
* 键
*/
key?: string;
data: T;
/**
* 附件列表
*/
fileList?: any[];
/**
* 创建人信息
*/
uname?: string;
/**
* 版本号
*/
version?: number;
/**
* 创建时间
*/
createdAt: Date;
/**
* 更新时间
*/
updatedAt: Date;
/**
* 标记时间
*/
markedAt?: Date;
uid?: string;
puid?: string;
};
// 保留原有的辅助类型
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';
query: string;
hash: string;
fileKey: string;
};
export type MarkData = {
md?: string;
mdList?: string[];
type?: MarkEnsureType;
data?: any;
key?: string;
push?: boolean;
pushTime?: Date;
summary?: string;
nodes?: MarkDataNode[];
[key: string]: any;
};
// 生成模拟的 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()
}
});
// 生成模拟的附件
const generateFileAttachment = (): any => ({
id: faker.string.uuid(),
name: faker.system.fileName(),
url: faker.internet.url(),
size: faker.number.int({ min: 1024, max: 50 * 1024 * 1024 }),
type: faker.helpers.arrayElement(['image', 'document', 'video', 'audio']),
mimeType: faker.system.mimeType(),
hash: faker.git.commitSha(),
uploadedAt: faker.date.recent()
});
// 生成模拟的 MarkData通用数据类型
const generateMarkData = <T = any>(): T => {
const dataVariants = [
// Markdown 数据
{
content: faker.lorem.paragraphs(3, '\n\n'),
format: 'markdown',
wordCount: faker.number.int({ min: 100, max: 1000 })
},
// JSON 数据
{
jsonData: {
name: faker.person.fullName(),
email: faker.internet.email(),
settings: {
theme: faker.helpers.arrayElement(['light', 'dark']),
language: faker.helpers.arrayElement(['zh-CN', 'en-US', 'ja-JP'])
}
},
schema: 'user-profile'
},
// 图片数据
{
src: faker.image.url(),
alt: faker.lorem.sentence(),
width: faker.number.int({ min: 400, max: 1920 }),
height: faker.number.int({ min: 300, max: 1080 }),
format: faker.helpers.arrayElement(['jpg', 'png', 'webp'])
},
// 代码数据
{
code: `function ${faker.hacker.noun()}() {\n return "${faker.hacker.phrase()}";\n}`,
language: faker.helpers.arrayElement(['javascript', 'typescript', 'python', 'java']),
lineCount: faker.number.int({ min: 10, max: 100 })
},
// 链接数据
{
url: faker.internet.url(),
title: faker.lorem.sentence(),
description: faker.lorem.paragraph(),
favicon: faker.image.url({ width: 32, height: 32 })
}
];
return faker.helpers.arrayElement(dataVariants) as T;
};
// 生成单个 Mark 记录
const generateMark = <T = any>(): Mark<T> => {
const createdAt = faker.date.past();
const updatedAt = faker.date.between({ from: createdAt, to: new Date() });
return {
id: faker.string.uuid(),
title: faker.datatype.boolean() ? faker.lorem.sentence({ min: 3, max: 8 }) : undefined,
description: faker.datatype.boolean() ? faker.lorem.paragraph() : undefined,
tags: faker.datatype.boolean()
? Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, () =>
faker.helpers.arrayElement(['技术', '前端', '后端', '设计', 'AI', '工具', '教程', '笔记', '生活', '工作'])
)
: undefined,
markType: faker.datatype.boolean()
? faker.helpers.arrayElement(ensureType)
: undefined,
cover: faker.datatype.boolean()
? faker.image.url({ width: 800, height: 600 })
: undefined,
link: faker.datatype.boolean()
? faker.internet.url()
: undefined,
summary: faker.datatype.boolean()
? faker.lorem.sentence()
: undefined,
key: faker.datatype.boolean()
? faker.system.filePath()
: undefined,
data: generateMarkData<T>(),
fileList: faker.datatype.boolean()
? Array.from({ length: faker.number.int({ min: 1, max: 4 }) }, generateFileAttachment)
: undefined,
uname: faker.datatype.boolean()
? faker.person.fullName()
: undefined,
version: faker.datatype.boolean()
? faker.number.int({ min: 1, max: 10 })
: undefined,
createdAt,
updatedAt,
markedAt: faker.datatype.boolean()
? faker.date.between({ from: createdAt, to: updatedAt })
: undefined,
uid: faker.datatype.boolean()
? faker.string.uuid()
: undefined,
puid: faker.datatype.boolean()
? faker.string.uuid()
: undefined
};
};
// 生成指定类型的 Mark 记录
const generateMarkWithType = (markType: MarkEnsureType): Mark => {
const mark = generateMark();
mark.markType = markType;
// 根据类型生成相应的数据
switch (markType) {
case 'markdown':
mark.data = {
content: faker.lorem.paragraphs(faker.number.int({ min: 2, max: 5 }), '\n\n'),
format: 'markdown',
wordCount: faker.number.int({ min: 100, max: 1000 })
};
break;
case 'json':
mark.data = {
jsonData: {
id: faker.string.uuid(),
name: faker.person.fullName(),
settings: {
theme: faker.helpers.arrayElement(['light', 'dark']),
notifications: faker.datatype.boolean()
}
}
};
break;
case 'image':
mark.data = {
src: faker.image.url(),
alt: faker.lorem.sentence(),
width: faker.number.int({ min: 400, max: 1920 }),
height: faker.number.int({ min: 300, max: 1080 })
};
break;
case 'video':
mark.data = {
src: faker.internet.url() + '/video.mp4',
duration: faker.number.int({ min: 30, max: 3600 }),
resolution: faker.helpers.arrayElement(['720p', '1080p', '4K'])
};
break;
case 'code':
mark.data = {
code: `function ${faker.hacker.noun()}() {\n return "${faker.hacker.phrase()}";\n}`,
language: faker.helpers.arrayElement(['javascript', 'typescript', 'python', 'java']),
lineCount: faker.number.int({ min: 10, max: 100 })
};
break;
default:
mark.data = generateMarkData();
}
return mark;
};
// 生成 20 条模拟数据
export const mockMarks: Mark[] = Array.from({ length: 20 }, () => generateMark());
// 生成每种类型的示例数据
export const mockMarksByType: Record<MarkEnsureType, Mark> = ensureType.reduce((acc, type) => {
acc[type] = generateMarkWithType(type);
return acc;
}, {} as Record<MarkEnsureType, Mark>);
// 导出生成器函数
export {
generateMark,
generateMarkWithType,
generateMarkData,
generateMarkDataNode,
generateFileAttachment
};

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,172 @@
# 表格拖拽多选功能
## 功能概述
表格组件现在支持拖拽多选功能,用户可以通过鼠标拖拽来快速选择多行数据,提升批量操作的效率。
## 功能特性
### ✅ 已实现的功能
1. **基础拖拽选择**
- 在表格行上拖拽可以选择多行
- 实时显示选择框和选中状态
- 拖拽结束后更新选中状态
2. **智能交互**
- 避免在复选框和操作按钮区域启动拖拽
- 设置最小拖拽距离阈值,避免意外触发
- 拖拽过程中禁用文本选择
3. **键盘修饰键支持**
- **Ctrl/Cmd + 拖拽**:追加选择模式(切换选中状态)
- **Shift + 点击复选框**:范围选择模式
- **Ctrl/Cmd + A**:全选
- **Escape**:取消拖拽或清空选择
4. **虚拟滚动兼容**
- 正确处理虚拟滚动的坐标转换
- 考虑滚动位置的行索引计算
5. **视觉反馈**
- 拖拽选择框的实时显示
- 选中行的高亮效果
- 拖拽过程中的样式变化
- 用户操作提示
## 使用方法
### 基本配置
```typescript
import { Table } from './base/table/Table';
import { RowSelection } from './base/table/types';
const rowSelection: RowSelection<Mark> = {
type: 'checkbox',
selectedRowKeys,
onChange: (selectedRowKeys, selectedRows) => {
// 处理选择变化
setSelectedRowKeys(selectedRowKeys);
},
dragSelection: {
enabled: true, // 启用拖拽多选
multi: true, // 支持多选
onDragStart: (startRowIndex) => {
console.log('开始拖拽选择');
},
onDragEnd: (selectedRows) => {
console.log('拖拽选择结束');
},
},
};
<Table
data={data}
columns={columns}
rowSelection={rowSelection}
// ... 其他属性
/>
```
### 配置选项
#### `DragSelectionConfig`
```typescript
interface DragSelectionConfig {
enabled?: boolean; // 是否启用拖拽选择,默认为 true
multi?: boolean; // 是否支持多选,默认为 true
onDragStart?: (startRowIndex: number) => void; // 拖拽开始回调
onDragEnd?: (selectedRows: Mark[]) => void; // 拖拽结束回调
}
```
### 用户操作指南
1. **基础拖拽选择**
- 在表格行(非复选框/操作按钮区域)按下鼠标左键
- 拖拽至目标行
- 释放鼠标完成选择
2. **追加选择**
- 按住 `Ctrl`Windows/Linux`Cmd`Mac
- 进行拖拽选择
- 已选中的行会切换状态(选中变未选中,未选中变选中)
3. **范围选择**
- 先点击一个复选框选中一行
- 按住 `Shift`
- 点击另一个复选框
- 两行之间的所有行都会被选中
4. **键盘快捷键**
- `Ctrl/Cmd + A`:全选所有行
- `Escape`:取消当前拖拽操作或清空所有选择
## 技术实现
### 核心组件
1. **状态管理**
- `DragSelectionState`:管理拖拽状态
- 鼠标位置跟踪
- 选择范围计算
2. **事件处理**
- `handleMouseDown`:开始拖拽检测
- `handleMouseMove`:拖拽过程处理
- `handleMouseUp`:完成选择操作
3. **坐标转换**
- 虚拟滚动坐标映射
- 行索引计算
- 碰撞检测算法
### 样式类名
- `.row-selected`:选中行样式
- `.row-drag-selected`:拖拽选中行样式
- `.drag-selection-box`:拖拽选择框样式
- `.drag-hint`:操作提示样式
## 性能优化
1. **事件防抖**
- 设置拖拽距离阈值
- 避免频繁状态更新
2. **虚拟滚动适配**
- 只处理可视区域内的行
- 优化大数据量场景
3. **内存管理**
- 及时清理事件监听器
- 合理的状态重置
## 兼容性
- ✅ 支持现有的复选框选择
- ✅ 兼容虚拟滚动
- ✅ 支持排序和筛选
- ✅ 响应式设计
- ✅ 键盘导航友好
## 示例代码
查看 `DragSelectionExample.tsx` 文件获取完整的使用示例。
## 注意事项
1. 拖拽选择不会在复选框和操作按钮区域启动
2. 需要设置合适的行高以确保拖拽体验
3. 大数据量时建议启用虚拟滚动
4. 移动端设备可能需要额外的触摸事件处理
## 未来规划
- [ ] 触摸设备支持
- [ ] 自定义拖拽选择框样式
- [ ] 更多键盘快捷键
- [ ] 拖拽选择动画效果
- [ ] 选择统计和操作面板

View File

@@ -0,0 +1,115 @@
// 使用拖拽多选功能的示例
import React, { useState } from 'react';
import { Table } from './Table';
import { Mark } from '../mock/collection';
import { TableColumn, RowSelection } from './types';
const ExampleTable: React.FC = () => {
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
// 示例数据
const data: Mark[] = [
{
id: '1',
title: '示例标记 1',
description: '这是第一个标记的描述',
data: { content: '这是第一个标记' },
createdAt: new Date(),
updatedAt: new Date()
},
{
id: '2',
title: '示例标记 2',
description: '这是第二个标记的描述',
data: { content: '这是第二个标记' },
createdAt: new Date(),
updatedAt: new Date()
},
{
id: '3',
title: '示例标记 3',
description: '这是第三个标记的描述',
data: { content: '这是第三个标记' },
createdAt: new Date(),
updatedAt: new Date()
},
// ... 更多数据
];
// 表格列配置
const columns: TableColumn<Mark>[] = [
{
key: 'title',
title: '标题',
dataIndex: 'title',
width: 200,
sortable: true,
},
{
key: 'description',
title: '描述',
dataIndex: 'description',
width: 300,
},
{
key: 'createdAt',
title: '创建时间',
dataIndex: 'createdAt',
width: 180,
render: (value: Date) => value.toLocaleString(),
},
];
// 行选择配置,启用拖拽多选
const rowSelection: RowSelection<Mark> = {
type: 'checkbox',
selectedRowKeys,
onChange: (selectedRowKeys: React.Key[], selectedRows: Mark[]) => {
console.log('选中的行键:', selectedRowKeys);
console.log('选中的行数据:', selectedRows);
setSelectedRowKeys(selectedRowKeys);
},
dragSelection: {
enabled: true, // 启用拖拽多选
multi: true, // 支持多选
onDragStart: (startRowIndex: number) => {
console.log('开始拖拽选择,起始行索引:', startRowIndex);
},
onDragEnd: (selectedRows: Mark[]) => {
console.log('拖拽选择结束,选中的行:', selectedRows);
},
},
};
return (
<div style={{ height: '600px', padding: '20px' }}>
<h2></h2>
<p>
<strong>使</strong>
<br />
<br />
<br />
Ctrl/Cmd +
<br />
Shift +
<br />
Ctrl/Cmd + A
<br />
Escape
</p>
<Table
data={data}
columns={columns}
rowSelection={rowSelection}
virtualScroll={{ rowHeight: 48 }}
loading={false}
/>
</div>
);
};
export default ExampleTable;

View File

@@ -0,0 +1,176 @@
# 数据管理表格组件
这是一个功能完整的React表格组件支持多选、排序、虚拟滚动、操作等功能并集成了Mock数据。
## 功能特性
### ✅ 已实现功能
1. **数据展示**
- 支持多种数据类型展示(标题、类型、标签、创建者等)
- 自定义列渲染(类型徽章、标签展示等)
- 响应式设计,适配移动端
2. **多选功能**
- 支持单行选择和全选
- 批量操作(批量删除)
- 选择状态实时显示
3. **排序功能**
- 支持多列排序(标题、类型、创建者、创建时间等)
- 升序/降序/取消排序
- 排序状态可视化指示
4. **虚拟滚动功能**
- 基于 react-virtualized 实现高性能虚拟滚动
- 支持大量数据展示而不影响性能
- 可配置行高和表格高度
- 固定表头,滚动内容区域
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) => {
// 操作逻辑
}
}
```
### 修改虚拟滚动配置
调整 `virtualScrollConfig` 对象的属性:
```tsx
const virtualScrollConfig = {
rowHeight: 60, // 行高度
height: 600 // 表格容器高度
};
```
## 性能优化
1. **虚拟滚动**:已实现基于 react-virtualized 的虚拟滚动,支持大量数据高性能展示
2. **懒加载**:支持服务端分页和按需加载
3. **缓存**:实现数据缓存机制
4. **防抖**:搜索和过滤功能添加防抖处理
## 后续优化建议
1. **搜索功能**:添加全局搜索和列过滤
2. **导出功能**支持数据导出为Excel/CSV
3. **列配置**:支持用户自定义显示列
4. **主题配置**:支持多主题切换
5. **国际化**:添加多语言支持
这个表格组件提供了现代化的数据管理界面,具备企业级应用所需的核心功能。

View File

@@ -0,0 +1,574 @@
import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { AutoSizer, List } from 'react-virtualized';
import { Mark } from '../mock/collection';
import { TableProps, SortState, DragSelectionState } from './types';
import './table.css';
// 虚拟滚动常量
const DEFAULT_ROW_HEIGHT = 48; // 每行高度
const HEADER_HEIGHT = 48; // 表头高度
export const Table: React.FC<TableProps> = ({
data,
columns,
loading = false,
rowSelection,
virtualScroll,
actions,
onSort
}) => {
const rowHeight = virtualScroll?.rowHeight || DEFAULT_ROW_HEIGHT;
const [sortState, setSortState] = useState<SortState>({ field: null, order: null });
// 拖拽选择状态
const [dragState, setDragState] = useState<DragSelectionState>({
isDragging: false,
startPosition: null,
endPosition: null,
startRowIndex: null,
dragRect: null,
selectedDuringDrag: new Set()
});
// DOM 引用
const tableBodyRef = useRef<HTMLDivElement>(null);
const selectionBoxRef = useRef<HTMLDivElement>(null);
const listRef = useRef<List>(null);
// 拖拽选择配置
const dragSelectionEnabled = rowSelection?.dragSelection?.enabled !== false;
// 处理排序
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 displayData = sortedData;
// 全选/取消全选
const handleSelectAll = (checked: boolean) => {
if (!rowSelection) return;
const allKeys = displayData.map(item => item.id);
const selectedKeys = checked ? allKeys : [];
const selectedRows = checked ? displayData : [];
rowSelection.onChange?.(selectedKeys, selectedRows);
};
// 上次点击的行索引用于Shift范围选择
const lastClickedRowRef = useRef<number | null>(null);
// 单行选择 - 支持Shift范围选择
const handleRowSelect = (record: Mark, checked: boolean, event?: React.ChangeEvent<HTMLInputElement>) => {
if (!rowSelection) return;
const currentKeys = rowSelection.selectedRowKeys || [];
const recordIndex = displayData.findIndex(item => item.id === record.id);
// 处理Shift键范围选择
if (event?.nativeEvent && (event.nativeEvent as any).shiftKey && lastClickedRowRef.current !== null) {
const startIndex = Math.min(lastClickedRowRef.current, recordIndex);
const endIndex = Math.max(lastClickedRowRef.current, recordIndex);
const rangeKeys = displayData.slice(startIndex, endIndex + 1).map(item => item.id);
const newKeys = checked
? [...new Set([...currentKeys, ...rangeKeys])]
: currentKeys.filter(key => !rangeKeys.includes(key as string));
const selectedRows = data.filter(item => newKeys.includes(item.id));
rowSelection.onChange?.(newKeys, selectedRows);
} else {
// 普通单行选择
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);
}
// 更新上次点击的行索引
lastClickedRowRef.current = recordIndex;
};
// 获取嵌套值
const getNestedValue = (obj: any, path: string) => {
return path.split('.').reduce((o, p) => o?.[p], obj);
};
// 根据坐标获取行索引(考虑虚拟滚动的滚动位置)
const getRowIndexFromPosition = useCallback((y: number): number => {
if (!tableBodyRef.current || !listRef.current) return -1;
const rect = tableBodyRef.current.getBoundingClientRect();
const relativeY = y - rect.top;
// 获取当前滚动位置
const scrollTop = (listRef.current as any).Grid?._scrollingContainer?.scrollTop || 0;
// 计算实际的行索引,考虑滚动位置
const actualY = relativeY + scrollTop;
const rowIndex = Math.floor(actualY / rowHeight);
return Math.max(0, Math.min(rowIndex, displayData.length - 1));
}, [rowHeight, displayData.length]);
// 计算拖拽选择范围内的行
const getRowsInDragRange = useCallback((startY: number, endY: number): number[] => {
if (!tableBodyRef.current) return [];
const minY = Math.min(startY, endY);
const maxY = Math.max(startY, endY);
const startRowIndex = getRowIndexFromPosition(minY);
const endRowIndex = getRowIndexFromPosition(maxY);
const rows: number[] = [];
for (let i = startRowIndex; i <= endRowIndex; i++) {
if (i >= 0 && i < displayData.length) {
rows.push(i);
}
}
return rows;
}, [getRowIndexFromPosition, displayData.length]);
// 检查拖拽选择框与行的碰撞检测
const isRowInDragSelection = useCallback((rowIndex: number): boolean => {
if (!dragState.dragRect || !tableBodyRef.current) return false;
const rect = tableBodyRef.current.getBoundingClientRect();
const rowTop = rowIndex * rowHeight;
const rowBottom = rowTop + rowHeight;
// 将拖拽选择框坐标转换为相对于表格的坐标
const selectionTop = dragState.dragRect.top - rect.top;
const selectionBottom = dragState.dragRect.bottom - rect.top;
// 检查行和选择框的垂直重叠
return !(rowBottom < selectionTop || rowTop > selectionBottom);
}, [dragState.dragRect, rowHeight]);
// 更新拖拽选择状态
const updateDragSelection = useCallback((currentPosition: { x: number; y: number }) => {
if (!dragState.isDragging || !dragState.startPosition) return;
const rowsInRange = getRowsInDragRange(dragState.startPosition.y, currentPosition.y);
const newSelectedKeys = new Set(rowsInRange.map(index => displayData[index].id));
setDragState(prev => ({
...prev,
endPosition: currentPosition,
selectedDuringDrag: newSelectedKeys,
dragRect: {
left: Math.min(dragState.startPosition!.x, currentPosition.x),
top: Math.min(dragState.startPosition!.y, currentPosition.y),
right: Math.max(dragState.startPosition!.x, currentPosition.x),
bottom: Math.max(dragState.startPosition!.y, currentPosition.y),
width: Math.abs(currentPosition.x - dragState.startPosition!.x),
height: Math.abs(currentPosition.y - dragState.startPosition!.y),
x: Math.min(dragState.startPosition!.x, currentPosition.x),
y: Math.min(dragState.startPosition!.y, currentPosition.y),
toJSON: () => ({})
} as DOMRect
}));
}, [dragState.isDragging, dragState.startPosition, getRowsInDragRange, displayData]);
// 拖拽最小距离阈值(像素)
const DRAG_THRESHOLD = 5;
// 鼠标按下事件处理
const handleMouseDown = useCallback((event: React.MouseEvent) => {
if (!dragSelectionEnabled || !rowSelection || event.button !== 0) return;
// 如果点击的是复选框或操作按钮,不启动拖拽选择
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.closest('.selection-column') || target.closest('.actions-column')) {
return;
}
const rect = tableBodyRef.current?.getBoundingClientRect();
if (!rect) return;
const startPosition = {
x: event.clientX,
y: event.clientY
};
const startRowIndex = getRowIndexFromPosition(event.clientY);
setDragState({
isDragging: false, // 先不设为true等达到阈值再设置
startPosition,
endPosition: startPosition,
startRowIndex,
dragRect: null,
selectedDuringDrag: new Set()
});
event.preventDefault();
}, [dragSelectionEnabled, rowSelection, getRowIndexFromPosition]);
// 鼠标移动事件处理
const handleMouseMove = useCallback((event: MouseEvent) => {
if (!dragState.startPosition) return;
const currentPosition = {
x: event.clientX,
y: event.clientY
};
// 检查是否超过拖拽阈值
const deltaX = Math.abs(currentPosition.x - dragState.startPosition.x);
const deltaY = Math.abs(currentPosition.y - dragState.startPosition.y);
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (!dragState.isDragging && distance > DRAG_THRESHOLD) {
// 达到阈值,开始拖拽选择
setDragState(prev => ({
...prev,
isDragging: true
}));
// 调用拖拽开始回调
rowSelection?.dragSelection?.onDragStart?.(dragState.startRowIndex!);
}
if (dragState.isDragging) {
updateDragSelection(currentPosition);
}
}, [dragState.startPosition, dragState.isDragging, dragState.startRowIndex, updateDragSelection, rowSelection]);
// 鼠标抬起事件处理
const handleMouseUp = useCallback((event: MouseEvent) => {
if (!dragState.startPosition) return;
// 如果没有开始拖拽(未达到阈值),直接重置状态
if (!dragState.isDragging) {
setDragState({
isDragging: false,
startPosition: null,
endPosition: null,
startRowIndex: null,
dragRect: null,
selectedDuringDrag: new Set()
});
return;
}
const selectedRows = displayData.filter(item => dragState.selectedDuringDrag.has(item.id));
const selectedKeys = Array.from(dragState.selectedDuringDrag);
// 处理选择模式 - Ctrl/Cmd 键多选Shift 键范围选择
const isCtrlOrCmd = event.ctrlKey || event.metaKey;
const currentSelectedKeys = rowSelection?.selectedRowKeys || [];
let newSelectedKeys: React.Key[];
if (isCtrlOrCmd) {
// Ctrl/Cmd + 拖拽:切换选择状态
const existingKeys = new Set(currentSelectedKeys);
selectedKeys.forEach(key => {
if (existingKeys.has(key)) {
existingKeys.delete(key);
} else {
existingKeys.add(key);
}
});
newSelectedKeys = Array.from(existingKeys);
} else {
// 普通拖拽:替换选择
newSelectedKeys = selectedKeys;
}
const finalSelectedRows = displayData.filter(item => newSelectedKeys.includes(item.id));
// 更新选择状态
rowSelection?.onChange?.(newSelectedKeys, finalSelectedRows);
// 调用拖拽结束回调
rowSelection?.dragSelection?.onDragEnd?.(finalSelectedRows);
// 重置拖拽状态
setDragState({
isDragging: false,
startPosition: null,
endPosition: null,
startRowIndex: null,
dragRect: null,
selectedDuringDrag: new Set()
});
}, [dragState.isDragging, dragState.selectedDuringDrag, displayData, rowSelection]);
// 键盘事件处理
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (!rowSelection) return;
// Ctrl/Cmd + A 全选
if ((event.ctrlKey || event.metaKey) && event.key === 'a') {
event.preventDefault();
handleSelectAll(true);
return;
}
// Escape 取消选择
if (event.key === 'Escape') {
if (dragState.isDragging) {
// 取消拖拽
setDragState({
isDragging: false,
startPosition: null,
endPosition: null,
startRowIndex: null,
dragRect: null,
selectedDuringDrag: new Set()
});
} else {
// 清空选择
handleSelectAll(false);
}
return;
}
}, [rowSelection, dragState.isDragging, handleSelectAll]);
// 添加全局鼠标事件监听器
useEffect(() => {
if (dragState.startPosition) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
if (dragState.isDragging) {
document.body.style.userSelect = 'none'; // 禁用文本选择
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = '';
};
}
}, [dragState.startPosition, dragState.isDragging, handleMouseMove, handleMouseUp]);
// 添加键盘事件监听器
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
// 渲染虚拟滚动行
const rowRenderer = ({ index, key, style }: any) => {
const record = displayData[index];
const isSelected = selectedKeys.includes(record.id);
const isDragSelected = dragState.selectedDuringDrag.has(record.id);
const isHighlighted = isSelected || isDragSelected;
return (
<div
key={key}
style={style}
className={`table-row virtual-row ${isHighlighted ? 'row-selected' : ''} ${isDragSelected ? 'row-drag-selected' : ''}`}
>
{rowSelection && (
<div className="selection-column">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => handleRowSelect(record, e.target.checked, e)}
disabled={rowSelection.getCheckboxProps?.(record)?.disabled}
/>
</div>
)}
{columns.map(column => (
<div key={column.key} className="table-cell" style={{ width: column.width }}>
{column.render
? column.render(getNestedValue(record, column.dataIndex), record, index)
: getNestedValue(record, column.dataIndex)
}
</div>
))}
{actions && actions.length > 0 && (
<div className="actions-column">
<div className="action-buttons">
{actions.map(action => (
<button
key={action.key}
className={action.className}
onClick={() => action.onClick(record)}
disabled={action.disabled?.(record)}
aria-label={action.tooltip || action.label}
>
{action.icon && <span className="btn-icon">{action.icon}</span>}
<span className="tooltip">{action.tooltip || action.label}</span>
</button>
))}
</div>
</div>
)}
</div>
);
};
if (loading) {
return (
<div className="table-loading">
<div className="loading-spinner"></div>
<span>...</span>
</div>
);
}
const selectedKeys = rowSelection?.selectedRowKeys || [];
const isAllSelected = displayData.length > 0 && displayData.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">
<div className="selected-info">
{selectedKeys.length}
{dragSelectionEnabled && (
<span className="drag-hint">
Ctrl/Cmd键可追加选择Shift键可范围选择
</span>
)}
</div>
<div className="bulk-actions">
<button className="btn btn-secondary" onClick={() => {
// 取消选中所有项
handleSelectAll(false);
}}>
</button>
<button className="btn btn-danger" onClick={() => {
// 批量删除逻辑
console.log('批量删除:', selectedKeys);
}}>
</button>
</div>
</div>
)}
{/* 表格 */}
<div className="table-wrapper">
{/* 固定表头 */}
<div className="table-header-wrapper" style={{ height: HEADER_HEIGHT }}>
<div className="table-header-row">
{rowSelection && (
<div className="selection-column">
<input
type="checkbox"
checked={isAllSelected}
ref={input => {
if (input) input.indeterminate = isIndeterminate;
}}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</div>
)}
{columns.map(column => (
<div
key={column.key}
style={{ width: column.width }}
className={`table-header-cell ${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>
</div>
))}
{actions && actions.length > 0 && (
<div className="actions-column"></div>
)}
</div>
</div>
{/* 虚拟滚动内容区域 */}
<div
className="table-body-wrapper"
ref={tableBodyRef}
onMouseDown={handleMouseDown}
style={{ cursor: dragState.isDragging ? 'crosshair' : 'default' }}
>
{displayData.length > 0 ? (
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
height={height}
width={width}
rowCount={displayData.length}
rowHeight={rowHeight}
rowRenderer={rowRenderer}
/>
)}
</AutoSizer>
) : (
<div className="empty-state">
<div className="empty-icon">📭</div>
<p></p>
</div>
)}
{/* 拖拽选择框 */}
{dragState.isDragging && dragState.dragRect && (
<div
ref={selectionBoxRef}
className="drag-selection-box"
style={{
position: 'absolute',
left: dragState.dragRect.x - (tableBodyRef.current?.getBoundingClientRect().left || 0),
top: dragState.dragRect.y - (tableBodyRef.current?.getBoundingClientRect().top || 0),
width: dragState.dragRect.width,
height: dragState.dragRect.height,
pointerEvents: 'none'
}}
/>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,270 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Eye, Edit, Trash2 } from 'lucide-react';
import { Table } from './Table';
import { DetailModal } from './DetailModal';
import { Mark } from '../mock/collection';
import { TableColumn, ActionButton } from './types';
type Props = {
dataSource?: Mark[];
}
export const Base = (props: Props) => {
const { dataSource = [] } = props;
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [data, setData] = useState<Mark[]>([]);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [currentRecord, setCurrentRecord] = useState<Mark | null>(null);
useEffect(() => {
if (dataSource) {
setData(dataSource);
}
}, [dataSource]);
// 表格列配置
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) => {
if (!value) return ''
return (
<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: 'data.visibility',
title: '可见性',
dataIndex: 'data.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: '详情',
icon: <Eye className="w-4 h-4 text-blue-600 hover:text-blue-700 cursor-pointer transition-colors" />,
onClick: (record: Mark) => {
handleViewDetail(record);
}
},
{
key: 'edit',
icon: <Edit className="w-4 h-4 text-green-600 hover:text-green-700 cursor-pointer transition-colors" />,
label: '编辑',
onClick: (record: Mark) => {
handleEdit(record);
}
},
{
key: 'delete',
label: '删除',
icon: <Trash2 className="w-4 h-4 text-red-600 hover:text-red-700 cursor-pointer transition-colors" />,
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(dataSource); // 重置为原始顺序
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 virtualScrollConfig = {
rowHeight: 60, // 因为有两行内容,需要更高的行高
// 移除固定高度,让表格自适应容器高度
};
return (
<div className='h-full flex flex-col'>
<div className='flex flex-col h-full' style={{ padding: '24px' }}>
{/* 固定头部区域 */}
<div style={{ marginBottom: '16px', flexShrink: 0 }}>
<h2 style={{ margin: '0 0 8px 0' }}></h2>
<p style={{ color: '#666', margin: '0' }}>
</p>
</div>
{/* 表格容器 - 占据剩余空间并支持滚动 */}
<div style={{
flex: 1,
minHeight: 0 // 重要允许flex容器收缩
}}>
<Table
data={data}
columns={columns}
actions={actions}
rowSelection={{
selectedRowKeys,
onChange: (keys, rows) => {
setSelectedRowKeys(keys);
}
}}
virtualScroll={virtualScrollConfig}
onSort={handleSort}
/>
</div>
<DetailModal
visible={detailModalVisible}
data={currentRecord}
onClose={() => {
setDetailModalVisible(false);
setCurrentRecord(null);
}}
/>
</div>
</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,563 @@
/* 表格容器 */
.table-container {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
height: calc(100% - 24px); /* 距离底部保持24px的间距 */
margin-bottom: 24px; /* 底部间距 */
display: flex;
flex-direction: column;
max-height: calc(100% - 24px); /* 添加最大高度限制 */
}
/* 工具栏 */
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f5f5f5;
border-bottom: 1px solid #e8e8e8;
flex-shrink: 0; /* 工具栏不压缩,保持固定高度 */
min-height: 56px; /* 确保工具栏有固定的最小高度 */
box-sizing: border-box; /* 包含padding和border在内的尺寸计算 */
}
.selected-info {
color: #666;
font-size: 14px;
display: flex;
flex-direction: column;
gap: 4px;
}
.drag-hint {
font-size: 12px;
color: #999;
font-style: italic;
}
.bulk-actions {
display: flex;
gap: 8px;
}
/* 表格主体 - 虚拟滚动布局 */
.table-wrapper {
display: flex;
flex-direction: column;
flex: 1; /* 占满剩余空间 */
min-height: 200px; /* 最小高度,避免过小 */
overflow: hidden; /* 防止溢出 */
height: 0; /* 重要配合flex: 1使用确保正确计算可用空间 */
}
/* 固定表头容器 */
.table-header-wrapper {
flex-shrink: 0;
border-bottom: 1px solid #e8e8e8;
background: #fafafa;
overflow: hidden;
}
.table-header-row {
display: flex;
align-items: center;
}
.table-header-cell {
display: flex;
align-items: center;
padding: 12px 16px;
border-right: 1px solid #e8e8e8;
font-weight: 600;
color: #333;
background: #fafafa;
}
.table-header-cell:last-child {
border-right: none;
}
.table-header-cell.sortable {
cursor: pointer;
user-select: none;
}
/* 虚拟滚动内容区域 */
.table-body-wrapper {
flex: 1;
overflow: hidden;
background: #fff;
min-height: 0; /* 重要允许flex子项收缩 */
position: relative; /* 为AutoSizer提供相对定位上下文 */
}
/* 拖拽选择框 */
.drag-selection-box {
background: rgba(24, 144, 255, 0.1);
border: 2px dashed #1890ff;
border-radius: 4px;
z-index: 1000;
animation: dragBoxPulse 0.3s ease-in-out;
}
@keyframes dragBoxPulse {
0% {
transform: scale(0.95);
opacity: 0.8;
}
50% {
transform: scale(1.02);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* 虚拟行样式 */
.virtual-row {
display: flex;
align-items: center;
border-bottom: 1px solid #e8e8e8;
background: #fff;
transition: background-color 0.2s;
position: relative;
}
.virtual-row:hover {
background: #f5f5f5;
}
/* 选中行样式 */
.virtual-row.row-selected {
background: #e6f7ff !important;
border-color: #91d5ff;
}
.virtual-row.row-selected:hover {
background: #bae7ff !important;
}
/* 拖拽选中行样式 */
.virtual-row.row-drag-selected {
background: #f0f9ff !important;
border-color: #69c0ff;
box-shadow: inset 0 0 0 1px #40a9ff;
}
.table-cell {
display: flex;
align-items: center;
padding: 12px 16px;
border-right: 1px solid #e8e8e8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-cell:last-child {
border-right: none;
}
/* 表头 */
.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;
position: relative;
}
.selection-column input[type="checkbox"] {
cursor: pointer;
transform: scale(1.1);
transition: all 0.2s;
}
.selection-column input[type="checkbox"]:hover {
transform: scale(1.2);
}
.selection-column input[type="checkbox"]:checked {
accent-color: #1890ff;
}
/* 拖拽选择时的表格样式 */
.table-body-wrapper[style*="cursor: crosshair"] {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.table-body-wrapper[style*="cursor: crosshair"] .virtual-row {
pointer-events: none;
}
.table-body-wrapper[style*="cursor: crosshair"] .selection-column,
.table-body-wrapper[style*="cursor: crosshair"] .actions-column {
pointer-events: auto;
}
/* 操作列 */
.actions-column {
width: auto;
min-width: 120px;
text-align: right;
padding: 8px 16px;
}
.action-buttons {
position: relative;
display: flex;
gap: 6px;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
}
/* 按钮样式 */
.btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 12px;
min-width: 32px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fff;
color: #333;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
text-decoration: none;
user-select: none;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
outline: none;
}
.btn:hover:not(:disabled) {
border-color: #40a9ff;
color: #40a9ff;
transform: translateY(-1px);
box-shadow: 0 4px 8px 0 rgba(64, 169, 255, 0.15);
}
.btn:focus:not(:disabled) {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(64, 169, 255, 0.2);
}
.btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important;
}
.btn-primary {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border-color: #1890ff;
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
border-color: #40a9ff;
color: #fff;
box-shadow: 0 4px 12px 0 rgba(24, 144, 255, 0.3);
}
.btn-primary:focus:not(:disabled) {
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3);
}
.btn-danger {
background: linear-gradient(135deg, #ff4d4f 0%, #f5222d 100%);
border-color: #ff4d4f;
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #ff7875 0%, #ff4d4f 100%);
border-color: #ff7875;
color: #fff;
box-shadow: 0 4px 12px 0 rgba(255, 77, 79, 0.3);
}
.btn-danger:focus:not(:disabled) {
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.3);
}
.btn-success {
background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
border-color: #52c41a;
color: #fff;
}
.btn-success:hover:not(:disabled) {
background: linear-gradient(135deg, #73d13d 0%, #52c41a 100%);
border-color: #73d13d;
color: #fff;
box-shadow: 0 4px 12px 0 rgba(82, 196, 26, 0.3);
}
.btn-success:focus:not(:disabled) {
box-shadow: 0 0 0 2px rgba(82, 196, 26, 0.3);
}
.btn-warning {
background: linear-gradient(135deg, #faad14 0%, #d48806 100%);
border-color: #faad14;
color: #fff;
}
.btn-warning:hover:not(:disabled) {
background: linear-gradient(135deg, #ffc53d 0%, #faad14 100%);
border-color: #ffc53d;
color: #fff;
box-shadow: 0 4px 12px 0 rgba(250, 173, 20, 0.3);
}
.btn-warning:focus:not(:disabled) {
box-shadow: 0 0 0 2px rgba(250, 173, 20, 0.3);
}
.btn-icon {
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
/* Tooltip 样式 */
.tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
font-size: 12px;
font-weight: 400;
line-height: 1.4;
border-radius: 4px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 4px solid transparent;
border-top-color: rgba(0, 0, 0, 0.85);
}
.btn:hover .tooltip {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(-4px);
}
/* 确保tooltip在不同位置的按钮中都能正确显示 */
.action-buttons {
position: relative;
}
/* 按钮尺寸变体 */
.btn-small {
padding: 4px 8px;
min-width: 24px;
height: 24px;
font-size: 11px;
}
.btn-small .btn-icon {
font-size: 12px;
}
.btn-medium {
padding: 8px 12px;
min-width: 32px;
height: 32px;
font-size: 12px;
}
.btn-medium .btn-icon {
font-size: 14px;
}
.btn-large {
padding: 12px 16px;
min-width: 40px;
height: 40px;
font-size: 14px;
}
.btn-large .btn-icon {
font-size: 16px;
}
/* 空状态 */
.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); }
}
/* 响应式 */
@media (max-width: 768px) {
.table-toolbar {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.bulk-actions {
justify-content: flex-end;
}
.table-wrapper {
min-height: 300px; /* 移动端最小高度仍然使用flex: 1占满剩余空间 */
}
.action-buttons {
flex-direction: row;
gap: 4px;
justify-content: flex-end;
}
.actions-column {
min-width: 80px;
padding: 6px 12px;
}
.btn {
font-size: 11px;
padding: 6px 10px;
min-width: 28px;
height: 28px;
}
.btn-icon {
font-size: 12px;
}
/* 移动端tooltip调整 */
.tooltip {
font-size: 11px;
padding: 4px 8px;
margin-bottom: 6px;
}
.tooltip::after {
border-width: 3px;
}
/* 移动端按钮尺寸调整 */
.btn-small {
padding: 3px 6px;
min-width: 20px;
height: 20px;
font-size: 10px;
}
.btn-medium {
padding: 6px 10px;
min-width: 28px;
height: 28px;
font-size: 11px;
}
.btn-large {
padding: 8px 12px;
min-width: 32px;
height: 32px;
font-size: 12px;
}
}

View File

@@ -0,0 +1,74 @@
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 DragSelectionConfig {
enabled?: boolean; // 是否启用拖拽选择,默认为 true
multi?: boolean; // 是否支持多选,默认为 true
onDragStart?: (startRowIndex: number) => void; // 拖拽开始回调
onDragEnd?: (selectedRows: Mark[]) => void; // 拖拽结束回调
}
// 拖拽选择状态
export interface DragSelectionState {
isDragging: boolean;
startPosition: { x: number; y: number } | null;
endPosition: { x: number; y: number } | null;
startRowIndex: number | null;
dragRect: DOMRect | null;
selectedDuringDrag: Set<React.Key>;
}
// 表格行选择配置
export interface RowSelection<T = any> {
type?: 'checkbox' | 'radio';
selectedRowKeys?: React.Key[];
onChange?: (selectedRowKeys: React.Key[], selectedRows: T[]) => void;
getCheckboxProps?: (record: T) => { disabled?: boolean };
dragSelection?: DragSelectionConfig; // 拖拽选择配置
}
// 虚拟滚动配置
export interface VirtualScrollConfig {
rowHeight?: number; // 行高度,默认 48px
height?: number; // 表格容器高度,默认 400px
}
// 表格操作按钮类型
export interface ActionButton {
key: string;
label: string;
className?: string;
icon?: React.ReactNode;
onClick: (record: Mark) => void;
disabled?: (record: Mark) => boolean;
tooltip?: string; // 可选的自定义tooltip文本
size?: 'small' | 'medium' | 'large'; // 按钮尺寸
}
// 表格属性
export interface TableProps {
data: Mark[];
columns: TableColumn<Mark>[];
loading?: boolean;
rowSelection?: RowSelection<Mark>;
virtualScroll?: VirtualScrollConfig;
actions?: ActionButton[];
onSort?: (field: string, order: 'asc' | 'desc' | null) => void;
}
// 排序状态
export interface SortState {
field: string | null;
order: 'asc' | 'desc' | null;
}

View File

@@ -0,0 +1,179 @@
/* MarkDetail 组件样式 */
.mark-detail-container {
background: #ffffff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
max-height: 200px;
overflow: hidden;
position: relative;
}
.mark-detail-container.expanded {
max-height: none;
overflow: visible;
}
.mark-detail-container h2 {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: #333;
line-height: 1.4;
}
.mark-detail-container p {
margin: 0 0 12px 0;
color: #666;
line-height: 1.5;
}
/* 封面图片样式 */
.mark-cover {
margin-bottom: 12px;
text-align: center;
}
.mark-cover img {
max-width: 100%;
height: auto;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* 摘要样式 */
.mark-summary {
margin-bottom: 12px;
padding: 12px;
background-color: #f8f9fa;
border-radius: 6px;
border-left: 4px solid #007bff;
}
.mark-summary h3 {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
color: #333;
}
.mark-summary p {
margin: 0;
font-size: 13px;
color: #555;
line-height: 1.4;
}
/* 链接样式 */
.mark-link {
margin-bottom: 12px;
}
.mark-link a {
color: #007bff;
text-decoration: none;
padding: 6px 12px;
border: 1px solid #007bff;
border-radius: 4px;
display: inline-block;
font-size: 13px;
transition: all 0.2s ease;
}
.mark-link a:hover {
background-color: #007bff;
color: white;
}
/* 类型和日期样式 */
.mark-type,
.mark-date {
margin-bottom: 8px;
font-size: 12px;
color: #666;
}
.mark-type span,
.mark-date span {
background-color: #f1f3f4;
padding: 2px 6px;
border-radius: 3px;
}
/* 标签样式 */
.mark-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.mark-tag {
background-color: #e3f2fd;
color: #1976d2;
padding: 4px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
/* 展开/收起按钮样式 */
.mark-expand-button {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 12px;
padding: 8px 12px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: #495057;
transition: all 0.2s ease;
user-select: none;
}
.mark-expand-button:hover {
background-color: #e9ecef;
border-color: #adb5bd;
}
.mark-expand-button:active {
transform: translateY(1px);
}
/* 收起状态下的渐变遮罩 */
.mark-detail-container.collapsed::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40px;
background: linear-gradient(transparent, white);
pointer-events: none;
}
/* 响应式设计 */
@media (max-width: 768px) {
.mark-detail-container {
padding: 12px;
}
.mark-detail-container h2 {
font-size: 16px;
}
.mark-detail-container p {
font-size: 14px;
}
.mark-expand-button {
font-size: 12px;
padding: 6px 10px;
}
}

View File

@@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { Maximize2, Minimize2 } from 'lucide-react';
import './MarkDetail.css';
export type MarkShow = {
id: string;
title?: string;
description?: string;
tags?: string[];
markType?: string;
cover?: string;
link?: string;
summary?: string;
key?: string;
data: any;
createdAt?: string;
updatedAt?: string;
markedAt?: Date;
}
export type SimpleMarkShow = {
id: string;
title?: string;
description?: string;
tags?: string[];
cover?: string;
link?: string;
summary?: string;
}
interface MarkDetailProps {
data: MarkShow;
}
export const MarkDetail: React.FC<MarkDetailProps> = ({ data }) => {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpanded = () => {
setIsExpanded(!isExpanded);
};
return (
<div className={`mark-detail-container ${isExpanded ? 'expanded' : 'collapsed'}`}>
<h2>{data.title}</h2>
<p>{data.description}</p>
{data.cover && (
<div className="mark-cover">
<img src={data.cover} alt={data.title} />
</div>
)}
{data.summary && isExpanded && (
<div className="mark-summary">
<h3></h3>
<p>{data.summary}</p>
</div>
)}
{data.link && isExpanded && (
<div className="mark-link">
<a href={data.link} target="_blank" rel="noopener noreferrer">
访
</a>
</div>
)}
{data.markType && isExpanded && (
<div className="mark-type">
<span>: {data.markType}</span>
</div>
)}
{data.createdAt && isExpanded && (
<div className="mark-date">
<span>: {new Date(data.createdAt).toLocaleString()}</span>
</div>
)}
{data.updatedAt && isExpanded && (
<div className="mark-date">
<span>: {new Date(data.updatedAt).toLocaleString()}</span>
</div>
)}
<div className="mark-tags">
{data.tags?.map(tag => (
<span key={tag} className="mark-tag">{tag}</span>
))}
</div>
<div className="mark-expand-button" onClick={toggleExpanded}>
{isExpanded ? (
<>
<Minimize2 size={16} />
</>
) : (
<>
<Maximize2 size={16} />
</>
)}
</div>
</div>
)
}

216
web/src/apps/muse/index.tsx Normal file
View File

@@ -0,0 +1,216 @@
import { ToastContainer, toast } from 'react-toastify';
import { AuthProvider } from '../login/AuthProvider';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { useState, useRef } from 'react';
import { App as Voice } from './videos/index.tsx';
import { ChatInterface } from './prompts/index.tsx';
import { BaseApp } from './base/index.tsx';
import { exampleUsage, markService } from './modules/mark-service.ts';
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"></h2>
<div className="text-sm text-gray-600">
<Voice />
</div>
</div>
</Panel>
);
};
export const MuseApp = () => {
const [showRightPanel, setShowRightPanel] = useState(true);
const [showLeftPanel, setShowLeftPanel] = useState(true);
const [showCenterPanel, setShowCenterPanel] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
// 导出数据库
const handleExportDB = async () => {
try {
const filename = `marks_backup_${new Date().toISOString().split('T')[0]}.json`;
await markService.exportToFile(filename);
toast.success('数据库导出成功!');
} catch (error) {
console.error('导出失败:', error);
toast.error('导出失败: ' + (error as Error).message);
}
};
// 触发导入文件选择
const handleImportDB = () => {
fileInputRef.current?.click();
};
// 处理文件选择并导入
const handleFileImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const result = await markService.importFromFile(file);
toast.success(
`导入完成!成功: ${result.success}条,失败: ${result.failed}条,总计: ${result.total}`
);
} catch (error) {
console.error('导入失败:', error);
toast.error('导入失败: ' + (error as Error).message);
}
// 重置文件输入
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// 删除数据库
const handleDeleteDB = async () => {
if (!window.confirm('确定要删除所有数据吗?此操作无法撤销!')) {
return;
}
try {
const success = await markService.clearDatabase();
if (success) {
toast.success('数据库已清空!');
} else {
toast.error('清空数据库失败!');
}
} catch (error) {
console.error('删除失败:', error);
toast.error('删除失败: ' + (error as Error).message);
}
};
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'
}`}
>
</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'
}`}
>
</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'
}`}
>
</button>
<button className="px-3 py-1 rounded text-sm bg-green-500 text-white hover:bg-green-600" onClick={() => {
exampleUsage()
}}>
DB
</button>
<button
className="px-3 py-1 rounded text-sm bg-blue-500 text-white hover:bg-blue-600"
onClick={handleExportDB}
>
DB
</button>
<button
className="px-3 py-1 rounded text-sm bg-purple-500 text-white hover:bg-purple-600"
onClick={handleImportDB}
>
DB
</button>
<button
className="px-3 py-1 rounded text-sm bg-red-500 text-white hover:bg-red-600"
onClick={handleDeleteDB}
>
DB
</button>
{/* 隐藏的文件输入元素 */}
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileImport}
style={{ display: 'none' }}
/>
</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,21 @@
import ReactModal from 'react-modal'
import { MarkDetail } from '../components/MarkDetal'
export const MarkModal = ({ isOpen, onRequestClose, markData }: { isOpen: boolean; onRequestClose: () => void; markData: any }) => {
return (
<ReactModal
isOpen={isOpen}
onRequestClose={onRequestClose}
contentLabel="Mark Detail Modal"
className="mark-modal"
overlayClassName="mark-modal-overlay"
>
<button className="mark-modal-close" onClick={onRequestClose}>
&times;
</button>
<MarkDetail data={markData} />
</ReactModal>
);
}

View File

@@ -0,0 +1,490 @@
import PouchDB from 'pouchdb-browser';
import { Mark, MarkEnsureType } from './mark';
console.log('PouchDB version:', PouchDB.version);
// 扩展 Mark 类型以包含 PouchDB 特有字段
export type MarkDocument = Mark & {
_id: string;
_rev?: string;
};
// 创建或获取数据库实例
export const createDB = (name: string = 'marks_db', opts?: { adapter?: string }) => {
return new PouchDB(name);
};
// 辅助函数:将 PouchDB 文档转换为 Mark 对象
const docToMark = (doc: any): Mark => {
const { _id, _rev, ...mark } = doc as MarkDocument;
return mark;
};
// Mark 数据库操作类
export class MarkDB {
private db: PouchDB.Database;
constructor(dbName: string = 'marks_db') {
this.db = createDB(dbName);
}
// 检查是否支持 find 方法
private supportsFindAPI(): boolean {
return typeof this.db.find === 'function';
}
// 回退方案:使用 allDocs 过滤数据
private async fallbackFind(filterFn: (doc: Mark) => boolean): Promise<Mark[]> {
const allDocs = await this.getAll();
return allDocs.filter(filterFn).sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
// 创建索引以支持查询
async createIndexes() {
if (!this.db) {
throw new Error('数据库未初始化');
}
// 检查是否支持 createIndex 方法 (需要 pouchdb-find 插件)
if (typeof this.db.createIndex !== 'function') {
console.warn('PouchDB Find plugin not available. Skipping index creation.');
console.warn('Some query features may not work optimally without indexes.');
return;
}
try {
// PouchDB 创建索引的正确方式
const indexes = [
{ index: { fields: ['uid'] } },
{ index: { fields: ['markType'] } },
{ index: { fields: ['tags'] } },
{ index: { fields: ['createdAt'] } },
{ index: { fields: ['title'] } },
{ index: { fields: ['uid', 'markType'] } },
{ index: { fields: ['createdAt', 'uid'] } }
];
const results = await Promise.allSettled(
indexes.map(indexDef => this.db.createIndex(indexDef))
);
// 检查索引创建结果
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`索引 ${index + 1} 创建成功:`, result.value);
} else {
// 如果索引已存在PouchDB 会返回错误,这是正常的
if (result.reason?.status !== 409) {
console.warn(`索引 ${index + 1} 创建失败:`, result.reason);
}
}
});
console.log('索引初始化完成');
} catch (error) {
console.error('创建索引失败:', error);
// 不再抛出错误,而是警告用户
console.warn('索引创建失败,但数据库可以继续使用(性能可能受影响)');
}
}
// 创建 Mark
async create(mark: Omit<Mark, 'id' | 'createdAt' | 'updatedAt'>): Promise<Mark> {
const now = new Date();
const newMark: Mark = {
...mark,
id: this.generateId(),
createdAt: now,
updatedAt: now
};
try {
const doc: MarkDocument = {
...newMark,
_id: newMark.id
};
const response = await this.db.put(doc);
return { ...newMark };
} catch (error) {
console.error('创建 Mark 失败:', error);
throw error;
}
}
// 根据 ID 获取 Mark
async getById(id: string): Promise<Mark | null> {
try {
const doc = await this.db.get(id);
return docToMark(doc);
} catch (error: any) {
if (error.status === 404) {
return null;
}
console.error('获取 Mark 失败:', error);
throw error;
}
}
// 获取所有 Marks
async getAll(): Promise<Mark[]> {
try {
const result = await this.db.allDocs({
include_docs: true,
attachments: false
});
return result.rows.map(row => docToMark(row.doc));
} catch (error) {
console.error('获取所有 Marks 失败:', error);
throw error;
}
}
// 按用户 ID 获取 Marks
async getByUserId(uid: string): Promise<Mark[]> {
try {
if (this.supportsFindAPI()) {
const result = await this.db.find({
selector: {
uid: uid
},
sort: [{ createdAt: 'desc' }]
});
return result.docs.map(doc => docToMark(doc));
} else {
// 回退方案:使用 allDocs 过滤
return await this.fallbackFind((mark: Mark) => mark.uid === uid);
}
} catch (error) {
console.error('按用户获取 Marks 失败:', error);
throw error;
}
}
// 按类型获取 Marks
async getByType(markType: MarkEnsureType): Promise<Mark[]> {
try {
if (this.supportsFindAPI()) {
const result = await this.db.find({
selector: {
markType: markType
},
sort: [{ createdAt: 'desc' }]
});
return result.docs.map(doc => docToMark(doc));
} else {
// 回退方案:使用 allDocs 过滤
return await this.fallbackFind((mark: Mark) => mark.markType === markType);
}
} catch (error) {
console.error('按类型获取 Marks 失败:', error);
throw error;
}
}
// 按标签搜索 Marks
async getByTag(tag: string): Promise<Mark[]> {
try {
if (this.supportsFindAPI()) {
const result = await this.db.find({
selector: {
tags: { $elemMatch: { $eq: tag } }
},
sort: [{ createdAt: 'desc' }]
});
return result.docs.map(doc => docToMark(doc));
} else {
// 回退方案:使用 allDocs 过滤
return await this.fallbackFind((mark: Mark) =>
Boolean(mark.tags && mark.tags.includes(tag))
);
}
} catch (error) {
console.error('按标签获取 Marks 失败:', error);
throw error;
}
}
// 搜索 Marks按标题或描述
async search(query: string): Promise<Mark[]> {
try {
if (this.supportsFindAPI()) {
const result = await this.db.find({
selector: {
$or: [
{ title: { $regex: query, $options: 'i' } },
{ description: { $regex: query, $options: 'i' } },
{ summary: { $regex: query, $options: 'i' } }
]
},
sort: [{ createdAt: 'desc' }]
});
return result.docs.map(doc => docToMark(doc));
} else {
// 回退方案:使用 allDocs 过滤,简单的字符串匹配
const lowerQuery = query.toLowerCase();
return await this.fallbackFind((mark: Mark) => {
const title = mark.title?.toLowerCase() || '';
const description = mark.description?.toLowerCase() || '';
const summary = mark.summary?.toLowerCase() || '';
return title.includes(lowerQuery) ||
description.includes(lowerQuery) ||
summary.includes(lowerQuery);
});
}
} catch (error) {
console.error('搜索 Marks 失败:', error);
throw error;
}
}
// 更新 Mark
async update(id: string, updates: Partial<Omit<Mark, 'id' | 'createdAt'>>): Promise<Mark> {
try {
const existingDoc = await this.db.get(id);
const existingMark = docToMark(existingDoc);
const updatedMark: Mark = {
...existingMark,
...updates,
updatedAt: new Date()
};
const doc: MarkDocument = {
...updatedMark,
_id: id,
_rev: existingDoc._rev
};
await this.db.put(doc);
return updatedMark;
} catch (error) {
console.error('更新 Mark 失败:', error);
throw error;
}
}
// 删除 Mark
async delete(id: string): Promise<boolean> {
try {
const doc = await this.db.get(id);
await this.db.remove(doc);
return true;
} catch (error) {
console.error('删除 Mark 失败:', error);
throw error;
}
}
// 批量删除 Marks
async deleteMultiple(ids: string[]): Promise<boolean> {
try {
const docs = await Promise.all(ids.map(id => this.db.get(id)));
const responses = await Promise.all(
docs.map(doc => this.db.remove(doc))
);
return responses.every(response => response.ok);
} catch (error) {
console.error('批量删除 Marks 失败:', error);
throw error;
}
}
// 分页获取 Marks
async getPaginated(page: number = 1, limit: number = 10, filters?: {
uid?: string;
markType?: MarkEnsureType;
tags?: string[];
}): Promise<{
marks: Mark[];
total: number;
page: number;
limit: number;
totalPages: number;
}> {
try {
if (this.supportsFindAPI()) {
// 使用 find API
let selector: any = {};
if (filters?.uid) {
selector.uid = filters.uid;
}
if (filters?.markType) {
selector.markType = filters.markType;
}
if (filters?.tags && filters.tags.length > 0) {
selector.tags = { $elemMatch: { $in: filters.tags } };
}
// 获取总数
const countResult = await this.db.find({
selector,
fields: []
});
const total = countResult.docs.length;
// 计算分页
const skip = (page - 1) * limit;
const totalPages = Math.ceil(total / limit);
// 获取数据
const result = await this.db.find({
selector,
sort: [{ createdAt: 'desc' }],
skip,
limit
});
return {
marks: result.docs.map(doc => docToMark(doc)),
total,
page,
limit,
totalPages
};
} else {
// 回退方案:获取所有数据后在内存中分页
let allMarks = await this.getAll();
// 应用过滤器
if (filters) {
allMarks = allMarks.filter(mark => {
let matches = true;
if (filters.uid && mark.uid !== filters.uid) {
matches = false;
}
if (filters.markType && mark.markType !== filters.markType) {
matches = false;
}
if (filters.tags && filters.tags.length > 0) {
const hasMatchingTag = filters.tags.some(tag =>
mark.tags && mark.tags.includes(tag)
);
if (!hasMatchingTag) {
matches = false;
}
}
return matches;
});
}
const total = allMarks.length;
const totalPages = Math.ceil(total / limit);
const skip = (page - 1) * limit;
const marks = allMarks.slice(skip, skip + limit);
return {
marks,
total,
page,
limit,
totalPages
};
}
} catch (error) {
console.error('分页获取 Marks 失败:', error);
throw error;
}
}
// 获取统计信息
async getStats(): Promise<{
total: number;
byType: Record<string, number>;
byUser: Record<string, number>;
recentActivity: number;
}> {
try {
const marks = await this.getAll();
const stats = {
total: marks.length,
byType: {} as Record<string, number>,
byUser: {} as Record<string, number>,
recentActivity: 0
};
// 统计按类型分组
marks.forEach(mark => {
if (mark.markType) {
stats.byType[mark.markType] = (stats.byType[mark.markType] || 0) + 1;
}
if (mark.uid) {
stats.byUser[mark.uid] = (stats.byUser[mark.uid] || 0) + 1;
}
});
// 统计最近7天的活动
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
stats.recentActivity = marks.filter(mark =>
new Date(mark.updatedAt) > weekAgo
).length;
return stats;
} catch (error) {
console.error('获取统计信息失败:', error);
throw error;
}
}
// 同步数据库(用于远程同步)
async sync(remoteDB: string | PouchDB.Database): Promise<void> {
try {
const remote = typeof remoteDB === 'string' ? new PouchDB(remoteDB) : remoteDB;
await this.db.sync(remote).on('complete', () => {
console.log('同步完成');
}).on('error', (err) => {
console.error('同步错误:', err);
});
} catch (error) {
console.error('同步失败:', error);
throw error;
}
}
// 清理数据库
async clear(): Promise<void> {
try {
const dbName = this.db.name;
await this.db.destroy();
this.db = createDB(dbName);
await this.createIndexes();
} catch (error) {
console.error('清理数据库失败:', error);
throw error;
}
}
// 生成唯一ID
private generateId(): string {
return `mark_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
// 关闭数据库连接
async close(): Promise<void> {
try {
await this.db.close();
} catch (error) {
console.error('关闭数据库失败:', error);
throw error;
}
}
}
// 创建默认实例
export const markDB = new MarkDB();
// 初始化数据库
export const initMarkDB = async () => {
await markDB.createIndexes();
return markDB;
};

View File

@@ -0,0 +1,232 @@
import { markDB, initMarkDB } from './db';
import { Mark } from './mark';
// Mark 服务类 - 提供业务逻辑层
export class MarkService {
private db = markDB;
// 初始化服务
async init() {
await initMarkDB();
}
// 创建新的 Mark
async createMark(markData: Omit<Mark, 'id' | 'createdAt' | 'updatedAt'>): Promise<Mark> {
return await this.db.create(markData);
}
// 根据 ID 获取 Mark
async getMark(id: string): Promise<Mark | null> {
return await this.db.getById(id);
}
// 获取所有 Marks
async getAllMarks(): Promise<Mark[]> {
return await this.db.getAll();
}
// 按用户获取 Marks
async getMarksByUser(userId: string): Promise<Mark[]> {
return await this.db.getByUserId(userId);
}
// 按类型获取 Marks
async getMarksByType(markType: string): Promise<Mark[]> {
return await this.db.getByType(markType as any);
}
// 按标签搜索 Marks
async getMarksByTag(tag: string): Promise<Mark[]> {
return await this.db.getByTag(tag);
}
// 搜索 Marks
async searchMarks(query: string): Promise<Mark[]> {
return await this.db.search(query);
}
// 更新 Mark
async updateMark(id: string, updates: Partial<Omit<Mark, 'id' | 'createdAt'>>): Promise<Mark> {
return await this.db.update(id, updates);
}
// 删除 Mark
async deleteMark(id: string): Promise<boolean> {
return await this.db.delete(id);
}
// 批量删除 Marks
async deleteMultipleMarks(ids: string[]): Promise<boolean> {
return await this.db.deleteMultiple(ids);
}
// 分页获取 Marks
async getMarksPaginated(
page: number = 1,
limit: number = 10,
filters?: {
uid?: string;
markType?: string;
tags?: string[];
}
) {
return await this.db.getPaginated(page, limit, filters);
}
// 获取统计信息
async getStats() {
return await this.db.getStats();
}
// 导出数据
async exportData(): Promise<Mark[]> {
return await this.getAllMarks();
}
// 导入数据
async importData(marks: Mark[]): Promise<number> {
let importedCount = 0;
try {
for (const mark of marks) {
const { id, createdAt, updatedAt, ...markData } = mark;
await this.createMark(markData);
importedCount++;
}
} catch (error) {
console.error('导入数据失败:', error);
}
return importedCount;
}
// 导出数据到文件
async exportToFile(filename: string = 'marks_backup.json'): Promise<void> {
try {
const marks = await this.exportData();
const exportData = {
version: '1.0',
exportTime: new Date().toISOString(),
totalCount: marks.length,
marks: marks
};
const jsonData = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
// 创建下载链接
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
console.log(`成功导出 ${marks.length} 条记录到文件: ${filename}`);
} catch (error) {
console.error('导出文件失败:', error);
throw new Error('导出文件失败: ' + (error as Error).message);
}
}
// 从文件导入数据
async importFromFile(file: File): Promise<{ success: number; failed: number; total: number }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target?.result as string;
const importData = JSON.parse(content);
// 验证数据格式
if (!importData.marks || !Array.isArray(importData.marks)) {
throw new Error('无效的数据格式缺少marks数组');
}
let successCount = 0;
let failedCount = 0;
const totalCount = importData.marks.length;
// 批量导入数据
for (const mark of importData.marks) {
try {
// 移除ID相关字段让系统重新生成
const { id, createdAt, updatedAt, _id, _rev, ...markData } = mark;
await this.createMark(markData);
successCount++;
} catch (error) {
console.warn('导入单条记录失败:', error);
failedCount++;
}
}
const result = {
success: successCount,
failed: failedCount,
total: totalCount
};
console.log(`导入完成: 成功${successCount}条,失败${failedCount}条,总计${totalCount}`);
resolve(result);
} catch (error) {
console.error('解析导入文件失败:', error);
reject(new Error('解析导入文件失败: ' + (error as Error).message));
}
};
reader.onerror = () => {
reject(new Error('读取文件失败'));
};
reader.readAsText(file);
});
}
// 清空数据库
async clearDatabase(): Promise<boolean> {
try {
await this.db.clear();
console.log('数据库已清空');
return true;
} catch (error) {
console.error('清空数据库失败:', error);
return false;
}
}
}
// 创建默认服务实例
export const markService = new MarkService();
// 使用示例函数
export const exampleUsage = async () => {
// 1. 初始化服务
await markService.init();
// 4. 获取所有标记
const allMarks = await markService.getAllMarks();
console.log('所有标记数量:', allMarks.length);
// 5. 搜索标记
const searchResults = await markService.searchMarks('测试');
console.log('搜索结果数量:', searchResults.length);
// 6. 按类型获取标记
const markdownMarks = await markService.getMarksByType('markdown');
console.log('Markdown 标记数量:', markdownMarks.length);
// 7. 分页获取标记
const paginatedResults = await markService.getMarksPaginated(1, 5);
console.log('分页结果:', {
currentPage: paginatedResults.page,
totalPages: paginatedResults.totalPages,
total: paginatedResults.total,
marksOnPage: paginatedResults.marks.length
});
// 8. 获取统计信息
const stats = await markService.getStats();
console.log('统计信息:', stats);
};

View File

@@ -0,0 +1,78 @@
export type Mark<T = any> = {
/**
* 标记ID
*/
id: string;
/**
* 标题
*/
title?: string;
/**
* 描述
*/
description?: string;
/**
* 标签
*/
tags?: string[];
/**
* 标记类型
*/
markType?: string;
/**
* 封面
*/
cover?: string;
/**
* 链接
*/
link?: string;
/**
* 摘要
*/
summary?: string;
/**
* 键
*/
key?: string;
data: T;
/**
* 附件列表
*/
fileList?: any[];
/**
* 创建人信息
*/
uname?: string;
/**
* 版本号
*/
version?: number;
/**
* 创建时间
*/
createdAt: Date;
/**
* 更新时间
*/
updatedAt: Date;
/**
* 标记时间
*/
markedAt?: Date;
uid?: string;
puid?: string;
}
const ensureType = ['markdown', 'json', 'html', 'image', 'video', 'audio', 'code', 'link', 'file']
export type MarkEnsureType = typeof ensureType[number];

View File

@@ -0,0 +1,243 @@
# Speak Database Service
基于 PouchDB 的语音记录数据库服务,提供完整的增删改查功能。
## 功能特性
- 🎤 语音记录的完整 CRUD 操作
- 📊 按天、说话人、类型等多维度查询
- 🔍 全文搜索功能
- 📈 统计分析功能
- 🔄 数据导入导出
- 📱 离线支持(基于 IndexedDB
- 🔀 语音记录合并功能
## 快速开始
### 1. 初始化服务
```typescript
import { speakService } from './speak-db';
// 初始化服务
await speakService.init();
```
### 2. 创建语音记录
```typescript
// 自动创建(自动生成当天序号)
const speak = await speakService.createSpeakAuto({
text: '这是识别的文字内容',
duration: 30, // 时长(秒)
speaker: 'user1',
type: 'normal'
});
// 手动指定所有字段
const manualSpeak = await speakService.createSpeak({
no: 1,
day: 365,
timestamp: new Date(),
text: '手动创建的语音记录',
duration: 45,
speaker: 'user2',
type: 'merge'
});
```
### 3. 查询语音记录
```typescript
// 获取所有记录
const allSpeaks = await speakService.getAllSpeaks();
// 获取今天的记录
const todaySpeaks = await speakService.getTodaySpeaks();
// 按天查询
const dayData = await speakService.getSpeaksByDay(365);
// 按说话人查询
const userSpeaks = await speakService.getSpeaksBySpeaker('user1');
// 按类型查询
const normalSpeaks = await speakService.getSpeaksByType('normal');
// 时间范围查询
const recentSpeaks = await speakService.getRecentSpeaks(7); // 最近7天
// 搜索文本内容
const searchResults = await speakService.searchSpeaks('关键词');
// 分页查询
const paginatedData = await speakService.getSpeaksPaginated(1, 10, {
speaker: 'user1',
type: 'normal'
});
```
### 4. 更新和删除
```typescript
// 更新记录
await speakService.updateSpeak('speak_id', {
text: '更新后的文字内容',
speaker: 'new_speaker'
});
// 删除单个记录
await speakService.deleteSpeak('speak_id');
// 批量删除
await speakService.deleteMultipleSpeaks(['id1', 'id2']);
// 清空今天的记录
await speakService.clearTodaySpeaks();
```
### 5. 统计功能
```typescript
// 总体统计
const stats = await speakService.getStats();
console.log('总记录数:', stats.total);
console.log('总时长:', stats.totalDuration);
console.log('平均时长:', stats.avgDuration);
// 今天的统计
const todayStats = await speakService.getTodayStats();
console.log('今天的记录数:', todayStats.total);
```
### 6. 数据导入导出
```typescript
// 导出到文件
await speakService.exportToFile('speaks_backup.json');
// 从文件导入
const fileInput = document.getElementById('fileInput') as HTMLInputElement;
const file = fileInput.files[0];
const result = await speakService.importFromFile(file);
console.log(`导入完成: 成功${result.success}条,失败${result.failed}条`);
```
### 7. 高级功能
```typescript
// 合并多个语音记录
const mergedSpeak = await speakService.mergeSpeaks(
['speak1_id', 'speak2_id'],
{
text: '合并后的文字内容',
speaker: 'merged_speaker'
}
);
// 获取短语音记录小于5秒
const shortSpeaks = await speakService.getShortSpeaks(5);
// 获取长语音记录大于60秒
const longSpeaks = await speakService.getLongSpeaks(60);
// 按时长范围查询
const mediumSpeaks = await speakService.getSpeaksByDuration(10, 60);
```
## 数据结构
### Speak 类型
```typescript
type Speak = {
id: string; // 唯一标识
no: number; // 当天序号
file?: string; // base64编码的音频文件
text?: string; // 识别的文字内容
timestamp: Date; // 生成时间戳
day: number; // 一年中的第几天
duration: number; // 音频时长(秒)
speaker?: string; // 说话人
type?: 'merge' | 'normal'; // 类型:合并或普通
createdAt?: Date; // 创建时间
updatedAt?: Date; // 更新时间
}
```
### 过滤器选项
```typescript
interface SpeakFilters {
day?: number; // 按天过滤
speaker?: string; // 按说话人过滤
type?: 'merge' | 'normal'; // 按类型过滤
startTime?: Date; // 开始时间
endTime?: Date; // 结束时间
}
```
## 索引和性能
数据库自动创建以下索引以优化查询性能:
- `day` - 按天查询
- `speaker` - 按说话人查询
- `type` - 按类型查询
- `timestamp` - 按时间查询
- `day + no` - 复合索引,按天内序号查询
- `timestamp + day` - 复合索引,时间和天的组合查询
## 注意事项
1. **时间处理**: 系统使用 `getDayOfYear()` 函数计算一年中的第几天
2. **序号管理**: 使用 `createSpeakAuto()` 可以自动生成当天的序号
3. **离线支持**: 基于 PouchDB支持离线使用
4. **数据同步**: 支持与远程数据库同步
5. **错误处理**: 所有操作都包含完整的错误处理
## 依赖
- `pouchdb-browser`: PouchDB 的浏览器版本
- `pouchdb-find`: 查询插件(可选,用于优化查询性能)
## 示例应用
```typescript
import { speakService } from './speak-db';
class VoiceRecorderApp {
async init() {
await speakService.init();
}
async recordVoice(audioBlob: Blob, text: string) {
// 将音频转换为 base64
const base64Audio = await this.blobToBase64(audioBlob);
// 创建语音记录
const speak = await speakService.createSpeakAuto({
file: base64Audio,
text: text,
duration: audioBlob.size / 1000, // 估算时长
speaker: 'current_user',
type: 'normal'
});
return speak;
}
async getTodayRecordings() {
return await speakService.getTodaySpeaks();
}
private async blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
}
```

View File

@@ -0,0 +1,26 @@
// Speak 数据库和服务的统一导出文件
// 类型定义
export {
Speak,
SpeakType,
CreateSpeakData,
UpdateSpeakData,
getDayOfYear
} from './speak';
// 数据库操作
export {
SpeakDB,
SpeakDocument,
speakDB,
initSpeakDB,
createSpeakDB
} from './speak-db';
// 服务层
export {
SpeakService,
speakService,
exampleUsage
} from './speak-service';

View File

@@ -0,0 +1,565 @@
import PouchDB from 'pouchdb-browser';
import { Speak } from './speak';
// 扩展 Speak 类型以包含 PouchDB 特有字段
export type SpeakDocument = Speak & {
_id: string;
_rev?: string;
};
// 创建或获取数据库实例
export const createSpeakDB = (name: string = 'speaks_db', opts?: { adapter?: string }) => {
return new PouchDB(name);
};
// 辅助函数:将 PouchDB 文档转换为 Speak 对象
const docToSpeak = (doc: any): Speak => {
const { _id, _rev, ...speak } = doc as SpeakDocument;
return speak;
};
// Speak 数据库操作类
export class SpeakDB {
private db: PouchDB.Database;
constructor(dbName: string = 'speaks_db') {
this.db = createSpeakDB(dbName);
}
// 检查是否支持 find 方法
private supportsFindAPI(): boolean {
return typeof this.db.find === 'function';
}
// 回退方案:使用 allDocs 过滤数据
private async fallbackFind(filterFn: (doc: Speak) => boolean): Promise<Speak[]> {
const allDocs = await this.getAll();
return allDocs.filter(filterFn).sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
}
// 创建索引以支持查询
async createIndexes() {
if (!this.db) {
throw new Error('数据库未初始化');
}
// 检查是否支持 createIndex 方法 (需要 pouchdb-find 插件)
if (typeof this.db.createIndex !== 'function') {
console.warn('PouchDB Find plugin not available. Skipping index creation.');
console.warn('Some query features may not work optimally without indexes.');
return;
}
try {
// PouchDB 创建索引的正确方式
const indexes = [
{ index: { fields: ['day'] } },
{ index: { fields: ['no'] } },
{ index: { fields: ['timestamp'] } },
{ index: { fields: ['speaker'] } },
{ index: { fields: ['type'] } },
{ index: { fields: ['duration'] } },
{ index: { fields: ['day', 'no'] } },
{ index: { fields: ['timestamp', 'day'] } },
{ index: { fields: ['speaker', 'day'] } }
];
const results = await Promise.allSettled(
indexes.map(indexDef => this.db.createIndex(indexDef))
);
// 检查索引创建结果
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Speak索引 ${index + 1} 创建成功:`, result.value);
} else {
// 如果索引已存在PouchDB 会返回错误,这是正常的
if (result.reason?.status !== 409) {
console.warn(`Speak索引 ${index + 1} 创建失败:`, result.reason);
}
}
});
console.log('Speak索引初始化完成');
} catch (error) {
console.error('创建Speak索引失败:', error);
// 不再抛出错误,而是警告用户
console.warn('Speak索引创建失败但数据库可以继续使用性能可能受影响');
}
}
// 创建 Speak
async create(speak: Omit<Speak, 'id'>): Promise<Speak> {
const newSpeak: Speak = {
...speak,
id: this.generateId()
};
try {
const doc: SpeakDocument = {
...newSpeak,
_id: newSpeak.id
};
const response = await this.db.put(doc);
return { ...newSpeak };
} catch (error) {
console.error('创建 Speak 失败:', error);
throw error;
}
}
// 根据 ID 获取 Speak
async getById(id: string): Promise<Speak | null> {
try {
const doc = await this.db.get(id);
return docToSpeak(doc);
} catch (error: any) {
if (error.status === 404) {
return null;
}
console.error('获取 Speak 失败:', error);
throw error;
}
}
// 获取所有 Speaks
async getAll(): Promise<Speak[]> {
try {
const result = await this.db.allDocs({
include_docs: true,
attachments: false
});
return result.rows
.map(row => docToSpeak(row.doc))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
} catch (error) {
console.error('获取所有 Speaks 失败:', error);
throw error;
}
}
// 按天获取 Speaks
async getByDay(day: number): Promise<Speak[]> {
try {
if (this.supportsFindAPI()) {
const result = await this.db.find({
selector: {
day: day
},
sort: [{ no: 'asc' }]
});
return result.docs.map(doc => docToSpeak(doc));
} else {
// 回退方案:使用 allDocs 过滤
return await this.fallbackFind((speak: Speak) => speak.day === day);
}
} catch (error) {
console.error('按天获取 Speaks 失败:', error);
throw error;
}
}
// 按说话人获取 Speaks
async getBySpeaker(speaker: string): Promise<Speak[]> {
try {
if (this.supportsFindAPI()) {
const result = await this.db.find({
selector: {
speaker: speaker
},
sort: [{ timestamp: 'desc' }]
});
return result.docs.map(doc => docToSpeak(doc));
} else {
// 回退方案:使用 allDocs 过滤
return await this.fallbackFind((speak: Speak) => speak.speaker === speaker);
}
} catch (error) {
console.error('按说话人获取 Speaks 失败:', error);
throw error;
}
}
// 按类型获取 Speaks
async getByType(type: 'merge' | 'normal'): Promise<Speak[]> {
try {
if (this.supportsFindAPI()) {
const result = await this.db.find({
selector: {
type: type
},
sort: [{ timestamp: 'desc' }]
});
return result.docs.map(doc => docToSpeak(doc));
} else {
// 回退方案:使用 allDocs 过滤
return await this.fallbackFind((speak: Speak) => speak.type === type);
}
} catch (error) {
console.error('按类型获取 Speaks 失败:', error);
throw error;
}
}
// 按时间范围获取 Speaks
async getByTimeRange(startTime: Date, endTime: Date): Promise<Speak[]> {
try {
if (this.supportsFindAPI()) {
const result = await this.db.find({
selector: {
timestamp: {
$gte: startTime,
$lte: endTime
}
},
sort: [{ timestamp: 'desc' }]
});
return result.docs.map(doc => docToSpeak(doc));
} else {
// 回退方案:使用 allDocs 过滤
return await this.fallbackFind((speak: Speak) => {
const speakTime = new Date(speak.timestamp);
return speakTime >= startTime && speakTime <= endTime;
});
}
} catch (error) {
console.error('按时间范围获取 Speaks 失败:', error);
throw error;
}
}
// 搜索 Speaks按文字内容
async search(query: string): Promise<Speak[]> {
try {
if (this.supportsFindAPI()) {
const result = await this.db.find({
selector: {
text: { $regex: query, $options: 'i' }
},
sort: [{ timestamp: 'desc' }]
});
return result.docs.map(doc => docToSpeak(doc));
} else {
// 回退方案:使用 allDocs 过滤,简单的字符串匹配
const lowerQuery = query.toLowerCase();
return await this.fallbackFind((speak: Speak) => {
const text = speak.text?.toLowerCase() || '';
return text.includes(lowerQuery);
});
}
} catch (error) {
console.error('搜索 Speaks 失败:', error);
throw error;
}
}
// 更新 Speak
async update(id: string, updates: Partial<Omit<Speak, 'id'>>): Promise<Speak> {
try {
const existingDoc = await this.db.get(id);
const existingSpeak = docToSpeak(existingDoc);
const updatedSpeak: Speak = {
...existingSpeak,
...updates
};
const doc: SpeakDocument = {
...updatedSpeak,
_id: id,
_rev: existingDoc._rev
};
await this.db.put(doc);
return updatedSpeak;
} catch (error) {
console.error('更新 Speak 失败:', error);
throw error;
}
}
// 删除 Speak
async delete(id: string): Promise<boolean> {
try {
const doc = await this.db.get(id);
await this.db.remove(doc);
return true;
} catch (error) {
console.error('删除 Speak 失败:', error);
throw error;
}
}
// 批量删除 Speaks
async deleteMultiple(ids: string[]): Promise<boolean> {
try {
const docs = await Promise.all(ids.map(id => this.db.get(id)));
const responses = await Promise.all(
docs.map(doc => this.db.remove(doc))
);
return responses.every(response => response.ok);
} catch (error) {
console.error('批量删除 Speaks 失败:', error);
throw error;
}
}
// 按天删除 Speaks
async deleteByDay(day: number): Promise<number> {
try {
const speaks = await this.getByDay(day);
const ids = speaks.map(speak => speak.id);
await this.deleteMultiple(ids);
return ids.length;
} catch (error) {
console.error('按天删除 Speaks 失败:', error);
throw error;
}
}
// 分页获取 Speaks
async getPaginated(page: number = 1, limit: number = 10, filters?: {
day?: number;
speaker?: string;
type?: 'merge' | 'normal';
startTime?: Date;
endTime?: Date;
}): Promise<{
speaks: Speak[];
total: number;
page: number;
limit: number;
totalPages: number;
}> {
try {
if (this.supportsFindAPI()) {
// 使用 find API
let selector: any = {};
if (filters?.day !== undefined) {
selector.day = filters.day;
}
if (filters?.speaker) {
selector.speaker = filters.speaker;
}
if (filters?.type) {
selector.type = filters.type;
}
if (filters?.startTime || filters?.endTime) {
selector.timestamp = {};
if (filters.startTime) {
selector.timestamp.$gte = filters.startTime;
}
if (filters.endTime) {
selector.timestamp.$lte = filters.endTime;
}
}
// 获取总数
const countResult = await this.db.find({
selector,
fields: []
});
const total = countResult.docs.length;
// 计算分页
const skip = (page - 1) * limit;
const totalPages = Math.ceil(total / limit);
// 获取数据
const result = await this.db.find({
selector,
sort: [{ timestamp: 'desc' }],
skip,
limit
});
return {
speaks: result.docs.map(doc => docToSpeak(doc)),
total,
page,
limit,
totalPages
};
} else {
// 回退方案:获取所有数据后在内存中分页
let allSpeaks = await this.getAll();
// 应用过滤器
if (filters) {
allSpeaks = allSpeaks.filter(speak => {
let matches = true;
if (filters.day !== undefined && speak.day !== filters.day) {
matches = false;
}
if (filters.speaker && speak.speaker !== filters.speaker) {
matches = false;
}
if (filters.type && speak.type !== filters.type) {
matches = false;
}
if (filters.startTime || filters.endTime) {
const speakTime = new Date(speak.timestamp);
if (filters.startTime && speakTime < filters.startTime) {
matches = false;
}
if (filters.endTime && speakTime > filters.endTime) {
matches = false;
}
}
return matches;
});
}
const total = allSpeaks.length;
const totalPages = Math.ceil(total / limit);
const skip = (page - 1) * limit;
const speaks = allSpeaks.slice(skip, skip + limit);
return {
speaks,
total,
page,
limit,
totalPages
};
}
} catch (error) {
console.error('分页获取 Speaks 失败:', error);
throw error;
}
}
// 获取统计信息
async getStats(): Promise<{
total: number;
totalDuration: number;
avgDuration: number;
byDay: Record<number, number>;
bySpeaker: Record<string, number>;
byType: Record<string, number>;
recentActivity: number;
}> {
try {
const speaks = await this.getAll();
const stats = {
total: speaks.length,
totalDuration: 0,
avgDuration: 0,
byDay: {} as Record<number, number>,
bySpeaker: {} as Record<string, number>,
byType: {} as Record<string, number>,
recentActivity: 0
};
// 统计总时长和各种分组
speaks.forEach(speak => {
stats.totalDuration += speak.duration || 0;
// 按天统计
stats.byDay[speak.day] = (stats.byDay[speak.day] || 0) + 1;
// 按说话人统计
if (speak.speaker) {
stats.bySpeaker[speak.speaker] = (stats.bySpeaker[speak.speaker] || 0) + 1;
}
// 按类型统计
if (speak.type) {
stats.byType[speak.type] = (stats.byType[speak.type] || 0) + 1;
}
});
// 计算平均时长
stats.avgDuration = stats.total > 0 ? stats.totalDuration / stats.total : 0;
// 统计最近7天的活动
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
stats.recentActivity = speaks.filter(speak =>
new Date(speak.timestamp) > weekAgo
).length;
return stats;
} catch (error) {
console.error('获取Speak统计信息失败:', error);
throw error;
}
}
// 获取当天的下一个序号
async getNextNo(day: number): Promise<number> {
try {
const todaySpeaks = await this.getByDay(day);
const maxNo = todaySpeaks.reduce((max, speak) => Math.max(max, speak.no), 0);
return maxNo + 1;
} catch (error) {
console.error('获取下一个序号失败:', error);
throw error;
}
}
// 同步数据库(用于远程同步)
async sync(remoteDB: string | PouchDB.Database): Promise<void> {
try {
const remote = typeof remoteDB === 'string' ? new PouchDB(remoteDB) : remoteDB;
await this.db.sync(remote).on('complete', () => {
console.log('Speak同步完成');
}).on('error', (err) => {
console.error('Speak同步错误:', err);
});
} catch (error) {
console.error('Speak同步失败:', error);
throw error;
}
}
// 清理数据库
async clear(): Promise<void> {
try {
const dbName = this.db.name;
await this.db.destroy();
this.db = createSpeakDB(dbName);
await this.createIndexes();
} catch (error) {
console.error('清理Speak数据库失败:', error);
throw error;
}
}
// 生成唯一ID
private generateId(): string {
return `speak_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
// 关闭数据库连接
async close(): Promise<void> {
try {
await this.db.close();
} catch (error) {
console.error('关闭Speak数据库失败:', error);
throw error;
}
}
}
// 创建默认实例
export const speakDB = new SpeakDB();
// 初始化数据库
export const initSpeakDB = async () => {
await speakDB.createIndexes();
return speakDB;
};

View File

@@ -0,0 +1,399 @@
import { speakDB, initSpeakDB } from './speak-db';
import { Speak, CreateSpeakData, UpdateSpeakData, SpeakType, getDayOfYear } from './speak';
// Speak 服务类 - 提供业务逻辑层
export class SpeakService {
private db = speakDB;
// 初始化服务
async init() {
await initSpeakDB();
}
// 创建新的 Speak
async createSpeak(speakData: CreateSpeakData): Promise<Speak> {
// 自动设置创建和更新时间
const now = new Date();
const completeData = {
...speakData,
createdAt: now,
updatedAt: now
};
return await this.db.create(completeData);
}
// 创建新的 Speak自动获取当天序号
async createSpeakAuto(speakData: Omit<CreateSpeakData, 'no' | 'day' | 'timestamp'>): Promise<Speak> {
const today = new Date();
const day = getDayOfYear(today);
const no = await this.db.getNextNo(day);
return await this.createSpeak({
...speakData,
day,
no,
timestamp: today
});
}
// 根据 ID 获取 Speak
async getSpeak(id: string): Promise<Speak | null> {
return await this.db.getById(id);
}
// 获取所有 Speaks
async getAllSpeaks(): Promise<Speak[]> {
return await this.db.getAll();
}
// 按天获取 Speaks
async getSpeaksByDay(day: number): Promise<Speak[]> {
return await this.db.getByDay(day);
}
// 获取今天的 Speaks
async getTodaySpeaks(): Promise<Speak[]> {
const today = getDayOfYear();
return await this.getSpeaksByDay(today);
}
// 按说话人获取 Speaks
async getSpeaksBySpeaker(speaker: string): Promise<Speak[]> {
return await this.db.getBySpeaker(speaker);
}
// 按类型获取 Speaks
async getSpeaksByType(type: SpeakType): Promise<Speak[]> {
return await this.db.getByType(type);
}
// 按时间范围获取 Speaks
async getSpeaksByTimeRange(startTime: Date, endTime: Date): Promise<Speak[]> {
return await this.db.getByTimeRange(startTime, endTime);
}
// 获取最近几天的 Speaks
async getRecentSpeaks(days: number = 7): Promise<Speak[]> {
const endTime = new Date();
const startTime = new Date();
startTime.setDate(startTime.getDate() - days);
return await this.getSpeaksByTimeRange(startTime, endTime);
}
// 搜索 Speaks
async searchSpeaks(query: string): Promise<Speak[]> {
return await this.db.search(query);
}
// 更新 Speak
async updateSpeak(id: string, updates: UpdateSpeakData): Promise<Speak> {
// 自动设置更新时间
const updatesWithTime = {
...updates,
updatedAt: new Date()
};
return await this.db.update(id, updatesWithTime);
}
// 删除 Speak
async deleteSpeak(id: string): Promise<boolean> {
return await this.db.delete(id);
}
// 批量删除 Speaks
async deleteMultipleSpeaks(ids: string[]): Promise<boolean> {
return await this.db.deleteMultiple(ids);
}
// 按天删除 Speaks
async deleteSpeaksByDay(day: number): Promise<number> {
return await this.db.deleteByDay(day);
}
// 清空今天的 Speaks
async clearTodaySpeaks(): Promise<number> {
const today = getDayOfYear();
return await this.deleteSpeaksByDay(today);
}
// 分页获取 Speaks
async getSpeaksPaginated(
page: number = 1,
limit: number = 10,
filters?: {
day?: number;
speaker?: string;
type?: SpeakType;
startTime?: Date;
endTime?: Date;
}
) {
return await this.db.getPaginated(page, limit, filters);
}
// 获取统计信息
async getStats() {
return await this.db.getStats();
}
// 获取今天的统计信息
async getTodayStats() {
const today = getDayOfYear();
const todaySpeaks = await this.getSpeaksByDay(today);
const stats = {
total: todaySpeaks.length,
totalDuration: todaySpeaks.reduce((sum, speak) => sum + (speak.duration || 0), 0),
avgDuration: 0,
bySpeaker: {} as Record<string, number>,
byType: {} as Record<string, number>
};
// 计算平均时长
stats.avgDuration = stats.total > 0 ? stats.totalDuration / stats.total : 0;
// 统计说话人和类型
todaySpeaks.forEach(speak => {
if (speak.speaker) {
stats.bySpeaker[speak.speaker] = (stats.bySpeaker[speak.speaker] || 0) + 1;
}
if (speak.type) {
stats.byType[speak.type] = (stats.byType[speak.type] || 0) + 1;
}
});
return stats;
}
// 导出数据
async exportData(): Promise<Speak[]> {
return await this.getAllSpeaks();
}
// 导入数据
async importData(speaks: Speak[]): Promise<number> {
let importedCount = 0;
try {
for (const speak of speaks) {
const { id, createdAt, updatedAt, ...speakData } = speak;
await this.createSpeak(speakData);
importedCount++;
}
} catch (error) {
console.error('导入Speak数据失败:', error);
}
return importedCount;
}
// 导出数据到文件
async exportToFile(filename: string = 'speaks_backup.json'): Promise<void> {
try {
const speaks = await this.exportData();
const exportData = {
version: '1.0',
exportTime: new Date().toISOString(),
totalCount: speaks.length,
speaks: speaks
};
const jsonData = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
// 创建下载链接
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
console.log(`成功导出 ${speaks.length} 条语音记录到文件: ${filename}`);
} catch (error) {
console.error('导出语音文件失败:', error);
throw new Error('导出语音文件失败: ' + (error as Error).message);
}
}
// 从文件导入数据
async importFromFile(file: File): Promise<{ success: number; failed: number; total: number }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target?.result as string;
const importData = JSON.parse(content);
// 验证数据格式
if (!importData.speaks || !Array.isArray(importData.speaks)) {
throw new Error('无效的数据格式缺少speaks数组');
}
let successCount = 0;
let failedCount = 0;
const totalCount = importData.speaks.length;
// 批量导入数据
for (const speak of importData.speaks) {
try {
// 移除ID相关字段让系统重新生成
const { id, createdAt, updatedAt, _id, _rev, ...speakData } = speak;
await this.createSpeak(speakData);
successCount++;
} catch (error) {
console.warn('导入单条语音记录失败:', error);
failedCount++;
}
}
const result = {
success: successCount,
failed: failedCount,
total: totalCount
};
console.log(`语音导入完成: 成功${successCount}条,失败${failedCount}条,总计${totalCount}`);
resolve(result);
} catch (error) {
console.error('解析导入语音文件失败:', error);
reject(new Error('解析导入语音文件失败: ' + (error as Error).message));
}
};
reader.onerror = () => {
reject(new Error('读取语音文件失败'));
};
reader.readAsText(file);
});
}
// 清空数据库
async clearDatabase(): Promise<boolean> {
try {
await this.db.clear();
console.log('语音数据库已清空');
return true;
} catch (error) {
console.error('清空语音数据库失败:', error);
return false;
}
}
// 合并多个语音记录
async mergeSpeaks(speakIds: string[], mergedData: {
text?: string;
speaker?: string;
duration?: number;
}): Promise<Speak> {
try {
// 获取要合并的语音记录
const speaks = await Promise.all(
speakIds.map(id => this.getSpeak(id))
);
// 过滤掉不存在的记录
const validSpeaks = speaks.filter(speak => speak !== null) as Speak[];
if (validSpeaks.length === 0) {
throw new Error('没有找到有效的语音记录进行合并');
}
// 计算合并后的数据
const firstSpeak = validSpeaks[0];
const totalDuration = validSpeaks.reduce((sum, speak) => sum + (speak.duration || 0), 0);
const combinedText = validSpeaks
.map(speak => speak.text || '')
.filter(text => text.length > 0)
.join(' ');
// 创建合并后的记录
const mergedSpeak = await this.createSpeakAuto({
text: mergedData.text || combinedText,
speaker: mergedData.speaker || firstSpeak.speaker,
duration: mergedData.duration || totalDuration,
type: 'merge'
});
// 删除原始记录
await this.deleteMultipleSpeaks(speakIds);
return mergedSpeak;
} catch (error) {
console.error('合并语音记录失败:', error);
throw error;
}
}
// 获取指定时长范围的语音记录
async getSpeaksByDuration(minDuration: number, maxDuration?: number): Promise<Speak[]> {
const allSpeaks = await this.getAllSpeaks();
return allSpeaks.filter(speak => {
const duration = speak.duration || 0;
if (maxDuration !== undefined) {
return duration >= minDuration && duration <= maxDuration;
}
return duration >= minDuration;
});
}
// 获取短语音记录(小于指定时长)
async getShortSpeaks(maxDuration: number = 5): Promise<Speak[]> {
return await this.getSpeaksByDuration(0, maxDuration);
}
// 获取长语音记录(大于指定时长)
async getLongSpeaks(minDuration: number = 60): Promise<Speak[]> {
return await this.getSpeaksByDuration(minDuration);
}
}
// 创建默认服务实例
export const speakService = new SpeakService();
// 使用示例函数
export const exampleUsage = async () => {
// 1. 初始化服务
await speakService.init();
// 2. 创建新的语音记录
const newSpeak = await speakService.createSpeakAuto({
text: '这是一个测试语音记录',
duration: 30,
speaker: 'user1',
type: 'normal'
});
console.log('创建的语音记录:', newSpeak);
// 3. 获取今天的语音记录
const todaySpeaks = await speakService.getTodaySpeaks();
console.log('今天的语音记录数量:', todaySpeaks.length);
// 4. 搜索语音记录
const searchResults = await speakService.searchSpeaks('测试');
console.log('搜索结果数量:', searchResults.length);
// 5. 按说话人获取记录
const userSpeaks = await speakService.getSpeaksBySpeaker('user1');
console.log('user1的语音记录数量:', userSpeaks.length);
// 6. 分页获取记录
const paginatedResults = await speakService.getSpeaksPaginated(1, 5);
console.log('分页结果:', {
currentPage: paginatedResults.page,
totalPages: paginatedResults.totalPages,
total: paginatedResults.total,
speaksOnPage: paginatedResults.speaks.length
});
// 7. 获取统计信息
const stats = await speakService.getStats();
console.log('统计信息:', stats);
// 8. 获取今天的统计信息
const todayStats = await speakService.getTodayStats();
console.log('今天的统计信息:', todayStats);
};

View File

@@ -0,0 +1,32 @@
// Speak 类型定义
export type Speak = {
id: string;
no: number; // 序号, 当天的序号
file?: string; // base64 编码的音频文件
text?: string; // 文字内容,识别的内容
timestamp: Date; // 生成时间戳
day: number; // 365天中的第几天
duration: number; // 音频时长,单位秒
speaker?: string; // 说话人
type?: 'merge' | 'normal'; // 语音类型,默认录制或者合并的
createdAt?: Date; // 创建时间
updatedAt?: Date; // 更新时间
}
// 语音类型枚举
export type SpeakType = 'merge' | 'normal';
// 创建 Speak 时的数据类型(排除自动生成的字段)
export type CreateSpeakData = Omit<Speak, 'id' | 'createdAt' | 'updatedAt'>;
// 更新 Speak 时的数据类型
export type UpdateSpeakData = Partial<Omit<Speak, 'id' | 'createdAt'>>;
// 获取今天是一年中的第几天
export function getDayOfYear(date: Date = new Date()): number {
const start = new Date(date.getFullYear(), 0, 0);
const diff = date.getTime() - start.getTime();
const oneDay = 1000 * 60 * 60 * 24;
return Math.floor(diff / oneDay);
}

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"></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,8 @@
import { VadVoice } from './modules/VadVoice';
export const App = () => {
return <div className="h-full overflow-hidden">
<VadVoice />
</div>
}

View File

@@ -0,0 +1,449 @@
import { MicVAD, utils } from "@ricky0123/vad-web"
import clsx from "clsx";
import { useState, useEffect, useRef } from "react";
import './style.css'
import { MoreHorizontal, Play, Pause } from "lucide-react";
import { Menu, MenuItem, MenuButton, } from '@szhsin/react-menu';
import '@szhsin/react-menu/dist/index.css';
import { toast } from 'react-toastify';
import { Speak } from "./speak-db/speak";
import { useVoiceStore } from "../store/voiceStore";
type VadVoiceProps = {
data: Speak;
}
const VoicePlayer = ({ data }: VadVoiceProps) => {
const [isPlaying, setIsPlaying] = useState(false);
const [audio, setAudio] = useState<HTMLAudioElement | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
if (!data.url) return;
const audioInstance = new Audio(data.url);
audioRef.current = audioInstance;
setAudio(audioInstance);
// 播放结束时重置状态
const handleEnded = () => {
setIsPlaying(false);
};
audioInstance.addEventListener('ended', handleEnded);
return () => {
audioInstance.removeEventListener('ended', handleEnded);
audioInstance.pause();
audioInstance.src = '';
};
}, [data.url]);
const handlePlay = () => {
if (audio) {
audio.play();
setIsPlaying(true);
}
};
const handlePause = () => {
if (audio) {
audio.pause();
setIsPlaying(false);
}
};
const handleStop = () => {
if (audio) {
audio.pause();
audio.currentTime = 0;
setIsPlaying(false);
}
};
const handleDownload = () => {
if (!data.url) return;
const link = document.createElement('a');
link.href = data.url;
link.download = `recording-${data.timestamp}.wav`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleDelete = () => {
const { deleteVoice } = useVoiceStore.getState();
deleteVoice(data.id)
.then(() => {
console.log('语音记录删除成功');
})
.catch((error) => {
console.error('删除语音记录失败:', error);
toast.error('删除失败: ' + (error instanceof Error ? error.message : '未知错误'));
});
};
const handleRecognize = () => {
if (data.text && data.text.trim()) {
toast.info('该语音记录已经识别过了,文字内容:' + data.text);
return;
}
const { recognizeVoice } = useVoiceStore.getState();
recognizeVoice(data.id)
.then((text) => {
console.log('语音识别成功:', text);
toast.success('识别成功!文字内容:' + text);
})
.catch((error) => {
console.error('语音识别失败:', error);
toast.error('识别失败: ' + (error instanceof Error ? error.message : '未知错误'));
});
};
return (
<div className="flex items-center space-x-1">
{/* 工具菜单 */}
<Menu menuButton={
<MenuButton className="w-8 h-8 hover:bg-gray-400 rounded flex items-center justify-center text-blank cursor-pointer">
<MoreHorizontal className="w-4 h-4" />
</MenuButton>
}>
<MenuItem onClick={handleDownload}>
<div className="flex items-center space-x-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span></span>
</div>
</MenuItem>
<MenuItem onClick={handleRecognize}>
<div className="flex items-center space-x-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 1a3 3 0 0 1 3 3v8a3 3 0 0 1-6 0V4a3 3 0 0 1 3-3Z M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8"
/>
</svg>
<span></span>
</div>
</MenuItem>
<MenuItem onClick={handleDelete}>
<div className="flex items-center space-x-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1-1H8a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<span></span>
</div>
</MenuItem>
</Menu>
{/* 播放/暂停按钮 */}
{!isPlaying ? (
<button
onClick={handlePlay}
className="w-8 h-8 hover:bg-gray-200 rounded-full flex items-center justify-center text-gray-700 transition-colors cursor-pointer"
title="播放"
>
<Play className="w-4 h-4" />
</button>
) : (
<button
onClick={handlePause}
onDoubleClick={(e) => {
e.stopPropagation();
handleStop();
}}
className="w-8 h-8 hover:bg-gray-200 rounded-full flex items-center justify-center text-gray-700 transition-colors cursor-pointer"
title="暂停"
>
<Pause className="w-4 h-4" />
</button>
)}
<div className="text-xs text-gray-500">{data?.duration}s</div>
</div>
);
}
export const ShowVoicePlayer = ({ data }: { data: Speak[] }) => {
return (<ul className="space-y-2 max-h-full">
{data.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">
<VoicePlayer data={item} />
</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">
#{item.no}
</div>
{item.text && (
<div
className="text-xs text-gray-600 mt-1 truncate cursor-pointer hover:bg-gray-100 rounded px-1 transition-colors"
title={`${item.text} (点击复制)`}
onClick={async (e) => {
e.stopPropagation();
if (!item.text) return;
try {
await navigator.clipboard.writeText(item.text);
toast.success('文字已复制到剪贴板', { autoClose: 1000 });
} catch (error) {
console.error('复制失败:', error);
toast.error('复制失败,请手动选择文字复制');
}
}}
>
📝 {item.text}
</div>
)}
</div>
</div>
</div>
</li>
))}
</ul>)
}
export const VadVoice = () => {
// 使用 Zustand store
const {
voiceList,
isLoading: storeLoading,
error: storeError,
initialize: initializeStore,
addVoice,
setError: setStoreError
} = useVoiceStore();
const [listen, setListen] = useState<boolean>(false);
const [vadStatus, setVadStatus] = useState<'idle' | 'initializing' | 'ready' | 'error'>('idle');
const [realListen, setRealListen] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>('');
const [userInteracted, setUserInteracted] = useState<boolean>(false);
const ref = useRef<MicVAD | null>(null);
const initializingRef = useRef<boolean>(false);
async function initializeVAD() {
if (ref.current || initializingRef.current) return;
initializingRef.current = true;
setVadStatus('initializing');
setErrorMessage('');
try {
console.log('Starting VAD initialization...');
// 添加延迟确保资源加载完成
await new Promise((resolve) => setTimeout(resolve, 500));
const myvad = await MicVAD.new({
onSpeechEnd: async (audio) => {
try {
const wavBuffer = utils.encodeWAV(audio)
const audioBlob = new Blob([wavBuffer], { type: 'audio/wav' })
const tempUrl = URL.createObjectURL(audioBlob)
// 从实际音频文件获取准确时长
const getDuration = (): Promise<number> => {
return new Promise((resolve) => {
const tempAudio = new Audio(tempUrl);
tempAudio.addEventListener('loadedmetadata', () => {
resolve(tempAudio.duration);
URL.revokeObjectURL(tempUrl); // 清理临时 URL
});
});
};
const duration = await getDuration();
console.log(`Detected speech end. Duration: ${duration.toFixed(2)}s`);
// 使用 store 添加语音记录
await addVoice(tempUrl, duration, audioBlob);
setRealListen(false);
} catch (error) {
console.error('保存语音记录失败:', error);
setStoreError(error instanceof Error ? error.message : '保存语音失败');
setRealListen(false);
}
},
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/",
onSpeechRealStart: () => {
console.log('VAD real start');
setRealListen(true);
}
});
ref.current = myvad;
await myvad.start();
setListen(true);
setVadStatus('ready');
console.log('VAD initialized successfully');
} catch (error) {
console.error('Failed to initialize VAD:', error);
setListen(false);
setVadStatus('error');
setErrorMessage(error instanceof Error ? error.message : 'Unknown error occurred');
ref.current = null;
} finally {
initializingRef.current = false;
}
}
// 只在用户交互后才初始化 VAD
const handleUserInteraction = async () => {
if (!userInteracted) {
setUserInteracted(true);
await initializeVAD();
}
};
// 初始化 store
useEffect(() => {
initializeStore();
}, [initializeStore]);
useEffect(() => {
// 页面加载时不自动初始化,等待用户交互
const handleFirstClick = () => {
if (!userInteracted) {
handleUserInteraction();
document.removeEventListener('click', handleFirstClick);
}
};
document.addEventListener('click', handleFirstClick);
// handleUserInteraction()
return () => {
document.removeEventListener('click', handleFirstClick);
// 清理 VAD 资源
if (ref.current) {
ref.current.destroy();
ref.current = null;
}
};
}, [])
const close = () => {
if (ref.current) {
ref.current.destroy();
ref.current = null;
setListen(false);
setVadStatus('idle');
}
}
const handleStartStop = async () => {
if (listen) {
close();
} else {
if (!userInteracted) {
await handleUserInteraction();
} else {
await initializeVAD();
}
}
};
const retryInitialization = async () => {
if (ref.current) {
close();
}
await initializeVAD();
};
return <div className="h-full flex flex-col">
{/* Audio Recordings List */}
<div className="flex-1 overflow-y-auto px-2 py-3 min-h-0 max-h-200">
{!userInteracted && vadStatus === 'idle' ? (
<div className="text-center text-gray-400 text-sm py-8">
<div className="mb-2">🎤</div>
<div>Click anywhere to initialize microphone</div>
<div className="text-xs mt-1">Browser requires user interaction for microphone access</div>
</div>
) : voiceList.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>
) : (
<ShowVoicePlayer data={voiceList} />
)}
</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-blue-500 rounded-full animate-pulse"></div>
)}
{realListen && (
<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">
{vadStatus === 'initializing' || storeLoading ? 'Initializing...' :
vadStatus === 'error' || storeError ? 'Error' :
listen ? 'Listening...' : 'Paused'}
</div>
<div className="text-xs text-gray-500">
{(vadStatus === 'error' && errorMessage) || storeError ? (
<span className="text-red-500">{errorMessage || storeError}</span>
) : (
`${voiceList.length} recording${voiceList.length !== 1 ? 's' : ''}`
)}
</div>
</div>
</div>
<div className="flex items-center space-x-2">
{vadStatus === 'error' && (
<button
onClick={retryInitialization}
className="px-2 py-1 text-xs font-medium text-blue-700 bg-blue-100 hover:bg-blue-200 rounded-md transition-colors"
>
Retry
</button>
)}
<button
onClick={handleStartStop}
disabled={vadStatus === 'initializing'}
className={clsx(
"px-3 py-1.5 text-xs font-medium rounded-md transition-colors",
vadStatus === 'initializing' && "opacity-50 cursor-not-allowed",
listen
? "bg-red-100 text-red-700 hover:bg-red-200"
: "bg-green-100 text-green-700 hover:bg-green-200"
)}
>
{vadStatus === 'initializing' ? 'Initializing...' : (listen ? 'Stop' : 'Start')}
</button>
</div>
</div>
</div>
</div >
}

View File

@@ -0,0 +1,136 @@
// https://git.xiongxiao.me/kevisual/video-tools/raw/branch/main/src/asr/provider/volcengine/auc.ts
import { nanoid } from "nanoid"
export const FlashURL = "https://openspeech.bytedance.com/api/v3/auc/bigmodel/recognize/flash"
export const AsrBaseURL = 'https://openspeech.bytedance.com/api/v3/auc/bigmodel/submit'
export const AsrBase = 'volc.bigasr.auc'
export const AsrTurbo = 'volc.bigasr.auc_turbo'
const uuid = () => nanoid()
type AsrOptions = {
url?: string
appid?: string
token?: string
type?: AsrType
}
type AsrType = 'flash' | 'standard' | 'turbo'
export class Asr {
url: string = FlashURL
appid: string = ""
token: string = ""
type: AsrType = 'flash'
constructor(options: AsrOptions = {}) {
this.appid = options.appid || ""
this.token = options.token || ""
this.type = options.type || 'flash'
if (this.type !== 'flash') {
this.url = AsrBaseURL
}
if (!this.appid || !this.token) {
throw new Error("VOLCENGINE_Asr_APPID or VOLCENGINE_Asr_TOKEN is not set")
}
}
header() {
const model = this.type === 'flash' ? AsrTurbo : AsrBase
return {
"X-Api-App-Key": this.appid,
"X-Api-Access-Key": this.token,
"X-Api-Resource-Id": model,
"X-Api-Request-Id": uuid(),
"X-Api-Sequence": "-1",
}
}
submit(body: AsrRequest) {
if (!body.audio || (!body.audio.url && !body.audio.data)) {
throw new Error("audio.url or audio.data is required")
}
const data: AsrRequest = {
...body,
}
return fetch(this.url, { method: "POST", headers: this.header(), body: JSON.stringify(data) })
}
async getText(body: AsrRequest) {
const res = await this.submit(body)
return res.json()
}
}
export type AsrResponse = {
audio_info: {
/**
* 音频时长,单位为 ms
*/
duration: number;
};
result: {
additions: {
duration: string;
};
text: string;
utterances: Array<{
end_time: number;
start_time: number;
text: string;
words: Array<{
confidence: number;
end_time: number;
start_time: number;
text: string;
}>;
}>;
};
}
export interface AsrRequest {
user?: {
uid: string;
};
audio: {
url?: string;
data?: string;
format?: 'wav' | 'pcm' | 'mp3' | 'ogg';
codec?: 'raw' | 'opus'; // raw / opus默认为 raw(pcm) 。
rate?: 8000 | 16000; // 采样率,支持 8000 或 16000默认为 16000 。
channel?: 1 | 2; // 声道数,支持 1 或 2默认为 1。
};
request?: {
model_name?: string; // 识别模型名称,如 "bigmodel"
enable_words?: boolean; // 是否开启词级别时间戳,默认为 false。
enable_sentence_info?: boolean; // 是否开启句子级别时间戳,默认为 false。
enable_utterance_info?: boolean; // 是否开启语句级别时间戳,默认为 true。
enable_punctuation_prediction?: boolean; // 是否开启标点符号预测,默认为 true。
enable_inverse_text_normalization?: boolean; // 是否开启文本规范化,默认为 true。
enable_separate_recognition_per_channel?: boolean; // 是否开启声道分离识别,默认为 false。
audio_channel_count?: 1 | 2; // 音频声道数,仅在 enable_separate_recognition_per_channel 开启时有效,支持 1 或 2默认为 1。
max_sentence_silence?: number; // 句子最大静音时间,仅在 enable_sentence_info 开启时有效,单位为 ms默认为 800。
custom_words?: string[];
enable_channel_split?: boolean; // 是否开启声道分离
enable_ddc?: boolean; // 是否开启 DDC双通道降噪
enable_speaker_info?: boolean; // 是否开启说话人分离
enable_punc?: boolean; // 是否开启标点符号预测(简写)
enable_itn?: boolean; // 是否开启文本规范化(简写)
vad_segment?: boolean; // 是否开启 VAD 断句
show_utterances?: boolean; // 是否返回语句级别结果
corpus?: {
boosting_table_name?: string;
correct_table_name?: string;
context?: string;
};
};
}
// const main = async () => {
// const base64Audio = wavToBase64(audioPath);
// const auc = new Asr({
// appid: config.VOLCENGINE_AUC_APPID,
// token: config.VOLCENGINE_AUC_TOKEN,
// });
// const result = await auc.getText({ audio: { data: base64Audio } });
// console.log(util.inspect(result, { showHidden: false, depth: null, colors: true }))
// }
// main();

View File

@@ -0,0 +1,30 @@
export const getConfig = () => {
// 从localStorage获取配置如果不存在则使用默认值
const getFromLocalStorage = (key: string, defaultValue: string) => {
try {
return localStorage.getItem(key) || defaultValue;
} catch (error) {
console.warn(`Failed to read ${key} from localStorage:`, error);
return defaultValue;
}
};
return {
VOLCENGINE_AUC_APPID: getFromLocalStorage('VOLCENGINE_AUC_APPID', ''),
VOLCENGINE_AUC_TOKEN: getFromLocalStorage('VOLCENGINE_AUC_TOKEN', ''),
};
};
export const setConfig = (config: { VOLCENGINE_AUC_APPID?: string; VOLCENGINE_AUC_TOKEN?: string }) => {
// 将配置保存到localStorage
try {
if (config.VOLCENGINE_AUC_APPID !== undefined) {
localStorage.setItem('VOLCENGINE_AUC_APPID', config.VOLCENGINE_AUC_APPID);
}
if (config.VOLCENGINE_AUC_TOKEN !== undefined) {
localStorage.setItem('VOLCENGINE_AUC_TOKEN', config.VOLCENGINE_AUC_TOKEN);
}
} catch (error) {
console.warn('Failed to save config to localStorage:', error);
}
};

View File

@@ -0,0 +1,243 @@
# Speak Database Service
基于 PouchDB 的语音记录数据库服务,提供完整的增删改查功能。
## 功能特性
- 🎤 语音记录的完整 CRUD 操作
- 📊 按天、说话人、类型等多维度查询
- 🔍 全文搜索功能
- 📈 统计分析功能
- 🔄 数据导入导出
- 📱 离线支持(基于 IndexedDB
- 🔀 语音记录合并功能
## 快速开始
### 1. 初始化服务
```typescript
import { speakService } from './speak-db';
// 初始化服务
await speakService.init();
```
### 2. 创建语音记录
```typescript
// 自动创建(自动生成当天序号)
const speak = await speakService.createSpeakAuto({
text: '这是识别的文字内容',
duration: 30, // 时长(秒)
speaker: 'user1',
type: 'normal'
});
// 手动指定所有字段
const manualSpeak = await speakService.createSpeak({
no: 1,
day: 365,
timestamp: new Date(),
text: '手动创建的语音记录',
duration: 45,
speaker: 'user2',
type: 'merge'
});
```
### 3. 查询语音记录
```typescript
// 获取所有记录
const allSpeaks = await speakService.getAllSpeaks();
// 获取今天的记录
const todaySpeaks = await speakService.getTodaySpeaks();
// 按天查询
const dayData = await speakService.getSpeaksByDay(365);
// 按说话人查询
const userSpeaks = await speakService.getSpeaksBySpeaker('user1');
// 按类型查询
const normalSpeaks = await speakService.getSpeaksByType('normal');
// 时间范围查询
const recentSpeaks = await speakService.getRecentSpeaks(7); // 最近7天
// 搜索文本内容
const searchResults = await speakService.searchSpeaks('关键词');
// 分页查询
const paginatedData = await speakService.getSpeaksPaginated(1, 10, {
speaker: 'user1',
type: 'normal'
});
```
### 4. 更新和删除
```typescript
// 更新记录
await speakService.updateSpeak('speak_id', {
text: '更新后的文字内容',
speaker: 'new_speaker'
});
// 删除单个记录
await speakService.deleteSpeak('speak_id');
// 批量删除
await speakService.deleteMultipleSpeaks(['id1', 'id2']);
// 清空今天的记录
await speakService.clearTodaySpeaks();
```
### 5. 统计功能
```typescript
// 总体统计
const stats = await speakService.getStats();
console.log('总记录数:', stats.total);
console.log('总时长:', stats.totalDuration);
console.log('平均时长:', stats.avgDuration);
// 今天的统计
const todayStats = await speakService.getTodayStats();
console.log('今天的记录数:', todayStats.total);
```
### 6. 数据导入导出
```typescript
// 导出到文件
await speakService.exportToFile('speaks_backup.json');
// 从文件导入
const fileInput = document.getElementById('fileInput') as HTMLInputElement;
const file = fileInput.files[0];
const result = await speakService.importFromFile(file);
console.log(`导入完成: 成功${result.success}条,失败${result.failed}条`);
```
### 7. 高级功能
```typescript
// 合并多个语音记录
const mergedSpeak = await speakService.mergeSpeaks(
['speak1_id', 'speak2_id'],
{
text: '合并后的文字内容',
speaker: 'merged_speaker'
}
);
// 获取短语音记录小于5秒
const shortSpeaks = await speakService.getShortSpeaks(5);
// 获取长语音记录大于60秒
const longSpeaks = await speakService.getLongSpeaks(60);
// 按时长范围查询
const mediumSpeaks = await speakService.getSpeaksByDuration(10, 60);
```
## 数据结构
### Speak 类型
```typescript
type Speak = {
id: string; // 唯一标识
no: number; // 当天序号
file?: string; // base64编码的音频文件
text?: string; // 识别的文字内容
timestamp: Date; // 生成时间戳
day: number; // 一年中的第几天
duration: number; // 音频时长(秒)
speaker?: string; // 说话人
type?: 'merge' | 'normal'; // 类型:合并或普通
createdAt?: Date; // 创建时间
updatedAt?: Date; // 更新时间
}
```
### 过滤器选项
```typescript
interface SpeakFilters {
day?: number; // 按天过滤
speaker?: string; // 按说话人过滤
type?: 'merge' | 'normal'; // 按类型过滤
startTime?: Date; // 开始时间
endTime?: Date; // 结束时间
}
```
## 索引和性能
数据库自动创建以下索引以优化查询性能:
- `day` - 按天查询
- `speaker` - 按说话人查询
- `type` - 按类型查询
- `timestamp` - 按时间查询
- `day + no` - 复合索引,按天内序号查询
- `timestamp + day` - 复合索引,时间和天的组合查询
## 注意事项
1. **时间处理**: 系统使用 `getDayOfYear()` 函数计算一年中的第几天
2. **序号管理**: 使用 `createSpeakAuto()` 可以自动生成当天的序号
3. **离线支持**: 基于 PouchDB支持离线使用
4. **数据同步**: 支持与远程数据库同步
5. **错误处理**: 所有操作都包含完整的错误处理
## 依赖
- `pouchdb-browser`: PouchDB 的浏览器版本
- `pouchdb-find`: 查询插件(可选,用于优化查询性能)
## 示例应用
```typescript
import { speakService } from './speak-db';
class VoiceRecorderApp {
async init() {
await speakService.init();
}
async recordVoice(audioBlob: Blob, text: string) {
// 将音频转换为 base64
const base64Audio = await this.blobToBase64(audioBlob);
// 创建语音记录
const speak = await speakService.createSpeakAuto({
file: base64Audio,
text: text,
duration: audioBlob.size / 1000, // 估算时长
speaker: 'current_user',
type: 'normal'
});
return speak;
}
async getTodayRecordings() {
return await speakService.getTodaySpeaks();
}
private async blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
}
```

View File

@@ -0,0 +1,26 @@
// Speak 数据库和服务的统一导出文件
// 类型定义
export {
Speak,
SpeakType,
CreateSpeakData,
UpdateSpeakData,
getDayOfYear
} from './speak';
// 数据库操作
export {
SpeakDB,
SpeakDocument,
speakDB,
initSpeakDB,
createSpeakDB
} from './speak-db';
// 服务层
export {
SpeakService,
speakService,
exampleUsage
} from './speak-service';

View File

@@ -0,0 +1,565 @@
import PouchDB from 'pouchdb-browser';
import { Speak } from './speak';
// 扩展 Speak 类型以包含 PouchDB 特有字段
export type SpeakDocument = Speak & {
_id: string;
_rev?: string;
};
// 创建或获取数据库实例
export const createSpeakDB = (name: string = 'speaks_db', opts?: { adapter?: string }) => {
return new PouchDB(name);
};
// 辅助函数:将 PouchDB 文档转换为 Speak 对象
const docToSpeak = (doc: any): Speak => {
const { _id, _rev, ...speak } = doc as SpeakDocument;
return speak;
};
// Speak 数据库操作类
export class SpeakDB {
private db: PouchDB.Database;
constructor(dbName: string = 'speaks_db') {
this.db = createSpeakDB(dbName);
}
// 检查是否支持 find 方法
private supportsFindAPI(): boolean {
return typeof this.db.find === 'function';
}
// 回退方案:使用 allDocs 过滤数据
private async fallbackFind(filterFn: (doc: Speak) => boolean): Promise<Speak[]> {
const allDocs = await this.getAll();
return allDocs.filter(filterFn).sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
}
// 创建索引以支持查询
async createIndexes() {
if (!this.db) {
throw new Error('数据库未初始化');
}
// 检查是否支持 createIndex 方法 (需要 pouchdb-find 插件)
if (typeof this.db.createIndex !== 'function') {
console.warn('PouchDB Find plugin not available. Skipping index creation.');
console.warn('Some query features may not work optimally without indexes.');
return;
}
try {
// PouchDB 创建索引的正确方式
const indexes = [
{ index: { fields: ['day'] } },
{ index: { fields: ['no'] } },
{ index: { fields: ['timestamp'] } },
{ index: { fields: ['speaker'] } },
{ index: { fields: ['type'] } },
{ index: { fields: ['duration'] } },
{ index: { fields: ['day', 'no'] } },
{ index: { fields: ['timestamp', 'day'] } },
{ index: { fields: ['speaker', 'day'] } }
];
const results = await Promise.allSettled(
indexes.map(indexDef => this.db.createIndex(indexDef))
);
// 检查索引创建结果
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Speak索引 ${index + 1} 创建成功:`, result.value);
} else {
// 如果索引已存在PouchDB 会返回错误,这是正常的
if (result.reason?.status !== 409) {
console.warn(`Speak索引 ${index + 1} 创建失败:`, result.reason);
}
}
});
console.log('Speak索引初始化完成');
} catch (error) {
console.error('创建Speak索引失败:', error);
// 不再抛出错误,而是警告用户
console.warn('Speak索引创建失败但数据库可以继续使用性能可能受影响');
}
}
// 创建 Speak
async create(speak: Omit<Speak, 'id'>): Promise<Speak> {
const newSpeak: Speak = {
...speak,
id: this.generateId()
};
try {
const doc: SpeakDocument = {
...newSpeak,
_id: newSpeak.id
};
const response = await this.db.put(doc);
return { ...newSpeak };
} catch (error) {
console.error('创建 Speak 失败:', error);
throw error;
}
}
// 根据 ID 获取 Speak
async getById(id: string): Promise<Speak | null> {
try {
const doc = await this.db.get(id);
return docToSpeak(doc);
} catch (error: any) {
if (error.status === 404) {
return null;
}
console.error('获取 Speak 失败:', error);
throw error;
}
}
// 获取所有 Speaks
async getAll(): Promise<Speak[]> {
try {
const result = await this.db.allDocs({
include_docs: true,
attachments: false
});
return result.rows
.map(row => docToSpeak(row.doc))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
} catch (error) {
console.error('获取所有 Speaks 失败:', error);
throw error;
}
}
// 按天获取 Speaks
async getByDay(day: number): Promise<Speak[]> {
try {
if (this.supportsFindAPI()) {
const result = await this.db.find({
selector: {
day: day
},
sort: [{ no: 'asc' }]
});
return result.docs.map(doc => docToSpeak(doc));
} else {
// 回退方案:使用 allDocs 过滤
return await this.fallbackFind((speak: Speak) => speak.day === day);
}
} catch (error) {
console.error('按天获取 Speaks 失败:', error);
throw error;
}
}
// 按说话人获取 Speaks
async getBySpeaker(speaker: string): Promise<Speak[]> {
try {
if (this.supportsFindAPI()) {
const result = await this.db.find({
selector: {
speaker: speaker
},
sort: [{ timestamp: 'desc' }]
});
return result.docs.map(doc => docToSpeak(doc));
} else {
// 回退方案:使用 allDocs 过滤
return await this.fallbackFind((speak: Speak) => speak.speaker === speaker);
}
} catch (error) {
console.error('按说话人获取 Speaks 失败:', error);
throw error;
}
}
// 按类型获取 Speaks
async getByType(type: 'merge' | 'normal'): Promise<Speak[]> {
try {
if (this.supportsFindAPI()) {
const result = await this.db.find({
selector: {
type: type
},
sort: [{ timestamp: 'desc' }]
});
return result.docs.map(doc => docToSpeak(doc));
} else {
// 回退方案:使用 allDocs 过滤
return await this.fallbackFind((speak: Speak) => speak.type === type);
}
} catch (error) {
console.error('按类型获取 Speaks 失败:', error);
throw error;
}
}
// 按时间范围获取 Speaks
async getByTimeRange(startTime: Date, endTime: Date): Promise<Speak[]> {
try {
if (this.supportsFindAPI()) {
const result = await this.db.find({
selector: {
timestamp: {
$gte: startTime,
$lte: endTime
}
},
sort: [{ timestamp: 'desc' }]
});
return result.docs.map(doc => docToSpeak(doc));
} else {
// 回退方案:使用 allDocs 过滤
return await this.fallbackFind((speak: Speak) => {
const speakTime = new Date(speak.timestamp);
return speakTime >= startTime && speakTime <= endTime;
});
}
} catch (error) {
console.error('按时间范围获取 Speaks 失败:', error);
throw error;
}
}
// 搜索 Speaks按文字内容
async search(query: string): Promise<Speak[]> {
try {
if (this.supportsFindAPI()) {
const result = await this.db.find({
selector: {
text: { $regex: query, $options: 'i' }
},
sort: [{ timestamp: 'desc' }]
});
return result.docs.map(doc => docToSpeak(doc));
} else {
// 回退方案:使用 allDocs 过滤,简单的字符串匹配
const lowerQuery = query.toLowerCase();
return await this.fallbackFind((speak: Speak) => {
const text = speak.text?.toLowerCase() || '';
return text.includes(lowerQuery);
});
}
} catch (error) {
console.error('搜索 Speaks 失败:', error);
throw error;
}
}
// 更新 Speak
async update(id: string, updates: Partial<Omit<Speak, 'id'>>): Promise<Speak> {
try {
const existingDoc = await this.db.get(id);
const existingSpeak = docToSpeak(existingDoc);
const updatedSpeak: Speak = {
...existingSpeak,
...updates
};
const doc: SpeakDocument = {
...updatedSpeak,
_id: id,
_rev: existingDoc._rev
};
await this.db.put(doc);
return updatedSpeak;
} catch (error) {
console.error('更新 Speak 失败:', error);
throw error;
}
}
// 删除 Speak
async delete(id: string): Promise<boolean> {
try {
const doc = await this.db.get(id);
await this.db.remove(doc);
return true;
} catch (error) {
console.error('删除 Speak 失败:', error);
throw error;
}
}
// 批量删除 Speaks
async deleteMultiple(ids: string[]): Promise<boolean> {
try {
const docs = await Promise.all(ids.map(id => this.db.get(id)));
const responses = await Promise.all(
docs.map(doc => this.db.remove(doc))
);
return responses.every(response => response.ok);
} catch (error) {
console.error('批量删除 Speaks 失败:', error);
throw error;
}
}
// 按天删除 Speaks
async deleteByDay(day: number): Promise<number> {
try {
const speaks = await this.getByDay(day);
const ids = speaks.map(speak => speak.id);
await this.deleteMultiple(ids);
return ids.length;
} catch (error) {
console.error('按天删除 Speaks 失败:', error);
throw error;
}
}
// 分页获取 Speaks
async getPaginated(page: number = 1, limit: number = 10, filters?: {
day?: number;
speaker?: string;
type?: 'merge' | 'normal';
startTime?: Date;
endTime?: Date;
}): Promise<{
speaks: Speak[];
total: number;
page: number;
limit: number;
totalPages: number;
}> {
try {
if (this.supportsFindAPI()) {
// 使用 find API
let selector: any = {};
if (filters?.day !== undefined) {
selector.day = filters.day;
}
if (filters?.speaker) {
selector.speaker = filters.speaker;
}
if (filters?.type) {
selector.type = filters.type;
}
if (filters?.startTime || filters?.endTime) {
selector.timestamp = {};
if (filters.startTime) {
selector.timestamp.$gte = filters.startTime;
}
if (filters.endTime) {
selector.timestamp.$lte = filters.endTime;
}
}
// 获取总数
const countResult = await this.db.find({
selector,
fields: []
});
const total = countResult.docs.length;
// 计算分页
const skip = (page - 1) * limit;
const totalPages = Math.ceil(total / limit);
// 获取数据
const result = await this.db.find({
selector,
sort: [{ timestamp: 'desc' }],
skip,
limit
});
return {
speaks: result.docs.map(doc => docToSpeak(doc)),
total,
page,
limit,
totalPages
};
} else {
// 回退方案:获取所有数据后在内存中分页
let allSpeaks = await this.getAll();
// 应用过滤器
if (filters) {
allSpeaks = allSpeaks.filter(speak => {
let matches = true;
if (filters.day !== undefined && speak.day !== filters.day) {
matches = false;
}
if (filters.speaker && speak.speaker !== filters.speaker) {
matches = false;
}
if (filters.type && speak.type !== filters.type) {
matches = false;
}
if (filters.startTime || filters.endTime) {
const speakTime = new Date(speak.timestamp);
if (filters.startTime && speakTime < filters.startTime) {
matches = false;
}
if (filters.endTime && speakTime > filters.endTime) {
matches = false;
}
}
return matches;
});
}
const total = allSpeaks.length;
const totalPages = Math.ceil(total / limit);
const skip = (page - 1) * limit;
const speaks = allSpeaks.slice(skip, skip + limit);
return {
speaks,
total,
page,
limit,
totalPages
};
}
} catch (error) {
console.error('分页获取 Speaks 失败:', error);
throw error;
}
}
// 获取统计信息
async getStats(): Promise<{
total: number;
totalDuration: number;
avgDuration: number;
byDay: Record<number, number>;
bySpeaker: Record<string, number>;
byType: Record<string, number>;
recentActivity: number;
}> {
try {
const speaks = await this.getAll();
const stats = {
total: speaks.length,
totalDuration: 0,
avgDuration: 0,
byDay: {} as Record<number, number>,
bySpeaker: {} as Record<string, number>,
byType: {} as Record<string, number>,
recentActivity: 0
};
// 统计总时长和各种分组
speaks.forEach(speak => {
stats.totalDuration += speak.duration || 0;
// 按天统计
stats.byDay[speak.day] = (stats.byDay[speak.day] || 0) + 1;
// 按说话人统计
if (speak.speaker) {
stats.bySpeaker[speak.speaker] = (stats.bySpeaker[speak.speaker] || 0) + 1;
}
// 按类型统计
if (speak.type) {
stats.byType[speak.type] = (stats.byType[speak.type] || 0) + 1;
}
});
// 计算平均时长
stats.avgDuration = stats.total > 0 ? stats.totalDuration / stats.total : 0;
// 统计最近7天的活动
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
stats.recentActivity = speaks.filter(speak =>
new Date(speak.timestamp) > weekAgo
).length;
return stats;
} catch (error) {
console.error('获取Speak统计信息失败:', error);
throw error;
}
}
// 获取当天的下一个序号
async getNextNo(day: number): Promise<number> {
try {
const todaySpeaks = await this.getByDay(day);
const maxNo = todaySpeaks.reduce((max, speak) => Math.max(max, speak.no), 0);
return maxNo + 1;
} catch (error) {
console.error('获取下一个序号失败:', error);
throw error;
}
}
// 同步数据库(用于远程同步)
async sync(remoteDB: string | PouchDB.Database): Promise<void> {
try {
const remote = typeof remoteDB === 'string' ? new PouchDB(remoteDB) : remoteDB;
await this.db.sync(remote).on('complete', () => {
console.log('Speak同步完成');
}).on('error', (err) => {
console.error('Speak同步错误:', err);
});
} catch (error) {
console.error('Speak同步失败:', error);
throw error;
}
}
// 清理数据库
async clear(): Promise<void> {
try {
const dbName = this.db.name;
await this.db.destroy();
this.db = createSpeakDB(dbName);
await this.createIndexes();
} catch (error) {
console.error('清理Speak数据库失败:', error);
throw error;
}
}
// 生成唯一ID
private generateId(): string {
return `speak_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
// 关闭数据库连接
async close(): Promise<void> {
try {
await this.db.close();
} catch (error) {
console.error('关闭Speak数据库失败:', error);
throw error;
}
}
}
// 创建默认实例
export const speakDB = new SpeakDB();
// 初始化数据库
export const initSpeakDB = async () => {
await speakDB.createIndexes();
return speakDB;
};

View File

@@ -0,0 +1,399 @@
import { speakDB, initSpeakDB } from './speak-db';
import { Speak, CreateSpeakData, UpdateSpeakData, SpeakType, getDayOfYear } from './speak';
// Speak 服务类 - 提供业务逻辑层
export class SpeakService {
private db = speakDB;
// 初始化服务
async init() {
await initSpeakDB();
}
// 创建新的 Speak
async createSpeak(speakData: CreateSpeakData): Promise<Speak> {
// 自动设置创建和更新时间
const now = new Date();
const completeData = {
...speakData,
createdAt: now,
updatedAt: now
};
return await this.db.create(completeData);
}
// 创建新的 Speak自动获取当天序号
async createSpeakAuto(speakData: Omit<CreateSpeakData, 'no' | 'day' | 'timestamp'>): Promise<Speak> {
const today = new Date();
const day = getDayOfYear(today);
const no = await this.db.getNextNo(day);
return await this.createSpeak({
...speakData,
day,
no,
timestamp: today.getTime()
});
}
// 根据 ID 获取 Speak
async getSpeak(id: string): Promise<Speak | null> {
return await this.db.getById(id);
}
// 获取所有 Speaks
async getAllSpeaks(): Promise<Speak[]> {
return await this.db.getAll();
}
// 按天获取 Speaks
async getSpeaksByDay(day: number): Promise<Speak[]> {
return await this.db.getByDay(day);
}
// 获取今天的 Speaks
async getTodaySpeaks(): Promise<Speak[]> {
const today = getDayOfYear();
return await this.getSpeaksByDay(today);
}
// 按说话人获取 Speaks
async getSpeaksBySpeaker(speaker: string): Promise<Speak[]> {
return await this.db.getBySpeaker(speaker);
}
// 按类型获取 Speaks
async getSpeaksByType(type: SpeakType): Promise<Speak[]> {
return await this.db.getByType(type);
}
// 按时间范围获取 Speaks
async getSpeaksByTimeRange(startTime: Date, endTime: Date): Promise<Speak[]> {
return await this.db.getByTimeRange(startTime, endTime);
}
// 获取最近几天的 Speaks
async getRecentSpeaks(days: number = 7): Promise<Speak[]> {
const endTime = new Date();
const startTime = new Date();
startTime.setDate(startTime.getDate() - days);
return await this.getSpeaksByTimeRange(startTime, endTime);
}
// 搜索 Speaks
async searchSpeaks(query: string): Promise<Speak[]> {
return await this.db.search(query);
}
// 更新 Speak
async updateSpeak(id: string, updates: UpdateSpeakData): Promise<Speak> {
// 自动设置更新时间
const updatesWithTime = {
...updates,
updatedAt: new Date()
};
return await this.db.update(id, updatesWithTime);
}
// 删除 Speak
async deleteSpeak(id: string): Promise<boolean> {
return await this.db.delete(id);
}
// 批量删除 Speaks
async deleteMultipleSpeaks(ids: string[]): Promise<boolean> {
return await this.db.deleteMultiple(ids);
}
// 按天删除 Speaks
async deleteSpeaksByDay(day: number): Promise<number> {
return await this.db.deleteByDay(day);
}
// 清空今天的 Speaks
async clearTodaySpeaks(): Promise<number> {
const today = getDayOfYear();
return await this.deleteSpeaksByDay(today);
}
// 分页获取 Speaks
async getSpeaksPaginated(
page: number = 1,
limit: number = 10,
filters?: {
day?: number;
speaker?: string;
type?: SpeakType;
startTime?: Date;
endTime?: Date;
}
) {
return await this.db.getPaginated(page, limit, filters);
}
// 获取统计信息
async getStats() {
return await this.db.getStats();
}
// 获取今天的统计信息
async getTodayStats() {
const today = getDayOfYear();
const todaySpeaks = await this.getSpeaksByDay(today);
const stats = {
total: todaySpeaks.length,
totalDuration: todaySpeaks.reduce((sum, speak) => sum + (speak.duration || 0), 0),
avgDuration: 0,
bySpeaker: {} as Record<string, number>,
byType: {} as Record<string, number>
};
// 计算平均时长
stats.avgDuration = stats.total > 0 ? stats.totalDuration / stats.total : 0;
// 统计说话人和类型
todaySpeaks.forEach(speak => {
if (speak.speaker) {
stats.bySpeaker[speak.speaker] = (stats.bySpeaker[speak.speaker] || 0) + 1;
}
if (speak.type) {
stats.byType[speak.type] = (stats.byType[speak.type] || 0) + 1;
}
});
return stats;
}
// 导出数据
async exportData(): Promise<Speak[]> {
return await this.getAllSpeaks();
}
// 导入数据
async importData(speaks: Speak[]): Promise<number> {
let importedCount = 0;
try {
for (const speak of speaks) {
const { id, createdAt, updatedAt, ...speakData } = speak;
await this.createSpeak(speakData);
importedCount++;
}
} catch (error) {
console.error('导入Speak数据失败:', error);
}
return importedCount;
}
// 导出数据到文件
async exportToFile(filename: string = 'speaks_backup.json'): Promise<void> {
try {
const speaks = await this.exportData();
const exportData = {
version: '1.0',
exportTime: new Date().toISOString(),
totalCount: speaks.length,
speaks: speaks
};
const jsonData = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
// 创建下载链接
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
console.log(`成功导出 ${speaks.length} 条语音记录到文件: ${filename}`);
} catch (error) {
console.error('导出语音文件失败:', error);
throw new Error('导出语音文件失败: ' + (error as Error).message);
}
}
// 从文件导入数据
async importFromFile(file: File): Promise<{ success: number; failed: number; total: number }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target?.result as string;
const importData = JSON.parse(content);
// 验证数据格式
if (!importData.speaks || !Array.isArray(importData.speaks)) {
throw new Error('无效的数据格式缺少speaks数组');
}
let successCount = 0;
let failedCount = 0;
const totalCount = importData.speaks.length;
// 批量导入数据
for (const speak of importData.speaks) {
try {
// 移除ID相关字段让系统重新生成
const { id, createdAt, updatedAt, _id, _rev, ...speakData } = speak;
await this.createSpeak(speakData);
successCount++;
} catch (error) {
console.warn('导入单条语音记录失败:', error);
failedCount++;
}
}
const result = {
success: successCount,
failed: failedCount,
total: totalCount
};
console.log(`语音导入完成: 成功${successCount}条,失败${failedCount}条,总计${totalCount}`);
resolve(result);
} catch (error) {
console.error('解析导入语音文件失败:', error);
reject(new Error('解析导入语音文件失败: ' + (error as Error).message));
}
};
reader.onerror = () => {
reject(new Error('读取语音文件失败'));
};
reader.readAsText(file);
});
}
// 清空数据库
async clearDatabase(): Promise<boolean> {
try {
await this.db.clear();
console.log('语音数据库已清空');
return true;
} catch (error) {
console.error('清空语音数据库失败:', error);
return false;
}
}
// 合并多个语音记录
async mergeSpeaks(speakIds: string[], mergedData: {
text?: string;
speaker?: string;
duration?: number;
}): Promise<Speak> {
try {
// 获取要合并的语音记录
const speaks = await Promise.all(
speakIds.map(id => this.getSpeak(id))
);
// 过滤掉不存在的记录
const validSpeaks = speaks.filter(speak => speak !== null) as Speak[];
if (validSpeaks.length === 0) {
throw new Error('没有找到有效的语音记录进行合并');
}
// 计算合并后的数据
const firstSpeak = validSpeaks[0];
const totalDuration = validSpeaks.reduce((sum, speak) => sum + (speak.duration || 0), 0);
const combinedText = validSpeaks
.map(speak => speak.text || '')
.filter(text => text.length > 0)
.join(' ');
// 创建合并后的记录
const mergedSpeak = await this.createSpeakAuto({
text: mergedData.text || combinedText,
speaker: mergedData.speaker || firstSpeak.speaker,
duration: mergedData.duration || totalDuration,
type: 'merge'
});
// 删除原始记录
await this.deleteMultipleSpeaks(speakIds);
return mergedSpeak;
} catch (error) {
console.error('合并语音记录失败:', error);
throw error;
}
}
// 获取指定时长范围的语音记录
async getSpeaksByDuration(minDuration: number, maxDuration?: number): Promise<Speak[]> {
const allSpeaks = await this.getAllSpeaks();
return allSpeaks.filter(speak => {
const duration = speak.duration || 0;
if (maxDuration !== undefined) {
return duration >= minDuration && duration <= maxDuration;
}
return duration >= minDuration;
});
}
// 获取短语音记录(小于指定时长)
async getShortSpeaks(maxDuration: number = 5): Promise<Speak[]> {
return await this.getSpeaksByDuration(0, maxDuration);
}
// 获取长语音记录(大于指定时长)
async getLongSpeaks(minDuration: number = 60): Promise<Speak[]> {
return await this.getSpeaksByDuration(minDuration);
}
}
// 创建默认服务实例
export const speakService = new SpeakService();
// 使用示例函数
export const exampleUsage = async () => {
// 1. 初始化服务
await speakService.init();
// 2. 创建新的语音记录
const newSpeak = await speakService.createSpeakAuto({
text: '这是一个测试语音记录',
duration: 30,
speaker: 'user1',
type: 'normal'
});
console.log('创建的语音记录:', newSpeak);
// 3. 获取今天的语音记录
const todaySpeaks = await speakService.getTodaySpeaks();
console.log('今天的语音记录数量:', todaySpeaks.length);
// 4. 搜索语音记录
const searchResults = await speakService.searchSpeaks('测试');
console.log('搜索结果数量:', searchResults.length);
// 5. 按说话人获取记录
const userSpeaks = await speakService.getSpeaksBySpeaker('user1');
console.log('user1的语音记录数量:', userSpeaks.length);
// 6. 分页获取记录
const paginatedResults = await speakService.getSpeaksPaginated(1, 5);
console.log('分页结果:', {
currentPage: paginatedResults.page,
totalPages: paginatedResults.totalPages,
total: paginatedResults.total,
speaksOnPage: paginatedResults.speaks.length
});
// 7. 获取统计信息
const stats = await speakService.getStats();
console.log('统计信息:', stats);
// 8. 获取今天的统计信息
const todayStats = await speakService.getTodayStats();
console.log('今天的统计信息:', todayStats);
};

View File

@@ -0,0 +1,41 @@
import { nanoid } from "nanoid";
// Speak 类型定义
export type Speak = {
id: string;
no: number; // 序号, 当天的序号
file?: string; // base64 编码的音频文件
text?: string; // 文字内容,识别的内容
timestamp: number; // 生成时间戳
day: number; // 365天中的第几天
duration: number; // 音频时长,单位秒
url?: string; // 音频文件的 URL 地址
speaker?: string; // 说话人
type?: 'merge' | 'normal'; // 语音类型,默认录制或者合并的
createdAt?: Date; // 创建时间
updatedAt?: Date; // 更新时间
}
// 语音类型枚举
export type SpeakType = 'merge' | 'normal';
// 创建 Speak 时的数据类型(排除自动生成的字段)
export type CreateSpeakData = Omit<Speak, 'id' | 'createdAt' | 'updatedAt'>;
// 更新 Speak 时的数据类型
export type UpdateSpeakData = Partial<Omit<Speak, 'id' | 'createdAt'>>;
// 获取今天是一年中的第几天
export function getDayOfYear(date: Date = new Date()): number {
const start = new Date(date.getFullYear(), 0, 0);
const diff = date.getTime() - start.getTime();
const oneDay = 1000 * 60 * 60 * 24;
return Math.floor(diff / oneDay);
}
// 一天是86400秒获取是当天第几秒
export const getNo = () => {
const now = new Date();
const secondsSinceMidnight = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
return secondsSinceMidnight;
}

View File

@@ -0,0 +1,5 @@
@import 'tailwindcss';
.low-energy-spin {
animation: 2.5s linear 0s infinite normal forwards running spin;
}

View File

@@ -0,0 +1,41 @@
import { Asr, type AsrRequest, type AsrResponse } from './auc.ts';
import { getConfig, setConfig } from './config.ts';
export const getText = async (base64Data: string) => {
const config = getConfig();
const VOLCENGINE_AUC_APPID = config.VOLCENGINE_AUC_APPID;
const VOLCENGINE_AUC_TOKEN = config.VOLCENGINE_AUC_TOKEN;
// 配置ASR请求
// 注意这里需要提供真实的API密钥
const asr = new Asr({
appid: VOLCENGINE_AUC_APPID, // 请替换为真实的APP ID
token: VOLCENGINE_AUC_TOKEN, // 请替换为真实的ACCESS TOKEN
type: 'flash' // 使用flash模式进行快速识别
});
const asrRequest: AsrRequest = {
audio: {
data: base64Data,
format: 'wav' as any,
rate: 16000,
channel: 1
},
request: {
enable_words: true,
enable_sentence_info: true,
enable_utterance_info: true,
enable_punctuation_prediction: true,
enable_inverse_text_normalization: true
}
};
// 调用ASR API
const response: AsrResponse = await asr.getText(asrRequest);
return {
text: response.result?.text || '',
response
}
}

View File

@@ -0,0 +1,108 @@
# Voice Store 使用说明
这个 Zustand store 管理语音记录列表,实现了以下功能:
## 主要特性
1. **本地存储**: 使用 IndexedDB 永久保存语音数据
2. **当天记录**: 自动获取和过滤当天的语音记录
3. **音频 URL 管理**: 将 base64 数据转换为 Blob URL 供播放使用
4. **不保存 URL**: 保存时不存储 URL 地址,避免无效引用
5. **自动清理**: 页面卸载时自动释放 Blob URL 资源
## 使用方法
### 基本使用
```typescript
import { useVoiceStore } from '../store/voiceStore';
const MyComponent = () => {
const {
voiceList,
isLoading,
error,
initialize,
addVoice,
updateVoice,
deleteVoice
} = useVoiceStore();
// 初始化(通常在组件挂载时调用)
useEffect(() => {
initialize();
}, [initialize]);
// ... 组件逻辑
};
```
### 添加语音记录
```typescript
// 通过 Blob 添加(推荐方式)
const audioBlob = new Blob([wavBuffer], { type: 'audio/wav' });
const newRecord = await addVoice(temporaryUrl, duration, audioBlob);
// 仅通过 URL 添加
const newRecord = await addVoice(url, duration);
```
### 更新记录
```typescript
await updateVoice(recordId, {
text: "识别的文字内容",
speaker: "说话人名称"
});
```
### 删除记录
```typescript
await deleteVoice(recordId);
```
## 数据流程
1. **初始化**: `initialize()` 从 IndexedDB 获取当天记录
2. **生成 URL**: 为每条记录的 base64 数据生成 Blob URL
3. **添加记录**: `addVoice()` 将音频转为 base64 保存到 IndexedDB
4. **播放**: 组件使用生成的 Blob URL 播放音频
5. **清理**: 页面卸载时释放所有 Blob URL
## 存储策略
- **IndexedDB**: 存储 base64 编码的音频数据、文本、元数据
- **内存**: 临时存储 Blob URL 用于播放
- **不存储**: URL 地址不会保存到 IndexedDB
## 注意事项
1. Blob URL 仅在当前会话有效,刷新页面后会重新生成
2. 大量语音记录可能占用较多内存,建议定期清理旧记录
3. 初始化时会自动过滤当天记录,跨天使用需要重新初始化
4. 组件卸载时会自动清理资源,无需手动管理
## 错误处理
Store 提供了错误状态管理:
```typescript
const { error, setError } = useVoiceStore();
// 检查错误状态
if (error) {
console.error('语音操作失败:', error);
}
// 清除错误
setError(null);
```
## 性能优化
- 使用 Zustand 的 devtools 进行调试
- Blob URL 按需生成,避免内存浪费
- IndexedDB 操作异步执行,不阻塞 UI
- 批量操作支持,提高大量数据处理效率

View File

@@ -0,0 +1,3 @@
// Store exports
export { useVoiceStore, useVoiceList, useVoiceLoading, useVoiceError, cleanupVoiceUrls } from './voiceStore';
export type { VoiceState } from './voiceStore';

View File

@@ -0,0 +1,350 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { Speak, getDayOfYear, CreateSpeakData } from '../modules/speak-db/speak';
import { speakService } from '../modules/speak-db/speak-service';
import { getText } from '../modules/text';
interface VoiceState {
// 状态数据
voiceList: Speak[];
isLoading: boolean;
error: string | null;
currentDay: number;
// 动作方法
initialize: () => Promise<void>;
addVoice: (url: string, duration: number, audioBlob?: Blob) => Promise<Speak>;
updateVoice: (id: string, updates: Partial<Speak>) => Promise<void>;
deleteVoice: (id: string) => Promise<void>;
recognizeVoice: (id: string) => Promise<string>;
clearTodayVoices: () => Promise<void>;
generateAudioUrls: () => Promise<void>;
refreshList: () => Promise<void>;
setError: (error: string | null) => void;
setLoading: (loading: boolean) => void;
}
// 辅助函数:将 Blob 转换为 base64 字符串
const blobToBase64 = (blob: Blob): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// 移除 data URL 前缀,只保留 base64 数据
resolve(result.split(',')[1] || result);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
};
// 辅助函数:将 base64 字符串转换为 Blob URL
const base64ToUrl = (base64: string, mimeType: string = 'audio/wav'): string => {
try {
// 如果已经是 blob URL直接返回
if (base64.startsWith('blob:')) {
return base64;
}
// 将 base64 转换为 ArrayBuffer
const binaryString = window.atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// 创建 Blob 和 URL
const blob = new Blob([bytes], { type: mimeType });
return URL.createObjectURL(blob);
} catch (error) {
console.error('转换 base64 到 URL 失败:', error);
return '';
}
};
export const useVoiceStore = create<VoiceState>()(
devtools(
(set, get) => ({
// 初始状态
voiceList: [],
isLoading: false,
error: null,
currentDay: getDayOfYear(),
// 初始化:从 IndexedDB 获取当天的记录
initialize: async () => {
const { setLoading, setError, generateAudioUrls } = get();
try {
setLoading(true);
setError(null);
// 初始化 speak service
await speakService.init();
// 获取当天的语音记录
const currentDay = getDayOfYear();
const todayVoices = await speakService.getSpeaksByDay(currentDay);
set({
voiceList: todayVoices,
currentDay: currentDay
});
// 为获取到的记录生成 audio URLs
await generateAudioUrls();
} catch (error) {
console.error('初始化语音列表失败:', error);
setError(error instanceof Error ? error.message : '初始化失败');
} finally {
setLoading(false);
}
},
// 添加新的语音记录
addVoice: async (url: string, duration: number, audioBlob?: Blob) => {
const { setError } = get();
try {
setError(null);
let fileData: string | undefined;
// 如果提供了 audioBlob将其转换为 base64 保存到 IndexedDB
if (audioBlob) {
fileData = await blobToBase64(audioBlob);
}
// 创建语音记录(不保存 url只保存 base64 数据)
const speakData = {
duration: Math.ceil(duration),
file: fileData, // 保存 base64 数据而不是 url
day: getDayOfYear(),
no: 0, // 将由 service 自动生成
timestamp: Date.now(),
type: 'normal' as const
};
// 保存到 IndexedDB不包含 url
const newSpeak = await speakService.createSpeakAuto(speakData);
// 为新记录生成 URL 并添加到状态
const speakWithUrl = {
...newSpeak,
url: newSpeak.file ? base64ToUrl(newSpeak.file) : url
};
set(state => ({
voiceList: [...state.voiceList, speakWithUrl]
}));
return speakWithUrl;
} catch (error) {
console.error('添加语音记录失败:', error);
setError(error instanceof Error ? error.message : '添加失败');
throw error;
}
},
// 更新语音记录
updateVoice: async (id: string, updates: Partial<Speak>) => {
const { setError } = get();
try {
setError(null);
// 从更新数据中移除 url因为 url 不应该保存到 IndexedDB
const { url, ...updatesWithoutUrl } = updates;
// 更新 IndexedDB 中的记录
const updatedSpeak = await speakService.updateSpeak(id, updatesWithoutUrl);
// 更新状态中的记录
set(state => ({
voiceList: state.voiceList.map(voice => {
if (voice.id === id) {
const updated = { ...voice, ...updates };
// 如果更新了 file 数据,重新生成 URL
if (updatesWithoutUrl.file) {
updated.url = base64ToUrl(updatesWithoutUrl.file);
}
return updated;
}
return voice;
})
}));
} catch (error) {
console.error('更新语音记录失败:', error);
setError(error instanceof Error ? error.message : '更新失败');
throw error;
}
},
// 删除语音记录
deleteVoice: async (id: string) => {
const { setError } = get();
try {
setError(null);
// 从 IndexedDB 删除
await speakService.deleteSpeak(id);
// 从状态中移除并释放 URL
set(state => {
const voiceToDelete = state.voiceList.find(voice => voice.id === id);
if (voiceToDelete && voiceToDelete.url && voiceToDelete.url.startsWith('blob:')) {
URL.revokeObjectURL(voiceToDelete.url);
}
return {
voiceList: state.voiceList.filter(voice => voice.id !== id)
};
});
} catch (error) {
console.error('删除语音记录失败:', error);
setError(error instanceof Error ? error.message : '删除失败');
throw error;
}
},
// 识别语音记录
recognizeVoice: async (id: string) => {
const { setError } = get();
try {
setError(null);
// 获取语音记录
const voice = get().voiceList.find(v => v.id === id);
if (!voice || !voice.file) {
throw new Error('找不到语音记录或音频数据');
}
// 调用语音识别API
const result = await getText(voice.file);
const recognizedText = result.text;
if (!recognizedText) {
throw new Error('语音识别失败,未能获取到文字内容');
}
// 更新数据库中的记录
await speakService.updateSpeak(id, { text: recognizedText });
// 更新状态中的记录
set(state => ({
voiceList: state.voiceList.map(voice =>
voice.id === id ? { ...voice, text: recognizedText } : voice
)
}));
return recognizedText;
} catch (error) {
console.error('语音识别失败:', error);
setError(error instanceof Error ? error.message : '语音识别失败');
throw error;
}
},
// 清空今天的语音记录
clearTodayVoices: async () => {
const { setError, currentDay } = get();
try {
setError(null);
// 从 IndexedDB 清空今天的记录
await speakService.deleteSpeaksByDay(currentDay);
// 清空状态并释放所有 URL
set(state => {
state.voiceList.forEach(voice => {
if (voice.url && voice.url.startsWith('blob:')) {
URL.revokeObjectURL(voice.url);
}
});
return { voiceList: [] };
});
} catch (error) {
console.error('清空今天语音记录失败:', error);
setError(error instanceof Error ? error.message : '清空失败');
throw error;
}
},
// 为所有记录生成音频 URL
generateAudioUrls: async () => {
const { voiceList } = get();
set(state => ({
voiceList: state.voiceList.map(voice => {
// 如果已经有 URL 且是 blob URL跳过
if (voice.url && voice.url.startsWith('blob:')) {
return voice;
}
// 如果有 file 数据,从 base64 生成 URL
if (voice.file) {
return {
...voice,
url: base64ToUrl(voice.file)
};
}
return voice;
})
}));
},
// 刷新列表(重新从 IndexedDB 获取)
refreshList: async () => {
const { initialize } = get();
await initialize();
},
// 设置错误信息
setError: (error: string | null) => {
set({ error });
},
// 设置加载状态
setLoading: (loading: boolean) => {
set({ isLoading: loading });
}
}),
{
name: 'voice-store', // persist key
}
)
);
// 导出类型以便其他地方使用
export type { VoiceState };
// 辅助 hooks
export const useVoiceList = () => useVoiceStore(state => state.voiceList);
export const useVoiceLoading = () => useVoiceStore(state => state.isLoading);
export const useVoiceError = () => useVoiceStore(state => state.error);
// 清理函数:页面卸载时释放所有 blob URLs
export const cleanupVoiceUrls = () => {
const { voiceList } = useVoiceStore.getState();
voiceList.forEach(voice => {
if (voice.url && voice.url.startsWith('blob:')) {
URL.revokeObjectURL(voice.url);
}
});
};
// 在页面卸载时自动清理
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', cleanupVoiceUrls);
}

View File

@@ -0,0 +1,179 @@
import { useShallow } from "zustand/shallow";
import { useState, useEffect } from "react";
import { Code } from "lucide-react";
import { useFileStore } from "../store/useFileStore";
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css'; // 可以选择其他主题
type ShowCodeProps = {
filename: string;
}
export const ShowCode = (props: ShowCodeProps) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [codeContent, setCodeContent] = useState<string>("");
const [loading, setLoading] = useState(false);
const fileCode = useFileStore(useShallow((state) => ({
getFileContent: state.getFileContent,
})))
// 获取文件扩展名来确定语言类型
const getLanguageFromFilename = (filename: string) => {
const ext = filename.split('.').pop()?.toLowerCase();
const languageMap: { [key: string]: string } = {
'ts': 'typescript',
'tsx': 'typescript',
'js': 'javascript',
'jsx': 'javascript',
'py': 'python',
'java': 'java',
'c': 'c',
'cpp': 'cpp',
'cs': 'csharp',
'php': 'php',
'rb': 'ruby',
'go': 'go',
'rs': 'rust',
'html': 'html',
'css': 'css',
'scss': 'scss',
'json': 'json',
'xml': 'xml',
'sql': 'sql',
'sh': 'bash',
'yml': 'yaml',
'yaml': 'yaml',
'md': 'markdown',
};
return languageMap[ext || ''] || 'plaintext';
};
const handleCodeIconClick = async () => {
setLoading(true);
setIsModalOpen(true);
try {
if (fileCode.getFileContent) {
const content = await fileCode.getFileContent(props.filename);
setCodeContent(content || "未找到文件内容");
} else {
setCodeContent("getFileContent 方法不可用");
}
} catch (error) {
setCodeContent("加载文件内容失败");
console.error("获取文件内容失败:", error);
} finally {
setLoading(false);
}
};
const closeModal = () => {
setIsModalOpen(false);
setCodeContent("");
};
// 使用 highlight.js 进行代码高亮
const highlightCode = (code: string) => {
if (!code) return '';
const language = getLanguageFromFilename(props.filename);
try {
const highlighted = hljs.highlight(code, { language }).value;
return highlighted;
} catch (error) {
// 如果特定语言高亮失败,使用自动检测
try {
const highlighted = hljs.highlightAuto(code).value;
return highlighted;
} catch (autoError) {
// 如果都失败了,返回转义的纯文本
return hljs.highlight(code, { language: 'plaintext' }).value;
}
}
};
return (
<div className="flex items-center gap-2">
{/* <span>{props.filename}</span> */}
<button
onClick={handleCodeIconClick}
className="p-1 hover:bg-gray-100 rounded-md transition-colors duration-200"
title={`查看 ${props.filename} 的代码`}
>
<Code size={16} className="text-blue-600 hover:text-blue-800" />
</button>
{/* 弹窗 */}
{isModalOpen && (
<div className="code-modal-overlay">
<div className="code-modal">
{/* 头部 */}
<div className="code-modal-header">
<h3 className="code-modal-title">
{props.filename}
</h3>
<button
onClick={closeModal}
className="code-modal-close"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 内容 */}
<div className="code-modal-content">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-2 text-gray-600">...</span>
</div>
) : (
<pre className="code-display">
<code
className={`language-${getLanguageFromFilename(props.filename)} hljs`}
dangerouslySetInnerHTML={{ __html: highlightCode(codeContent) }}
/>
</pre>
)}
</div>
{/* 底部 */}
<div className="code-modal-footer">
<button
onClick={closeModal}
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 transition-colors"
>
</button>
</div>
</div>
</div>
)}
</div>
)
}
export const ShowJson = (props: { data: any }) => {
const highlightJson = (data: any) => {
const jsonString = JSON.stringify(data, null, 2);
try {
const highlighted = hljs.highlight(jsonString, { language: 'json' }).value;
return highlighted;
} catch (error) {
// 如果高亮失败,返回原始 JSON 字符串
return hljs.highlight(jsonString, { language: 'plaintext' }).value;
}
};
return (
<pre className="p-4 bg-gray-100 rounded-md overflow-auto">
<code
className="language-json hljs"
dangerouslySetInnerHTML={{ __html: highlightJson(props.data) }}
/>
</pre>
);
}

308
web/src/apps/test/run.tsx Normal file
View File

@@ -0,0 +1,308 @@
import { query, call } from '@/modules/query'
import { useEffect, useState } from 'react'
import { Edit2, Check, X } from 'lucide-react'
import { useFileStore } from './store/useFileStore'
import { useShallow } from 'zustand/shallow'
import { ShowCode, ShowJson } from './modules/ShowCode'
export type CallProps<T = any> = {
filename: string
title?: string
description?: string
data?: T
}
const useData = (params: any) => {
const [value, setValue] = useState<any>(null)
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const [queryData, setQueryData] = useState<any>(params || {})
const [isEditing, setIsEditing] = useState<boolean>(false)
const [editingData, setEditingData] = useState<string>('')
const [jsonError, setJsonError] = useState<string | null>(null)
const [isEditingFilename, setIsEditingFilename] = useState<boolean>(false)
const [editingFilename, setEditingFilename] = useState<string>('')
return {
value, setValue, loading, setLoading,
error, setError, queryData, setQueryData,
isEditing, setIsEditing, editingData, setEditingData,
jsonError, setJsonError, isEditingFilename, setIsEditingFilename,
editingFilename, setEditingFilename
}
}
export const Call = (props: CallProps) => {
const {
value, setValue, loading, setLoading, error, setError,
queryData, setQueryData, isEditing, setIsEditing,
editingData, setEditingData, jsonError, setJsonError,
isEditingFilename, setIsEditingFilename, editingFilename, setEditingFilename
} = useData(props.data)
const fileCode = useFileStore(useShallow((state) => ({
fetchFiles: state.fetchFiles,
getFileContent: state.getFileContent,
files: state.files
})))
const [filename, setFilename] = useState<string>(props.filename)
useEffect(() => {
fileCode.fetchFiles()
}, [])
const callTest = async () => {
setLoading(true)
setError(null)
try {
const res = await call(filename, queryData)
setValue(res)
} catch (err) {
setError(err instanceof Error ? err.message : '请求失败')
console.error('调用接口失败:', err)
} finally {
setLoading(false)
}
}
const clearResult = () => {
setValue(null)
setError(null)
}
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
// 可以添加一个临时的成功提示
} catch (err) {
console.error('复制失败:', err)
}
}
const startEditing = () => {
setEditingData(JSON.stringify(queryData, null, 2))
setIsEditing(true)
setJsonError(null)
}
const cancelEditing = () => {
setIsEditing(false)
setEditingData('')
setJsonError(null)
}
const confirmEditing = () => {
try {
const parsedData = JSON.parse(editingData)
setQueryData(parsedData)
setIsEditing(false)
setEditingData('')
setJsonError(null)
} catch (err) {
setJsonError('JSON 格式错误,请检查输入的数据')
}
}
const startEditingFilename = () => {
setEditingFilename(filename)
setIsEditingFilename(true)
}
const cancelEditingFilename = () => {
setIsEditingFilename(false)
setEditingFilename('')
}
const confirmEditingFilename = () => {
if (editingFilename.trim()) {
setFilename(editingFilename.trim())
setIsEditingFilename(false)
setEditingFilename('')
}
}
return (
<div className="max-w-4xl mx-auto p-6 space-y-6 border rounded-lg bg-background mb-1">
{/* 头部信息卡片 */}
<div className="bg-card rounded-lg border p-6 shadow-sm">
<h2 className="text-xl font-semibold text-foreground mb-4">API {props.title ? '--' + props.title : ''}</h2>
<div className="grid gap-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground min-w-[80px] flex gap-1">:
<ShowCode filename={filename} />
</span>
{isEditingFilename ? (
<div className="flex items-center gap-2 flex-1">
<input
type="text"
value={editingFilename}
onChange={(e) => setEditingFilename(e.target.value)}
className="text-sm bg-muted px-2 py-1 rounded font-mono border focus:outline-none focus:ring-2 focus:ring-primary flex-1"
placeholder="输入文件名"
onKeyDown={(e) => {
if (e.key === 'Enter') confirmEditingFilename()
if (e.key === 'Escape') cancelEditingFilename()
}}
autoFocus
/>
<div className="flex gap-1">
<button
onClick={confirmEditingFilename}
className="flex items-center gap-1 px-2 py-1 text-xs bg-primary text-primary-foreground hover:bg-primary/90 rounded transition-colors"
>
<Check className="w-3 h-3" />
</button>
<button
onClick={cancelEditingFilename}
className="flex items-center gap-1 px-2 py-1 text-xs bg-secondary text-secondary-foreground hover:bg-secondary/90 rounded transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
) : (
<div className="relative group flex-1">
<code className="text-sm bg-muted px-2 py-1 rounded font-mono">
{filename}
</code>
<button
onClick={startEditingFilename}
className="absolute top-0 right-0 flex items-center gap-1 px-1 py-0.5 text-xs bg-background/80 hover:bg-background border rounded opacity-0 group-hover:opacity-100 transition-opacity translate-x-1 -translate-y-1"
title="编辑文件名"
>
<Edit2 className="w-3 h-3" />
</button>
</div>
)}
</div>
{props.description && (
<div className="flex items-start gap-2">
<span className="text-sm font-medium text-muted-foreground min-w-[80px]">:</span>
<span className="text-sm text-foreground">{props.description}</span>
</div>
)}
{queryData && (
<div className="flex items-start gap-2">
<span className="text-sm font-medium text-muted-foreground min-w-[80px]">:</span>
<div className="flex-1">
{isEditing ? (
<div className="space-y-2">
<textarea
value={editingData}
onChange={(e) => setEditingData(e.target.value)}
className="w-full h-32 text-sm bg-muted p-3 rounded font-mono border resize-vertical focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="输入 JSON 数据"
/>
{jsonError && (
<p className="text-sm text-destructive">{jsonError}</p>
)}
<div className="flex gap-2">
<button
onClick={confirmEditing}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-primary text-primary-foreground hover:bg-primary/90 rounded transition-colors"
>
<Check className="w-3 h-3" />
</button>
<button
onClick={cancelEditing}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-secondary text-secondary-foreground hover:bg-secondary/90 rounded transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
) : (
<div className="relative group">
<pre className="text-sm bg-muted p-3 rounded font-mono overflow-x-auto border" onClick={startEditing}>
{JSON.stringify(queryData, null, 2)}
</pre>
<button
onClick={startEditing}
className="absolute top-2 right-2 flex items-center gap-1 px-2 py-1 text-xs bg-background/80 hover:bg-background border rounded opacity-0 group-hover:opacity-100 transition-opacity"
title="编辑数据"
>
<Edit2 className="w-3 h-3" />
</button>
</div>
)}
</div>
</div>
)}
</div>
</div>
{/* 操作按钮 */}
<div className="flex gap-3">
<button
onClick={callTest}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? (
<>
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"></div>
...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</>
)}
</button>
{(value || error) && (
<button
onClick={clearResult}
className="flex items-center gap-2 px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
{/* 错误提示 */}
{error && (
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-4">
<div className="flex items-center gap-2 text-destructive">
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="font-medium"></span>
</div>
<p className="mt-2 text-sm text-destructive/80">{error}</p>
</div>
)}
{/* 响应结果 */}
{value && (
<div className="bg-card rounded-lg border shadow-sm">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-medium text-foreground"></h3>
<button
onClick={() => copyToClipboard(JSON.stringify(value, null, 2))}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-muted hover:bg-muted/80 rounded transition-colors"
title="复制到剪贴板"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
<div className="p-4">
{value && <ShowJson data={value} />}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { create } from 'zustand'
import { query } from '../../../modules/query'
import { timeout } from 'es-toolkit'
interface FileStore {
files: Array<{ path: string; content: string }>
loading: boolean
error: string | null
fetchFiles: () => Promise<void>
loaded?: boolean
getFileContent?: (path: string) => Promise<string | undefined>
}
export const useFileStore = create<FileStore>((set, get) => ({
files: [],
loading: false,
error: null,
loaded: false,
fetchFiles: async () => {
const loading = get().loading
if (loading) return
set({ loading: true, error: null })
const loaded = get().loaded
if (loaded) {
set({ loading: false })
return
}
try {
const res = await query.post({ path: 'file-code' })
set({ files: res.data, loading: false, loaded: true })
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '获取文件列表失败'
set({ error: errorMessage, loading: false, loaded: false })
console.error('获取文件列表失败:', err)
}
},
getFileContent: async (path) => {
const loaded = get().loaded
if (!loaded) {
console.warn('文件未加载,无法获取内容')
await get().fetchFiles()
await timeout(2000);
}
const files = get().files
const file = files.find((f) => f.path === path)
return file ? file.content : undefined
}
}))

View File

@@ -0,0 +1,46 @@
---
import '../styles/global.css';
export interface Props {
title?: string;
description?: string;
lang?: string;
charset?: string;
}
const { title = 'Light Code', description = 'A lightweight code editor', lang = 'zh-CN', charset = 'UTF-8' } = Astro.props;
---
<!doctype html>
<html lang={lang}>
<head>
<meta charset={charset} />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<meta name='description' content={description} />
<title>{title}</title>
<!-- 样式 -->
<slot name='head' />
</head>
<body>
<slot />
<!-- 脚本 -->
<slot name='scripts' />
</body>
</html>
<style>
html {
font-family: system-ui, sans-serif;
}
html,
body {
margin: 0;
padding: 0;
min-height: 100vh;
}
* {
box-sizing: border-box;
}
</style>

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

Some files were not shown because too many files have changed in this diff Show More