update
This commit is contained in:
70
web/src/apps/muse/base/index.tsx
Normal file
70
web/src/apps/muse/base/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
web/src/apps/muse/base/mock/collection.ts
Normal file
183
web/src/apps/muse/base/mock/collection.ts
Normal 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
|
||||
};
|
||||
153
web/src/apps/muse/base/table/DetailModal.tsx
Normal file
153
web/src/apps/muse/base/table/DetailModal.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { Mark } from '../mock/collection';
|
||||
import './modal.css';
|
||||
|
||||
interface DetailModalProps {
|
||||
visible: boolean;
|
||||
data: Mark | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DetailModal: React.FC<DetailModalProps> = ({ visible, data, onClose }) => {
|
||||
if (!visible || !data) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>详情信息</h3>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="detail-section">
|
||||
<h4>基本信息</h4>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<label>标题:</label>
|
||||
<span>{data.title}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>类型:</label>
|
||||
<span className={`type-badge type-${data.markType}`}>{data.markType}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>创建者:</label>
|
||||
<span>{data.uname}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>可见性:</label>
|
||||
<span className={`visibility-badge visibility-${data.config.visibility}`}>
|
||||
{data.config.visibility === 'public' ? '公开' :
|
||||
data.config.visibility === 'private' ? '私有' : '受限'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>描述</h4>
|
||||
<p>{data.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>标签</h4>
|
||||
<div className="tags-container">
|
||||
{data.tags.map((tag, index) => (
|
||||
<span key={index} className="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>时间信息</h4>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<label>标记时间:</label>
|
||||
<span>{new Date(data.markedAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>创建时间:</label>
|
||||
<span>{new Date(data.createdAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>更新时间:</label>
|
||||
<span>{new Date(data.updatedAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>版本:</label>
|
||||
<span>v{data.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.fileList.length > 0 && (
|
||||
<div className="detail-section">
|
||||
<h4>附件文件 ({data.fileList.length})</h4>
|
||||
<div className="file-list">
|
||||
{data.fileList.map((file, index) => (
|
||||
<div key={index} className="file-item">
|
||||
<div className="file-info">
|
||||
<span className="file-name">{file.name}</span>
|
||||
<span className="file-size">{formatFileSize(file.size)}</span>
|
||||
</div>
|
||||
<span className={`file-type file-type-${file.type}`}>{file.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>数据摘要</h4>
|
||||
<p className="summary-text">{data.data.summary || data.summary}</p>
|
||||
</div>
|
||||
|
||||
{data.config.allowComments !== undefined && (
|
||||
<div className="detail-section">
|
||||
<h4>权限设置</h4>
|
||||
<div className="permission-grid">
|
||||
<div className="permission-item">
|
||||
<label>允许评论:</label>
|
||||
<span className={data.config.allowComments ? 'enabled' : 'disabled'}>
|
||||
{data.config.allowComments ? '是' : '否'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="permission-item">
|
||||
<label>允许下载:</label>
|
||||
<span className={data.config.allowDownload ? 'enabled' : 'disabled'}>
|
||||
{data.config.allowDownload ? '是' : '否'}
|
||||
</span>
|
||||
</div>
|
||||
{data.config.expiredAt && (
|
||||
<div className="permission-item">
|
||||
<label>过期时间:</label>
|
||||
<span>{new Date(data.config.expiredAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-default" onClick={onClose}>关闭</button>
|
||||
<button className="btn btn-primary" onClick={() => {
|
||||
alert('编辑功能待实现');
|
||||
}}>编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 格式化文件大小
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
180
web/src/apps/muse/base/table/README.md
Normal file
180
web/src/apps/muse/base/table/README.md
Normal 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. **国际化**:添加多语言支持
|
||||
|
||||
这个表格组件提供了现代化的数据管理界面,具备企业级应用所需的核心功能。
|
||||
306
web/src/apps/muse/base/table/Table.tsx
Normal file
306
web/src/apps/muse/base/table/Table.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
281
web/src/apps/muse/base/table/index.tsx
Normal file
281
web/src/apps/muse/base/table/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
305
web/src/apps/muse/base/table/modal.css
Normal file
305
web/src/apps/muse/base/table/modal.css
Normal file
@@ -0,0 +1,305 @@
|
||||
/* 模态框样式 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* 详情部分 */
|
||||
.detail-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.detail-section h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #1890ff;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
min-width: 80px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 类型徽章 */
|
||||
.type-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.type-markdown { background: #1890ff; }
|
||||
.type-json { background: #52c41a; }
|
||||
.type-html { background: #fa8c16; }
|
||||
.type-image { background: #eb2f96; }
|
||||
.type-video { background: #722ed1; }
|
||||
.type-audio { background: #13c2c2; }
|
||||
.type-code { background: #666; }
|
||||
.type-link { background: #1890ff; }
|
||||
.type-file { background: #999; }
|
||||
|
||||
/* 可见性徽章 */
|
||||
.visibility-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.visibility-public {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
border: 1px solid #b7eb8f;
|
||||
}
|
||||
|
||||
.visibility-private {
|
||||
background: #fff2f0;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ffccc7;
|
||||
}
|
||||
|
||||
.visibility-restricted {
|
||||
background: #fffbe6;
|
||||
color: #faad14;
|
||||
border: 1px solid #ffe58f;
|
||||
}
|
||||
|
||||
/* 标签容器 */
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 4px 8px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
|
||||
/* 文件列表 */
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.file-type {
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.file-type-self {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.file-type-data {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.file-type-generate {
|
||||
background: #fff7e6;
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
/* 摘要文本 */
|
||||
.summary-text {
|
||||
line-height: 1.6;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #1890ff;
|
||||
}
|
||||
|
||||
/* 权限网格 */
|
||||
.permission-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.permission-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.permission-item label {
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.enabled {
|
||||
color: #52c41a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
color: #ff4d4f;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.modal-content {
|
||||
margin: 10px;
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.modal-body,
|
||||
.modal-footer {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.permission-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
312
web/src/apps/muse/base/table/table.css
Normal file
312
web/src/apps/muse/base/table/table.css
Normal 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;
|
||||
}
|
||||
}
|
||||
58
web/src/apps/muse/base/table/types.ts
Normal file
58
web/src/apps/muse/base/table/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user