Initial commit: restore project after Git corruption
This commit is contained in:
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
|
||||
dist
|
||||
|
||||
.turbo
|
||||
|
||||
filebrowser
|
||||
|
||||
filebrowser.db
|
||||
|
||||
/data
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal 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
24
compose.yml
Normal 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
23
package.json
Normal 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
6706
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
pnpm-workspace.yaml
Normal file
8
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
packages:
|
||||
- web
|
||||
- server
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@tailwindcss/oxide'
|
||||
- esbuild
|
||||
- sharp
|
||||
8
readme.md
Normal file
8
readme.md
Normal 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
25
server/.gitignore
vendored
Normal 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
22
server/bun.config.ts
Normal 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_*',
|
||||
});
|
||||
24
server/code/root/light-code-demo/main.ts
Normal file
24
server/code/root/light-code-demo/main.ts
Normal 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()
|
||||
46
server/code/root/light-code-demo/sign.ts
Normal file
46
server/code/root/light-code-demo/sign.ts
Normal 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()
|
||||
56
server/code/root/light-code-demo/weather.ts
Normal file
56
server/code/root/light-code-demo/weather.ts
Normal 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()
|
||||
57
server/code/root/listen-demo/origin.ts
Normal file
57
server/code/root/listen-demo/origin.ts
Normal 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)
|
||||
14
server/code/root/listen-demo/router.ts
Normal file
14
server/code/root/listen-demo/router.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { QueryRouterServer } from "@kevisual/router";
|
||||
|
||||
const app = new QueryRouterServer();
|
||||
|
||||
app.route({
|
||||
path: 'main'
|
||||
}).define(async (ctx) => {
|
||||
ctx.body = {
|
||||
message: 'this is main. filename: root/listen-demo/router.ts',
|
||||
params: ctx.query
|
||||
}
|
||||
}).addTo(app)
|
||||
|
||||
app.wait()
|
||||
14
server/code/test/demo/main.ts
Normal file
14
server/code/test/demo/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { QueryRouterServer } from "@kevisual/router";
|
||||
|
||||
const app = new QueryRouterServer();
|
||||
|
||||
app.route({
|
||||
path: 'main'
|
||||
}).define(async (ctx) => {
|
||||
ctx.body = {
|
||||
message: 'this is main. filename: test/demo/main.ts',
|
||||
params: ctx.query
|
||||
}
|
||||
}).addTo(app)
|
||||
|
||||
app.wait()
|
||||
1
server/demo/test/a/index.html
Normal file
1
server/demo/test/a/index.html
Normal file
@@ -0,0 +1 @@
|
||||
测试静态页面
|
||||
31
server/package.json
Normal file
31
server/package.json
Normal 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
384
server/pnpm-lock.yaml
generated
Normal 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
3
server/src/app.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { App } from '@kevisual/router'
|
||||
|
||||
export const app = new App()
|
||||
23
server/src/cache/index.ts
vendored
Normal file
23
server/src/cache/index.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createStorage } from 'unstorage'
|
||||
import fsLiteDriver from "unstorage/drivers/fs-lite";
|
||||
import { codeRoot } from '@/modules/config.ts';
|
||||
import memoryDriver from "unstorage/drivers/memory";
|
||||
|
||||
export const storage = createStorage({
|
||||
// @ts-ignore
|
||||
driver: memoryDriver(),
|
||||
});
|
||||
|
||||
export const codeStorage = createStorage({
|
||||
// @ts-ignore
|
||||
driver: fsLiteDriver({
|
||||
base: codeRoot
|
||||
})
|
||||
})
|
||||
|
||||
// storage.setItem('test-ke/test-key.json', 'test-value');
|
||||
// console.log('Cache test-key:', await storage.getItem('test-key'));
|
||||
|
||||
// codeStorage.setItem('root/light-code-demo/main.ts', 'test-value2');
|
||||
console.log('Cache test-key:', await codeStorage.getItem('root/light-code-demo/main.ts'));
|
||||
console.log('has', await codeStorage.hasItem('root/light-code-demo/main.ts'));
|
||||
52
server/src/db/collections/project.ts
Normal file
52
server/src/db/collections/project.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
|
||||
import { Field } from '../types/index.ts';
|
||||
|
||||
export const projectStatus = ['提交', '审核中', '审核通过']; // 提交,审核中,审核通过
|
||||
export type projectStatus = typeof projectStatus[number];
|
||||
|
||||
export const projectFields: Field[] = [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'key',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'owner',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'data',
|
||||
type: 'json'
|
||||
},
|
||||
{
|
||||
name: 'files',
|
||||
type: 'json'
|
||||
},
|
||||
{
|
||||
name: "createdAt",
|
||||
onCreate: true,
|
||||
onUpdate: false,
|
||||
type: "autodate"
|
||||
},
|
||||
{
|
||||
name: "updatedAt",
|
||||
onCreate: true,
|
||||
onUpdate: true,
|
||||
type: "autodate"
|
||||
},
|
||||
];
|
||||
|
||||
export const name = 'xx_projects';
|
||||
|
||||
export const type = 'base';
|
||||
37
server/src/db/init.ts
Normal file
37
server/src/db/init.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { pb, db } from '../modules/db.ts'
|
||||
import * as projects from './collections/project.ts'
|
||||
// 要求
|
||||
// 1. collection只做新增,不做修改
|
||||
// 2. collection不存在就创建
|
||||
// 3. 每一个collection的定义在文档中需要有
|
||||
|
||||
|
||||
export const main = async () => {
|
||||
try {
|
||||
await db.ensureLogin().catch(() => { throw new Error('Login failed'); });
|
||||
// 程序第一次运行的时候执行,如果已经初始化过则跳过
|
||||
const collections = await db.pb.collections.getFullList({
|
||||
filter: 'name ~ "xx_%"',
|
||||
})
|
||||
console.log('Existing collections:', collections.map(c => c.name));
|
||||
const dbs = [projects]
|
||||
for (const coll of dbs) {
|
||||
const exists = collections.find(c => c.name === coll.name)
|
||||
if (exists) {
|
||||
console.log(`Collection ${coll.name} already exists, skipping creation.`);
|
||||
continue;
|
||||
}
|
||||
// 第一步,获取那个叉叉开头的 Collection。第二步,获取它的版本。
|
||||
const createdCollection = await db.pb.collections.create({
|
||||
name: coll.name,
|
||||
type: coll?.type || 'base',
|
||||
fields: coll?.projectFields,
|
||||
})
|
||||
console.log('Created collection:', createdCollection);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during DB initialization:', error);
|
||||
}
|
||||
}
|
||||
main()
|
||||
26
server/src/db/types/collection.ts
Normal file
26
server/src/db/types/collection.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type Field = {
|
||||
/**
|
||||
* The unique identifier for the field
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* The name of the field
|
||||
*/
|
||||
name: string;
|
||||
type?: 'text' | 'json' | 'autodate' | 'boolean' | 'number' | 'email' | 'url' | 'file' | 'relation';
|
||||
/**
|
||||
* Indicates whether the field is required
|
||||
*/
|
||||
required?: boolean;
|
||||
/**
|
||||
* Only for 'autodate' type
|
||||
* Indicates whether to set the date on record creation or update
|
||||
*/
|
||||
onCreate?: boolean;
|
||||
/** Only for 'autodate' type
|
||||
* Indicates whether to set the date on record update
|
||||
*/
|
||||
onUpdate?: boolean;
|
||||
options?: Record<string, any>;
|
||||
[key: string]: any;
|
||||
}
|
||||
1
server/src/db/types/index.ts
Normal file
1
server/src/db/types/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './collection.ts';
|
||||
19
server/src/index.ts
Normal file
19
server/src/index.ts
Normal 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 }
|
||||
7
server/src/modules/config.ts
Normal file
7
server/src/modules/config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useConfig } from '@kevisual/use-config'
|
||||
|
||||
import path from 'path';
|
||||
|
||||
export const config = useConfig()
|
||||
|
||||
export const codeRoot = path.join(process.cwd(), 'code');
|
||||
24
server/src/modules/db.ts
Normal file
24
server/src/modules/db.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const POCKETBASE_URL = 'https://pocketbase.pro.xiongxiao.me/';
|
||||
|
||||
export const pb = new PocketBase(POCKETBASE_URL);
|
||||
|
||||
export class DB {
|
||||
pb: PocketBase
|
||||
constructor(pb: PocketBase) {
|
||||
this.pb = pb
|
||||
}
|
||||
async ensureLogin() {
|
||||
const pb = this.pb;
|
||||
if (!pb.authStore.isValid) {
|
||||
await pb.collection("_superusers").authWithPassword('xiongxiao@xiongxiao.me', '123456xx');
|
||||
}
|
||||
return pb.authStore.record;
|
||||
}
|
||||
async getCollection(name: string) {
|
||||
await this.ensureLogin();
|
||||
return this.pb.collection(name);
|
||||
}
|
||||
}
|
||||
export const db = new DB(pb);
|
||||
50
server/src/modules/run-code/run.ts
Normal file
50
server/src/modules/run-code/run.ts
Normal 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)
|
||||
});
|
||||
}
|
||||
8
server/src/routes/auth.ts
Normal file
8
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { app } from '../app.ts'
|
||||
|
||||
app.route({
|
||||
path: 'auth',
|
||||
id: 'auth'
|
||||
}).define(async (ctx) => {
|
||||
// Authentication logic here
|
||||
}).addTo(app);
|
||||
24
server/src/routes/call/index.ts
Normal file
24
server/src/routes/call/index.ts
Normal 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)
|
||||
68
server/src/routes/file-code/index.ts
Normal file
68
server/src/routes/file-code/index.ts
Normal 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)
|
||||
5
server/src/routes/index.ts
Normal file
5
server/src/routes/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import './call/index.ts';
|
||||
|
||||
import './file-code/index.ts';
|
||||
|
||||
import './auth.ts'
|
||||
37
server/src/test/check-code.ts
Normal file
37
server/src/test/check-code.ts
Normal 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)
|
||||
5
server/src/test/common.ts
Normal file
5
server/src/test/common.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Query } from '@kevisual/query'
|
||||
|
||||
export const query = new Query({
|
||||
url: 'http://localhost:4005/api/router',
|
||||
})
|
||||
48
server/src/test/test-upload.ts
Normal file
48
server/src/test/test-upload.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { query } from './common.ts'
|
||||
|
||||
|
||||
|
||||
export const testUpload = async () => {
|
||||
|
||||
const res = await query.post({
|
||||
path: 'file-code',
|
||||
key: 'upload',
|
||||
upload: {
|
||||
user: 'test',
|
||||
key: 'demo',
|
||||
files: [
|
||||
{
|
||||
type: 'file',
|
||||
filepath: 'main.ts',
|
||||
content: `import { QueryRouterServer } from "@kevisual/router";
|
||||
|
||||
const app = new QueryRouterServer();
|
||||
|
||||
app.route({
|
||||
path: 'main'
|
||||
}).define(async (ctx) => {
|
||||
ctx.body = {
|
||||
message: 'this is main. filename: test/demo/main.ts',
|
||||
params: ctx.query
|
||||
}
|
||||
}).addTo(app)
|
||||
|
||||
app.wait()`
|
||||
},
|
||||
]
|
||||
}
|
||||
})
|
||||
console.log('Upload response:', res);
|
||||
}
|
||||
|
||||
// testUpload();
|
||||
|
||||
const callTestDemo = async () => {
|
||||
const res = await query.post({
|
||||
path: 'call',
|
||||
filename: 'test/demo/main.ts',
|
||||
})
|
||||
console.log('Call response:', res);
|
||||
}
|
||||
|
||||
callTestDemo();
|
||||
19
server/tsconfig.json
Normal file
19
server/tsconfig.json
Normal 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
18
turbo.json
Normal 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
6
web/.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
# PocketBase配置
|
||||
VITE_POCKETBASE_URL=http://localhost:8090
|
||||
|
||||
# 可选:其他配置
|
||||
# VITE_APP_NAME=Light Code Center
|
||||
# VITE_DEBUG=true
|
||||
6
web/.gitignore
vendored
Normal file
6
web/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
|
||||
.astro
|
||||
|
||||
dist
|
||||
36
web/astro.config.mjs
Normal file
36
web/astro.config.mjs
Normal 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
22
web/components.json
Normal 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
82
web/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
17
web/src/apps/login/AuthProvider.tsx
Normal file
17
web/src/apps/login/AuthProvider.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const initAuth = useAuthStore(state => state.initAuth);
|
||||
|
||||
useEffect(() => {
|
||||
// 在应用启动时初始化认证状态
|
||||
initAuth();
|
||||
}, [initAuth]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
70
web/src/apps/login/DashboardApp.tsx
Normal file
70
web/src/apps/login/DashboardApp.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { AuthProvider } from './AuthProvider';
|
||||
import { ProtectedRoute } from '@/apps/login/ProtectedRoute';
|
||||
import { UserInfo } from '@/apps/login/UserInfo';
|
||||
import { useAuth } from '../../store/authStore';
|
||||
import { UserType } from '../../lib/pocketbase';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
|
||||
const DashboardContent: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900">用户仪表板</h1>
|
||||
<UserInfo />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<div className="border-4 border-dashed border-gray-200 rounded-lg p-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
欢迎,{user?.email || '用户'}!
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-8">
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">项目管理</h3>
|
||||
<p className="text-gray-600">管理您的代码项目和文件</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">设置</h3>
|
||||
<p className="text-gray-600">配置您的账户设置</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DashboardApp: React.FC = () => {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<ProtectedRoute requiredUserType={UserType.USER}>
|
||||
<DashboardContent />
|
||||
</ProtectedRoute>
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={5000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop={false}
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme="light"
|
||||
/>
|
||||
</AuthProvider>
|
||||
);
|
||||
};
|
||||
200
web/src/apps/login/LoginForm.tsx
Normal file
200
web/src/apps/login/LoginForm.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuthStore, useAuth, useAuthActions } from '@/store/authStore';
|
||||
import { UserType } from '@/lib/pocketbase';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface LoginFormData {
|
||||
email: string;
|
||||
password: string;
|
||||
userType: UserType;
|
||||
}
|
||||
|
||||
export const LoginForm: React.FC = () => {
|
||||
const { isLoading, error } = useAuth();
|
||||
const { login, clearError } = useAuthActions();
|
||||
|
||||
const [formData, setFormData] = useState<LoginFormData>({
|
||||
email: '',
|
||||
password: '',
|
||||
userType: UserType.USER,
|
||||
});
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
|
||||
// 清除错误信息
|
||||
if (error) {
|
||||
clearError();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.email || !formData.password) {
|
||||
toast.error('请填写邮箱和密码');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await login(formData);
|
||||
toast.success('登录成功!');
|
||||
|
||||
// 登录成功后跳转到首页或仪表板
|
||||
window.location.href = formData.userType === UserType.ADMIN ? '/admin' : '/dashboard';
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '登录失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserTypeChange = (userType: UserType) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
userType,
|
||||
}));
|
||||
|
||||
if (error) {
|
||||
clearError();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
登录您的账户
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
选择您的账户类型并输入登录信息
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{/* 用户类型选择 */}
|
||||
<div className="rounded-md shadow-sm space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 block mb-2">
|
||||
账户类型
|
||||
</label>
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUserTypeChange(UserType.USER)}
|
||||
className={`flex-1 py-2 px-4 rounded-md text-sm font-medium transition-colors ${
|
||||
formData.userType === UserType.USER
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
普通用户
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUserTypeChange(UserType.ADMIN)}
|
||||
className={`flex-1 py-2 px-4 rounded-md text-sm font-medium transition-colors ${
|
||||
formData.userType === UserType.ADMIN
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
管理员
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 邮箱输入 */}
|
||||
<div>
|
||||
<label htmlFor="email" className="text-sm font-medium text-gray-700 block mb-1">
|
||||
邮箱地址
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="请输入邮箱地址"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 密码输入 */}
|
||||
<div>
|
||||
<label htmlFor="password" className="text-sm font-medium text-gray-700 block mb-1">
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误信息显示 */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
{error}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={`group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white transition-colors ${
|
||||
isLoading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: formData.userType === UserType.ADMIN
|
||||
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
||||
: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500'
|
||||
} focus:outline-none focus:ring-2 focus:ring-offset-2`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
登录中...
|
||||
</div>
|
||||
) : (
|
||||
`登录${formData.userType === UserType.ADMIN ? '管理员' : '用户'}账户`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
{formData.userType === UserType.ADMIN
|
||||
? '管理员账户将使用 superuser 权限登录'
|
||||
: '普通用户账户将使用标准权限登录'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
23
web/src/apps/login/LoginPage.tsx
Normal file
23
web/src/apps/login/LoginPage.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { LoginForm } from './LoginForm';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
|
||||
export const LoginPage: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<LoginForm />
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={5000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop={false}
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme="light"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
103
web/src/apps/login/ProtectedRoute.tsx
Normal file
103
web/src/apps/login/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { useAuth, usePermissions } from '@/store/authStore';
|
||||
import { UserType } from '@/lib/pocketbase';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
requiredUserType?: UserType;
|
||||
fallback?: React.ReactNode;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
children,
|
||||
requiredUserType,
|
||||
fallback,
|
||||
redirectTo = '/login'
|
||||
}) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const { canAccess } = usePermissions();
|
||||
|
||||
// 加载中显示
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<span className="text-gray-600">验证身份中...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 未登录
|
||||
if (!isAuthenticated) {
|
||||
if (fallback) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
// 重定向到登录页面
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = redirectTo;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">需要登录</h2>
|
||||
<p className="text-gray-600 mb-6">请先登录您的账户</p>
|
||||
<a
|
||||
href={redirectTo}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
前往登录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (requiredUserType && !canAccess(requiredUserType)) {
|
||||
if (fallback) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-red-600 mb-4">权限不足</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
您需要{requiredUserType === UserType.ADMIN ? '管理员' : '用户'}权限才能访问此页面
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
className="bg-gray-600 text-white px-6 py-2 rounded-md hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
返回首页
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// 专门用于管理员页面的保护组件
|
||||
export const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<ProtectedRoute requiredUserType={UserType.ADMIN}>
|
||||
{children}
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
|
||||
// 专门用于普通用户页面的保护组件
|
||||
export const UserRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<ProtectedRoute requiredUserType={UserType.USER}>
|
||||
{children}
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
110
web/src/apps/login/UserInfo.tsx
Normal file
110
web/src/apps/login/UserInfo.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { useAuth, useAuthActions, usePermissions } from '@/store/authStore';
|
||||
import { UserType } from '@/lib/pocketbase';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const UserInfo: React.FC = () => {
|
||||
const { isAuthenticated, user, userType, isLoading } = useAuth();
|
||||
const { logout } = useAuthActions();
|
||||
const { isAdmin, isUser } = usePermissions();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
toast.success('已成功登出');
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return (
|
||||
<div className="flex items-center space-x-4">
|
||||
<a
|
||||
href="/login"
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
登录
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* 用户信息 */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* 用户头像 */}
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium ${
|
||||
isAdmin ? 'bg-red-600' : 'bg-blue-600'
|
||||
}`}>
|
||||
{user.email?.charAt(0).toUpperCase() || 'U'}
|
||||
</div>
|
||||
|
||||
{/* 用户详情 */}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{(user as any).name || (user as any).username || user.email}
|
||||
</span>
|
||||
<span className={`text-xs ${
|
||||
isAdmin ? 'text-red-600' : 'text-blue-600'
|
||||
}`}>
|
||||
{isAdmin ? '管理员' : '用户'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* 仪表板链接 */}
|
||||
<a
|
||||
href={isAdmin ? '/admin' : '/dashboard'}
|
||||
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
|
||||
isAdmin
|
||||
? 'bg-red-100 text-red-700 hover:bg-red-200'
|
||||
: 'bg-blue-100 text-blue-700 hover:bg-blue-200'
|
||||
}`}
|
||||
>
|
||||
{isAdmin ? '管理面板' : '仪表板'}
|
||||
</a>
|
||||
|
||||
{/* 登出按钮 */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-3 py-1 rounded-md text-sm font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 简化版本的用户状态显示组件
|
||||
export const UserStatus: React.FC = () => {
|
||||
const { isAuthenticated, user, userType } = useAuth();
|
||||
const { isAdmin } = usePermissions();
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return (
|
||||
<span className="text-sm text-gray-500">未登录</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
isAdmin ? 'bg-red-500' : 'bg-green-500'
|
||||
}`}></div>
|
||||
<span className="text-sm text-gray-700">
|
||||
{isAdmin ? '管理员' : '用户'}: {user.email}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
46
web/src/apps/muse/base/card/MarkDetailList.css
Normal file
46
web/src/apps/muse/base/card/MarkDetailList.css
Normal 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;
|
||||
}
|
||||
364
web/src/apps/muse/base/card/MarkDetailList.tsx
Normal file
364
web/src/apps/muse/base/card/MarkDetailList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
191
web/src/apps/muse/base/docs/README.md
Normal file
191
web/src/apps/muse/base/docs/README.md
Normal 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
|
||||
542
web/src/apps/muse/base/docs/docs.css
Normal file
542
web/src/apps/muse/base/docs/docs.css
Normal 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;
|
||||
}
|
||||
}
|
||||
222
web/src/apps/muse/base/docs/example.tsx
Normal file
222
web/src/apps/muse/base/docs/example.tsx
Normal 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)
|
||||
- 图片支持: 
|
||||
|
||||
---
|
||||
|
||||
希望你喜欢这个使用 **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;
|
||||
222
web/src/apps/muse/base/docs/index.tsx
Normal file
222
web/src/apps/muse/base/docs/index.tsx
Normal 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;
|
||||
127
web/src/apps/muse/base/graph/sigma/index.tsx
Normal file
127
web/src/apps/muse/base/graph/sigma/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
92
web/src/apps/muse/base/index.tsx
Normal file
92
web/src/apps/muse/base/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
326
web/src/apps/muse/base/mock/collection.ts
Normal file
326
web/src/apps/muse/base/mock/collection.ts
Normal 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
|
||||
};
|
||||
153
web/src/apps/muse/base/table/DetailModal.tsx
Normal file
153
web/src/apps/muse/base/table/DetailModal.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { Mark } from '../mock/collection';
|
||||
import './modal.css';
|
||||
|
||||
interface DetailModalProps {
|
||||
visible: boolean;
|
||||
data: Mark | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DetailModal: React.FC<DetailModalProps> = ({ visible, data, onClose }) => {
|
||||
if (!visible || !data) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>详情信息</h3>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="detail-section">
|
||||
<h4>基本信息</h4>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<label>标题:</label>
|
||||
<span>{data.title}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>类型:</label>
|
||||
<span className={`type-badge type-${data.markType}`}>{data.markType}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>创建者:</label>
|
||||
<span>{data.uname}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>可见性:</label>
|
||||
<span className={`visibility-badge visibility-${data.config.visibility}`}>
|
||||
{data.config.visibility === 'public' ? '公开' :
|
||||
data.config.visibility === 'private' ? '私有' : '受限'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>描述</h4>
|
||||
<p>{data.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>标签</h4>
|
||||
<div className="tags-container">
|
||||
{data.tags.map((tag, index) => (
|
||||
<span key={index} className="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>时间信息</h4>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<label>标记时间:</label>
|
||||
<span>{new Date(data.markedAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>创建时间:</label>
|
||||
<span>{new Date(data.createdAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>更新时间:</label>
|
||||
<span>{new Date(data.updatedAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>版本:</label>
|
||||
<span>v{data.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.fileList.length > 0 && (
|
||||
<div className="detail-section">
|
||||
<h4>附件文件 ({data.fileList.length})</h4>
|
||||
<div className="file-list">
|
||||
{data.fileList.map((file, index) => (
|
||||
<div key={index} className="file-item">
|
||||
<div className="file-info">
|
||||
<span className="file-name">{file.name}</span>
|
||||
<span className="file-size">{formatFileSize(file.size)}</span>
|
||||
</div>
|
||||
<span className={`file-type file-type-${file.type}`}>{file.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>数据摘要</h4>
|
||||
<p className="summary-text">{data.data.summary || data.summary}</p>
|
||||
</div>
|
||||
|
||||
{data.config.allowComments !== undefined && (
|
||||
<div className="detail-section">
|
||||
<h4>权限设置</h4>
|
||||
<div className="permission-grid">
|
||||
<div className="permission-item">
|
||||
<label>允许评论:</label>
|
||||
<span className={data.config.allowComments ? 'enabled' : 'disabled'}>
|
||||
{data.config.allowComments ? '是' : '否'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="permission-item">
|
||||
<label>允许下载:</label>
|
||||
<span className={data.config.allowDownload ? 'enabled' : 'disabled'}>
|
||||
{data.config.allowDownload ? '是' : '否'}
|
||||
</span>
|
||||
</div>
|
||||
{data.config.expiredAt && (
|
||||
<div className="permission-item">
|
||||
<label>过期时间:</label>
|
||||
<span>{new Date(data.config.expiredAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-default" onClick={onClose}>关闭</button>
|
||||
<button className="btn btn-primary" onClick={() => {
|
||||
alert('编辑功能待实现');
|
||||
}}>编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 格式化文件大小
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
172
web/src/apps/muse/base/table/DragSelection.md
Normal file
172
web/src/apps/muse/base/table/DragSelection.md
Normal 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. 移动端设备可能需要额外的触摸事件处理
|
||||
|
||||
## 未来规划
|
||||
|
||||
- [ ] 触摸设备支持
|
||||
- [ ] 自定义拖拽选择框样式
|
||||
- [ ] 更多键盘快捷键
|
||||
- [ ] 拖拽选择动画效果
|
||||
- [ ] 选择统计和操作面板
|
||||
115
web/src/apps/muse/base/table/DragSelectionExample.tsx
Normal file
115
web/src/apps/muse/base/table/DragSelectionExample.tsx
Normal 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;
|
||||
176
web/src/apps/muse/base/table/README.md
Normal file
176
web/src/apps/muse/base/table/README.md
Normal 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. **国际化**:添加多语言支持
|
||||
|
||||
这个表格组件提供了现代化的数据管理界面,具备企业级应用所需的核心功能。
|
||||
574
web/src/apps/muse/base/table/Table.tsx
Normal file
574
web/src/apps/muse/base/table/Table.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
270
web/src/apps/muse/base/table/index.tsx
Normal file
270
web/src/apps/muse/base/table/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
305
web/src/apps/muse/base/table/modal.css
Normal file
305
web/src/apps/muse/base/table/modal.css
Normal file
@@ -0,0 +1,305 @@
|
||||
/* 模态框样式 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* 详情部分 */
|
||||
.detail-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.detail-section h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #1890ff;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
min-width: 80px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 类型徽章 */
|
||||
.type-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.type-markdown { background: #1890ff; }
|
||||
.type-json { background: #52c41a; }
|
||||
.type-html { background: #fa8c16; }
|
||||
.type-image { background: #eb2f96; }
|
||||
.type-video { background: #722ed1; }
|
||||
.type-audio { background: #13c2c2; }
|
||||
.type-code { background: #666; }
|
||||
.type-link { background: #1890ff; }
|
||||
.type-file { background: #999; }
|
||||
|
||||
/* 可见性徽章 */
|
||||
.visibility-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.visibility-public {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
border: 1px solid #b7eb8f;
|
||||
}
|
||||
|
||||
.visibility-private {
|
||||
background: #fff2f0;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ffccc7;
|
||||
}
|
||||
|
||||
.visibility-restricted {
|
||||
background: #fffbe6;
|
||||
color: #faad14;
|
||||
border: 1px solid #ffe58f;
|
||||
}
|
||||
|
||||
/* 标签容器 */
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 4px 8px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
|
||||
/* 文件列表 */
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.file-type {
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.file-type-self {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.file-type-data {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.file-type-generate {
|
||||
background: #fff7e6;
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
/* 摘要文本 */
|
||||
.summary-text {
|
||||
line-height: 1.6;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #1890ff;
|
||||
}
|
||||
|
||||
/* 权限网格 */
|
||||
.permission-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.permission-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.permission-item label {
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.enabled {
|
||||
color: #52c41a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
color: #ff4d4f;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.modal-content {
|
||||
margin: 10px;
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.modal-body,
|
||||
.modal-footer {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.permission-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
563
web/src/apps/muse/base/table/table.css
Normal file
563
web/src/apps/muse/base/table/table.css
Normal 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;
|
||||
}
|
||||
}
|
||||
74
web/src/apps/muse/base/table/types.ts
Normal file
74
web/src/apps/muse/base/table/types.ts
Normal 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;
|
||||
}
|
||||
179
web/src/apps/muse/components/MarkDetail.css
Normal file
179
web/src/apps/muse/components/MarkDetail.css
Normal 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;
|
||||
}
|
||||
}
|
||||
104
web/src/apps/muse/components/MarkDetal.tsx
Normal file
104
web/src/apps/muse/components/MarkDetal.tsx
Normal 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
216
web/src/apps/muse/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
21
web/src/apps/muse/modules/MarkModal.tsx
Normal file
21
web/src/apps/muse/modules/MarkModal.tsx
Normal 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}>
|
||||
×
|
||||
</button>
|
||||
<MarkDetail data={markData} />
|
||||
</ReactModal>
|
||||
);
|
||||
}
|
||||
490
web/src/apps/muse/modules/db.ts
Normal file
490
web/src/apps/muse/modules/db.ts
Normal 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;
|
||||
};
|
||||
232
web/src/apps/muse/modules/mark-service.ts
Normal file
232
web/src/apps/muse/modules/mark-service.ts
Normal 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);
|
||||
|
||||
};
|
||||
78
web/src/apps/muse/modules/mark.ts
Normal file
78
web/src/apps/muse/modules/mark.ts
Normal 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];
|
||||
243
web/src/apps/muse/modules/speak-db/README.md
Normal file
243
web/src/apps/muse/modules/speak-db/README.md
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
26
web/src/apps/muse/modules/speak-db/index.ts
Normal file
26
web/src/apps/muse/modules/speak-db/index.ts
Normal 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';
|
||||
565
web/src/apps/muse/modules/speak-db/speak-db.ts
Normal file
565
web/src/apps/muse/modules/speak-db/speak-db.ts
Normal 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;
|
||||
};
|
||||
399
web/src/apps/muse/modules/speak-db/speak-service.ts
Normal file
399
web/src/apps/muse/modules/speak-db/speak-service.ts
Normal 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);
|
||||
};
|
||||
32
web/src/apps/muse/modules/speak-db/speak.ts
Normal file
32
web/src/apps/muse/modules/speak-db/speak.ts
Normal 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);
|
||||
}
|
||||
|
||||
190
web/src/apps/muse/prompts/index.tsx
Normal file
190
web/src/apps/muse/prompts/index.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Bot, User } from 'lucide-react';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
content: string;
|
||||
role: 'user' | 'assistant';
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export const ChatInterface: React.FC = () => {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: '1',
|
||||
content: '你好!我是AI助手,有什么可以帮助您的吗?',
|
||||
role: 'assistant',
|
||||
timestamp: new Date()
|
||||
}
|
||||
]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// 自动滚动到最新消息
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
// 发送消息
|
||||
const handleSend = async () => {
|
||||
if (!inputValue.trim() || isLoading) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
content: inputValue.trim(),
|
||||
role: 'user',
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInputValue('');
|
||||
setIsLoading(true);
|
||||
|
||||
// 模拟AI回复
|
||||
setTimeout(() => {
|
||||
const aiMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
content: `我收到了您的消息:"${userMessage.content}"。这里是我的回复,您还有其他问题吗?`,
|
||||
role: 'assistant',
|
||||
timestamp: new Date()
|
||||
};
|
||||
setMessages(prev => [...prev, aiMessage]);
|
||||
setIsLoading(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-50">
|
||||
{/* 头部 */}
|
||||
<div className="bg-white border-b border-gray-200 p-4 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<Bot className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">智能工作台</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>
|
||||
);
|
||||
};
|
||||
8
web/src/apps/muse/videos/index.tsx
Normal file
8
web/src/apps/muse/videos/index.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { VadVoice } from './modules/VadVoice';
|
||||
|
||||
|
||||
export const App = () => {
|
||||
return <div className="h-full overflow-hidden">
|
||||
<VadVoice />
|
||||
</div>
|
||||
}
|
||||
449
web/src/apps/muse/videos/modules/VadVoice.tsx
Normal file
449
web/src/apps/muse/videos/modules/VadVoice.tsx
Normal 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 >
|
||||
}
|
||||
136
web/src/apps/muse/videos/modules/auc.ts
Normal file
136
web/src/apps/muse/videos/modules/auc.ts
Normal 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();
|
||||
30
web/src/apps/muse/videos/modules/config.ts
Normal file
30
web/src/apps/muse/videos/modules/config.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
243
web/src/apps/muse/videos/modules/speak-db/README.md
Normal file
243
web/src/apps/muse/videos/modules/speak-db/README.md
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
26
web/src/apps/muse/videos/modules/speak-db/index.ts
Normal file
26
web/src/apps/muse/videos/modules/speak-db/index.ts
Normal 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';
|
||||
565
web/src/apps/muse/videos/modules/speak-db/speak-db.ts
Normal file
565
web/src/apps/muse/videos/modules/speak-db/speak-db.ts
Normal 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;
|
||||
};
|
||||
399
web/src/apps/muse/videos/modules/speak-db/speak-service.ts
Normal file
399
web/src/apps/muse/videos/modules/speak-db/speak-service.ts
Normal 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);
|
||||
};
|
||||
41
web/src/apps/muse/videos/modules/speak-db/speak.ts
Normal file
41
web/src/apps/muse/videos/modules/speak-db/speak.ts
Normal 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;
|
||||
}
|
||||
5
web/src/apps/muse/videos/modules/style.css
Normal file
5
web/src/apps/muse/videos/modules/style.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
.low-energy-spin {
|
||||
animation: 2.5s linear 0s infinite normal forwards running spin;
|
||||
}
|
||||
41
web/src/apps/muse/videos/modules/text.ts
Normal file
41
web/src/apps/muse/videos/modules/text.ts
Normal 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
|
||||
}
|
||||
}
|
||||
108
web/src/apps/muse/videos/store/README.md
Normal file
108
web/src/apps/muse/videos/store/README.md
Normal 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
|
||||
- 批量操作支持,提高大量数据处理效率
|
||||
3
web/src/apps/muse/videos/store/index.ts
Normal file
3
web/src/apps/muse/videos/store/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Store exports
|
||||
export { useVoiceStore, useVoiceList, useVoiceLoading, useVoiceError, cleanupVoiceUrls } from './voiceStore';
|
||||
export type { VoiceState } from './voiceStore';
|
||||
350
web/src/apps/muse/videos/store/voiceStore.ts
Normal file
350
web/src/apps/muse/videos/store/voiceStore.ts
Normal 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);
|
||||
}
|
||||
179
web/src/apps/test/modules/ShowCode.tsx
Normal file
179
web/src/apps/test/modules/ShowCode.tsx
Normal 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
308
web/src/apps/test/run.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
web/src/apps/test/store/useFileStore.ts
Normal file
50
web/src/apps/test/store/useFileStore.ts
Normal 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
|
||||
}
|
||||
}))
|
||||
46
web/src/components/html.astro
Normal file
46
web/src/components/html.astro
Normal 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
9
web/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_POCKETBASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
143
web/src/lib/pocketbase.ts
Normal file
143
web/src/lib/pocketbase.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import PocketBase, { RecordModel } from 'pocketbase';
|
||||
|
||||
// PocketBase API URL - 你需要根据实际情况修改
|
||||
export const pb = new PocketBase(
|
||||
import.meta.env?.VITE_POCKETBASE_URL || 'https://pocketbase.pro.xiongxiao.me'
|
||||
);
|
||||
|
||||
// 用户类型
|
||||
export enum UserType {
|
||||
USER = 'user',
|
||||
ADMIN = 'superuser'
|
||||
}
|
||||
|
||||
// 用户信息接口,扩展PocketBase的RecordModel
|
||||
export interface UserInfo extends RecordModel {
|
||||
email: string;
|
||||
username?: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
verified?: boolean;
|
||||
}
|
||||
|
||||
// 管理员信息接口
|
||||
export interface AdminInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
// 登录响应接口
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
record: UserInfo | AdminInfo;
|
||||
userType: UserType;
|
||||
}
|
||||
|
||||
// 登录参数接口
|
||||
export interface LoginParams {
|
||||
email: string;
|
||||
password: string;
|
||||
userType: UserType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 普通用户登录
|
||||
*/
|
||||
export async function loginUser(email: string, password: string): Promise<LoginResponse> {
|
||||
try {
|
||||
const authData = await pb.collection('users').authWithPassword(email, password);
|
||||
|
||||
return {
|
||||
token: pb.authStore.token,
|
||||
record: authData.record as UserInfo,
|
||||
userType: UserType.USER
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('User login failed:', error);
|
||||
throw new Error('用户登录失败,请检查邮箱和密码');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员登录
|
||||
*/
|
||||
export async function loginAdmin(email: string, password: string): Promise<LoginResponse> {
|
||||
try {
|
||||
const authData = await pb.admins.authWithPassword(email, password);
|
||||
|
||||
return {
|
||||
token: pb.authStore.token,
|
||||
record: authData.record as unknown as AdminInfo,
|
||||
userType: UserType.ADMIN
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Admin login failed:', error);
|
||||
throw new Error('管理员登录失败,请检查邮箱和密码');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一登录函数
|
||||
*/
|
||||
export async function login(params: LoginParams): Promise<LoginResponse> {
|
||||
const { email, password, userType } = params;
|
||||
|
||||
if (userType === UserType.ADMIN) {
|
||||
return await loginAdmin(email, password);
|
||||
} else {
|
||||
return await loginUser(email, password);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
export function logout(): void {
|
||||
pb.authStore.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已登录
|
||||
*/
|
||||
export function isLoggedIn(): boolean {
|
||||
return pb.authStore.isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
export function getCurrentUser(): UserInfo | AdminInfo | null {
|
||||
if (!pb.authStore.isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return pb.authStore.model as UserInfo | AdminInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查当前用户是否为管理员
|
||||
*/
|
||||
export function isAdmin(): boolean {
|
||||
return pb.authStore.isValid && pb.authStore.isAdmin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新认证token
|
||||
*/
|
||||
export async function refreshAuth(): Promise<void> {
|
||||
try {
|
||||
if (pb.authStore.isValid) {
|
||||
if (pb.authStore.isAdmin) {
|
||||
await pb.admins.authRefresh();
|
||||
} else {
|
||||
await pb.collection('users').authRefresh();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth refresh failed:', error);
|
||||
pb.authStore.clear();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user