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

View File

@@ -0,0 +1,70 @@
import { useState } from "react";
import { Base } from "./table/index";
const tabs = [
{
key: 'table',
title: '表格'
},
{
key: 'graph',
title: '关系图'
},
{
key: 'world',
title: '世界'
}
];
export const BaseApp = () => {
const [activeTab, setActiveTab] = useState('table');
const renderContent = () => {
switch (activeTab) {
case 'table':
return <Base />;
case 'graph':
return (
<div className="flex items-center justify-center h-96 text-gray-500">
</div>
);
case 'world':
return (
<div className="flex items-center justify-center h-96 text-gray-500">
</div>
);
default:
return null;
}
};
return (
<div className="w-full h-full">
{/* Tab 导航栏 */}
<div className="border-b border-gray-200">
<nav className="flex space-x-8">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === tab.key
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab.title}
</button>
))}
</nav>
</div>
{/* Tab 内容区域 */}
<div className="flex-1">
{renderContent()}
</div>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import { faker } from '@faker-js/faker';
import { nanoid, customAlphabet } from 'nanoid';
export const random = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ');
// 类型定义
export type MarkDataNode = {
id?: string;
content?: string;
type?: string;
title?: string;
position?: { x: number; y: number };
size?: { width: number; height: number };
metadata?: Record<string, any>;
[key: string]: any;
};
export type MarkFile = {
id: string;
name: string;
url: string;
size: number;
type: 'self' | 'data' | 'generate'; // generate为生成文件
query: string; // 'data.nodes[id].content';
hash: string;
fileKey: string; // 文件的名称, 唯一
};
export type MarkData = {
md?: string; // markdown
mdList?: string[]; // markdown list
type?: string; // 类型 markdown | json | html | image | video | audio | code | link | file
data?: any;
key?: string; // 文件的名称, 唯一
push?: boolean; // 是否推送到elasticsearch
pushTime?: Date; // 推送时间
summary?: string; // 摘要
nodes?: MarkDataNode[]; // 节点
[key: string]: any;
};
export type MarkConfig = {
visibility?: 'public' | 'private' | 'restricted';
allowComments?: boolean;
allowDownload?: boolean;
password?: string;
expiredAt?: Date;
[key: string]: any;
};
export type MarkAuth = {
permissions?: string[];
roles?: string[];
userId?: string;
[key: string]: any;
};
export type Mark = {
id: string;
title: string;
description: string;
cover: string;
thumbnail: string;
key: string;
markType: string;
link: string;
tags: string[];
summary: string;
data: MarkData;
uid: string;
puid: string;
config: MarkConfig;
fileList: MarkFile[];
uname: string;
markedAt: Date;
createdAt: Date;
updatedAt: Date;
version: number;
};
// 生成模拟的 MarkDataNode
const generateMarkDataNode = (): MarkDataNode => ({
id: random(12),
content: faker.lorem.paragraph(),
type: faker.helpers.arrayElement(['text', 'image', 'video', 'code', 'link']),
title: faker.lorem.sentence(),
position: {
x: faker.number.int({ min: 0, max: 1920 }),
y: faker.number.int({ min: 0, max: 1080 })
},
size: {
width: faker.number.int({ min: 100, max: 800 }),
height: faker.number.int({ min: 50, max: 600 })
},
metadata: {
createdBy: faker.person.fullName(),
lastModified: faker.date.recent(),
isLocked: faker.datatype.boolean()
}
});
// 生成模拟的 MarkFile
const generateMarkFile = (): MarkFile => ({
id: faker.string.uuid(),
name: faker.system.fileName(),
url: faker.internet.url(),
size: faker.number.int({ min: 1024, max: 50 * 1024 * 1024 }), // 1KB to 50MB
type: faker.helpers.arrayElement(['self', 'data', 'generate']),
query: `data.nodes[${random(12)}].content`,
hash: faker.git.commitSha(),
fileKey: faker.system.fileName()
});
// 生成模拟的 MarkData
const generateMarkData = (): MarkData => ({
md: faker.lorem.paragraphs(3, '\n\n'),
mdList: Array.from({ length: faker.number.int({ min: 3, max: 8 }) }, () => faker.lorem.sentence()),
type: faker.helpers.arrayElement(['markdown', 'json', 'html', 'image', 'video', 'audio', 'code', 'link', 'file']),
data: {
author: faker.person.fullName(),
category: faker.helpers.arrayElement(['技术', '生活', '工作', '学习', '思考']),
priority: faker.helpers.arrayElement(['low', 'medium', 'high'])
},
key: faker.system.fileName(),
push: faker.datatype.boolean(),
pushTime: faker.date.recent(),
summary: faker.lorem.paragraph(),
nodes: Array.from({ length: faker.number.int({ min: 2, max: 6 }) }, generateMarkDataNode)
});
// 生成模拟的 MarkConfig
const generateMarkConfig = (): MarkConfig => ({
visibility: faker.helpers.arrayElement(['public', 'private', 'restricted']),
allowComments: faker.datatype.boolean(),
allowDownload: faker.datatype.boolean(),
password: faker.datatype.boolean() ? faker.internet.password() : undefined,
expiredAt: faker.datatype.boolean() ? faker.date.future() : undefined,
theme: faker.helpers.arrayElement(['light', 'dark', 'auto']),
language: faker.helpers.arrayElement(['zh-CN', 'en-US', 'ja-JP'])
});
// 生成单个 Mark 记录
const generateMark = (): Mark => {
const markType = faker.helpers.arrayElement(['markdown', 'json', 'html', 'image', 'video', 'audio', 'code', 'link', 'file']);
const title = faker.lorem.sentence({ min: 3, max: 8 });
return {
id: faker.string.uuid(),
title,
description: faker.lorem.paragraph(),
cover: faker.image.url({ width: 800, height: 600 }),
thumbnail: faker.image.url({ width: 200, height: 150 }),
key: faker.system.filePath(),
markType,
link: faker.internet.url(),
tags: Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, () =>
faker.helpers.arrayElement(['技术', '前端', '后端', '设计', 'AI', '工具', '教程', '笔记'])
),
summary: faker.lorem.sentence(),
data: generateMarkData(),
uid: faker.string.uuid(),
puid: faker.string.uuid(),
config: generateMarkConfig(),
fileList: Array.from({ length: faker.number.int({ min: 0, max: 4 }) }, generateMarkFile),
uname: faker.person.fullName(),
markedAt: faker.date.past(),
createdAt: faker.date.past(),
updatedAt: faker.date.recent(),
version: faker.number.int({ min: 1, max: 10 })
};
};
// 生成 20 条模拟数据
export const mockMarks: Mark[] = Array.from({ length: 20 }, generateMark);
// 导出生成器函数
export {
generateMark,
generateMarkData,
generateMarkFile,
generateMarkDataNode,
generateMarkConfig
};

View File

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

View File

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

View File

@@ -0,0 +1,306 @@
import React, { useState, useMemo } from 'react';
import { Mark } from '../mock/collection';
import { TableProps, SortState } from './types';
import './table.css';
export const Table: React.FC<TableProps> = ({
data,
columns,
loading = false,
rowSelection,
pagination,
actions,
onSort
}) => {
const [sortState, setSortState] = useState<SortState>({ field: null, order: null });
const [currentPage, setCurrentPage] = useState(1);
// 处理排序
const handleSort = (field: string) => {
let newOrder: 'asc' | 'desc' | null = 'asc';
if (sortState.field === field) {
if (sortState.order === 'asc') {
newOrder = 'desc';
} else if (sortState.order === 'desc') {
newOrder = null;
}
}
const newSortState = { field: newOrder ? field : null, order: newOrder };
setSortState(newSortState);
onSort?.(newSortState.field!, newSortState.order!);
};
// 排序后的数据
const sortedData = useMemo(() => {
if (!sortState.field || !sortState.order) return data;
return [...data].sort((a, b) => {
const aVal = getNestedValue(a, sortState.field!);
const bVal = getNestedValue(b, sortState.field!);
if (aVal < bVal) return sortState.order === 'asc' ? -1 : 1;
if (aVal > bVal) return sortState.order === 'asc' ? 1 : -1;
return 0;
});
}, [data, sortState]);
// 分页数据
const paginatedData = useMemo(() => {
if (!pagination) return sortedData;
const start = (currentPage - 1) * pagination.pageSize;
const end = start + pagination.pageSize;
return sortedData.slice(start, end);
}, [sortedData, currentPage, pagination]);
// 处理分页变化
const handlePageChange = (page: number) => {
setCurrentPage(page);
if (pagination && typeof pagination === 'object') {
pagination.onChange?.(page, pagination.pageSize);
}
};
// 处理页大小变化
const handlePageSizeChange = (pageSize: number) => {
setCurrentPage(1);
if (pagination && typeof pagination === 'object') {
pagination.onChange?.(1, pageSize);
}
};
// 全选/取消全选
const handleSelectAll = (checked: boolean) => {
if (!rowSelection) return;
const allKeys = paginatedData.map(item => item.id);
const selectedKeys = checked ? allKeys : [];
const selectedRows = checked ? paginatedData : [];
rowSelection.onChange?.(selectedKeys, selectedRows);
};
// 单行选择
const handleRowSelect = (record: Mark, checked: boolean) => {
if (!rowSelection) return;
const currentKeys = rowSelection.selectedRowKeys || [];
const newKeys = checked
? [...currentKeys, record.id]
: currentKeys.filter(key => key !== record.id);
const selectedRows = data.filter(item => newKeys.includes(item.id));
rowSelection.onChange?.(newKeys, selectedRows);
};
// 获取嵌套值
const getNestedValue = (obj: any, path: string) => {
return path.split('.').reduce((o, p) => o?.[p], obj);
};
if (loading) {
return (
<div className="table-loading">
<div className="loading-spinner"></div>
<span>...</span>
</div>
);
}
const selectedKeys = rowSelection?.selectedRowKeys || [];
const isAllSelected = paginatedData.length > 0 && paginatedData.every(item => selectedKeys.includes(item.id));
const isIndeterminate = selectedKeys.length > 0 && !isAllSelected;
return (
<div className="table-container">
{/* 表格工具栏 */}
{rowSelection && selectedKeys.length > 0 && (
<div className="table-toolbar">
<span className="selected-info">
{selectedKeys.length}
</span>
<div className="bulk-actions">
<button className="btn btn-danger" onClick={() => {
// 批量删除逻辑
console.log('批量删除:', selectedKeys);
}}>
</button>
</div>
</div>
)}
{/* 表格 */}
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
{rowSelection && (
<th className="selection-column">
<input
type="checkbox"
checked={isAllSelected}
ref={input => {
if (input) input.indeterminate = isIndeterminate;
}}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</th>
)}
{columns.map(column => (
<th
key={column.key}
style={{ width: column.width }}
className={column.sortable ? 'sortable' : ''}
>
<div className="table-header">
<span>{column.title}</span>
{column.sortable && (
<div
className="sort-indicators"
onClick={() => handleSort(column.dataIndex)}
>
<span className={`sort-arrow sort-up ${
sortState.field === column.dataIndex && sortState.order === 'asc' ? 'active' : ''
}`}></span>
<span className={`sort-arrow sort-down ${
sortState.field === column.dataIndex && sortState.order === 'desc' ? 'active' : ''
}`}></span>
</div>
)}
</div>
</th>
))}
{actions && actions.length > 0 && (
<th className="actions-column"></th>
)}
</tr>
</thead>
<tbody>
{paginatedData.map((record, index) => (
<tr key={record.id} className="table-row">
{rowSelection && (
<td className="selection-column">
<input
type="checkbox"
checked={selectedKeys.includes(record.id)}
onChange={(e) => handleRowSelect(record, e.target.checked)}
disabled={rowSelection.getCheckboxProps?.(record)?.disabled}
/>
</td>
)}
{columns.map(column => (
<td key={column.key}>
{column.render
? column.render(getNestedValue(record, column.dataIndex), record, index)
: getNestedValue(record, column.dataIndex)
}
</td>
))}
{actions && actions.length > 0 && (
<td className="actions-column">
<div className="action-buttons">
{actions.map(action => (
<button
key={action.key}
className={`btn btn-${action.type || 'default'}`}
onClick={() => action.onClick(record)}
disabled={action.disabled?.(record)}
title={action.label}
>
{action.icon && <span className="btn-icon">{action.icon}</span>}
{action.label}
</button>
))}
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
{paginatedData.length === 0 && (
<div className="empty-state">
<div className="empty-icon">📭</div>
<p></p>
</div>
)}
</div>
{/* 分页 */}
{pagination && (
<div className="pagination-wrapper">
<div className="pagination-info">
{pagination.showTotal && pagination.showTotal(
pagination.total,
[
(currentPage - 1) * pagination.pageSize + 1,
Math.min(currentPage * pagination.pageSize, pagination.total)
]
)}
</div>
<div className="pagination-controls">
<button
className="btn btn-default"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage <= 1}
>
</button>
<div className="page-numbers">
{Array.from({ length: Math.ceil(pagination.total / pagination.pageSize) })
.map((_, i) => i + 1)
.filter(page => {
const distance = Math.abs(page - currentPage);
return distance === 0 || distance <= 2 || page === 1 || page === Math.ceil(pagination.total / pagination.pageSize);
})
.map((page, index, pages) => {
const prevPage = pages[index - 1];
const showEllipsis = prevPage && page - prevPage > 1;
return (
<React.Fragment key={page}>
{showEllipsis && <span className="page-ellipsis">...</span>}
<button
className={`btn page-btn ${currentPage === page ? 'active' : ''}`}
onClick={() => handlePageChange(page)}
>
{page}
</button>
</React.Fragment>
);
})
}
</div>
<button
className="btn btn-default"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= Math.ceil(pagination.total / pagination.pageSize)}
>
</button>
</div>
{pagination.showSizeChanger && (
<div className="page-size-selector">
<select
value={pagination.pageSize}
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
>
<option value={10}>10/</option>
<option value={20}>20/</option>
<option value={50}>50/</option>
<option value={100}>100/</option>
</select>
</div>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,281 @@
import React, { useState, useMemo } from 'react';
import { Table } from './Table';
import { DetailModal } from './DetailModal';
import { mockMarks, Mark } from '../mock/collection';
import { TableColumn, ActionButton } from './types';
export const Base = () => {
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [data, setData] = useState<Mark[]>(mockMarks);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [currentRecord, setCurrentRecord] = useState<Mark | null>(null);
// 表格列配置
const columns: TableColumn<Mark>[] = [
{
key: 'title',
title: '标题',
dataIndex: 'title',
width: 300,
sortable: true,
render: (value: string, record: Mark) => (
<div>
<div className="title-text" style={{ fontWeight: 600, marginBottom: 4 }}>
{value}
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{record.description.slice(0, 60)}...
</div>
</div>
)
},
{
key: 'markType',
title: '类型',
dataIndex: 'markType',
width: 100,
sortable: true,
render: (value: string) => (
<span
style={{
padding: '4px 8px',
borderRadius: '4px',
backgroundColor: getTypeColor(value),
color: '#fff',
fontSize: '12px'
}}
>
{value}
</span>
)
},
{
key: 'tags',
title: '标签',
dataIndex: 'tags',
width: 200,
render: (tags: string[]) => (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
{tags.slice(0, 3).map((tag, index) => (
<span
key={index}
style={{
padding: '2px 6px',
backgroundColor: '#f0f0f0',
borderRadius: '2px',
fontSize: '11px',
color: '#666'
}}
>
{tag}
</span>
))}
{tags.length > 3 && (
<span style={{ fontSize: '11px', color: '#999' }}>
+{tags.length - 3}
</span>
)}
</div>
)
},
{
key: 'uname',
title: '创建者',
dataIndex: 'uname',
width: 120,
sortable: true
},
{
key: 'createdAt',
title: '创建时间',
dataIndex: 'createdAt',
width: 180,
sortable: true,
render: (value: Date) => new Date(value).toLocaleString('zh-CN')
},
{
key: 'config.visibility',
title: '可见性',
dataIndex: 'config.visibility',
width: 100,
render: (value: string) => (
<span style={{
color: value === 'public' ? '#52c41a' : value === 'private' ? '#ff4d4f' : '#faad14'
}}>
{value === 'public' ? '公开' : value === 'private' ? '私有' : '受限'}
</span>
)
}
];
// 操作按钮配置
const actions: ActionButton[] = [
{
key: 'view',
label: '详情',
type: 'primary',
icon: '👁',
onClick: (record: Mark) => {
handleViewDetail(record);
}
},
{
key: 'edit',
label: '编辑',
icon: '✏️',
onClick: (record: Mark) => {
handleEdit(record);
}
},
{
key: 'delete',
label: '删除',
type: 'danger',
icon: '🗑️',
onClick: (record: Mark) => {
handleDelete(record);
}
}
];
// 获取类型颜色
const getTypeColor = (type: string): string => {
const colors: Record<string, string> = {
markdown: '#1890ff',
json: '#52c41a',
html: '#fa8c16',
image: '#eb2f96',
video: '#722ed1',
audio: '#13c2c2',
code: '#666',
link: '#1890ff',
file: '#999'
};
return colors[type] || '#999';
};
// 处理详情查看
const handleViewDetail = (record: Mark) => {
setCurrentRecord(record);
setDetailModalVisible(true);
};
// 处理编辑
const handleEdit = (record: Mark) => {
alert(`编辑: ${record.title}`);
// 这里可以打开编辑对话框或跳转到编辑页面
};
// 处理删除
const handleDelete = (record: Mark) => {
if (window.confirm(`确定要删除"${record.title}"吗?`)) {
setData(prevData => prevData.filter(item => item.id !== record.id));
// 如果当前选中的项包含被删除的项,也要从选中列表中移除
setSelectedRowKeys(prev => prev.filter(key => key !== record.id));
}
};
// 处理批量删除
const handleBatchDelete = () => {
if (selectedRowKeys.length === 0) return;
if (window.confirm(`确定要删除选中的 ${selectedRowKeys.length} 项吗?`)) {
setData(prevData => prevData.filter(item => !selectedRowKeys.includes(item.id)));
setSelectedRowKeys([]);
}
};
// 排序处理
const handleSort = (field: string, order: 'asc' | 'desc' | null) => {
if (!order) {
setData(mockMarks); // 重置为原始顺序
return;
}
const sortedData = [...data].sort((a, b) => {
const getNestedValue = (obj: any, path: string) => {
return path.split('.').reduce((o, p) => o?.[p], obj);
};
const aVal = getNestedValue(a, field);
const bVal = getNestedValue(b, field);
if (aVal < bVal) return order === 'asc' ? -1 : 1;
if (aVal > bVal) return order === 'asc' ? 1 : -1;
return 0;
});
setData(sortedData);
};
// 分页配置
const paginationConfig = {
current: currentPage,
pageSize: pageSize,
total: data.length,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number, range: [number, number]) =>
`${range[0]}-${range[1]} 条,共 ${total}`,
onChange: (page: number, size: number) => {
setCurrentPage(page);
setPageSize(size);
}
};
return (
<div style={{ padding: '24px' }}>
<div style={{ marginBottom: '16px' }}>
<h2></h2>
<p style={{ color: '#666', margin: '8px 0' }}>
</p>
</div>
{selectedRowKeys.length > 0 && (
<div style={{
marginBottom: '16px',
padding: '12px',
backgroundColor: '#e6f7ff',
borderRadius: '4px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<span> {selectedRowKeys.length} </span>
<button
className="btn btn-danger"
onClick={handleBatchDelete}
>
</button>
</div>
)}
<Table
data={data}
columns={columns}
actions={actions}
rowSelection={{
selectedRowKeys,
onChange: (keys, rows) => {
setSelectedRowKeys(keys);
}
}}
pagination={paginationConfig}
onSort={handleSort}
/>
<DetailModal
visible={detailModalVisible}
data={currentRecord}
onClose={() => {
setDetailModalVisible(false);
setCurrentRecord(null);
}}
/>
</div>
);
};

View File

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

View File

@@ -0,0 +1,312 @@
/* 表格容器 */
.table-container {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
/* 工具栏 */
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f5f5f5;
border-bottom: 1px solid #e8e8e8;
}
.selected-info {
color: #666;
font-size: 14px;
}
.bulk-actions {
display: flex;
gap: 8px;
}
/* 表格主体 */
.table-wrapper {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.data-table th,
.data-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #e8e8e8;
}
.data-table th {
background: #fafafa;
font-weight: 600;
color: #333;
position: sticky;
top: 0;
z-index: 10;
}
.data-table tbody tr:hover {
background: #f5f5f5;
}
/* 表头 */
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.sortable {
cursor: pointer;
user-select: none;
}
.sort-indicators {
display: flex;
flex-direction: column;
margin-left: 8px;
}
.sort-arrow {
font-size: 10px;
line-height: 1;
color: #ccc;
cursor: pointer;
transition: color 0.2s;
}
.sort-arrow.active {
color: #1890ff;
}
.sort-up {
margin-bottom: 2px;
}
/* 选择列 */
.selection-column {
width: 48px;
text-align: center;
}
.selection-column input[type="checkbox"] {
cursor: pointer;
}
/* 操作列 */
.actions-column {
width: 200px;
text-align: right;
}
.action-buttons {
display: flex;
gap: 8px;
justify-content: flex-end;
}
/* 按钮样式 */
.btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #fff;
color: #333;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.btn:hover {
border-color: #40a9ff;
color: #40a9ff;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #1890ff;
border-color: #1890ff;
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: #40a9ff;
border-color: #40a9ff;
}
.btn-danger {
background: #ff4d4f;
border-color: #ff4d4f;
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: #ff7875;
border-color: #ff7875;
}
.btn-icon {
font-size: 12px;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 64px 16px;
color: #999;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
/* 加载状态 */
.table-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 16px;
color: #666;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid #f0f0f0;
border-top: 3px solid #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 分页 */
.pagination-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #e8e8e8;
background: #fafafa;
}
.pagination-info {
color: #666;
font-size: 14px;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 8px;
}
.page-numbers {
display: flex;
align-items: center;
gap: 4px;
}
.page-btn {
min-width: 32px;
height: 32px;
padding: 0 8px;
border: 1px solid #d9d9d9;
background: #fff;
color: #333;
cursor: pointer;
transition: all 0.2s;
}
.page-btn:hover {
border-color: #40a9ff;
color: #40a9ff;
}
.page-btn.active {
background: #1890ff;
border-color: #1890ff;
color: #fff;
}
.page-ellipsis {
padding: 0 8px;
color: #999;
}
.page-size-selector {
margin-left: 16px;
}
.page-size-selector select {
padding: 4px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #fff;
color: #333;
font-size: 14px;
}
/* 响应式 */
@media (max-width: 768px) {
.table-toolbar {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.bulk-actions {
justify-content: flex-end;
}
.pagination-wrapper {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.pagination-controls {
justify-content: center;
}
.page-size-selector {
margin-left: 0;
text-align: center;
}
.action-buttons {
flex-direction: column;
gap: 4px;
}
.actions-column {
width: 120px;
}
.btn {
font-size: 11px;
padding: 4px 8px;
}
}

View File

@@ -0,0 +1,58 @@
import { Mark } from '../mock/collection';
// 表格列配置
export interface TableColumn<T = any> {
key: string;
title: string;
dataIndex: string;
width?: number;
render?: (value: any, record: T, index: number) => React.ReactNode;
sortable?: boolean;
fixed?: 'left' | 'right';
}
// 表格行选择配置
export interface RowSelection<T = any> {
type?: 'checkbox' | 'radio';
selectedRowKeys?: React.Key[];
onChange?: (selectedRowKeys: React.Key[], selectedRows: T[]) => void;
getCheckboxProps?: (record: T) => { disabled?: boolean };
}
// 分页配置
export interface PaginationConfig {
current: number;
pageSize: number;
total: number;
showSizeChanger?: boolean;
showQuickJumper?: boolean;
showTotal?: (total: number, range: [number, number]) => string;
onChange?: (page: number, pageSize: number) => void;
}
// 表格操作按钮类型
export interface ActionButton {
key: string;
label: string;
type?: 'primary' | 'default' | 'danger';
icon?: React.ReactNode;
onClick: (record: Mark) => void;
disabled?: (record: Mark) => boolean;
}
// 表格属性
export interface TableProps {
data: Mark[];
columns: TableColumn<Mark>[];
loading?: boolean;
rowSelection?: RowSelection<Mark>;
pagination?: PaginationConfig | false;
actions?: ActionButton[];
onSort?: (field: string, order: 'asc' | 'desc' | null) => void;
}
// 排序状态
export interface SortState {
field: string | null;
order: 'asc' | 'desc' | null;
}

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

@@ -0,0 +1,126 @@
import { ToastContainer } from 'react-toastify';
import { AuthProvider } from '../login/AuthProvider';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { useState } from 'react';
import { VadVoice } from './videos/modules/VadVoice.tsx';
import { ChatInterface } from './prompts/index.tsx';
import { BaseApp } from './base/index.tsx';
const LeftPanel = () => {
return (
<Panel defaultSize={50} minSize={10}>
<div className="h-full border-r border-gray-200">
<BaseApp />
</div>
</Panel>
);
};
const CenterPanel = () => {
return (
<Panel defaultSize={25} minSize={10}>
<div className="h-full border-r border-gray-200">
<ChatInterface />
</div>
</Panel>
);
};
const RightPanel = ({ isVisible }: { isVisible: boolean }) => {
if (!isVisible) return null;
return (
<Panel defaultSize={25} minSize={0}>
<div className="h-full bg-gray-50 p-4">
<h2 className="text-lg font-semibold mb-4">Right Panel</h2>
<div className="text-sm text-gray-600">
<VadVoice />
</div>
</div>
</Panel>
);
};
export const MuseApp = () => {
const [showRightPanel, setShowRightPanel] = useState(true);
const [showLeftPanel, setShowLeftPanel] = useState(true);
const [showCenterPanel, setShowCenterPanel] = useState(true);
return (
<div className="h-screen flex flex-col">
{/* Panel Controls */}
<div className="bg-white border-b border-gray-200 p-2 z-10">
<div className="flex gap-2">
<button
onClick={() => setShowLeftPanel(!showLeftPanel)}
className={`px-3 py-1 rounded text-sm ${showLeftPanel
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
AI
</button>
<button
onClick={() => setShowCenterPanel(!showCenterPanel)}
className={`px-3 py-1 rounded text-sm ${showCenterPanel
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
Center Panel
</button>
<button
onClick={() => setShowRightPanel(!showRightPanel)}
className={`px-3 py-1 rounded text-sm ${showRightPanel
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
Right Panel
</button>
</div>
</div>
{/* Resizable Panels */}
<div className="flex-1 overflow-hidden">
<PanelGroup direction="horizontal">
{showLeftPanel && <LeftPanel />}
{showLeftPanel && showCenterPanel && (
<PanelResizeHandle className="w-1 bg-gray-300 hover:bg-gray-400 cursor-col-resize transition-colors" />
)}
{showCenterPanel && <CenterPanel />}
{showCenterPanel && showRightPanel && (
<PanelResizeHandle className="w-1 bg-gray-300 hover:bg-gray-400 cursor-col-resize transition-colors" />
)}
{showRightPanel && <RightPanel isVisible={showRightPanel} />}
</PanelGroup>
</div>
</div>
);
}
export const App: React.FC = () => {
return (
<AuthProvider>
<MuseApp />
<ToastContainer
position="top-right"
autoClose={5000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="light"
/>
</AuthProvider>
);
};

View File

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

View File

@@ -0,0 +1,130 @@
import { MicVAD, utils } from "@ricky0123/vad-web"
import clsx from "clsx";
import { useState, useEffect, useRef } from "react";
import './style.css'
type speakType = {
timestamp: number;
url: string;
}
export const VadVoice = () => {
const [userList, setUserList] = useState<speakType[]>([]);
const [listen, setListen] = useState<boolean>(true);
const ref = useRef<MicVAD | null>(null);
async function main() {
const myvad = await MicVAD.new({
onSpeechEnd: (audio) => {
// do something with `audio` (Float32Array of audio samples at sample rate 16000)...
const wavBuffer = utils.encodeWAV(audio)
const base64 = utils.arrayBufferToBase64(wavBuffer)
// const url = `data:audio/wav;base64,${base64}`
const url = URL.createObjectURL(new Blob([wavBuffer], { type: 'audio/wav' }))
setUserList((prev) => [...prev, { timestamp: Date.now(), url }]);
},
onnxWASMBasePath: "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/dist/",
baseAssetPath: "https://cdn.jsdelivr.net/npm/@ricky0123/vad-web@0.0.27/dist/",
})
ref.current = myvad;
myvad.start();
return myvad;
}
useEffect(() => {
main();
}, [])
const close = () => {
if (ref.current) {
ref.current.destroy();
ref.current = null;
setListen(false);
}
}
return <div className="h-full flex flex-col">
{/* Audio Recordings List */}
<div className="flex-1 overflow-y-auto px-2 py-3">
{userList.length === 0 ? (
<div className="text-center text-gray-400 text-sm py-8">
<div className="mb-2">🎤</div>
<div>No recordings yet</div>
<div className="text-xs mt-1">Start talking to record</div>
</div>
) : (
<ul className="space-y-2">
{userList.map((item, index) => (
<li key={index} className="bg-white rounded-lg border border-gray-200 p-2 hover:shadow-sm transition-shadow">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 flex-1 min-w-0">
<div className="flex-shrink-0">
<audio
controls
style={{
transform: 'scale(0.85)',
transformOrigin: 'left center'
}}
>
<source src={item.url} type="audio/wav" />
Your browser does not support the audio element.
</audio>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-400 truncate">
{new Date(item.timestamp).toLocaleTimeString()}
</div>
<div className="text-xs text-gray-300">
#{userList.length - index}
</div>
</div>
</div>
</div>
</li>
))}
</ul>
)}
</div>
{/* Voice Control Bottom Section */}
<div className="border-t border-gray-200 p-3 bg-gray-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="relative">
<div className={clsx(
"h-8 w-8 rounded-lg bg-gradient-to-l from-[#7928CA] to-[#008080] flex items-center justify-center",
{ "animate-pulse": listen, "low-energy-spin": listen }
)}>
<div className="w-2 h-2 bg-white rounded-full"></div>
</div>
{listen && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
)}
</div>
<div>
<div className="text-sm font-medium text-gray-900">
{listen ? 'Listening...' : 'Paused'}
</div>
<div className="text-xs text-gray-500">
{userList.length} recording{userList.length !== 1 ? 's' : ''}
</div>
</div>
</div>
<button
onClick={() => {
if (listen) {
close();
} else {
main();
setListen(true);
}
}}
className={clsx(
"px-3 py-1.5 text-xs font-medium rounded-md transition-colors",
listen
? "bg-red-100 text-red-700 hover:bg-red-200"
: "bg-green-100 text-green-700 hover:bg-green-200"
)}
>
{listen ? 'Stop' : 'Start'}
</button>
</div>
</div>
</div >
}

View File

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