init
This commit is contained in:
20
.cnb.yml
Normal file
20
.cnb.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
# .cnb.yml
|
||||
include:
|
||||
- https://cnb.cool/kevisual/cnb/-/blob/main/.cnb/template.yml
|
||||
|
||||
.common_env: &common_env
|
||||
env:
|
||||
USERNAME: root
|
||||
imports:
|
||||
- https://cnb.cool/kevisual/env/-/blob/main/.env.development
|
||||
|
||||
$:
|
||||
vscode:
|
||||
- docker:
|
||||
image: docker.cnb.cool/kevisual/dev-env:latest
|
||||
services:
|
||||
- vscode
|
||||
- docker
|
||||
env: !reference [.common_env, env]
|
||||
imports: !reference [.common_env, imports]
|
||||
stages: !reference [.dev_template, stages]
|
||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
node_modules
|
||||
dist
|
||||
pack-dist
|
||||
|
||||
.DS_Store
|
||||
|
||||
.turbo
|
||||
|
||||
.pnpm-store
|
||||
|
||||
.tanstack
|
||||
.env*
|
||||
|
||||
!.env.example
|
||||
.pnpm-lock.yaml
|
||||
72
AGENTS.md
Normal file
72
AGENTS.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# AGENTS.md
|
||||
|
||||
本指南为在此仓库中工作的 AI 编码代理提供关键信息。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ui/ # shadcn/ui 组件(Base UI 基础组件)
|
||||
├── lib/ # 工具函数(cn() 函数用于 className 合并)
|
||||
├── modules/ # 应用模块(query client、basename)
|
||||
├── pages/ # 页面组件(默认导出)
|
||||
├── routes/ # TanStack Router 基于文件的路由
|
||||
├── styles/ # 全局样式、主题 CSS
|
||||
└── main.tsx # 应用入口
|
||||
```
|
||||
|
||||
|
||||
## 代码风格指南
|
||||
|
||||
### 模块目录结构
|
||||
|
||||
每个新模块(如 `page-app`)应遵循以下结构:
|
||||
|
||||
```
|
||||
pages/page-app/
|
||||
├── components/ # 模块专属组件
|
||||
├── hooks/ # 模块 React Query hooks(API 查询封装)
|
||||
├── modules/ # 模块功能函数(UI 组件、工具函数等)
|
||||
└── store/ # 模块状态管理(Zustand)
|
||||
```
|
||||
|
||||
### hooks/ 文件夹说明
|
||||
|
||||
每个模块的 `hooks/` 文件夹用于封装与该模块相关的 React Query hooks:
|
||||
|
||||
- **use-api-query.ts**: 使用 `@tanstack/react-query` 的 `useQuery` 封装 API 调用
|
||||
- 定义 `queryKeys` 常量用于缓存标识
|
||||
- 封装 `useQuery` hooks 用于数据获取(GET 请求)
|
||||
- 封装 `useMutation` hooks 用于数据修改(POST/PUT/DELETE 请求)
|
||||
- 支持预取(prefetch)和无限滚动(infinite query)
|
||||
- **index.ts**: 导出模块所有 hooks,便于统一导入使用
|
||||
|
||||
### 状态和数据获取
|
||||
|
||||
- **@tanstack/react-query** 用于数据获取、缓存和状态管理
|
||||
- 在模块的 `hooks/` 文件夹中封装 API 调用
|
||||
- QueryClient 实例位于 `src/modules/query.ts`
|
||||
- 在 `src/routes/__root.tsx` 中通过 `QueryClientProvider` 提供
|
||||
- **Zustand** 用于全局状态管理
|
||||
- **@kevisual/query** 用于底层 API 请求封装
|
||||
- **React Hook Form** 用于表单管理
|
||||
|
||||
## 核心依赖
|
||||
|
||||
- **@base-ui/react**: Headless UI 基础组件
|
||||
- **@tanstack/react-query**: 数据获取、缓存和状态管理(配合 hooks/ 使用)
|
||||
- **@tanstack/react-router**: 基于 TanStack Router 插件的文件路由
|
||||
- **class-variance-authority**: 基于变体的样式系统
|
||||
- **clsx + tailwind-merge**: 通过 `cn()` 提供 className 工具函数
|
||||
- **lucide-react**: 图标库
|
||||
- **react-hook-form**: 表单处理
|
||||
- **sonner**: Toast 通知
|
||||
- **zustand**: 状态管理
|
||||
- **tailwindcss v4**: 使用 @tailwindcss/vite 插件进行样式处理
|
||||
|
||||
## 主题系统
|
||||
|
||||
- **主题配色**: 采用黑白配色方案,提供简洁优雅的视觉体验
|
||||
- **主题模式**: 支持 light(浅色)和 dark(深色)模式切换
|
||||
- **主题实现**: 使用 `next-themes` 进行主题管理
|
||||
- **CSS 变量**: 主题相关的 CSS 变量定义在 `src/styles/theme.css` 中
|
||||
13
README.md
Normal file
13
README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# vite-react-template
|
||||
|
||||
## download template
|
||||
|
||||
```bash
|
||||
ev sync clone -i https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template
|
||||
```
|
||||
|
||||
## clone auth update
|
||||
|
||||
```bash
|
||||
ev sync clone -l -i https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/public/auth.json
|
||||
```
|
||||
20
components.json
Normal file
20
components.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
32
index.html
Normal file
32
index.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/jpg" href="https://kevisual.xiongxiao.me/root/center/panda.jpg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Light Code</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
21
kevisual.json
Normal file
21
kevisual.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kevisual",
|
||||
"share": "public"
|
||||
},
|
||||
"registry": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template",
|
||||
"clone": {
|
||||
".": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"syncd": [
|
||||
{
|
||||
"files": [
|
||||
"**/*"
|
||||
],
|
||||
"registry": ""
|
||||
}
|
||||
],
|
||||
"sync": {}
|
||||
}
|
||||
74
package.json
Normal file
74
package.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "muse",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"basename": "/root/muse",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"ui": "bunx shadcn@latest add ",
|
||||
"pub": "envision deploy ./dist -k muse -v 0.0.1 -y y -u"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@kevisual/api": "^0.0.62",
|
||||
"@kevisual/context": "^0.0.8",
|
||||
"@kevisual/router": "0.1.1",
|
||||
"@kevisual/video-tools": "^0.0.13",
|
||||
"@ricky0123/vad-web": "^0.0.30",
|
||||
"@szhsin/react-menu": "^4.5.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-router": "^1.166.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"convex": "^1.32.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"es-toolkit": "^1.45.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"graphology": "^0.26.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"marked": "^17.0.4",
|
||||
"nanoid": "^5.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"pouchdb-browser": "^9.0.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-resizable-panels": "^4.7.2",
|
||||
"react-virtualized": "^9.22.6",
|
||||
"sigma": "^3.0.2",
|
||||
"sonner": "^2.0.7",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kevisual/ai": "latest",
|
||||
"@kevisual/kv-login": "^0.1.17",
|
||||
"@kevisual/query": "0.0.53",
|
||||
"@kevisual/types": "^0.0.12",
|
||||
"@kevisual/vite-html-plugin": "^0.0.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-router-devtools": "^1.166.7",
|
||||
"@tanstack/router-plugin": "^1.166.7",
|
||||
"@types/node": "^25.4.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"dotenv": "^17.3.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "v8.0.0-beta.16"
|
||||
}
|
||||
}
|
||||
4012
pnpm-lock.yaml
generated
Normal file
4012
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
public/auth.json
Normal file
44
public/auth.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kevisual",
|
||||
"share": "public"
|
||||
},
|
||||
"registry": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template",
|
||||
"clone": {
|
||||
".": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"syncd": [
|
||||
{
|
||||
"files": [
|
||||
"**/*"
|
||||
],
|
||||
"registry": ""
|
||||
}
|
||||
],
|
||||
"scripts": {
|
||||
"auth": "ev sync clone -l -i https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/public/auth.json"
|
||||
},
|
||||
"sync": {
|
||||
"AGENTS.md": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/AGENTS.md",
|
||||
"vite.config.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/vite.config.ts",
|
||||
"src/main.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/main.tsx",
|
||||
"public/auth.json": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/public/auth.json",
|
||||
"src/agents/index.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/agents/index.ts",
|
||||
"src/modules/basename.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/modules/basename.ts",
|
||||
"src/modules/query.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/modules/query.ts",
|
||||
"src/routes/demo.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/routes/demo.tsx",
|
||||
"src/routes/index.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/routes/index.tsx",
|
||||
"src/routes/login.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/routes/login.tsx",
|
||||
"src/styles/theme.css": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/styles/theme.css",
|
||||
"src/pages/auth/index.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/index.tsx",
|
||||
"src/pages/auth/page.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/page.tsx",
|
||||
"src/pages/auth/store.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/store.ts",
|
||||
"src/pages/demo/page.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/demo/page.tsx",
|
||||
"src/pages/auth/hooks/index.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/hooks/index.ts",
|
||||
"src/pages/auth/hooks/use-api-query.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/hooks/use-api-query.ts",
|
||||
"src/pages/auth/modules/BaseHeader.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/modules/BaseHeader.tsx",
|
||||
"src/pages/demo/store/index.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/demo/store/index.ts"
|
||||
}
|
||||
}
|
||||
39
public/demo.html
Normal file
39
public/demo.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="https://kevisual.xiongxiao.me/root/center/panda.jpg" />
|
||||
<title>Demo</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" crossorigin href="./render.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module">
|
||||
import { render } from './render.js';
|
||||
console.log('render', render);
|
||||
const opts = {
|
||||
renderRoot: document.getElementById('root'),
|
||||
}
|
||||
render(opts);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
3
src/agents/app.ts
Normal file
3
src/agents/app.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { QueryRouterServer } from "@kevisual/router/browser"
|
||||
import { useContextKey } from '@kevisual/context'
|
||||
export const app = useContextKey('app', new QueryRouterServer())
|
||||
1
src/agents/index.ts
Normal file
1
src/agents/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './app.ts'
|
||||
187
src/components/ui/alert-dialog.tsx
Normal file
187
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Popup.Props & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Popup
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Close.Props &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Close
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
render={<Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
48
src/components/ui/badge.tsx
Normal file
48
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
|
||||
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ className, variant })),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
125
src/components/ui/breadcrumb.tsx
Normal file
125
src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||
|
||||
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="breadcrumb"
|
||||
data-slot="breadcrumb"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground gap-1.5 text-sm flex flex-wrap items-center wrap-break-word",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("gap-1 inline-flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"a">) {
|
||||
return useRender({
|
||||
defaultTagName: "a",
|
||||
props: mergeProps<"a">(
|
||||
{
|
||||
className: cn("hover:text-foreground transition-colors", className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "breadcrumb-link",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<ChevronRightIcon />
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-5 [&>svg]:size-4 flex items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon
|
||||
/>
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
58
src/components/ui/button.tsx
Normal file
58
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
94
src/components/ui/card.tsx
Normal file
94
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
29
src/components/ui/checkbox.tsx
Normal file
29
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon
|
||||
/>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
188
src/components/ui/command.tsx
Normal file
188
src/components/ui/command.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
} from "@/components/ui/input-group"
|
||||
import { SearchIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground rounded-xl! p-1 flex size-full flex-col overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = false,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"rounded-xl! top-1/3 translate-y-0 overflow-hidden p-0",
|
||||
className
|
||||
)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||
<InputGroup className="bg-input/30 border-input/30 h-8! rounded-lg! shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-72 scroll-py-1 outline-none overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn("text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-selected:bg-muted data-selected:text-foreground data-selected:*:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! [&_svg:not([class*='size-'])]:size-4 group/command-item data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||
</CommandPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn("text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
149
src/components/ui/dialog.tsx
Normal file
149
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("gap-2 flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-base leading-none font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
260
src/components/ui/dropdown-menu.tsx
Normal file
260
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 data-popup-open:bg-accent data-popup-open:text-accent-foreground flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 w-auto", className)}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn("text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
149
src/components/ui/input-group.tsx
Normal file
149
src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 h-8 rounded-lg border transition-colors in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-3 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start": "pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem] order-first",
|
||||
"inline-end": "pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem] order-last",
|
||||
"block-start":
|
||||
"px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start",
|
||||
"block-end":
|
||||
"px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"gap-2 text-sm shadow-none flex items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "",
|
||||
"icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||
VariantProps<typeof inputGroupButtonVariants> & {
|
||||
type?: "button" | "submit" | "reset"
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn("rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn("rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1 resize-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
20
src/components/ui/input.tsx
Normal file
20
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
26
src/components/ui/kbd.tsx
Normal file
26
src/components/ui/kbd.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 h-5 w-fit min-w-5 gap-1 rounded-sm px-1 font-sans text-xs font-medium [&_svg:not([class*='size-'])]:size-3 pointer-events-none inline-flex items-center justify-center select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn("gap-1 inline-flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
18
src/components/ui/label.tsx
Normal file
18
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
265
src/components/ui/menubar.tsx
Normal file
265
src/components/ui/menubar.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
function Menubar({ className, ...props }: MenubarPrimitive.Props) {
|
||||
return (
|
||||
<MenubarPrimitive
|
||||
data-slot="menubar"
|
||||
className={cn("bg-background h-8 gap-0.5 rounded-lg border p-[3px] flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarMenu({ ...props }: React.ComponentProps<typeof DropdownMenu>) {
|
||||
return <DropdownMenu data-slot="menubar-menu" {...props} />
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuGroup>) {
|
||||
return <DropdownMenuGroup data-slot="menubar-group" {...props} />
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPortal>) {
|
||||
return <DropdownMenuPortal data-slot="menubar-portal" {...props} />
|
||||
}
|
||||
|
||||
function MenubarTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuTrigger>) {
|
||||
return (
|
||||
<DropdownMenuTrigger
|
||||
data-slot="menubar-trigger"
|
||||
className={cn(
|
||||
"hover:bg-muted aria-expanded:bg-muted rounded-sm px-1.5 py-[2px] text-sm font-medium flex items-center outline-hidden select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="menubar-content"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn("bg-popover text-popover-foreground data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2", className )}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuItem>) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
data-slot="menubar-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn("focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-disabled:opacity-50 data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/menubar-item", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="menubar-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm data-inset:pl-7 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="left-1.5 size-4 [&_svg:not([class*='size-'])]:size-4 pointer-events-none absolute flex items-center justify-center">
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuRadioGroup>) {
|
||||
return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />
|
||||
}
|
||||
|
||||
function MenubarRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="menubar-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm data-disabled:opacity-50 data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="left-1.5 size-4 [&_svg:not([class*='size-'])]:size-4 pointer-events-none absolute flex items-center justify-center">
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuLabel> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuLabel
|
||||
data-slot="menubar-label"
|
||||
data-inset={inset}
|
||||
className={cn("px-1.5 py-1 text-sm font-medium data-inset:pl-7", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuSeparator>) {
|
||||
return (
|
||||
<DropdownMenuSeparator
|
||||
data-slot="menubar-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuShortcut>) {
|
||||
return (
|
||||
<DropdownMenuShortcut
|
||||
data-slot="menubar-shortcut"
|
||||
className={cn("text-muted-foreground group-focus/menubar-item:text-accent-foreground text-xs tracking-widest ml-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuSub>) {
|
||||
return <DropdownMenuSub data-slot="menubar-sub" {...props} />
|
||||
}
|
||||
|
||||
function MenubarSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuSubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuSubTrigger
|
||||
data-slot="menubar-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn("focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuSubContent>) {
|
||||
return (
|
||||
<DropdownMenuSubContent
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-32 rounded-lg p-1 shadow-lg ring-1 duration-100", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarPortal,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarItem,
|
||||
MenubarShortcut,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
}
|
||||
88
src/components/ui/popover.tsx
Normal file
88
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 flex flex-col gap-2.5 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-72 origin-(--transform-origin) outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Title
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Description.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Description
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
}
|
||||
191
src/components/ui/select.tsx
Normal file
191
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-1.5 py-1 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 gap-2 shrink-0 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px pointer-events-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 top-0 w-full", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 bottom-0 w-full", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
129
src/components/ui/sheet.tsx
Normal file
129
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn("bg-background data-open:animate-in data-closed:animate-out data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("gap-0.5 p-4 flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("gap-2 p-4 mt-auto flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground text-base font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
49
src/components/ui/sonner.tsx
Normal file
49
src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
80
src/components/ui/tabs.tsx
Normal file
80
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"gap-2 group/tabs flex data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("text-sm flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-lg border bg-transparent px-2.5 py-2 text-base transition-colors focus-visible:ring-3 aria-invalid:ring-3 md:text-sm placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
64
src/components/ui/tooltip.tsx
Normal file
64
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-foreground text-background z-50 w-fit max-w-xs origin-(--transform-origin)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 bg-foreground fill-foreground z-50 data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
126
src/index.css
Normal file
126
src/index.css
Normal file
@@ -0,0 +1,126 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "./styles/theme.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
27
src/main.tsx
Normal file
27
src/main.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import './index.css'
|
||||
import { getDynamicBasename } from './modules/basename'
|
||||
import './agents/index.ts';
|
||||
// Set up a Router instance
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
basepath: getDynamicBasename(),
|
||||
defaultPreload: 'intent',
|
||||
scrollRestoration: true,
|
||||
})
|
||||
|
||||
// Register things for typesafety
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById('root')!
|
||||
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement)
|
||||
root.render(<RouterProvider router={router} />)
|
||||
}
|
||||
32
src/modules/basename.ts
Normal file
32
src/modules/basename.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// @ts-ignore
|
||||
export const basename = BASE_NAME;
|
||||
|
||||
export const wrapBasename = (path: string) => {
|
||||
const hasEnd = path.endsWith('/')
|
||||
if (basename) {
|
||||
return `${basename}${path}` + (hasEnd ? '' : '/');
|
||||
} else {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// 动态计算 basename,根据当前 URL 路径
|
||||
export const getDynamicBasename = (): string => {
|
||||
const path = window.location.pathname
|
||||
const [user, key, id] = path.split('/').filter(Boolean)
|
||||
if (key === 'v1' && id) {
|
||||
return `/${user}/v1/${id}`
|
||||
}
|
||||
// 默认使用构建时的 basename
|
||||
return basename
|
||||
}
|
||||
|
||||
export const openLink = (path: string, target: string = '_self') => {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
window.open(path, target);
|
||||
return;
|
||||
}
|
||||
const url = new URL(path, window.location.origin);
|
||||
url.pathname = wrapBasename(url.pathname);
|
||||
window.open(url.toString(), target);
|
||||
}
|
||||
18
src/modules/query.ts
Normal file
18
src/modules/query.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Query, DataOpts } from '@kevisual/query';
|
||||
import { QueryLoginBrowser } from '@kevisual/api/query-login'
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const query = useContextKey('query', new Query({
|
||||
url: '/api/router',
|
||||
}));
|
||||
|
||||
export const queryClient = useContextKey('queryClient', new Query({
|
||||
url: '/client/router',
|
||||
}));
|
||||
|
||||
export const queryLogin = useContextKey('queryLogin', new QueryLoginBrowser({
|
||||
query: query
|
||||
}));
|
||||
|
||||
export const stackQueryClient = useContextKey('stackQueryClient', new QueryClient());
|
||||
1
src/pages/auth/hooks/index.ts
Normal file
1
src/pages/auth/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './use-api-query';
|
||||
55
src/pages/auth/hooks/use-api-query.ts
Normal file
55
src/pages/auth/hooks/use-api-query.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { queryLogin } from '@/modules/query';
|
||||
import { toast } from 'sonner';
|
||||
import type { UserInfo } from '../store';
|
||||
|
||||
export const authQueryKeys = {
|
||||
me: ['auth', 'me'] as const,
|
||||
token: ['auth', 'token'] as const,
|
||||
} as const;
|
||||
|
||||
export const useMe = () => {
|
||||
return useQuery({
|
||||
queryKey: authQueryKeys.me,
|
||||
queryFn: async () => {
|
||||
const res = await queryLogin.getMe();
|
||||
if (res.code === 200) {
|
||||
return res.data;
|
||||
}
|
||||
throw new Error(res.message || 'Failed to fetch user info');
|
||||
},
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
};
|
||||
|
||||
export const useSwitchOrg = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (username?: string) => {
|
||||
const res = await queryLogin.switchUser(username || '');
|
||||
if (res.code === 200) {
|
||||
return res.data;
|
||||
}
|
||||
throw new Error(res.message || 'Switch failed');
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('切换成功');
|
||||
queryClient.invalidateQueries({ queryKey: authQueryKeys.me });
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || '请求失败');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetToken = () => {
|
||||
return useQuery({
|
||||
queryKey: authQueryKeys.token,
|
||||
queryFn: () => queryLogin.getToken(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
};
|
||||
57
src/pages/auth/index.tsx
Normal file
57
src/pages/auth/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect } from "react"
|
||||
import { useLayoutStore } from "./store"
|
||||
import { useShallow } from "zustand/shallow"
|
||||
import { LogIn, LockKeyhole } from "lucide-react"
|
||||
export { BaseHeader } from './modules/BaseHeader'
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from '@tanstack/react-router';
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode,
|
||||
mustLogin?: boolean,
|
||||
}
|
||||
export const AuthProvider = ({ children, mustLogin }: Props) => {
|
||||
const store = useLayoutStore(useShallow(state => ({
|
||||
init: state.init,
|
||||
me: state.me,
|
||||
openLinkList: state.openLinkList,
|
||||
})));
|
||||
useEffect(() => {
|
||||
store.init()
|
||||
}, [])
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate();
|
||||
const isOpen = useMemo(() => {
|
||||
return store.openLinkList.some(item => location.pathname.startsWith(item))
|
||||
}, [location.pathname])
|
||||
const loginUrl = '/root/login/?redirect=' + encodeURIComponent(window.location.href);
|
||||
if (mustLogin && !store.me && !isOpen) {
|
||||
return (
|
||||
<div className="w-full h-full min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-6 p-10 rounded-2xl border border-border bg-card shadow-lg max-w-sm w-full mx-4">
|
||||
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-muted">
|
||||
<LockKeyhole className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h2 className="text-xl font-semibold text-foreground">需要登录</h2>
|
||||
<p className="text-sm text-muted-foreground">请先登录以继续访问此页面</p>
|
||||
</div>
|
||||
<div
|
||||
className="inline-flex items-center justify-center gap-2 w-full px-6 py-2.5 rounded-lg bg-foreground text-background text-sm font-medium transition-opacity hover:opacity-80 active:opacity-70"
|
||||
onClick={() => {
|
||||
// window.open(loginUrl, '_blank');
|
||||
navigate({ to: '/login' });
|
||||
}}
|
||||
>
|
||||
<LogIn className="w-4 h-4" />
|
||||
立即登录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <>
|
||||
{children}
|
||||
</>
|
||||
}
|
||||
93
src/pages/auth/modules/BaseHeader.tsx
Normal file
93
src/pages/auth/modules/BaseHeader.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Home, User, LogIn, LogOut } from 'lucide-react';
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useLayoutStore } from '../store';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const BaseHeader = (props: { main?: React.ComponentType | null }) => {
|
||||
const store = useLayoutStore(useShallow(state => ({
|
||||
me: state.me,
|
||||
clearMe: state.clearMe,
|
||||
links: state.links,
|
||||
showBaseHeader: state.showBaseHeader,
|
||||
})));
|
||||
const navigate = useNavigate();
|
||||
const meInfo = useMemo(() => {
|
||||
if (!store.me) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => navigate({ to: '/login' })}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<LogIn className="w-4 h-4" />
|
||||
<span>去登录</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{store.me.avatar && (
|
||||
<img
|
||||
src={store.me.avatar}
|
||||
alt="Avatar"
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
{!store.me.avatar && (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
<span className="font-medium text-gray-700">{store.me?.username}</span>
|
||||
<button
|
||||
onClick={() => store.clearMe?.()}
|
||||
className="flex items-center gap-1 px-2 py-1 text-sm text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors cursor-pointer"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}, [store.me, store.clearMe])
|
||||
if (!store.showBaseHeader) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2 text-lg w-full h-12 items-center justify-between bg-gray-200">
|
||||
<div className='px-2 flex items-center gap-1'>
|
||||
{
|
||||
store.links.map(link => (
|
||||
<div key={link.key || link.title}
|
||||
className="cursor-pointer flex items-center justify-center gap-1 p-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
onClick={() => {
|
||||
if (!link.href) return;
|
||||
if (link.href.startsWith('http') || link.isRoot) {
|
||||
window.open(link.href, '_blank');
|
||||
return;
|
||||
}
|
||||
navigate({
|
||||
to: link.href
|
||||
})
|
||||
}}
|
||||
>
|
||||
{link.key === 'home' && <Home className="w-4 h-4" />}
|
||||
{link.icon && <>{link.icon}</>}
|
||||
{!link.icon && link.title}
|
||||
</div>
|
||||
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div className='mr-4'>
|
||||
{meInfo}
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const LayoutMain = () => {
|
||||
return <BaseHeader />
|
||||
}
|
||||
81
src/pages/auth/page.tsx
Normal file
81
src/pages/auth/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
import '@kevisual/kv-login';
|
||||
import { checkPluginLogin } from '@kevisual/kv-login'
|
||||
import { useEffect } from 'react';
|
||||
import { useLayoutStore } from './store';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
|
||||
export const LoginComponent = ({ onLoginSuccess }: { onLoginSuccess: () => void }) => {
|
||||
useEffect(() => {
|
||||
// 监听登录成功事件
|
||||
const handleLoginSuccess = () => {
|
||||
console.log('监听到登录成功事件,关闭弹窗');
|
||||
onLoginSuccess();
|
||||
};
|
||||
const loginEmitter = useContextKey('login-emitter')
|
||||
console.log('KvLogin Types:', loginEmitter);
|
||||
|
||||
loginEmitter.on('login-success', handleLoginSuccess);
|
||||
|
||||
// 清理监听器
|
||||
return () => {
|
||||
loginEmitter.off('login-success', handleLoginSuccess);
|
||||
};
|
||||
}, [onLoginSuccess]);
|
||||
|
||||
// @ts-ignore
|
||||
return (<kv-login></kv-login>)
|
||||
}
|
||||
export const App = () => {
|
||||
const store = useLayoutStore(useShallow((state) => ({
|
||||
init: state.init,
|
||||
loginPageConfig: state.loginPageConfig,
|
||||
})));
|
||||
useEffect(() => {
|
||||
checkPluginLogin();
|
||||
}, []);
|
||||
const navigate = useNavigate();
|
||||
const handleLoginSuccess = async () => {
|
||||
await store.init()
|
||||
navigate({ to: '/' })
|
||||
};
|
||||
const { title, subtitle, footer } = store.loginPageConfig;
|
||||
return (
|
||||
<div className='w-full h-full relative overflow-hidden bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900'>
|
||||
{/* 背景装饰 - 圆形光晕 */}
|
||||
<div className='absolute top-1/4 -left-32 w-96 h-96 bg-purple-500/30 rounded-full blur-3xl'></div>
|
||||
<div className='absolute bottom-1/4 -right-32 w-96 h-96 bg-blue-500/30 rounded-full blur-3xl'></div>
|
||||
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-indigo-500/20 rounded-full blur-3xl'></div>
|
||||
|
||||
{/* 背景装饰 - 网格图案 */}
|
||||
<div className='absolute inset-0 opacity-[0.03] bg-[linear-gradient(rgba(255,255,255,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.1)_1px,transparent_1px)] bg-[size:50px_50px]'></div>
|
||||
|
||||
{/* 顶部装饰文字 */}
|
||||
<div className='absolute top-10 left-0 right-0 text-center'>
|
||||
<h1 className='text-4xl font-bold text-white/90 tracking-wider'>{title}</h1>
|
||||
<p className='mt-2 text-white/50 text-sm tracking-widest'>{subtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* 登录卡片容器 */}
|
||||
<div className='w-full h-full flex items-center justify-center p-8'>
|
||||
<div className='relative'>
|
||||
{/* 卡片外圈光效 */}
|
||||
<div className='absolute -inset-1 bg-gradient-to-r from-purple-500 via-blue-500 to-indigo-500 rounded-2xl blur opacity-30'></div>
|
||||
|
||||
{/* 登录组件容器 */}
|
||||
<div className='relative bg-slate-900/80 backdrop-blur-xl rounded-2xl border border-white/10 shadow-2xl overflow-hidden'>
|
||||
<LoginComponent onLoginSuccess={handleLoginSuccess} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部装饰 */}
|
||||
<div className='absolute bottom-6 left-0 right-0 text-center'>
|
||||
<p className='text-white/30 text-xs'>{footer}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App;
|
||||
134
src/pages/auth/store.ts
Normal file
134
src/pages/auth/store.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
|
||||
import { queryLogin, stackQueryClient } from '@/modules/query';
|
||||
import { create } from 'zustand';
|
||||
import { toast } from 'sonner';
|
||||
import { authQueryKeys } from './hooks';
|
||||
export type UserInfo = {
|
||||
id?: string;
|
||||
username?: string;
|
||||
nickname?: string | null;
|
||||
needChangePassword?: boolean;
|
||||
description?: string | null;
|
||||
type?: 'user' | 'org';
|
||||
orgs?: string[];
|
||||
avatar?: string;
|
||||
};
|
||||
export type LayoutStore = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openUser: boolean;
|
||||
setOpenUser: (openUser: boolean) => void;
|
||||
me?: UserInfo;
|
||||
setMe: (me: UserInfo) => void;
|
||||
clearMe: () => void;
|
||||
getMe: () => Promise<void>;
|
||||
switchOrg: (username?: string) => Promise<void>;
|
||||
isAdmin: boolean;
|
||||
setIsAdmin: (isAdmin: boolean) => void
|
||||
init: () => Promise<void>;
|
||||
openLinkList: string[];
|
||||
setOpenLinkList: (openLinkList: string[]) => void;
|
||||
loginPageConfig: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
footer: string;
|
||||
};
|
||||
setLoginPageConfig: (config: Partial<LayoutStore['loginPageConfig']>) => void;
|
||||
links: HeaderLink[];
|
||||
setLinks: (links: HeaderLink[]) => void;
|
||||
showBaseHeader: boolean;
|
||||
setShowBaseHeader: (showBaseHeader: boolean) => void;
|
||||
serverData: Record<string, any> | null;
|
||||
setServerData: (data: Record<string, any>) => void;
|
||||
initConvex: () => Promise<void>;
|
||||
};
|
||||
type HeaderLink = {
|
||||
title?: string;
|
||||
href: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
key?: string;
|
||||
isRoot?: boolean;
|
||||
};
|
||||
|
||||
export const useLayoutStore = create<LayoutStore>((set, get) => ({
|
||||
open: false,
|
||||
setOpen: (open) => set({ open }),
|
||||
openUser: false,
|
||||
setOpenUser: (openUser) => set({ openUser }),
|
||||
me: undefined,
|
||||
setMe: (me) => set({ me }),
|
||||
clearMe: () => {
|
||||
set({ me: undefined, isAdmin: false });
|
||||
},
|
||||
getMe: async () => {
|
||||
const data = await stackQueryClient.fetchQuery({
|
||||
queryKey: authQueryKeys.me,
|
||||
queryFn: async () => {
|
||||
const res = await queryLogin.getMe();
|
||||
if (res.code === 200) {
|
||||
return res.data;
|
||||
}
|
||||
throw new Error(res.message || 'Failed to fetch user info');
|
||||
},
|
||||
});
|
||||
set({ me: data, isAdmin: data?.orgs?.includes?.('admin') || false });
|
||||
},
|
||||
switchOrg: async (username?: string) => {
|
||||
const res = await queryLogin.switchUser(username || '');
|
||||
if (res.code === 200) {
|
||||
toast.success('切换成功');
|
||||
stackQueryClient.invalidateQueries({ queryKey: authQueryKeys.me });
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
toast.error(res.message || '请求失败');
|
||||
}
|
||||
},
|
||||
isAdmin: false,
|
||||
setIsAdmin: (isAdmin) => set({ isAdmin }),
|
||||
init: async () => {
|
||||
await queryLogin.init();
|
||||
const token = await queryLogin.checkLocalToken();
|
||||
if (token) {
|
||||
set({ me: {} });
|
||||
try {
|
||||
// const data = await stackQueryClient.fetchQuery({
|
||||
// queryKey: authQueryKeys.me,
|
||||
// }) as UserInfo;
|
||||
const userInfo = await queryLogin.checkLocalUser();
|
||||
if (userInfo) {
|
||||
set({ me: userInfo as UserInfo, isAdmin: userInfo.orgs?.includes?.('admin') || false });
|
||||
} else {
|
||||
set({ me: undefined, isAdmin: false });
|
||||
}
|
||||
} catch {
|
||||
set({ me: undefined, isAdmin: false });
|
||||
}
|
||||
}
|
||||
// 获取服务端数据
|
||||
// @ts-ignore
|
||||
const sererData = window.__SERVER_DATA__;
|
||||
if (sererData) {
|
||||
set({ serverData: sererData });
|
||||
}
|
||||
},
|
||||
initConvex: async () => { },
|
||||
openLinkList: ['/login'],
|
||||
setOpenLinkList: (openLinkList) => set({ openLinkList }),
|
||||
loginPageConfig: {
|
||||
title: '可视化管理平台',
|
||||
subtitle: '让工具和智能化触手可及',
|
||||
footer: '欢迎使用可视化管理平台 · 连接您的工具',
|
||||
},
|
||||
setLoginPageConfig: (config) => set((state) => ({
|
||||
loginPageConfig: { ...state.loginPageConfig, ...config },
|
||||
})),
|
||||
links: [{ title: '', href: '/', key: 'home' }],
|
||||
setLinks: (links) => set({ links }),
|
||||
showBaseHeader: true,
|
||||
setShowBaseHeader: (showBaseHeader) => set({ showBaseHeader }),
|
||||
serverData: null,
|
||||
setServerData: (data) => set({ serverData: data }),
|
||||
}));
|
||||
8
src/pages/demo/page.tsx
Normal file
8
src/pages/demo/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useDemoStore } from './store/index'
|
||||
export const App = () => {
|
||||
const demoStore = useDemoStore()
|
||||
console.log('demo', demoStore.formData)
|
||||
return <div>App</div>
|
||||
}
|
||||
|
||||
export default App;
|
||||
95
src/pages/demo/store/index.ts
Normal file
95
src/pages/demo/store/index.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { create } from 'zustand';
|
||||
import { query } from '@/modules/query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Data {
|
||||
id: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
type State = {
|
||||
formData: Record<string, any>;
|
||||
setFormData: (data: Record<string, any>) => void;
|
||||
showEdit: boolean;
|
||||
setShowEdit: (showEdit: boolean) => void;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
list: Data[];
|
||||
getItem: (id: string) => Promise<any>;
|
||||
getList: () => Promise<any>;
|
||||
updateData: (data: Data) => Promise<void>;
|
||||
deleteData: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useDemoStore = create<State>((set, get) => {
|
||||
return {
|
||||
formData: {},
|
||||
setFormData: (data) => set({ formData: data }),
|
||||
showEdit: false,
|
||||
setShowEdit: (showEdit) => set({ showEdit }),
|
||||
loading: false,
|
||||
setLoading: (loading) => set({ loading }),
|
||||
list: [],
|
||||
getItem: async (id) => {
|
||||
const { setLoading } = get();
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await query.post({
|
||||
path: 'demo',
|
||||
key: 'item',
|
||||
data: { id }
|
||||
})
|
||||
if (res.code === 200) {
|
||||
return res;
|
||||
} else {
|
||||
toast.error(res.message || '请求失败');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
getList: async () => {
|
||||
const { setLoading } = get();
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await query.post({
|
||||
path: 'demo',
|
||||
key: 'list'
|
||||
});
|
||||
if (res.code === 200) {
|
||||
const list = res.data?.list || []
|
||||
set({ list });
|
||||
} else {
|
||||
toast.error(res.message || '请求失败');
|
||||
}
|
||||
return res;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
updateData: async (data) => {
|
||||
const res = await query.post({
|
||||
path: 'demo',
|
||||
key: 'update',
|
||||
data
|
||||
})
|
||||
if (res.code === 200) {
|
||||
get().getList()
|
||||
} else {
|
||||
toast.error(res.message || '请求失败');
|
||||
}
|
||||
},
|
||||
deleteData: async (id) => {
|
||||
const res = await query.post({
|
||||
path: 'demo',
|
||||
key: 'delete',
|
||||
data: { id }
|
||||
})
|
||||
if (res.code === 200) {
|
||||
get().getList()
|
||||
} else {
|
||||
toast.error(res.message || '请求失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
46
src/pages/muse/base/card/MarkDetailList.css
Normal file
46
src/pages/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
src/pages/muse/base/card/MarkDetailList.tsx
Normal file
364
src/pages/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
src/pages/muse/base/docs/README.md
Normal file
191
src/pages/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
src/pages/muse/base/docs/docs.css
Normal file
542
src/pages/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
src/pages/muse/base/docs/example.tsx
Normal file
222
src/pages/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
src/pages/muse/base/docs/index.tsx
Normal file
222
src/pages/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
src/pages/muse/base/graph/sigma/index.tsx
Normal file
127
src/pages/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
src/pages/muse/base/index.tsx
Normal file
92
src/pages/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
src/pages/muse/base/mock/collection.ts
Normal file
326
src/pages/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
src/pages/muse/base/table/DetailModal.tsx
Normal file
153
src/pages/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
src/pages/muse/base/table/DragSelection.md
Normal file
172
src/pages/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
src/pages/muse/base/table/DragSelectionExample.tsx
Normal file
115
src/pages/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
src/pages/muse/base/table/README.md
Normal file
176
src/pages/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
src/pages/muse/base/table/Table.tsx
Normal file
574
src/pages/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
src/pages/muse/base/table/index.tsx
Normal file
270
src/pages/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
src/pages/muse/base/table/modal.css
Normal file
305
src/pages/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
src/pages/muse/base/table/table.css
Normal file
563
src/pages/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
src/pages/muse/base/table/types.ts
Normal file
74
src/pages/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
src/pages/muse/components/MarkDetail.css
Normal file
179
src/pages/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
src/pages/muse/components/MarkDetal.tsx
Normal file
104
src/pages/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>
|
||||
)
|
||||
}
|
||||
46
src/pages/muse/components/PasswordInput.tsx
Normal file
46
src/pages/muse/components/PasswordInput.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
interface PasswordInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PasswordInput: React.FC<PasswordInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className = '',
|
||||
}) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={`pr-10 ${className}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePasswordVisibility}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 p-1 hover:bg-gray-100 rounded transition-colors"
|
||||
title={showPassword ? '隐藏密码' : '显示密码'}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
213
src/pages/muse/index.tsx
Normal file
213
src/pages/muse/index.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { toast } from 'sonner';
|
||||
import { Panel, Group, Separator } from 'react-resizable-panels';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { App as Voice } from './voice/index.tsx';
|
||||
import { ChatInterface } from './prompts/index.tsx';
|
||||
import { BaseApp } from './base/index.tsx';
|
||||
import { exampleUsage, markService } from './modules/mark-service.ts';
|
||||
import { useMuseSetting } from './store/museSetting.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,
|
||||
showLeftPanel,
|
||||
showCenterPanel,
|
||||
toggleRightPanel,
|
||||
toggleLeftPanel,
|
||||
toggleCenterPanel,
|
||||
} = useMuseSetting();
|
||||
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={toggleLeftPanel}
|
||||
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={toggleCenterPanel}
|
||||
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={toggleRightPanel}
|
||||
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">
|
||||
<Group orientation="horizontal">
|
||||
{showLeftPanel && <LeftPanel />}
|
||||
|
||||
{showLeftPanel && showCenterPanel && (
|
||||
<Separator className="w-1 bg-gray-300 hover:bg-gray-400 cursor-col-resize transition-colors" />
|
||||
)}
|
||||
|
||||
{showCenterPanel && <CenterPanel />}
|
||||
|
||||
{showCenterPanel && showRightPanel && (
|
||||
<Separator className="w-1 bg-gray-300 hover:bg-gray-400 cursor-col-resize transition-colors" />
|
||||
)}
|
||||
|
||||
{showRightPanel && <RightPanel isVisible={showRightPanel} />}
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export const App: React.FC = () => {
|
||||
return (
|
||||
<MuseApp />
|
||||
);
|
||||
};
|
||||
|
||||
export const Record: React.FC = () => {
|
||||
return (
|
||||
<Voice />
|
||||
);
|
||||
}
|
||||
21
src/pages/muse/modules/MarkModal.tsx
Normal file
21
src/pages/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
src/pages/muse/modules/db.ts
Normal file
490
src/pages/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
src/pages/muse/modules/mark-service.ts
Normal file
232
src/pages/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
src/pages/muse/modules/mark.ts
Normal file
78
src/pages/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
src/pages/muse/modules/speak-db/README.md
Normal file
243
src/pages/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
src/pages/muse/modules/speak-db/index.ts
Normal file
26
src/pages/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
src/pages/muse/modules/speak-db/speak-db.ts
Normal file
565
src/pages/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
src/pages/muse/modules/speak-db/speak-service.ts
Normal file
399
src/pages/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
src/pages/muse/modules/speak-db/speak.ts
Normal file
32
src/pages/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
src/pages/muse/prompts/index.tsx
Normal file
190
src/pages/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>
|
||||
);
|
||||
};
|
||||
55
src/pages/muse/store/museSetting.ts
Normal file
55
src/pages/muse/store/museSetting.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface MuseSettingState {
|
||||
// 面板显示状态
|
||||
showRightPanel: boolean;
|
||||
showLeftPanel: boolean;
|
||||
showCenterPanel: boolean;
|
||||
|
||||
// 切换面板显示状态的方法
|
||||
toggleRightPanel: () => void;
|
||||
toggleLeftPanel: () => void;
|
||||
toggleCenterPanel: () => void;
|
||||
|
||||
// 设置面板状态的方法
|
||||
setShowRightPanel: (show: boolean) => void;
|
||||
setShowLeftPanel: (show: boolean) => void;
|
||||
setShowCenterPanel: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export const useMuseSetting = create<MuseSettingState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// 默认状态 - 所有面板都显示
|
||||
showRightPanel: true,
|
||||
showLeftPanel: true,
|
||||
showCenterPanel: true,
|
||||
|
||||
// 切换方法
|
||||
toggleRightPanel: () =>
|
||||
set((state) => ({ showRightPanel: !state.showRightPanel })),
|
||||
toggleLeftPanel: () =>
|
||||
set((state) => ({ showLeftPanel: !state.showLeftPanel })),
|
||||
toggleCenterPanel: () =>
|
||||
set((state) => ({ showCenterPanel: !state.showCenterPanel })),
|
||||
|
||||
// 设置方法
|
||||
setShowRightPanel: (show: boolean) =>
|
||||
set({ showRightPanel: show }),
|
||||
setShowLeftPanel: (show: boolean) =>
|
||||
set({ showLeftPanel: show }),
|
||||
setShowCenterPanel: (show: boolean) =>
|
||||
set({ showCenterPanel: show }),
|
||||
}),
|
||||
{
|
||||
name: 'muse-settings', // localStorage 中的 key
|
||||
// 可选:指定要持久化的字段
|
||||
partialize: (state) => ({
|
||||
showRightPanel: state.showRightPanel,
|
||||
showLeftPanel: state.showLeftPanel,
|
||||
showCenterPanel: state.showCenterPanel,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
8
src/pages/muse/voice/index.tsx
Normal file
8
src/pages/muse/voice/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>
|
||||
}
|
||||
246
src/pages/muse/voice/modules/AudioRecorder.README.md
Normal file
246
src/pages/muse/voice/modules/AudioRecorder.README.md
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Audio Recorder Module Documentation
|
||||
*
|
||||
* @description AudioRecorder模块使用说明文档
|
||||
* @tags audio, recorder, documentation, audioworklet
|
||||
* @createdAt 2025-12-24
|
||||
*/
|
||||
|
||||
# AudioRecorder 音频录制模块
|
||||
|
||||
## 概述
|
||||
|
||||
`AudioRecorder` 是一个独立的音频录制类,使用现代的 `AudioWorklet` API 替代了已弃用的 `ScriptProcessorNode`。该模块可以在纯 JavaScript 环境中运行,不依赖于 React Hooks。
|
||||
|
||||
## 主要特性
|
||||
|
||||
- ✅ 使用 AudioWorklet API(替代已弃用的 ScriptProcessorNode)
|
||||
- ✅ 独立的类设计,不依赖 React Hooks
|
||||
- ✅ 可在纯 JavaScript 环境运行
|
||||
- ✅ 支持自定义采样率和缓冲区大小
|
||||
- ✅ 内置音频数据格式转换(Float32Array 转 Base64)
|
||||
- ✅ 完善的资源清理机制
|
||||
- ✅ TypeScript 类型支持
|
||||
|
||||
## 安装
|
||||
|
||||
该模块已包含在项目中,位于 `src/apps/muse/voice/modules/AudioRecorder.ts`
|
||||
|
||||
## 基本使用
|
||||
|
||||
### 1. 创建实例
|
||||
|
||||
```typescript
|
||||
import { AudioRecorder } from './modules/AudioRecorder';
|
||||
|
||||
const recorder = new AudioRecorder({
|
||||
sampleRate: 16000, // 采样率,默认 16000
|
||||
bufferSize: 4096, // 缓冲区大小,默认 4096
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 设置音频数据回调
|
||||
|
||||
```typescript
|
||||
recorder.onAudioData((audioData: Float32Array) => {
|
||||
// 处理音频数据
|
||||
console.log('Received audio data:', audioData);
|
||||
|
||||
// 可以转换为 Base64
|
||||
const base64 = AudioRecorder.float32ArrayToBase64(audioData);
|
||||
console.log('Base64 data:', base64);
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 开始录制
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await recorder.start();
|
||||
console.log('Recording started');
|
||||
} catch (error) {
|
||||
console.error('Failed to start recording:', error);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 停止录制
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await recorder.stop();
|
||||
console.log('Recording stopped');
|
||||
} catch (error) {
|
||||
console.error('Failed to stop recording:', error);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 销毁实例
|
||||
|
||||
```typescript
|
||||
await recorder.destroy();
|
||||
```
|
||||
|
||||
## React 组件中使用
|
||||
|
||||
```typescript
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AudioRecorder } from './modules/AudioRecorder';
|
||||
|
||||
export const RecordingComponent = () => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const recorderRef = useRef<AudioRecorder | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 初始化录制器
|
||||
recorderRef.current = new AudioRecorder({
|
||||
sampleRate: 16000,
|
||||
bufferSize: 4096,
|
||||
});
|
||||
|
||||
// 设置音频数据回调
|
||||
recorderRef.current.onAudioData((audioData) => {
|
||||
// 处理音频数据
|
||||
const base64 = AudioRecorder.float32ArrayToBase64(audioData);
|
||||
// 发送到服务器或进行其他处理
|
||||
});
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
recorderRef.current?.destroy();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleStart = async () => {
|
||||
try {
|
||||
await recorderRef.current?.start();
|
||||
setIsRecording(true);
|
||||
} catch (error) {
|
||||
console.error('Error starting recording:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
try {
|
||||
await recorderRef.current?.stop();
|
||||
setIsRecording(false);
|
||||
} catch (error) {
|
||||
console.error('Error stopping recording:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={isRecording ? handleStop : handleStart}>
|
||||
{isRecording ? 'Stop Recording' : 'Start Recording'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 纯 JavaScript 使用
|
||||
|
||||
```javascript
|
||||
import { AudioRecorder } from './AudioRecorder.js';
|
||||
|
||||
// 创建实例
|
||||
const recorder = new AudioRecorder({
|
||||
sampleRate: 16000,
|
||||
bufferSize: 4096,
|
||||
});
|
||||
|
||||
// 设置回调
|
||||
recorder.onAudioData((audioData) => {
|
||||
const base64 = AudioRecorder.float32ArrayToBase64(audioData);
|
||||
console.log('Audio data:', base64);
|
||||
});
|
||||
|
||||
// 开始录制
|
||||
document.getElementById('startBtn').addEventListener('click', async () => {
|
||||
await recorder.start();
|
||||
});
|
||||
|
||||
// 停止录制
|
||||
document.getElementById('stopBtn').addEventListener('click', async () => {
|
||||
await recorder.stop();
|
||||
});
|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
### 构造函数
|
||||
|
||||
```typescript
|
||||
constructor(config?: AudioRecorderConfig)
|
||||
```
|
||||
|
||||
#### 参数
|
||||
|
||||
- `config.sampleRate` (number, 可选): 音频采样率,默认 16000 Hz
|
||||
- `config.bufferSize` (number, 可选): 音频缓冲区大小,默认 4096
|
||||
|
||||
### 方法
|
||||
|
||||
#### `onAudioData(callback: AudioDataCallback): void`
|
||||
|
||||
设置音频数据回调函数。
|
||||
|
||||
- `callback`: 接收 `Float32Array` 类型的音频数据
|
||||
|
||||
#### `async start(): Promise<void>`
|
||||
|
||||
开始录制音频。会请求麦克风权限。
|
||||
|
||||
#### `async stop(): Promise<void>`
|
||||
|
||||
停止录制音频并清理资源。
|
||||
|
||||
#### `getIsRecording(): boolean`
|
||||
|
||||
获取当前录制状态。
|
||||
|
||||
#### `async destroy(): Promise<void>`
|
||||
|
||||
销毁录制器实例并清理所有资源。
|
||||
|
||||
#### `static float32ArrayToBase64(float32Array: Float32Array): string`
|
||||
|
||||
静态方法:将 Float32Array 转换为 Base64 字符串。
|
||||
|
||||
## 技术细节
|
||||
|
||||
### AudioWorklet vs ScriptProcessorNode
|
||||
|
||||
| 特性 | ScriptProcessorNode (已弃用) | AudioWorklet |
|
||||
|------|----------------------------|--------------|
|
||||
| 执行环境 | 主线程 | 独立音频线程 |
|
||||
| 性能 | 可能阻塞 UI | 不阻塞 UI |
|
||||
| 延迟 | 较高 | 较低 |
|
||||
| 浏览器支持 | 已弃用 | 现代标准 |
|
||||
|
||||
### 浏览器兼容性
|
||||
|
||||
AudioWorklet API 支持:
|
||||
- Chrome 66+
|
||||
- Firefox 76+
|
||||
- Safari 14.1+
|
||||
- Edge 79+
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **HTTPS 要求**: 在生产环境中,麦克风访问需要 HTTPS 协议
|
||||
2. **用户权限**: 首次使用需要用户授予麦克风权限
|
||||
3. **资源清理**: 使用完毕后务必调用 `destroy()` 方法清理资源
|
||||
4. **错误处理**: 建议使用 try-catch 包裹异步方法调用
|
||||
|
||||
## 示例项目
|
||||
|
||||
参考 `src/apps/muse/voice/test/test-record.tsx` 查看完整的使用示例。
|
||||
|
||||
## 更新日志
|
||||
|
||||
### 2025-12-24
|
||||
- 初始版本发布
|
||||
- 使用 AudioWorklet 替代 ScriptProcessorNode
|
||||
- 支持独立使用,不依赖 React Hooks
|
||||
- 内置 Base64 转换工具
|
||||
202
src/pages/muse/voice/modules/AudioRecorder.example.ts
Normal file
202
src/pages/muse/voice/modules/AudioRecorder.example.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Audio Recorder Usage Example
|
||||
*
|
||||
* @description AudioRecorder在纯JavaScript环境中的使用示例
|
||||
* @tags audio, recorder, example, javascript
|
||||
* @createdAt 2025-12-24
|
||||
*/
|
||||
|
||||
import { AudioRecorder } from './AudioRecorder';
|
||||
|
||||
/**
|
||||
* 示例1: 基本使用
|
||||
*/
|
||||
export async function basicExample() {
|
||||
// 创建录制器实例
|
||||
const recorder = new AudioRecorder({
|
||||
sampleRate: 16000,
|
||||
bufferSize: 4096,
|
||||
});
|
||||
|
||||
// 设置音频数据回调
|
||||
recorder.onAudioData((audioData) => {
|
||||
console.log('Received audio data, length:', audioData.length);
|
||||
|
||||
// 转换为 Base64
|
||||
const base64 = AudioRecorder.float32ArrayToBase64(audioData);
|
||||
console.log('Base64 encoded:', base64.substring(0, 50) + '...');
|
||||
});
|
||||
|
||||
// 开始录制
|
||||
try {
|
||||
await recorder.start();
|
||||
console.log('✅ Recording started successfully');
|
||||
|
||||
// 录制5秒后停止
|
||||
setTimeout(async () => {
|
||||
await recorder.stop();
|
||||
console.log('✅ Recording stopped');
|
||||
|
||||
// 清理资源
|
||||
await recorder.destroy();
|
||||
console.log('✅ Recorder destroyed');
|
||||
}, 5000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例2: 发送到WebSocket
|
||||
*/
|
||||
export async function websocketExample() {
|
||||
const ws = new WebSocket('ws://localhost:8080/audio');
|
||||
|
||||
const recorder = new AudioRecorder({
|
||||
sampleRate: 16000,
|
||||
bufferSize: 4096,
|
||||
});
|
||||
|
||||
// 将音频数据发送到WebSocket
|
||||
recorder.onAudioData((audioData) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const base64 = AudioRecorder.float32ArrayToBase64(audioData);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'audio',
|
||||
data: base64,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
ws.onopen = async () => {
|
||||
console.log('WebSocket connected');
|
||||
await recorder.start();
|
||||
};
|
||||
|
||||
ws.onclose = async () => {
|
||||
console.log('WebSocket disconnected');
|
||||
await recorder.stop();
|
||||
await recorder.destroy();
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例3: 带有状态管理的录制器类
|
||||
*/
|
||||
export class ManagedRecorder {
|
||||
private recorder: AudioRecorder;
|
||||
private isRecording: boolean = false;
|
||||
private audioChunks: Float32Array[] = [];
|
||||
private onStatusChange?: (status: 'idle' | 'recording' | 'processing') => void;
|
||||
|
||||
constructor() {
|
||||
this.recorder = new AudioRecorder({
|
||||
sampleRate: 16000,
|
||||
bufferSize: 4096,
|
||||
});
|
||||
|
||||
// 收集音频数据
|
||||
this.recorder.onAudioData((audioData) => {
|
||||
this.audioChunks.push(new Float32Array(audioData));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置状态变化回调
|
||||
*/
|
||||
onStatus(callback: (status: 'idle' | 'recording' | 'processing') => void) {
|
||||
this.onStatusChange = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始录制
|
||||
*/
|
||||
async start() {
|
||||
if (this.isRecording) {
|
||||
console.warn('Already recording');
|
||||
return;
|
||||
}
|
||||
|
||||
this.audioChunks = [];
|
||||
await this.recorder.start();
|
||||
this.isRecording = true;
|
||||
this.onStatusChange?.('recording');
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止录制并返回所有音频数据
|
||||
*/
|
||||
async stop(): Promise<Float32Array> {
|
||||
if (!this.isRecording) {
|
||||
console.warn('Not recording');
|
||||
return new Float32Array(0);
|
||||
}
|
||||
|
||||
this.onStatusChange?.('processing');
|
||||
await this.recorder.stop();
|
||||
this.isRecording = false;
|
||||
|
||||
// 合并所有音频块
|
||||
const totalLength = this.audioChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const combined = new Float32Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const chunk of this.audioChunks) {
|
||||
combined.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
this.onStatusChange?.('idle');
|
||||
return combined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取录制状态
|
||||
*/
|
||||
getStatus(): 'idle' | 'recording' {
|
||||
return this.isRecording ? 'recording' : 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁录制器
|
||||
*/
|
||||
async destroy() {
|
||||
await this.recorder.destroy();
|
||||
this.audioChunks = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例4: 使用ManagedRecorder
|
||||
*/
|
||||
export async function managedRecorderExample() {
|
||||
const recorder = new ManagedRecorder();
|
||||
|
||||
// 监听状态变化
|
||||
recorder.onStatus((status) => {
|
||||
console.log('Status changed:', status);
|
||||
});
|
||||
|
||||
// 开始录制
|
||||
await recorder.start();
|
||||
console.log('Recording...');
|
||||
|
||||
// 5秒后停止并获取数据
|
||||
setTimeout(async () => {
|
||||
const audioData = await recorder.stop();
|
||||
console.log('Recorded audio length:', audioData.length);
|
||||
|
||||
// 转换为Base64
|
||||
const base64 = AudioRecorder.float32ArrayToBase64(audioData);
|
||||
console.log('Total Base64 length:', base64.length);
|
||||
|
||||
// 清理
|
||||
await recorder.destroy();
|
||||
}, 5000);
|
||||
}
|
||||
245
src/pages/muse/voice/modules/AudioRecorder.ts
Normal file
245
src/pages/muse/voice/modules/AudioRecorder.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Audio Recorder Module
|
||||
*
|
||||
* @description 独立的音频录制模块,使用AudioWorklet替代已弃用的ScriptProcessorNode,可在纯JS环境运行
|
||||
* @tags audio, recorder, audioworklet, web-audio-api
|
||||
* @createdAt 2025-12-24
|
||||
*/
|
||||
|
||||
export type AudioDataCallback = (audioData: Float32Array) => void;
|
||||
|
||||
export interface AudioRecorderConfig {
|
||||
sampleRate?: number;
|
||||
bufferSize?: number;
|
||||
}
|
||||
|
||||
export class AudioRecorder {
|
||||
private audioContext: AudioContext | null = null;
|
||||
private mediaStream: MediaStream | null = null;
|
||||
private sourceNode: MediaStreamAudioSourceNode | null = null;
|
||||
private workletNode: AudioWorkletNode | null = null;
|
||||
private isRecording: boolean = false;
|
||||
private onAudioDataCallback: AudioDataCallback | null = null;
|
||||
private config: Required<AudioRecorderConfig>;
|
||||
|
||||
constructor(config: AudioRecorderConfig = {}) {
|
||||
this.config = {
|
||||
sampleRate: config.sampleRate ?? 16000,
|
||||
bufferSize: config.bufferSize ?? 4096,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音频数据回调函数
|
||||
*/
|
||||
public onAudioData(callback: AudioDataCallback): void {
|
||||
this.onAudioDataCallback = callback;
|
||||
}
|
||||
getMediaStream(): MediaStream | null {
|
||||
return this.mediaStream;
|
||||
}
|
||||
/**
|
||||
* 开始录制
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (this.isRecording) {
|
||||
console.warn('Recording is already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取麦克风权限
|
||||
this.mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
}
|
||||
});
|
||||
|
||||
// 创建音频上下文
|
||||
this.audioContext = new AudioContext({
|
||||
sampleRate: this.config.sampleRate
|
||||
});
|
||||
|
||||
// 加载AudioWorklet处理器
|
||||
await this.loadAudioWorklet();
|
||||
|
||||
// 创建音频源节点
|
||||
this.sourceNode = this.audioContext.createMediaStreamSource(this.mediaStream);
|
||||
|
||||
// 创建AudioWorklet节点
|
||||
this.workletNode = new AudioWorkletNode(
|
||||
this.audioContext,
|
||||
'audio-recorder-processor',
|
||||
{
|
||||
processorOptions: {
|
||||
bufferSize: this.config.bufferSize,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 监听音频数据
|
||||
this.workletNode.port.onmessage = (event) => {
|
||||
if (event.data.type === 'audio-data' && this.onAudioDataCallback) {
|
||||
this.onAudioDataCallback(event.data.audioData);
|
||||
}
|
||||
};
|
||||
|
||||
// 连接节点
|
||||
this.sourceNode.connect(this.workletNode);
|
||||
this.workletNode.connect(this.audioContext.destination);
|
||||
|
||||
this.isRecording = true;
|
||||
console.log('Recording started');
|
||||
} catch (error) {
|
||||
console.error('Error starting recording:', error);
|
||||
await this.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止录制
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.isRecording) {
|
||||
console.warn('Recording is not in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.cleanup();
|
||||
this.isRecording = false;
|
||||
console.log('Recording stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取录制状态
|
||||
*/
|
||||
public getIsRecording(): boolean {
|
||||
return this.isRecording;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载AudioWorklet处理器
|
||||
*/
|
||||
private async loadAudioWorklet(): Promise<void> {
|
||||
if (!this.audioContext) {
|
||||
throw new Error('AudioContext is not initialized');
|
||||
}
|
||||
|
||||
// 创建AudioWorklet处理器代码
|
||||
const processorCode = `
|
||||
class AudioRecorderProcessor extends AudioWorkletProcessor {
|
||||
constructor(options) {
|
||||
super();
|
||||
this.bufferSize = options.processorOptions?.bufferSize || 4096;
|
||||
this.buffer = [];
|
||||
this.bufferLength = 0;
|
||||
}
|
||||
|
||||
process(inputs, outputs, parameters) {
|
||||
const input = inputs[0];
|
||||
if (input && input.length > 0) {
|
||||
const channelData = input[0];
|
||||
|
||||
// 累积音频数据
|
||||
this.buffer.push(new Float32Array(channelData));
|
||||
this.bufferLength += channelData.length;
|
||||
|
||||
// 当累积的数据达到bufferSize时,发送数据
|
||||
if (this.bufferLength >= this.bufferSize) {
|
||||
// 合并buffer中的所有数据
|
||||
const combinedData = new Float32Array(this.bufferLength);
|
||||
let offset = 0;
|
||||
for (const chunk of this.buffer) {
|
||||
combinedData.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
// 发送音频数据
|
||||
this.port.postMessage({
|
||||
type: 'audio-data',
|
||||
audioData: combinedData
|
||||
});
|
||||
|
||||
// 重置buffer
|
||||
this.buffer = [];
|
||||
this.bufferLength = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('audio-recorder-processor', AudioRecorderProcessor);
|
||||
`;
|
||||
|
||||
// 将处理器代码转换为Blob URL
|
||||
const blob = new Blob([processorCode], { type: 'application/javascript' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
try {
|
||||
await this.audioContext.audioWorklet.addModule(url);
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
private async cleanup(): Promise<void> {
|
||||
// 断开连接
|
||||
if (this.workletNode) {
|
||||
this.workletNode.disconnect();
|
||||
this.workletNode.port.onmessage = null;
|
||||
this.workletNode = null;
|
||||
}
|
||||
|
||||
if (this.sourceNode) {
|
||||
this.sourceNode.disconnect();
|
||||
this.sourceNode = null;
|
||||
}
|
||||
|
||||
// 关闭音频上下文
|
||||
if (this.audioContext) {
|
||||
await this.audioContext.close();
|
||||
this.audioContext = null;
|
||||
}
|
||||
|
||||
// 停止媒体流
|
||||
if (this.mediaStream) {
|
||||
this.mediaStream.getTracks().forEach(track => track.stop());
|
||||
this.mediaStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Float32Array转Base64
|
||||
*/
|
||||
public static float32ArrayToBase64(float32Array: Float32Array): string {
|
||||
const buffer = new ArrayBuffer(float32Array.length * 4);
|
||||
const view = new DataView(buffer);
|
||||
for (let i = 0; i < float32Array.length; i++) {
|
||||
view.setFloat32(i * 4, float32Array[i], true);
|
||||
}
|
||||
const binary = new Uint8Array(buffer);
|
||||
let binaryString = '';
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
binaryString += String.fromCharCode(binary[i]);
|
||||
}
|
||||
return typeof window !== 'undefined' && window.btoa
|
||||
? window.btoa(binaryString)
|
||||
: Buffer.from(binaryString, 'binary').toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁实例
|
||||
*/
|
||||
public async destroy(): Promise<void> {
|
||||
await this.stop();
|
||||
this.onAudioDataCallback = null;
|
||||
}
|
||||
}
|
||||
148
src/pages/muse/voice/modules/SettingModal.tsx
Normal file
148
src/pages/muse/voice/modules/SettingModal.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../../../components/ui/dialog";
|
||||
import { useSettingStore } from '../store/settingStore';
|
||||
import { PasswordInput } from '../../components/PasswordInput';
|
||||
import { X, RotateCcw } from 'lucide-react';
|
||||
|
||||
export const SettingModal: React.FC = () => {
|
||||
const {
|
||||
isOpen,
|
||||
autoRecognize,
|
||||
listen,
|
||||
volcengineAucAppId,
|
||||
volcengineAucToken,
|
||||
closeModal,
|
||||
setAutoRecognize,
|
||||
setListen,
|
||||
setVolcengineAucAppId,
|
||||
setVolcengineAucToken,
|
||||
resetToDefault,
|
||||
} = useSettingStore();
|
||||
|
||||
const handleClose = () => {
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (window.confirm('确定要重置所有设置为默认值吗?')) {
|
||||
resetToDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose} >
|
||||
<DialogContent className="max-w-md max-h-[80vh] overflow-y-auto" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>语音设置</DialogTitle>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1 hover:bg-gray-100 rounded transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
配置语音录制和识别相关设置
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 语音识别设置 */}
|
||||
<div className="space-y-4">
|
||||
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-gray-700">自动识别语音</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRecognize}
|
||||
onChange={(e) => setAutoRecognize(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div
|
||||
onClick={() => setAutoRecognize(!autoRecognize)}
|
||||
className={`w-11 h-6 rounded-full cursor-pointer transition-colors ${autoRecognize ? 'bg-blue-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 bg-white rounded-full shadow-md transform transition-transform ${autoRecognize ? 'translate-x-5' : 'translate-x-0.5'
|
||||
} mt-0.5`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-gray-700">自动启动监听</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={listen}
|
||||
onChange={(e) => setListen(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div
|
||||
onClick={() => setListen(!listen)}
|
||||
className={`w-11 h-6 rounded-full cursor-pointer transition-colors ${listen ? 'bg-blue-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 bg-white rounded-full shadow-md transform transition-transform ${listen ? 'translate-x-5' : 'translate-x-0.5'
|
||||
} mt-0.5`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 火山引擎配置 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-900">火山引擎配置</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">App ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={volcengineAucAppId}
|
||||
onChange={(e) => setVolcengineAucAppId(e.target.value)}
|
||||
placeholder="请输入火山引擎 App ID"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Token</label>
|
||||
<PasswordInput
|
||||
value={volcengineAucToken}
|
||||
onChange={setVolcengineAucToken}
|
||||
placeholder="请输入火山引擎 Token"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
38
src/pages/muse/voice/modules/ShowText.tsx
Normal file
38
src/pages/muse/voice/modules/ShowText.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Tooltip, TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Copy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
||||
export const ShowText = (props: { text: string, icon?: any, title?: string }) => {
|
||||
const title = props.title || '';
|
||||
const icon = props.icon || '🕘';
|
||||
return (
|
||||
<div className="flex relative w-full overflow-hidden">
|
||||
<Tooltip >
|
||||
<TooltipTrigger className="text-left text-xs text-gray-400 mt-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{icon} {title ? `${title}:` : ''} {props.text}
|
||||
</TooltipTrigger>
|
||||
{props.text && <TooltipContent className="max-w-xs">
|
||||
{props.text}
|
||||
</TooltipContent>
|
||||
}
|
||||
</Tooltip>
|
||||
<div className="cursor-pointer shrink-0" onClick={() => {
|
||||
// copy
|
||||
navigator.clipboard.writeText(props.text).then(() => {
|
||||
toast.success('已复制', {
|
||||
position: 'top-center'
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error('复制失败:', error);
|
||||
toast.error('复制失败,请手动选择文字复制');
|
||||
});
|
||||
}}>
|
||||
<Copy className='text-gray-400' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
662
src/pages/muse/voice/modules/VadVoice.tsx
Normal file
662
src/pages/muse/voice/modules/VadVoice.tsx
Normal file
@@ -0,0 +1,662 @@
|
||||
import { MicVAD, utils } from "@ricky0123/vad-web"
|
||||
import clsx from "clsx";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import './style.css'
|
||||
import { MoreHorizontal, Play, Pause, Settings, FileAudio, StopCircle, Loader, Copy } from "lucide-react";
|
||||
import { Menu, MenuItem, MenuButton, } from '@szhsin/react-menu';
|
||||
import '@szhsin/react-menu/dist/index.css';
|
||||
import { toast } from 'sonner';
|
||||
import { Speak } from "./speak-db/speak";
|
||||
import { useVoiceStore } from "../store/voiceStore";
|
||||
import { useSettingStore } from "../store/settingStore";
|
||||
import { SettingModal } from "./SettingModal";
|
||||
import { AudioRecorder } from "./AudioRecorder";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { ShowText } from "./ShowText";
|
||||
|
||||
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>
|
||||
{data.text && data.text.trim() ? (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<MenuItem>
|
||||
<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>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
此语音包含文字内容:"{data.text}"。删除后将无法恢复,确定要删除吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>确认删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : (
|
||||
<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[] }) => {
|
||||
useEffect(() => {
|
||||
const bottomElement = document.getElementById('voice-list-bottom');
|
||||
if (bottomElement) {
|
||||
bottomElement.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [data]);
|
||||
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="flex items-center justify-between space-x-2">
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 truncate">
|
||||
{new Date(item.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-300">
|
||||
#{item.no}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
{item.text && item.text.trim() ? (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-5 h-5 hover:bg-red-100 rounded flex items-center justify-center text-red-500 transition-colors cursor-pointer"
|
||||
title="删除"
|
||||
>
|
||||
<svg className="w-3 h-3" 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>
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
此语音包含文字内容:"{item.text}"。删除后将无法恢复,确定要删除吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const { deleteVoice } = useVoiceStore.getState();
|
||||
deleteVoice(item.id)
|
||||
.then(() => {
|
||||
console.log('语音记录删除成功');
|
||||
toast.success('删除成功', { autoClose: 200 });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('删除语音记录失败:', error);
|
||||
toast.error('删除失败: ' + (error instanceof Error ? error.message : '未知错误'));
|
||||
});
|
||||
}}
|
||||
>
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const { deleteVoice } = useVoiceStore.getState();
|
||||
deleteVoice(item.id)
|
||||
.then(() => {
|
||||
console.log('语音记录删除成功');
|
||||
toast.success('删除成功', { autoClose: 200 });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('删除语音记录失败:', error);
|
||||
toast.error('删除失败: ' + (error instanceof Error ? error.message : '未知错误'));
|
||||
});
|
||||
}}
|
||||
className="w-5 h-5 hover:bg-red-100 rounded flex items-center justify-center text-red-500 transition-colors cursor-pointer"
|
||||
title="删除"
|
||||
>
|
||||
<svg className="w-3 h-3" 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>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
))}
|
||||
|
||||
<div id="voice-list-bottom" />
|
||||
</ul>)
|
||||
}
|
||||
export const VadVoice = () => {
|
||||
// 使用 Zustand store
|
||||
const {
|
||||
voiceList,
|
||||
isLoading: storeLoading,
|
||||
error: storeError,
|
||||
initialize: initializeStore,
|
||||
addVoice,
|
||||
setError: setStoreError,
|
||||
relatimeParialText,
|
||||
relatimeFinalText,
|
||||
lastRecognizedText
|
||||
} = useVoiceStore();
|
||||
const showText = relatimeFinalText || relatimeParialText;
|
||||
// 使用设置 store
|
||||
const {
|
||||
openModal: openSettingModal,
|
||||
listen,
|
||||
setListen,
|
||||
autoRecognize,
|
||||
setAutoRecognize
|
||||
} = useSettingStore();
|
||||
|
||||
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 audioRecorderRef = useRef<AudioRecorder | null>(null);
|
||||
const initializingRef = useRef<boolean>(false);
|
||||
|
||||
async function initializeVAD(ls: boolean = true) {
|
||||
if (!ls) { return }
|
||||
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({
|
||||
getStream: async () => {
|
||||
const audioRecorder = audioRecorderRef.current || new AudioRecorder({
|
||||
sampleRate: 16000,
|
||||
bufferSize: 4096,
|
||||
});
|
||||
await audioRecorder.start();
|
||||
// 设置音频数据回调
|
||||
audioRecorder.onAudioData((audioData) => {
|
||||
const base64 = AudioRecorder.float32ArrayToBase64(audioData);
|
||||
const relatime = useVoiceStore.getState().relatime;
|
||||
relatime?.sendBase64(base64, { isRelatime: true });
|
||||
});
|
||||
audioRecorderRef.current = audioRecorder;
|
||||
return audioRecorder.getMediaStream()!;
|
||||
},
|
||||
onSpeechEnd: async (audio) => {
|
||||
try {
|
||||
const wavBuffer = utils.encodeWAV(audio)
|
||||
const relatime = useVoiceStore.getState().relatime;
|
||||
relatime?.sendBase64?.(utils.arrayBufferToBase64(wavBuffer), { isRelatime: false });
|
||||
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);
|
||||
const relatime = useVoiceStore.getState().relatime;
|
||||
relatime?.setStartTime?.(Date.now());
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
// 如果是tauri的环境,不需要用户交互
|
||||
// @ts-ignore
|
||||
if (window.__TAURI_INTERNALS__) {
|
||||
handleUserInteraction();
|
||||
} else {
|
||||
document.addEventListener('click', handleFirstClick);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('click', handleFirstClick);
|
||||
};
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (!userInteracted) {
|
||||
return
|
||||
}
|
||||
console.log('VadVoice listen changed:', listen, userInteracted);
|
||||
initializeVAD(listen);
|
||||
return () => {
|
||||
ref.current?.destroy?.();
|
||||
ref.current = null;
|
||||
setVadStatus('idle');
|
||||
}
|
||||
|
||||
}, [listen, userInteracted]);
|
||||
const close = () => {
|
||||
if (ref.current) {
|
||||
ref.current.destroy();
|
||||
ref.current = null;
|
||||
setListen(false);
|
||||
setVadStatus('idle');
|
||||
audioRecorderRef.current?.stop();
|
||||
audioRecorderRef.current = null;
|
||||
console.log('VAD closed');
|
||||
}
|
||||
}
|
||||
|
||||
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-76 overflow-hidden">
|
||||
{
|
||||
voiceList.length === 0 ? (
|
||||
<div className="text-center text-gray-400 text-sm py-8">
|
||||
<div className="mb-2">🎤</div>
|
||||
<div>暂无录音</div>
|
||||
<div className="text-xs mt-1">开始说话以录音</div>
|
||||
</div>
|
||||
) : (
|
||||
<ShowVoicePlayer data={voiceList} />
|
||||
)
|
||||
}
|
||||
{!userInteracted && vadStatus === 'idle' && listen && (
|
||||
<div className="text-center text-gray-400 text-sm py-8" onClick={() => {
|
||||
setUserInteracted(true);
|
||||
initializeVAD(listen)
|
||||
}}>
|
||||
<div className="mb-2">🎤</div>
|
||||
<div>点击任意位置以初始化麦克风</div>
|
||||
<div className="text-xs mt-1">浏览器需要用户交互以访问麦克风</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Voice Control Bottom Section */}
|
||||
<div className="border-t border-gray-200 p-3 bg-gray-50 w-full overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center space-x-3 min-w-0 flex-1">
|
||||
<div className="relative shrink-0">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{listen && realListen && (
|
||||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-[90%]">
|
||||
<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 className="w-full">
|
||||
{lastRecognizedText && <ShowText text={lastRecognizedText} title="上次识别" icon={'🕘'} />}
|
||||
<ShowText text={showText} icon={'📝'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 shrink-0">
|
||||
{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"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleStartStop}
|
||||
disabled={vadStatus === 'initializing'}
|
||||
className={clsx(
|
||||
"md:flex w-8 h-8 text-xs font-medium rounded-full items-center justify-center transition-colors cursor-pointer",
|
||||
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' ? <Loader className="w-4 h-4 inline-block animate-spin" /> : (listen ? <StopCircle className="w-4 h-4 inline-block" /> :
|
||||
<Play className="w-4 h-4 inline-block" />
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
const newStatus = !autoRecognize;
|
||||
setAutoRecognize(newStatus);
|
||||
}}
|
||||
className={clsx(
|
||||
"hidden md:flex w-8 h-8 hover:bg-gray-200 rounded-full items-center justify-center text-gray-700 transition-colors cursor-pointer",
|
||||
{ "bg-blue-200": autoRecognize }
|
||||
)}
|
||||
|
||||
title={autoRecognize ? '自动转文字中' : '转文字禁用中'}>
|
||||
<FileAudio className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={openSettingModal}
|
||||
className="w-8 h-8 hover:bg-gray-200 rounded-full flex items-center justify-center text-gray-700 transition-colors cursor-pointer"
|
||||
title="设置"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设置弹窗 */}
|
||||
<SettingModal />
|
||||
</div >
|
||||
}
|
||||
136
src/pages/muse/voice/modules/auc.ts
Normal file
136
src/pages/muse/voice/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();
|
||||
32
src/pages/muse/voice/modules/config.ts
Normal file
32
src/pages/muse/voice/modules/config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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 {
|
||||
// 火山引擎APPID
|
||||
VOLCENGINE_AUC_APPID: getFromLocalStorage('VOLCENGINE_AUC_APPID', ''),
|
||||
// 火山引擎Access Token
|
||||
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);
|
||||
}
|
||||
};
|
||||
10
src/pages/muse/voice/modules/index.ts
Normal file
10
src/pages/muse/voice/modules/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Audio Recorder Module Exports
|
||||
*
|
||||
* @description 音频录制模块的导出文件
|
||||
* @tags audio, recorder, export
|
||||
* @createdAt 2025-12-24
|
||||
*/
|
||||
|
||||
export { AudioRecorder } from './AudioRecorder';
|
||||
export type { AudioDataCallback, AudioRecorderConfig } from './AudioRecorder';
|
||||
243
src/pages/muse/voice/modules/speak-db/README.md
Normal file
243
src/pages/muse/voice/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);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
10
src/pages/muse/voice/modules/speak-db/index.ts
Normal file
10
src/pages/muse/voice/modules/speak-db/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Speak 数据库和服务的统一导出文件
|
||||
|
||||
// 类型定义
|
||||
export * from './speak.ts';
|
||||
|
||||
// 数据库操作
|
||||
export * from './speak-db';
|
||||
|
||||
// 服务层
|
||||
export * from './speak-service';
|
||||
565
src/pages/muse/voice/modules/speak-db/speak-db.ts
Normal file
565
src/pages/muse/voice/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
src/pages/muse/voice/modules/speak-db/speak-service.ts
Normal file
399
src/pages/muse/voice/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
src/pages/muse/voice/modules/speak-db/speak.ts
Normal file
41
src/pages/muse/voice/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;
|
||||
}
|
||||
58
src/pages/muse/voice/modules/style.css
Normal file
58
src/pages/muse/voice/modules/style.css
Normal file
@@ -0,0 +1,58 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
.low-energy-spin {
|
||||
animation: 2.5s linear 0s infinite normal forwards running spin;
|
||||
}
|
||||
|
||||
/* 自定义滑块样式 */
|
||||
.slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-track {
|
||||
background: #e5e7eb;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: #3b82f6;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
margin-top: -6px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.slider::-moz-range-track {
|
||||
background: #e5e7eb;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
background: #3b82f6;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
41
src/pages/muse/voice/modules/text.ts
Normal file
41
src/pages/muse/voice/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
src/pages/muse/voice/store/README.md
Normal file
108
src/pages/muse/voice/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
src/pages/muse/voice/store/index.ts
Normal file
3
src/pages/muse/voice/store/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Store exports
|
||||
export { useVoiceStore, useVoiceList, useVoiceLoading, useVoiceError, cleanupVoiceUrls } from './voiceStore';
|
||||
export type { VoiceState } from './voiceStore';
|
||||
80
src/pages/muse/voice/store/relatime.ts
Normal file
80
src/pages/muse/voice/store/relatime.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { WSServer } from '@kevisual/video-tools/src/asr/ws.ts'
|
||||
import { useVoiceStore } from './voiceStore'
|
||||
|
||||
export class Relatime {
|
||||
asr: WSServer
|
||||
ready = false
|
||||
timeoutHandle: NodeJS.Timeout | null = null
|
||||
startTime: number = 0
|
||||
isRelatime: boolean = true
|
||||
constructor() {
|
||||
// const url = new URL('/ws/asr', "http://localhost:51015")
|
||||
const url = new URL('/ws/asr', window.location.origin)
|
||||
url.searchParams.set('id', 'muse-voice-relatime')
|
||||
const ws = new WSServer({
|
||||
url: url.toString(),
|
||||
onConnect: () => {
|
||||
console.log('WebSocket connected');
|
||||
ws.emitter.on("message", (data) => {
|
||||
// console.log("Received message:", data.data);
|
||||
const json = JSON.parse(data.data);
|
||||
console.log('json', json);
|
||||
if (json && json.type === 'connected') {
|
||||
ws.ws.send(JSON.stringify({ type: 'init' }));
|
||||
}
|
||||
if (json && json.type === 'asr' && json.code === 200) {
|
||||
ws.emitter.emit('asr');
|
||||
}
|
||||
if (json && json.type === 'partial' || json.type === 'result') {
|
||||
const text = json.text || '';
|
||||
const isPartial = json.type === 'partial';
|
||||
const isResult = json.type === 'result';
|
||||
if (isPartial) {
|
||||
// 部分结果
|
||||
useVoiceStore.getState().setRelatimeParialText(text);
|
||||
} else {
|
||||
// 最终结果
|
||||
useVoiceStore.getState().setRelatimeFinalText(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
ws.emitter.once('asr', async () => {
|
||||
console.log('ASR ready');
|
||||
this.ready = true;
|
||||
});
|
||||
}
|
||||
})
|
||||
this.asr = ws
|
||||
}
|
||||
send(data: Buffer) {
|
||||
if (!this.ready) return;
|
||||
const voice = data.toString('base64');
|
||||
this.asr.ws.send(JSON.stringify({ voice }));
|
||||
}
|
||||
setIsRelatime(isRelatime: boolean) {
|
||||
this.isRelatime = isRelatime;
|
||||
}
|
||||
async sendBase64(data: string, opts?: { isRelatime?: boolean }) {
|
||||
if (!this.ready) return;
|
||||
if (opts?.isRelatime !== this.isRelatime) {
|
||||
return;
|
||||
}
|
||||
// console.log('send 花费时间:', Date.now() - this.startTime);
|
||||
const connected = await this.asr.checkConnected();
|
||||
if (!connected) return;
|
||||
this.asr.ws.send(JSON.stringify({ voice: data, format: 'float32', time: Date.now(), ...opts }));
|
||||
// if (this.timeoutHandle) {
|
||||
// clearTimeout(this.timeoutHandle);
|
||||
// }
|
||||
// this.timeoutHandle = setTimeout(() => {
|
||||
// this.asr.sendBlankJson()
|
||||
// this.timeoutHandle = null;
|
||||
// }, 20000); // 20秒钟没有数据则发送空JSON保持连接
|
||||
}
|
||||
setStartTime(time: number) {
|
||||
this.startTime = time;
|
||||
}
|
||||
showCostTime() {
|
||||
console.log('当前花费时间:', Date.now() - this.startTime);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user