This commit is contained in:
2026-03-11 22:31:37 +08:00
commit 704303be13
113 changed files with 18949 additions and 0 deletions

20
.cnb.yml Normal file
View 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
View 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
View 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 hooksAPI 查询封装)
├── 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

44
public/auth.json Normal file
View 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
View 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
View 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
View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1 @@
export * from './use-api-query';

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

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

View 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 || '请求失败');
}
}
}
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,153 @@
import React from 'react';
import { Mark } from '../mock/collection';
import './modal.css';
interface DetailModalProps {
visible: boolean;
data: Mark | null;
onClose: () => void;
}
export const DetailModal: React.FC<DetailModalProps> = ({ visible, data, onClose }) => {
if (!visible || !data) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3></h3>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<div className="detail-section">
<h4></h4>
<div className="detail-grid">
<div className="detail-item">
<label>:</label>
<span>{data.title}</span>
</div>
<div className="detail-item">
<label>:</label>
<span className={`type-badge type-${data.markType}`}>{data.markType}</span>
</div>
<div className="detail-item">
<label>:</label>
<span>{data.uname}</span>
</div>
<div className="detail-item">
<label>:</label>
<span className={`visibility-badge visibility-${data.config.visibility}`}>
{data.config.visibility === 'public' ? '公开' :
data.config.visibility === 'private' ? '私有' : '受限'}
</span>
</div>
</div>
</div>
<div className="detail-section">
<h4></h4>
<p>{data.description}</p>
</div>
<div className="detail-section">
<h4></h4>
<div className="tags-container">
{data.tags.map((tag, index) => (
<span key={index} className="tag">{tag}</span>
))}
</div>
</div>
<div className="detail-section">
<h4></h4>
<div className="detail-grid">
<div className="detail-item">
<label>:</label>
<span>{new Date(data.markedAt).toLocaleString('zh-CN')}</span>
</div>
<div className="detail-item">
<label>:</label>
<span>{new Date(data.createdAt).toLocaleString('zh-CN')}</span>
</div>
<div className="detail-item">
<label>:</label>
<span>{new Date(data.updatedAt).toLocaleString('zh-CN')}</span>
</div>
<div className="detail-item">
<label>:</label>
<span>v{data.version}</span>
</div>
</div>
</div>
{data.fileList.length > 0 && (
<div className="detail-section">
<h4> ({data.fileList.length})</h4>
<div className="file-list">
{data.fileList.map((file, index) => (
<div key={index} className="file-item">
<div className="file-info">
<span className="file-name">{file.name}</span>
<span className="file-size">{formatFileSize(file.size)}</span>
</div>
<span className={`file-type file-type-${file.type}`}>{file.type}</span>
</div>
))}
</div>
</div>
)}
<div className="detail-section">
<h4></h4>
<p className="summary-text">{data.data.summary || data.summary}</p>
</div>
{data.config.allowComments !== undefined && (
<div className="detail-section">
<h4></h4>
<div className="permission-grid">
<div className="permission-item">
<label>:</label>
<span className={data.config.allowComments ? 'enabled' : 'disabled'}>
{data.config.allowComments ? '是' : '否'}
</span>
</div>
<div className="permission-item">
<label>:</label>
<span className={data.config.allowDownload ? 'enabled' : 'disabled'}>
{data.config.allowDownload ? '是' : '否'}
</span>
</div>
{data.config.expiredAt && (
<div className="permission-item">
<label>:</label>
<span>{new Date(data.config.expiredAt).toLocaleString('zh-CN')}</span>
</div>
)}
</div>
</div>
)}
</div>
<div className="modal-footer">
<button className="btn btn-default" onClick={onClose}></button>
<button className="btn btn-primary" onClick={() => {
alert('编辑功能待实现');
}}></button>
</div>
</div>
</div>
);
};
// 格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}

View File

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

View File

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

View File

@@ -0,0 +1,176 @@
# 数据管理表格组件
这是一个功能完整的React表格组件支持多选、排序、虚拟滚动、操作等功能并集成了Mock数据。
## 功能特性
### ✅ 已实现功能
1. **数据展示**
- 支持多种数据类型展示(标题、类型、标签、创建者等)
- 自定义列渲染(类型徽章、标签展示等)
- 响应式设计,适配移动端
2. **多选功能**
- 支持单行选择和全选
- 批量操作(批量删除)
- 选择状态实时显示
3. **排序功能**
- 支持多列排序(标题、类型、创建者、创建时间等)
- 升序/降序/取消排序
- 排序状态可视化指示
4. **虚拟滚动功能**
- 基于 react-virtualized 实现高性能虚拟滚动
- 支持大量数据展示而不影响性能
- 可配置行高和表格高度
- 固定表头,滚动内容区域
5. **操作功能**
- 详情查看(弹窗形式)
- 编辑功能
- 删除功能(单个/批量)
- 删除确认对话框
6. **详情模态框**
- 完整的数据信息展示
- 分区域显示(基本信息、描述、标签、时间信息等)
- 附件文件列表
- 权限设置显示
## 文件结构
```
base/table/
├── index.tsx # 主组件入口,集成所有功能
├── Table.tsx # 基础表格组件
├── DetailModal.tsx # 详情查看模态框
├── types.ts # TypeScript类型定义
├── table.css # 表格样式
└── modal.css # 模态框样式
```
## 使用的Mock数据
数据来源:`base/mock/collection.ts`
- 20条模拟的Mark记录
- 包含完整的用户、文件、配置等信息
- 支持各种数据类型和状态
## 组件特色
### 1. 类型安全
- 完整的TypeScript类型定义
- 严格的类型检查
- 良好的IDE支持
### 2. 用户体验
- 直观的操作界面
- 实时的状态反馈
- 响应式设计
- 加载状态和空状态处理
### 3. 数据展示
- 多种数据类型的可视化展示
- 颜色编码的类型和状态
- 格式化的时间和文件大小
### 4. 交互功能
- 丰富的操作按钮
- 确认对话框
- 详情查看弹窗
- 批量操作支持
## 技术实现
### 状态管理
- 使用React Hooks进行状态管理
- 分离的数据状态和UI状态
- 受控组件模式
### 样式设计
- 现代化的UI设计
- 一致的视觉风格
- 响应式布局
- 无障碍访问支持
### 数据处理
- 客户端排序和分页
- 嵌套数据访问
- 数据格式化和转换
## 快速开始
1. 确保已安装依赖:
```bash
pnpm install
```
2. 启动开发服务器:
```bash
npm run dev
```
3. 访问表格页面查看效果
## 自定义配置
### 添加新列
在 `index.tsx` 中的 `columns` 数组中添加新的列配置:
```tsx
{
key: 'newColumn',
title: '新列',
dataIndex: 'fieldName',
width: 120,
sortable: true,
render: (value, record) => {
// 自定义渲染逻辑
return <span>{value}</span>;
}
}
```
### 添加新操作
在 `actions` 数组中添加新的操作按钮:
```tsx
{
key: 'newAction',
label: '新操作',
type: 'primary',
icon: '🔧',
onClick: (record) => {
// 操作逻辑
}
}
```
### 修改虚拟滚动配置
调整 `virtualScrollConfig` 对象的属性:
```tsx
const virtualScrollConfig = {
rowHeight: 60, // 行高度
height: 600 // 表格容器高度
};
```
## 性能优化
1. **虚拟滚动**:已实现基于 react-virtualized 的虚拟滚动,支持大量数据高性能展示
2. **懒加载**:支持服务端分页和按需加载
3. **缓存**:实现数据缓存机制
4. **防抖**:搜索和过滤功能添加防抖处理
## 后续优化建议
1. **搜索功能**:添加全局搜索和列过滤
2. **导出功能**支持数据导出为Excel/CSV
3. **列配置**:支持用户自定义显示列
4. **主题配置**:支持多主题切换
5. **国际化**:添加多语言支持
这个表格组件提供了现代化的数据管理界面,具备企业级应用所需的核心功能。

View File

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

View File

@@ -0,0 +1,270 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Eye, Edit, Trash2 } from 'lucide-react';
import { Table } from './Table';
import { DetailModal } from './DetailModal';
import { Mark } from '../mock/collection';
import { TableColumn, ActionButton } from './types';
type Props = {
dataSource?: Mark[];
}
export const Base = (props: Props) => {
const { dataSource = [] } = props;
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [data, setData] = useState<Mark[]>([]);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [currentRecord, setCurrentRecord] = useState<Mark | null>(null);
useEffect(() => {
if (dataSource) {
setData(dataSource);
}
}, [dataSource]);
// 表格列配置
const columns: TableColumn<Mark>[] = [
{
key: 'title',
title: '标题',
dataIndex: 'title',
width: 300,
sortable: true,
render: (value: string, record: Mark) => (
<div>
<div className="title-text" style={{ fontWeight: 600, marginBottom: 4 }}>
{value}
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{record.description?.slice?.(0, 60)}...
</div>
</div>
)
},
{
key: 'markType',
title: '类型',
dataIndex: 'markType',
width: 100,
sortable: true,
render: (value: string) => {
if (!value) return ''
return (
<span
style={{
padding: '4px 8px',
borderRadius: '4px',
backgroundColor: getTypeColor(value),
color: '#fff',
fontSize: '12px'
}}
>
{value}
</span>
)
}
},
{
key: 'tags',
title: '标签',
dataIndex: 'tags',
width: 200,
render: (tags: string[] = []) => (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
{tags?.slice?.(0, 3).map((tag, index) => (
<span
key={index}
style={{
padding: '2px 6px',
backgroundColor: '#f0f0f0',
borderRadius: '2px',
fontSize: '11px',
color: '#666'
}}
>
{tag}
</span>
))}
{tags.length > 3 && (
<span style={{ fontSize: '11px', color: '#999' }}>
+{tags.length - 3}
</span>
)}
</div>
)
},
{
key: 'uname',
title: '创建者',
dataIndex: 'uname',
width: 120,
sortable: true
},
{
key: 'createdAt',
title: '创建时间',
dataIndex: 'createdAt',
width: 180,
sortable: true,
render: (value: Date) => new Date(value).toLocaleString('zh-CN')
},
{
key: 'data.visibility',
title: '可见性',
dataIndex: 'data.visibility',
width: 100,
render: (value: string) => (
<span style={{
color: value === 'public' ? '#52c41a' : value === 'private' ? '#ff4d4f' : '#faad14'
}}>
{value === 'public' ? '公开' : value === 'private' ? '私有' : '受限'}
</span>
)
}
];
// 操作按钮配置
const actions: ActionButton[] = [
{
key: 'view',
label: '详情',
icon: <Eye className="w-4 h-4 text-blue-600 hover:text-blue-700 cursor-pointer transition-colors" />,
onClick: (record: Mark) => {
handleViewDetail(record);
}
},
{
key: 'edit',
icon: <Edit className="w-4 h-4 text-green-600 hover:text-green-700 cursor-pointer transition-colors" />,
label: '编辑',
onClick: (record: Mark) => {
handleEdit(record);
}
},
{
key: 'delete',
label: '删除',
icon: <Trash2 className="w-4 h-4 text-red-600 hover:text-red-700 cursor-pointer transition-colors" />,
onClick: (record: Mark) => {
handleDelete(record);
}
}
];
// 获取类型颜色
const getTypeColor = (type: string): string => {
const colors: Record<string, string> = {
markdown: '#1890ff',
json: '#52c41a',
html: '#fa8c16',
image: '#eb2f96',
video: '#722ed1',
audio: '#13c2c2',
code: '#666',
link: '#1890ff',
file: '#999'
};
return colors[type] || '#999';
};
// 处理详情查看
const handleViewDetail = (record: Mark) => {
setCurrentRecord(record);
setDetailModalVisible(true);
};
// 处理编辑
const handleEdit = (record: Mark) => {
alert(`编辑: ${record.title}`);
// 这里可以打开编辑对话框或跳转到编辑页面
};
// 处理删除
const handleDelete = (record: Mark) => {
if (window.confirm(`确定要删除"${record.title}"吗?`)) {
setData(prevData => prevData.filter(item => item.id !== record.id));
// 如果当前选中的项包含被删除的项,也要从选中列表中移除
setSelectedRowKeys(prev => prev.filter(key => key !== record.id));
}
};
// 处理批量删除
const handleBatchDelete = () => {
if (selectedRowKeys.length === 0) return;
if (window.confirm(`确定要删除选中的 ${selectedRowKeys.length} 项吗?`)) {
setData(prevData => prevData.filter(item => !selectedRowKeys.includes(item.id)));
setSelectedRowKeys([]);
}
};
// 排序处理
const handleSort = (field: string, order: 'asc' | 'desc' | null) => {
if (!order) {
setData(dataSource); // 重置为原始顺序
return;
}
const sortedData = [...data].sort((a, b) => {
const getNestedValue = (obj: any, path: string) => {
return path.split('.').reduce((o, p) => o?.[p], obj);
};
const aVal = getNestedValue(a, field);
const bVal = getNestedValue(b, field);
if (aVal < bVal) return order === 'asc' ? -1 : 1;
if (aVal > bVal) return order === 'asc' ? 1 : -1;
return 0;
});
setData(sortedData);
};
// 虚拟滚动配置
const virtualScrollConfig = {
rowHeight: 60, // 因为有两行内容,需要更高的行高
// 移除固定高度,让表格自适应容器高度
};
return (
<div className='h-full flex flex-col'>
<div className='flex flex-col h-full' style={{ padding: '24px' }}>
{/* 固定头部区域 */}
<div style={{ marginBottom: '16px', flexShrink: 0 }}>
<h2 style={{ margin: '0 0 8px 0' }}></h2>
<p style={{ color: '#666', margin: '0' }}>
</p>
</div>
{/* 表格容器 - 占据剩余空间并支持滚动 */}
<div style={{
flex: 1,
minHeight: 0 // 重要允许flex容器收缩
}}>
<Table
data={data}
columns={columns}
actions={actions}
rowSelection={{
selectedRowKeys,
onChange: (keys, rows) => {
setSelectedRowKeys(keys);
}
}}
virtualScroll={virtualScrollConfig}
onSort={handleSort}
/>
</div>
<DetailModal
visible={detailModalVisible}
data={currentRecord}
onClose={() => {
setDetailModalVisible(false);
setCurrentRecord(null);
}}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,305 @@
/* 模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal-content {
background: #fff;
border-radius: 8px;
max-width: 800px;
width: 100%;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e8e8e8;
background: #fafafa;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #333;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.modal-close:hover {
background: #f0f0f0;
color: #333;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #e8e8e8;
background: #fafafa;
}
/* 详情部分 */
.detail-section {
margin-bottom: 24px;
}
.detail-section h4 {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
color: #333;
border-bottom: 2px solid #1890ff;
padding-bottom: 8px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 12px;
}
.detail-item {
display: flex;
align-items: center;
gap: 8px;
}
.detail-item label {
font-weight: 600;
color: #666;
min-width: 80px;
font-size: 14px;
}
.detail-item span {
color: #333;
font-size: 14px;
}
/* 类型徽章 */
.type-badge {
padding: 4px 8px;
border-radius: 4px;
color: #fff;
font-size: 12px;
font-weight: 500;
}
.type-markdown { background: #1890ff; }
.type-json { background: #52c41a; }
.type-html { background: #fa8c16; }
.type-image { background: #eb2f96; }
.type-video { background: #722ed1; }
.type-audio { background: #13c2c2; }
.type-code { background: #666; }
.type-link { background: #1890ff; }
.type-file { background: #999; }
/* 可见性徽章 */
.visibility-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.visibility-public {
background: #f6ffed;
color: #52c41a;
border: 1px solid #b7eb8f;
}
.visibility-private {
background: #fff2f0;
color: #ff4d4f;
border: 1px solid #ffccc7;
}
.visibility-restricted {
background: #fffbe6;
color: #faad14;
border: 1px solid #ffe58f;
}
/* 标签容器 */
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag {
padding: 4px 8px;
background: #f0f0f0;
border-radius: 4px;
font-size: 12px;
color: #666;
border: 1px solid #d9d9d9;
}
/* 文件列表 */
.file-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border: 1px solid #e8e8e8;
border-radius: 4px;
background: #fafafa;
}
.file-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.file-name {
font-weight: 500;
color: #333;
font-size: 14px;
}
.file-size {
font-size: 12px;
color: #999;
}
.file-type {
padding: 2px 6px;
border-radius: 2px;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
}
.file-type-self {
background: #e6f7ff;
color: #1890ff;
}
.file-type-data {
background: #f6ffed;
color: #52c41a;
}
.file-type-generate {
background: #fff7e6;
color: #fa8c16;
}
/* 摘要文本 */
.summary-text {
line-height: 1.6;
color: #666;
margin: 0;
padding: 12px;
background: #f9f9f9;
border-radius: 4px;
border-left: 4px solid #1890ff;
}
/* 权限网格 */
.permission-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.permission-item {
display: flex;
align-items: center;
gap: 8px;
}
.permission-item label {
font-weight: 600;
color: #666;
font-size: 14px;
}
.enabled {
color: #52c41a;
font-weight: 500;
}
.disabled {
color: #ff4d4f;
font-weight: 500;
}
/* 响应式 */
@media (max-width: 768px) {
.modal-content {
margin: 10px;
max-height: 95vh;
}
.modal-header,
.modal-body,
.modal-footer {
padding: 16px;
}
.detail-grid {
grid-template-columns: 1fr;
}
.permission-grid {
grid-template-columns: 1fr;
}
.file-item {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.modal-footer {
flex-direction: column;
}
}

View File

@@ -0,0 +1,563 @@
/* 表格容器 */
.table-container {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
height: calc(100% - 24px); /* 距离底部保持24px的间距 */
margin-bottom: 24px; /* 底部间距 */
display: flex;
flex-direction: column;
max-height: calc(100% - 24px); /* 添加最大高度限制 */
}
/* 工具栏 */
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f5f5f5;
border-bottom: 1px solid #e8e8e8;
flex-shrink: 0; /* 工具栏不压缩,保持固定高度 */
min-height: 56px; /* 确保工具栏有固定的最小高度 */
box-sizing: border-box; /* 包含padding和border在内的尺寸计算 */
}
.selected-info {
color: #666;
font-size: 14px;
display: flex;
flex-direction: column;
gap: 4px;
}
.drag-hint {
font-size: 12px;
color: #999;
font-style: italic;
}
.bulk-actions {
display: flex;
gap: 8px;
}
/* 表格主体 - 虚拟滚动布局 */
.table-wrapper {
display: flex;
flex-direction: column;
flex: 1; /* 占满剩余空间 */
min-height: 200px; /* 最小高度,避免过小 */
overflow: hidden; /* 防止溢出 */
height: 0; /* 重要配合flex: 1使用确保正确计算可用空间 */
}
/* 固定表头容器 */
.table-header-wrapper {
flex-shrink: 0;
border-bottom: 1px solid #e8e8e8;
background: #fafafa;
overflow: hidden;
}
.table-header-row {
display: flex;
align-items: center;
}
.table-header-cell {
display: flex;
align-items: center;
padding: 12px 16px;
border-right: 1px solid #e8e8e8;
font-weight: 600;
color: #333;
background: #fafafa;
}
.table-header-cell:last-child {
border-right: none;
}
.table-header-cell.sortable {
cursor: pointer;
user-select: none;
}
/* 虚拟滚动内容区域 */
.table-body-wrapper {
flex: 1;
overflow: hidden;
background: #fff;
min-height: 0; /* 重要允许flex子项收缩 */
position: relative; /* 为AutoSizer提供相对定位上下文 */
}
/* 拖拽选择框 */
.drag-selection-box {
background: rgba(24, 144, 255, 0.1);
border: 2px dashed #1890ff;
border-radius: 4px;
z-index: 1000;
animation: dragBoxPulse 0.3s ease-in-out;
}
@keyframes dragBoxPulse {
0% {
transform: scale(0.95);
opacity: 0.8;
}
50% {
transform: scale(1.02);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* 虚拟行样式 */
.virtual-row {
display: flex;
align-items: center;
border-bottom: 1px solid #e8e8e8;
background: #fff;
transition: background-color 0.2s;
position: relative;
}
.virtual-row:hover {
background: #f5f5f5;
}
/* 选中行样式 */
.virtual-row.row-selected {
background: #e6f7ff !important;
border-color: #91d5ff;
}
.virtual-row.row-selected:hover {
background: #bae7ff !important;
}
/* 拖拽选中行样式 */
.virtual-row.row-drag-selected {
background: #f0f9ff !important;
border-color: #69c0ff;
box-shadow: inset 0 0 0 1px #40a9ff;
}
.table-cell {
display: flex;
align-items: center;
padding: 12px 16px;
border-right: 1px solid #e8e8e8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-cell:last-child {
border-right: none;
}
/* 表头 */
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.sortable {
cursor: pointer;
user-select: none;
}
.sort-indicators {
display: flex;
flex-direction: column;
margin-left: 8px;
}
.sort-arrow {
font-size: 10px;
line-height: 1;
color: #ccc;
cursor: pointer;
transition: color 0.2s;
}
.sort-arrow.active {
color: #1890ff;
}
.sort-up {
margin-bottom: 2px;
}
/* 选择列 */
.selection-column {
width: 48px;
text-align: center;
position: relative;
}
.selection-column input[type="checkbox"] {
cursor: pointer;
transform: scale(1.1);
transition: all 0.2s;
}
.selection-column input[type="checkbox"]:hover {
transform: scale(1.2);
}
.selection-column input[type="checkbox"]:checked {
accent-color: #1890ff;
}
/* 拖拽选择时的表格样式 */
.table-body-wrapper[style*="cursor: crosshair"] {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.table-body-wrapper[style*="cursor: crosshair"] .virtual-row {
pointer-events: none;
}
.table-body-wrapper[style*="cursor: crosshair"] .selection-column,
.table-body-wrapper[style*="cursor: crosshair"] .actions-column {
pointer-events: auto;
}
/* 操作列 */
.actions-column {
width: auto;
min-width: 120px;
text-align: right;
padding: 8px 16px;
}
.action-buttons {
position: relative;
display: flex;
gap: 6px;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
}
/* 按钮样式 */
.btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 12px;
min-width: 32px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fff;
color: #333;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
text-decoration: none;
user-select: none;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
outline: none;
}
.btn:hover:not(:disabled) {
border-color: #40a9ff;
color: #40a9ff;
transform: translateY(-1px);
box-shadow: 0 4px 8px 0 rgba(64, 169, 255, 0.15);
}
.btn:focus:not(:disabled) {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(64, 169, 255, 0.2);
}
.btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important;
}
.btn-primary {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border-color: #1890ff;
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
border-color: #40a9ff;
color: #fff;
box-shadow: 0 4px 12px 0 rgba(24, 144, 255, 0.3);
}
.btn-primary:focus:not(:disabled) {
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3);
}
.btn-danger {
background: linear-gradient(135deg, #ff4d4f 0%, #f5222d 100%);
border-color: #ff4d4f;
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #ff7875 0%, #ff4d4f 100%);
border-color: #ff7875;
color: #fff;
box-shadow: 0 4px 12px 0 rgba(255, 77, 79, 0.3);
}
.btn-danger:focus:not(:disabled) {
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.3);
}
.btn-success {
background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
border-color: #52c41a;
color: #fff;
}
.btn-success:hover:not(:disabled) {
background: linear-gradient(135deg, #73d13d 0%, #52c41a 100%);
border-color: #73d13d;
color: #fff;
box-shadow: 0 4px 12px 0 rgba(82, 196, 26, 0.3);
}
.btn-success:focus:not(:disabled) {
box-shadow: 0 0 0 2px rgba(82, 196, 26, 0.3);
}
.btn-warning {
background: linear-gradient(135deg, #faad14 0%, #d48806 100%);
border-color: #faad14;
color: #fff;
}
.btn-warning:hover:not(:disabled) {
background: linear-gradient(135deg, #ffc53d 0%, #faad14 100%);
border-color: #ffc53d;
color: #fff;
box-shadow: 0 4px 12px 0 rgba(250, 173, 20, 0.3);
}
.btn-warning:focus:not(:disabled) {
box-shadow: 0 0 0 2px rgba(250, 173, 20, 0.3);
}
.btn-icon {
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
/* Tooltip 样式 */
.tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
font-size: 12px;
font-weight: 400;
line-height: 1.4;
border-radius: 4px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 4px solid transparent;
border-top-color: rgba(0, 0, 0, 0.85);
}
.btn:hover .tooltip {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(-4px);
}
/* 确保tooltip在不同位置的按钮中都能正确显示 */
.action-buttons {
position: relative;
}
/* 按钮尺寸变体 */
.btn-small {
padding: 4px 8px;
min-width: 24px;
height: 24px;
font-size: 11px;
}
.btn-small .btn-icon {
font-size: 12px;
}
.btn-medium {
padding: 8px 12px;
min-width: 32px;
height: 32px;
font-size: 12px;
}
.btn-medium .btn-icon {
font-size: 14px;
}
.btn-large {
padding: 12px 16px;
min-width: 40px;
height: 40px;
font-size: 14px;
}
.btn-large .btn-icon {
font-size: 16px;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 64px 16px;
color: #999;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
/* 加载状态 */
.table-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 16px;
color: #666;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid #f0f0f0;
border-top: 3px solid #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 响应式 */
@media (max-width: 768px) {
.table-toolbar {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.bulk-actions {
justify-content: flex-end;
}
.table-wrapper {
min-height: 300px; /* 移动端最小高度仍然使用flex: 1占满剩余空间 */
}
.action-buttons {
flex-direction: row;
gap: 4px;
justify-content: flex-end;
}
.actions-column {
min-width: 80px;
padding: 6px 12px;
}
.btn {
font-size: 11px;
padding: 6px 10px;
min-width: 28px;
height: 28px;
}
.btn-icon {
font-size: 12px;
}
/* 移动端tooltip调整 */
.tooltip {
font-size: 11px;
padding: 4px 8px;
margin-bottom: 6px;
}
.tooltip::after {
border-width: 3px;
}
/* 移动端按钮尺寸调整 */
.btn-small {
padding: 3px 6px;
min-width: 20px;
height: 20px;
font-size: 10px;
}
.btn-medium {
padding: 6px 10px;
min-width: 28px;
height: 28px;
font-size: 11px;
}
.btn-large {
padding: 8px 12px;
min-width: 32px;
height: 32px;
font-size: 12px;
}
}

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,21 @@
import ReactModal from 'react-modal'
import { MarkDetail } from '../components/MarkDetal'
export const MarkModal = ({ isOpen, onRequestClose, markData }: { isOpen: boolean; onRequestClose: () => void; markData: any }) => {
return (
<ReactModal
isOpen={isOpen}
onRequestClose={onRequestClose}
contentLabel="Mark Detail Modal"
className="mark-modal"
overlayClassName="mark-modal-overlay"
>
<button className="mark-modal-close" onClick={onRequestClose}>
&times;
</button>
<MarkDetail data={markData} />
</ReactModal>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,190 @@
import React, { useState, useRef, useEffect } from 'react';
import { Send, Bot, User } from 'lucide-react';
interface Message {
id: string;
content: string;
role: 'user' | 'assistant';
timestamp: Date;
}
export const ChatInterface: React.FC = () => {
const [messages, setMessages] = useState<Message[]>([
{
id: '1',
content: '你好我是AI助手有什么可以帮助您的吗',
role: 'assistant',
timestamp: new Date()
}
]);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// 自动滚动到最新消息
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// 发送消息
const handleSend = async () => {
if (!inputValue.trim() || isLoading) return;
const userMessage: Message = {
id: Date.now().toString(),
content: inputValue.trim(),
role: 'user',
timestamp: new Date()
};
setMessages(prev => [...prev, userMessage]);
setInputValue('');
setIsLoading(true);
// 模拟AI回复
setTimeout(() => {
const aiMessage: Message = {
id: (Date.now() + 1).toString(),
content: `我收到了您的消息:"${userMessage.content}"。这里是我的回复,您还有其他问题吗?`,
role: 'assistant',
timestamp: new Date()
};
setMessages(prev => [...prev, aiMessage]);
setIsLoading(false);
}, 1000);
};
// 处理键盘事件
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// 格式化时间
const formatTime = (date: Date) => {
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
};
return (
<div className="h-full flex flex-col bg-gray-50">
{/* 头部 */}
<div className="bg-white border-b border-gray-200 p-4 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center">
<Bot className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-xl font-semibold text-gray-900"></h1>
<p className="text-sm text-gray-500">线 · </p>
</div>
</div>
</div>
{/* 对话列表区域 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex gap-3 ${
message.role === 'user' ? 'justify-end' : 'justify-start'
}`}
>
{message.role === 'assistant' && (
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center flex-shrink-0">
<Bot className="w-5 h-5 text-white" />
</div>
)}
<div
className={`max-w-[70%] rounded-lg px-4 py-2 ${
message.role === 'user'
? 'bg-blue-500 text-white'
: 'bg-white text-gray-900 shadow-sm border border-gray-200'
}`}
>
<div className="text-sm leading-relaxed whitespace-pre-wrap">
{message.content}
</div>
<div
className={`text-xs mt-1 ${
message.role === 'user' ? 'text-blue-100' : 'text-gray-500'
}`}
>
{formatTime(message.timestamp)}
</div>
</div>
{message.role === 'user' && (
<div className="w-8 h-8 bg-gray-600 rounded-full flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-white" />
</div>
)}
</div>
))}
{/* 加载状态 */}
{isLoading && (
<div className="flex gap-3 justify-start">
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center flex-shrink-0">
<Bot className="w-5 h-5 text-white" />
</div>
<div className="bg-white rounded-lg px-4 py-2 shadow-sm border border-gray-200">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 输入框区域 */}
<div className="bg-white border-t border-gray-200 p-4">
<div className="flex gap-3 items-end">
<div className="flex-1 relative">
<textarea
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入您的消息... (按 Enter 发送Shift+Enter 换行)"
className="w-full px-4 py-3 border border-gray-300 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows={1}
style={{ minHeight: '96px', maxHeight: '180px' }}
disabled={isLoading}
/>
</div>
<button
onClick={handleSend}
disabled={!inputValue.trim() || isLoading}
className={`p-3 rounded-lg flex items-center justify-center transition-colors ${
!inputValue.trim() || isLoading
? 'bg-gray-300 cursor-not-allowed'
: 'bg-blue-500 hover:bg-blue-600 text-white'
}`}
>
<Send className="w-5 h-5" />
</button>
</div>
{/* 提示文本 */}
<div className="mt-2 text-xs text-gray-500 text-center">
AI助手会根据您的输入生成回复使
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,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,
}),
}
)
);

View File

@@ -0,0 +1,8 @@
import { VadVoice } from './modules/VadVoice';
export const App = () => {
return <div className="h-full overflow-hidden">
<VadVoice />
</div>
}

View File

@@ -0,0 +1,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 转换工具

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

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

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

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

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

View File

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

View File

@@ -0,0 +1,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);
}
};

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

View File

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

View File

@@ -0,0 +1,10 @@
// Speak 数据库和服务的统一导出文件
// 类型定义
export * from './speak.ts';
// 数据库操作
export * from './speak-db';
// 服务层
export * from './speak-service';

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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