Compare commits
2 Commits
15af405d02
...
b5430eb8d0
| Author | SHA1 | Date | |
|---|---|---|---|
| b5430eb8d0 | |||
| e2e1e9e9e9 |
855
pnpm-lock.yaml
generated
855
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -31,15 +31,20 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
"es-toolkit": "^1.40.0",
|
"es-toolkit": "^1.40.0",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"graphology": "^0.26.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"pocketbase": "^0.26.2",
|
"pocketbase": "^0.26.2",
|
||||||
|
"pouchdb-adapter-memory": "^9.0.0",
|
||||||
|
"pouchdb-browser": "^9.0.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
|
"sigma": "^3.0.2",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"three": "^0.180.0",
|
"three": "^0.180.0",
|
||||||
"wavesurfer.js": "^7.11.0",
|
"wavesurfer.js": "^7.11.0",
|
||||||
@@ -50,11 +55,14 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kevisual/types": "^0.0.10",
|
"@kevisual/types": "^0.0.10",
|
||||||
|
"@types/pouchdb": "^6.4.2",
|
||||||
|
"@types/pouchdb-browser": "^6.1.5",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.2",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.2",
|
||||||
"@types/three": "^0.180.0",
|
"@types/three": "^0.180.0",
|
||||||
"@vitejs/plugin-basic-ssl": "^2.1.0",
|
"@vitejs/plugin-basic-ssl": "^2.1.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"pouchdb": "^9.0.0",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
127
web/src/apps/muse/base/graph/sigma/index.tsx
Normal file
127
web/src/apps/muse/base/graph/sigma/index.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import Graph from 'graphology';
|
||||||
|
import Sigma from 'sigma';
|
||||||
|
import { Mark } from '../../../modules/mark';
|
||||||
|
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
dataSource?: Mark[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SigmaGraph = (props: Props) => {
|
||||||
|
const { dataSource = [] } = props;
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sigmaRef = useRef<Sigma | null>(null);
|
||||||
|
const graphRef = useRef<Graph | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
// 创建图实例
|
||||||
|
const graph = new Graph();
|
||||||
|
graphRef.current = graph;
|
||||||
|
|
||||||
|
// 创建 Sigma 实例
|
||||||
|
const sigma = new Sigma(graph, containerRef.current, {
|
||||||
|
renderLabels: true,
|
||||||
|
labelRenderedSizeThreshold: 8,
|
||||||
|
labelDensity: 8,
|
||||||
|
});
|
||||||
|
sigmaRef.current = sigma;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sigma.kill();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!graphRef.current || !dataSource.length) return;
|
||||||
|
|
||||||
|
const graph = graphRef.current;
|
||||||
|
|
||||||
|
// 清空现有图数据
|
||||||
|
graph.clear();
|
||||||
|
|
||||||
|
// 添加节点
|
||||||
|
dataSource.forEach((mark, index) => {
|
||||||
|
graph.addNode(`node-${index}`, {
|
||||||
|
x: Math.random() * 800,
|
||||||
|
y: Math.random() * 600,
|
||||||
|
size: Math.random() * 20 + 5,
|
||||||
|
label: mark.title || `Mark ${index}`,
|
||||||
|
color: `#${Math.floor(Math.random() * 16777215).toString(16)}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// // 添加一些随机边(基于数据关系或随机生成)
|
||||||
|
// for (let i = 0; i < Math.min(dataSource.length - 1, 20); i++) {
|
||||||
|
// const sourceIndex = Math.floor(Math.random() * dataSource.length);
|
||||||
|
// const targetIndex = Math.floor(Math.random() * dataSource.length);
|
||||||
|
|
||||||
|
// if (sourceIndex !== targetIndex) {
|
||||||
|
// try {
|
||||||
|
// graph.addEdge(`node-${sourceIndex}`, `node-${targetIndex}`, {
|
||||||
|
// size: Math.random() * 5 + 1,
|
||||||
|
// color: '#999',
|
||||||
|
// });
|
||||||
|
// } catch (error) {
|
||||||
|
// // 边已存在,忽略错误
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 刷新 Sigma 渲染
|
||||||
|
if (sigmaRef.current) {
|
||||||
|
sigmaRef.current.refresh();
|
||||||
|
}
|
||||||
|
}, [dataSource]);
|
||||||
|
|
||||||
|
// 添加交互事件处理
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sigmaRef.current) return;
|
||||||
|
|
||||||
|
const sigma = sigmaRef.current;
|
||||||
|
|
||||||
|
// 节点点击事件
|
||||||
|
const handleNodeClick = (event: any) => {
|
||||||
|
console.log('Node clicked:', event.node);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 节点悬停事件
|
||||||
|
const handleNodeHover = (event: any) => {
|
||||||
|
console.log('Node hovered:', event.node);
|
||||||
|
};
|
||||||
|
|
||||||
|
sigma.on('clickNode', handleNodeClick);
|
||||||
|
sigma.on('enterNode', handleNodeHover);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sigma.off('clickNode', handleNodeClick);
|
||||||
|
sigma.off('enterNode', handleNodeHover);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', height: '100vh', position: 'relative' }}>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
background: '#f5f5f5'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 10,
|
||||||
|
left: 10,
|
||||||
|
background: 'rgba(255,255,255,0.8)',
|
||||||
|
padding: '10px',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}>
|
||||||
|
<p>节点数量: {dataSource.length}</p>
|
||||||
|
<p>使用鼠标拖拽和缩放来探索图形</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Base } from "./table/index";
|
import { Base } from "./table/index";
|
||||||
|
import { markService } from "../modules/mark-service";
|
||||||
|
import { SigmaGraph } from "./graph/sigma/index";
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
key: 'table',
|
key: 'table',
|
||||||
@@ -13,20 +14,31 @@ const tabs = [
|
|||||||
{
|
{
|
||||||
key: 'world',
|
key: 'world',
|
||||||
title: '世界'
|
title: '世界'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'docs',
|
||||||
|
title: '文档'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export const BaseApp = () => {
|
export const BaseApp = () => {
|
||||||
const [activeTab, setActiveTab] = useState('table');
|
const [activeTab, setActiveTab] = useState('table');
|
||||||
|
const [dataSource, setDataSource] = useState<any[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
getMarks();
|
||||||
|
}, []);
|
||||||
|
const getMarks = async () => {
|
||||||
|
const marks = await markService.getAllMarks();
|
||||||
|
setDataSource(marks);
|
||||||
|
}
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'table':
|
case 'table':
|
||||||
return <Base />;
|
return <Base dataSource={dataSource} />;
|
||||||
case 'graph':
|
case 'graph':
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-96 text-gray-500">
|
<div className="w-full h-96">
|
||||||
关系图模块暂未实现
|
<SigmaGraph dataSource={dataSource} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'world':
|
case 'world':
|
||||||
@@ -35,6 +47,12 @@ export const BaseApp = () => {
|
|||||||
世界模块暂未实现
|
世界模块暂未实现
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
case 'docs':
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96 text-gray-500">
|
||||||
|
文档模块暂未实现
|
||||||
|
</div>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -49,11 +67,10 @@ export const BaseApp = () => {
|
|||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
className={`py-2 px-1 border-b-2 font-medium text-sm ${activeTab === tab.key
|
||||||
activeTab === tab.key
|
? 'border-blue-500 text-blue-600'
|
||||||
? 'border-blue-500 text-blue-600'
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{tab.title}
|
{tab.title}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,7 +3,87 @@ import { nanoid, customAlphabet } from 'nanoid';
|
|||||||
|
|
||||||
export const random = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ');
|
export const random = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ');
|
||||||
|
|
||||||
// 类型定义
|
// 确保类型定义
|
||||||
|
const ensureType = ['markdown', 'json', 'html', 'image', 'video', 'audio', 'code', 'link', 'file'];
|
||||||
|
export type MarkEnsureType = typeof ensureType[number];
|
||||||
|
|
||||||
|
// 根据新的类型定义
|
||||||
|
export type Mark<T = any> = {
|
||||||
|
/**
|
||||||
|
* 标记ID
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标题
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 描述
|
||||||
|
*/
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标签
|
||||||
|
*/
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记类型
|
||||||
|
*/
|
||||||
|
markType?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 封面
|
||||||
|
*/
|
||||||
|
cover?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 链接
|
||||||
|
*/
|
||||||
|
link?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 摘要
|
||||||
|
*/
|
||||||
|
summary?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 键
|
||||||
|
*/
|
||||||
|
key?: string;
|
||||||
|
data: T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附件列表
|
||||||
|
*/
|
||||||
|
fileList?: any[];
|
||||||
|
/**
|
||||||
|
* 创建人信息
|
||||||
|
*/
|
||||||
|
uname?: string;
|
||||||
|
/**
|
||||||
|
* 版本号
|
||||||
|
*/
|
||||||
|
version?: number;
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
createdAt: Date;
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
updatedAt: Date;
|
||||||
|
/**
|
||||||
|
* 标记时间
|
||||||
|
*/
|
||||||
|
markedAt?: Date;
|
||||||
|
uid?: string;
|
||||||
|
puid?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保留原有的辅助类型
|
||||||
export type MarkDataNode = {
|
export type MarkDataNode = {
|
||||||
id?: string;
|
id?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -20,64 +100,25 @@ export type MarkFile = {
|
|||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
size: number;
|
size: number;
|
||||||
type: 'self' | 'data' | 'generate'; // generate为生成文件
|
type: 'self' | 'data' | 'generate';
|
||||||
query: string; // 'data.nodes[id].content';
|
query: string;
|
||||||
hash: string;
|
hash: string;
|
||||||
fileKey: string; // 文件的名称, 唯一
|
fileKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MarkData = {
|
export type MarkData = {
|
||||||
md?: string; // markdown
|
md?: string;
|
||||||
mdList?: string[]; // markdown list
|
mdList?: string[];
|
||||||
type?: string; // 类型 markdown | json | html | image | video | audio | code | link | file
|
type?: MarkEnsureType;
|
||||||
data?: any;
|
data?: any;
|
||||||
key?: string; // 文件的名称, 唯一
|
key?: string;
|
||||||
push?: boolean; // 是否推送到elasticsearch
|
push?: boolean;
|
||||||
pushTime?: Date; // 推送时间
|
pushTime?: Date;
|
||||||
summary?: string; // 摘要
|
summary?: string;
|
||||||
nodes?: MarkDataNode[]; // 节点
|
nodes?: MarkDataNode[];
|
||||||
[key: string]: any;
|
[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
|
// 生成模拟的 MarkDataNode
|
||||||
const generateMarkDataNode = (): MarkDataNode => ({
|
const generateMarkDataNode = (): MarkDataNode => ({
|
||||||
id: random(12),
|
id: random(12),
|
||||||
@@ -99,85 +140,187 @@ const generateMarkDataNode = (): MarkDataNode => ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 生成模拟的 MarkFile
|
// 生成模拟的附件
|
||||||
const generateMarkFile = (): MarkFile => ({
|
const generateFileAttachment = (): any => ({
|
||||||
id: faker.string.uuid(),
|
id: faker.string.uuid(),
|
||||||
name: faker.system.fileName(),
|
name: faker.system.fileName(),
|
||||||
url: faker.internet.url(),
|
url: faker.internet.url(),
|
||||||
size: faker.number.int({ min: 1024, max: 50 * 1024 * 1024 }), // 1KB to 50MB
|
size: faker.number.int({ min: 1024, max: 50 * 1024 * 1024 }),
|
||||||
type: faker.helpers.arrayElement(['self', 'data', 'generate']),
|
type: faker.helpers.arrayElement(['image', 'document', 'video', 'audio']),
|
||||||
query: `data.nodes[${random(12)}].content`,
|
mimeType: faker.system.mimeType(),
|
||||||
hash: faker.git.commitSha(),
|
hash: faker.git.commitSha(),
|
||||||
fileKey: faker.system.fileName()
|
uploadedAt: faker.date.recent()
|
||||||
});
|
});
|
||||||
|
|
||||||
// 生成模拟的 MarkData
|
// 生成模拟的 MarkData(通用数据类型)
|
||||||
const generateMarkData = (): MarkData => ({
|
const generateMarkData = <T = any>(): T => {
|
||||||
md: faker.lorem.paragraphs(3, '\n\n'),
|
const dataVariants = [
|
||||||
mdList: Array.from({ length: faker.number.int({ min: 3, max: 8 }) }, () => faker.lorem.sentence()),
|
// Markdown 数据
|
||||||
type: faker.helpers.arrayElement(['markdown', 'json', 'html', 'image', 'video', 'audio', 'code', 'link', 'file']),
|
{
|
||||||
data: {
|
content: faker.lorem.paragraphs(3, '\n\n'),
|
||||||
author: faker.person.fullName(),
|
format: 'markdown',
|
||||||
category: faker.helpers.arrayElement(['技术', '生活', '工作', '学习', '思考']),
|
wordCount: faker.number.int({ min: 100, max: 1000 })
|
||||||
priority: faker.helpers.arrayElement(['low', 'medium', 'high'])
|
},
|
||||||
},
|
// JSON 数据
|
||||||
key: faker.system.fileName(),
|
{
|
||||||
push: faker.datatype.boolean(),
|
jsonData: {
|
||||||
pushTime: faker.date.recent(),
|
name: faker.person.fullName(),
|
||||||
summary: faker.lorem.paragraph(),
|
email: faker.internet.email(),
|
||||||
nodes: Array.from({ length: faker.number.int({ min: 2, max: 6 }) }, generateMarkDataNode)
|
settings: {
|
||||||
});
|
theme: faker.helpers.arrayElement(['light', 'dark']),
|
||||||
|
language: faker.helpers.arrayElement(['zh-CN', 'en-US', 'ja-JP'])
|
||||||
// 生成模拟的 MarkConfig
|
}
|
||||||
const generateMarkConfig = (): MarkConfig => ({
|
},
|
||||||
visibility: faker.helpers.arrayElement(['public', 'private', 'restricted']),
|
schema: 'user-profile'
|
||||||
allowComments: faker.datatype.boolean(),
|
},
|
||||||
allowDownload: faker.datatype.boolean(),
|
// 图片数据
|
||||||
password: faker.datatype.boolean() ? faker.internet.password() : undefined,
|
{
|
||||||
expiredAt: faker.datatype.boolean() ? faker.date.future() : undefined,
|
src: faker.image.url(),
|
||||||
theme: faker.helpers.arrayElement(['light', 'dark', 'auto']),
|
alt: faker.lorem.sentence(),
|
||||||
language: faker.helpers.arrayElement(['zh-CN', 'en-US', 'ja-JP'])
|
width: faker.number.int({ min: 400, max: 1920 }),
|
||||||
});
|
height: faker.number.int({ min: 300, max: 1080 }),
|
||||||
|
format: faker.helpers.arrayElement(['jpg', 'png', 'webp'])
|
||||||
|
},
|
||||||
|
// 代码数据
|
||||||
|
{
|
||||||
|
code: `function ${faker.hacker.noun()}() {\n return "${faker.hacker.phrase()}";\n}`,
|
||||||
|
language: faker.helpers.arrayElement(['javascript', 'typescript', 'python', 'java']),
|
||||||
|
lineCount: faker.number.int({ min: 10, max: 100 })
|
||||||
|
},
|
||||||
|
// 链接数据
|
||||||
|
{
|
||||||
|
url: faker.internet.url(),
|
||||||
|
title: faker.lorem.sentence(),
|
||||||
|
description: faker.lorem.paragraph(),
|
||||||
|
favicon: faker.image.url({ width: 32, height: 32 })
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return faker.helpers.arrayElement(dataVariants) as T;
|
||||||
|
};
|
||||||
|
|
||||||
// 生成单个 Mark 记录
|
// 生成单个 Mark 记录
|
||||||
const generateMark = (): Mark => {
|
const generateMark = <T = any>(): Mark<T> => {
|
||||||
const markType = faker.helpers.arrayElement(['markdown', 'json', 'html', 'image', 'video', 'audio', 'code', 'link', 'file']);
|
const createdAt = faker.date.past();
|
||||||
const title = faker.lorem.sentence({ min: 3, max: 8 });
|
const updatedAt = faker.date.between({ from: createdAt, to: new Date() });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: faker.string.uuid(),
|
id: faker.string.uuid(),
|
||||||
title,
|
title: faker.datatype.boolean() ? faker.lorem.sentence({ min: 3, max: 8 }) : undefined,
|
||||||
description: faker.lorem.paragraph(),
|
description: faker.datatype.boolean() ? faker.lorem.paragraph() : undefined,
|
||||||
cover: faker.image.url({ width: 800, height: 600 }),
|
tags: faker.datatype.boolean()
|
||||||
thumbnail: faker.image.url({ width: 200, height: 150 }),
|
? Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, () =>
|
||||||
key: faker.system.filePath(),
|
faker.helpers.arrayElement(['技术', '前端', '后端', '设计', 'AI', '工具', '教程', '笔记', '生活', '工作'])
|
||||||
markType,
|
)
|
||||||
link: faker.internet.url(),
|
: undefined,
|
||||||
tags: Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, () =>
|
markType: faker.datatype.boolean()
|
||||||
faker.helpers.arrayElement(['技术', '前端', '后端', '设计', 'AI', '工具', '教程', '笔记'])
|
? faker.helpers.arrayElement(ensureType)
|
||||||
),
|
: undefined,
|
||||||
summary: faker.lorem.sentence(),
|
cover: faker.datatype.boolean()
|
||||||
data: generateMarkData(),
|
? faker.image.url({ width: 800, height: 600 })
|
||||||
uid: faker.string.uuid(),
|
: undefined,
|
||||||
puid: faker.string.uuid(),
|
link: faker.datatype.boolean()
|
||||||
config: generateMarkConfig(),
|
? faker.internet.url()
|
||||||
fileList: Array.from({ length: faker.number.int({ min: 0, max: 4 }) }, generateMarkFile),
|
: undefined,
|
||||||
uname: faker.person.fullName(),
|
summary: faker.datatype.boolean()
|
||||||
markedAt: faker.date.past(),
|
? faker.lorem.sentence()
|
||||||
createdAt: faker.date.past(),
|
: undefined,
|
||||||
updatedAt: faker.date.recent(),
|
key: faker.datatype.boolean()
|
||||||
version: faker.number.int({ min: 1, max: 10 })
|
? faker.system.filePath()
|
||||||
|
: undefined,
|
||||||
|
data: generateMarkData<T>(),
|
||||||
|
fileList: faker.datatype.boolean()
|
||||||
|
? Array.from({ length: faker.number.int({ min: 1, max: 4 }) }, generateFileAttachment)
|
||||||
|
: undefined,
|
||||||
|
uname: faker.datatype.boolean()
|
||||||
|
? faker.person.fullName()
|
||||||
|
: undefined,
|
||||||
|
version: faker.datatype.boolean()
|
||||||
|
? faker.number.int({ min: 1, max: 10 })
|
||||||
|
: undefined,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
markedAt: faker.datatype.boolean()
|
||||||
|
? faker.date.between({ from: createdAt, to: updatedAt })
|
||||||
|
: undefined,
|
||||||
|
uid: faker.datatype.boolean()
|
||||||
|
? faker.string.uuid()
|
||||||
|
: undefined,
|
||||||
|
puid: faker.datatype.boolean()
|
||||||
|
? faker.string.uuid()
|
||||||
|
: undefined
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 生成指定类型的 Mark 记录
|
||||||
|
const generateMarkWithType = (markType: MarkEnsureType): Mark => {
|
||||||
|
const mark = generateMark();
|
||||||
|
mark.markType = markType;
|
||||||
|
|
||||||
|
// 根据类型生成相应的数据
|
||||||
|
switch (markType) {
|
||||||
|
case 'markdown':
|
||||||
|
mark.data = {
|
||||||
|
content: faker.lorem.paragraphs(faker.number.int({ min: 2, max: 5 }), '\n\n'),
|
||||||
|
format: 'markdown',
|
||||||
|
wordCount: faker.number.int({ min: 100, max: 1000 })
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'json':
|
||||||
|
mark.data = {
|
||||||
|
jsonData: {
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
name: faker.person.fullName(),
|
||||||
|
settings: {
|
||||||
|
theme: faker.helpers.arrayElement(['light', 'dark']),
|
||||||
|
notifications: faker.datatype.boolean()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'image':
|
||||||
|
mark.data = {
|
||||||
|
src: faker.image.url(),
|
||||||
|
alt: faker.lorem.sentence(),
|
||||||
|
width: faker.number.int({ min: 400, max: 1920 }),
|
||||||
|
height: faker.number.int({ min: 300, max: 1080 })
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'video':
|
||||||
|
mark.data = {
|
||||||
|
src: faker.internet.url() + '/video.mp4',
|
||||||
|
duration: faker.number.int({ min: 30, max: 3600 }),
|
||||||
|
resolution: faker.helpers.arrayElement(['720p', '1080p', '4K'])
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'code':
|
||||||
|
mark.data = {
|
||||||
|
code: `function ${faker.hacker.noun()}() {\n return "${faker.hacker.phrase()}";\n}`,
|
||||||
|
language: faker.helpers.arrayElement(['javascript', 'typescript', 'python', 'java']),
|
||||||
|
lineCount: faker.number.int({ min: 10, max: 100 })
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
mark.data = generateMarkData();
|
||||||
|
}
|
||||||
|
|
||||||
|
return mark;
|
||||||
|
};
|
||||||
|
|
||||||
// 生成 20 条模拟数据
|
// 生成 20 条模拟数据
|
||||||
export const mockMarks: Mark[] = Array.from({ length: 20 }, generateMark);
|
export const mockMarks: Mark[] = Array.from({ length: 20 }, () => generateMark());
|
||||||
|
|
||||||
|
// 生成每种类型的示例数据
|
||||||
|
export const mockMarksByType: Record<MarkEnsureType, Mark> = ensureType.reduce((acc, type) => {
|
||||||
|
acc[type] = generateMarkWithType(type);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<MarkEnsureType, Mark>);
|
||||||
|
|
||||||
// 导出生成器函数
|
// 导出生成器函数
|
||||||
export {
|
export {
|
||||||
generateMark,
|
generateMark,
|
||||||
|
generateMarkWithType,
|
||||||
generateMarkData,
|
generateMarkData,
|
||||||
generateMarkFile,
|
|
||||||
generateMarkDataNode,
|
generateMarkDataNode,
|
||||||
generateMarkConfig
|
generateFileAttachment
|
||||||
};
|
};
|
||||||
@@ -1,17 +1,26 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { Table } from './Table';
|
import { Table } from './Table';
|
||||||
import { DetailModal } from './DetailModal';
|
import { DetailModal } from './DetailModal';
|
||||||
import { mockMarks, Mark } from '../mock/collection';
|
import { Mark } from '../mock/collection';
|
||||||
import { TableColumn, ActionButton } from './types';
|
import { TableColumn, ActionButton } from './types';
|
||||||
|
|
||||||
export const Base = () => {
|
type Props = {
|
||||||
|
dataSource?: Mark[];
|
||||||
|
}
|
||||||
|
export const Base = (props: Props) => {
|
||||||
|
const { dataSource = [] } = props;
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
const [data, setData] = useState<Mark[]>(mockMarks);
|
const [data, setData] = useState<Mark[]>([]);
|
||||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||||
const [currentRecord, setCurrentRecord] = useState<Mark | null>(null);
|
const [currentRecord, setCurrentRecord] = useState<Mark | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dataSource) {
|
||||||
|
setData(dataSource);
|
||||||
|
}
|
||||||
|
}, [dataSource]);
|
||||||
// 表格列配置
|
// 表格列配置
|
||||||
const columns: TableColumn<Mark>[] = [
|
const columns: TableColumn<Mark>[] = [
|
||||||
{
|
{
|
||||||
@@ -26,7 +35,7 @@ export const Base = () => {
|
|||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||||
{record.description.slice(0, 60)}...
|
{record.description?.slice?.(0, 60)}...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -37,28 +46,31 @@ export const Base = () => {
|
|||||||
dataIndex: 'markType',
|
dataIndex: 'markType',
|
||||||
width: 100,
|
width: 100,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (value: string) => (
|
render: (value: string) => {
|
||||||
<span
|
if (!value) return ''
|
||||||
style={{
|
return (
|
||||||
padding: '4px 8px',
|
<span
|
||||||
borderRadius: '4px',
|
style={{
|
||||||
backgroundColor: getTypeColor(value),
|
padding: '4px 8px',
|
||||||
color: '#fff',
|
borderRadius: '4px',
|
||||||
fontSize: '12px'
|
backgroundColor: getTypeColor(value),
|
||||||
}}
|
color: '#fff',
|
||||||
>
|
fontSize: '12px'
|
||||||
{value}
|
}}
|
||||||
</span>
|
>
|
||||||
)
|
{value}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'tags',
|
key: 'tags',
|
||||||
title: '标签',
|
title: '标签',
|
||||||
dataIndex: 'tags',
|
dataIndex: 'tags',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (tags: string[]) => (
|
render: (tags: string[] = []) => (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||||
{tags.slice(0, 3).map((tag, index) => (
|
{tags?.slice?.(0, 3).map((tag, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
style={{
|
style={{
|
||||||
@@ -96,13 +108,13 @@ export const Base = () => {
|
|||||||
render: (value: Date) => new Date(value).toLocaleString('zh-CN')
|
render: (value: Date) => new Date(value).toLocaleString('zh-CN')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'config.visibility',
|
key: 'data.visibility',
|
||||||
title: '可见性',
|
title: '可见性',
|
||||||
dataIndex: 'config.visibility',
|
dataIndex: 'data.visibility',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (value: string) => (
|
render: (value: string) => (
|
||||||
<span style={{
|
<span style={{
|
||||||
color: value === 'public' ? '#52c41a' : value === 'private' ? '#ff4d4f' : '#faad14'
|
color: value === 'public' ? '#52c41a' : value === 'private' ? '#ff4d4f' : '#faad14'
|
||||||
}}>
|
}}>
|
||||||
{value === 'public' ? '公开' : value === 'private' ? '私有' : '受限'}
|
{value === 'public' ? '公开' : value === 'private' ? '私有' : '受限'}
|
||||||
</span>
|
</span>
|
||||||
@@ -180,7 +192,7 @@ export const Base = () => {
|
|||||||
// 处理批量删除
|
// 处理批量删除
|
||||||
const handleBatchDelete = () => {
|
const handleBatchDelete = () => {
|
||||||
if (selectedRowKeys.length === 0) return;
|
if (selectedRowKeys.length === 0) return;
|
||||||
|
|
||||||
if (window.confirm(`确定要删除选中的 ${selectedRowKeys.length} 项吗?`)) {
|
if (window.confirm(`确定要删除选中的 ${selectedRowKeys.length} 项吗?`)) {
|
||||||
setData(prevData => prevData.filter(item => !selectedRowKeys.includes(item.id)));
|
setData(prevData => prevData.filter(item => !selectedRowKeys.includes(item.id)));
|
||||||
setSelectedRowKeys([]);
|
setSelectedRowKeys([]);
|
||||||
@@ -190,7 +202,7 @@ export const Base = () => {
|
|||||||
// 排序处理
|
// 排序处理
|
||||||
const handleSort = (field: string, order: 'asc' | 'desc' | null) => {
|
const handleSort = (field: string, order: 'asc' | 'desc' | null) => {
|
||||||
if (!order) {
|
if (!order) {
|
||||||
setData(mockMarks); // 重置为原始顺序
|
setData(dataSource); // 重置为原始顺序
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,10 +210,10 @@ export const Base = () => {
|
|||||||
const getNestedValue = (obj: any, path: string) => {
|
const getNestedValue = (obj: any, path: string) => {
|
||||||
return path.split('.').reduce((o, p) => o?.[p], obj);
|
return path.split('.').reduce((o, p) => o?.[p], obj);
|
||||||
};
|
};
|
||||||
|
|
||||||
const aVal = getNestedValue(a, field);
|
const aVal = getNestedValue(a, field);
|
||||||
const bVal = getNestedValue(b, field);
|
const bVal = getNestedValue(b, field);
|
||||||
|
|
||||||
if (aVal < bVal) return order === 'asc' ? -1 : 1;
|
if (aVal < bVal) return order === 'asc' ? -1 : 1;
|
||||||
if (aVal > bVal) return order === 'asc' ? 1 : -1;
|
if (aVal > bVal) return order === 'asc' ? 1 : -1;
|
||||||
return 0;
|
return 0;
|
||||||
@@ -217,7 +229,7 @@ export const Base = () => {
|
|||||||
total: data.length,
|
total: data.length,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
showQuickJumper: true,
|
showQuickJumper: true,
|
||||||
showTotal: (total: number, range: [number, number]) =>
|
showTotal: (total: number, range: [number, number]) =>
|
||||||
`第 ${range[0]}-${range[1]} 条,共 ${total} 条`,
|
`第 ${range[0]}-${range[1]} 条,共 ${total} 条`,
|
||||||
onChange: (page: number, size: number) => {
|
onChange: (page: number, size: number) => {
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
@@ -235,17 +247,17 @@ export const Base = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedRowKeys.length > 0 && (
|
{selectedRowKeys.length > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
marginBottom: '16px',
|
marginBottom: '16px',
|
||||||
padding: '12px',
|
padding: '12px',
|
||||||
backgroundColor: '#e6f7ff',
|
backgroundColor: '#e6f7ff',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center'
|
alignItems: 'center'
|
||||||
}}>
|
}}>
|
||||||
<span>已选择 {selectedRowKeys.length} 项</span>
|
<span>已选择 {selectedRowKeys.length} 项</span>
|
||||||
<button
|
<button
|
||||||
className="btn btn-danger"
|
className="btn btn-danger"
|
||||||
onClick={handleBatchDelete}
|
onClick={handleBatchDelete}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useState } from 'react';
|
|||||||
import { VadVoice } from './videos/modules/VadVoice.tsx';
|
import { VadVoice } from './videos/modules/VadVoice.tsx';
|
||||||
import { ChatInterface } from './prompts/index.tsx';
|
import { ChatInterface } from './prompts/index.tsx';
|
||||||
import { BaseApp } from './base/index.tsx';
|
import { BaseApp } from './base/index.tsx';
|
||||||
|
import { exampleUsage } from './modules/mark-service.ts';
|
||||||
|
|
||||||
const LeftPanel = () => {
|
const LeftPanel = () => {
|
||||||
return (
|
return (
|
||||||
@@ -79,6 +80,16 @@ export const MuseApp = () => {
|
|||||||
>
|
>
|
||||||
Right Panel
|
Right Panel
|
||||||
</button>
|
</button>
|
||||||
|
<button className="px-3 py-1 rounded text-sm bg-green-500 text-white hover:bg-green-600" onClick={() => {
|
||||||
|
exampleUsage()
|
||||||
|
}}>
|
||||||
|
初始化DB
|
||||||
|
</button>
|
||||||
|
<button className="px-3 py-1 rounded text-sm bg-red-500 text-white hover:bg-red-600" onClick={() => {
|
||||||
|
// 删除DB的逻辑
|
||||||
|
}}>
|
||||||
|
删除DB
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
398
web/src/apps/muse/modules/db.ts
Normal file
398
web/src/apps/muse/modules/db.ts
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
import PouchDB from 'pouchdb-browser';
|
||||||
|
import { Mark, MarkEnsureType } from './mark';
|
||||||
|
|
||||||
|
console.log('PouchDB version:', PouchDB.version);
|
||||||
|
// 扩展 Mark 类型以包含 PouchDB 特有字段
|
||||||
|
export type MarkDocument = Mark & {
|
||||||
|
_id: string;
|
||||||
|
_rev?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建或获取数据库实例
|
||||||
|
export const createDB = (name: string = 'marks_db', opts?: { adapter?: string }) => {
|
||||||
|
return new PouchDB(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 辅助函数:将 PouchDB 文档转换为 Mark 对象
|
||||||
|
const docToMark = (doc: any): Mark => {
|
||||||
|
const { _id, _rev, ...mark } = doc as MarkDocument;
|
||||||
|
return mark;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mark 数据库操作类
|
||||||
|
export class MarkDB {
|
||||||
|
private db: PouchDB.Database;
|
||||||
|
|
||||||
|
constructor(dbName: string = 'marks_db') {
|
||||||
|
this.db = createDB(dbName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建索引以支持查询
|
||||||
|
async createIndexes() {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new Error('数据库未初始化');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// PouchDB 创建索引的正确方式
|
||||||
|
const indexes = [
|
||||||
|
{ index: { fields: ['uid'] } },
|
||||||
|
{ index: { fields: ['markType'] } },
|
||||||
|
{ index: { fields: ['tags'] } },
|
||||||
|
{ index: { fields: ['createdAt'] } },
|
||||||
|
{ index: { fields: ['title'] } },
|
||||||
|
{ index: { fields: ['uid', 'markType'] } },
|
||||||
|
{ index: { fields: ['createdAt', 'uid'] } }
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
indexes.map(indexDef => this.db.createIndex(indexDef))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 检查索引创建结果
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
console.log(`索引 ${index + 1} 创建成功:`, result.value);
|
||||||
|
} else {
|
||||||
|
// 如果索引已存在,PouchDB 会返回错误,这是正常的
|
||||||
|
if (result.reason?.status !== 409) {
|
||||||
|
console.warn(`索引 ${index + 1} 创建失败:`, result.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('索引初始化完成');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建索引失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 Mark
|
||||||
|
async create(mark: Omit<Mark, 'id' | 'createdAt' | 'updatedAt'>): Promise<Mark> {
|
||||||
|
const now = new Date();
|
||||||
|
const newMark: Mark = {
|
||||||
|
...mark,
|
||||||
|
id: this.generateId(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doc: MarkDocument = {
|
||||||
|
...newMark,
|
||||||
|
_id: newMark.id
|
||||||
|
};
|
||||||
|
const response = await this.db.put(doc);
|
||||||
|
return { ...newMark };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建 Mark 失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 ID 获取 Mark
|
||||||
|
async getById(id: string): Promise<Mark | null> {
|
||||||
|
try {
|
||||||
|
const doc = await this.db.get(id);
|
||||||
|
return docToMark(doc);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
console.error('获取 Mark 失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有 Marks
|
||||||
|
async getAll(): Promise<Mark[]> {
|
||||||
|
try {
|
||||||
|
const result = await this.db.allDocs({
|
||||||
|
include_docs: true,
|
||||||
|
attachments: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows.map(row => docToMark(row.doc));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取所有 Marks 失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按用户 ID 获取 Marks
|
||||||
|
async getByUserId(uid: string): Promise<Mark[]> {
|
||||||
|
try {
|
||||||
|
const result = await this.db.find({
|
||||||
|
selector: {
|
||||||
|
uid: uid
|
||||||
|
},
|
||||||
|
sort: [{ createdAt: 'desc' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.docs.map(doc => docToMark(doc));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('按用户获取 Marks 失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按类型获取 Marks
|
||||||
|
async getByType(markType: MarkEnsureType): Promise<Mark[]> {
|
||||||
|
try {
|
||||||
|
const result = await this.db.find({
|
||||||
|
selector: {
|
||||||
|
markType: markType
|
||||||
|
},
|
||||||
|
sort: [{ createdAt: 'desc' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.docs.map(doc => docToMark(doc));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('按类型获取 Marks 失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按标签搜索 Marks
|
||||||
|
async getByTag(tag: string): Promise<Mark[]> {
|
||||||
|
try {
|
||||||
|
const result = await this.db.find({
|
||||||
|
selector: {
|
||||||
|
tags: { $elemMatch: { $eq: tag } }
|
||||||
|
},
|
||||||
|
sort: [{ createdAt: 'desc' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.docs.map(doc => docToMark(doc));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('按标签获取 Marks 失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索 Marks(按标题或描述)
|
||||||
|
async search(query: string): Promise<Mark[]> {
|
||||||
|
try {
|
||||||
|
const result = await this.db.find({
|
||||||
|
selector: {
|
||||||
|
$or: [
|
||||||
|
{ title: { $regex: query, $options: 'i' } },
|
||||||
|
{ description: { $regex: query, $options: 'i' } },
|
||||||
|
{ summary: { $regex: query, $options: 'i' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
sort: [{ createdAt: 'desc' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.docs.map(doc => docToMark(doc));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索 Marks 失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 Mark
|
||||||
|
async update(id: string, updates: Partial<Omit<Mark, 'id' | 'createdAt'>>): Promise<Mark> {
|
||||||
|
try {
|
||||||
|
const existingDoc = await this.db.get(id);
|
||||||
|
const existingMark = docToMark(existingDoc);
|
||||||
|
const updatedMark: Mark = {
|
||||||
|
...existingMark,
|
||||||
|
...updates,
|
||||||
|
updatedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
const doc: MarkDocument = {
|
||||||
|
...updatedMark,
|
||||||
|
_id: id,
|
||||||
|
_rev: existingDoc._rev
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.db.put(doc);
|
||||||
|
return updatedMark;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新 Mark 失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除 Mark
|
||||||
|
async delete(id: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const doc = await this.db.get(id);
|
||||||
|
await this.db.remove(doc);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除 Mark 失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除 Marks
|
||||||
|
async deleteMultiple(ids: string[]): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const docs = await Promise.all(ids.map(id => this.db.get(id)));
|
||||||
|
const responses = await Promise.all(
|
||||||
|
docs.map(doc => this.db.remove(doc))
|
||||||
|
);
|
||||||
|
return responses.every(response => response.ok);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量删除 Marks 失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页获取 Marks
|
||||||
|
async getPaginated(page: number = 1, limit: number = 10, filters?: {
|
||||||
|
uid?: string;
|
||||||
|
markType?: MarkEnsureType;
|
||||||
|
tags?: string[];
|
||||||
|
}): Promise<{
|
||||||
|
marks: Mark[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// 构建查询选择器
|
||||||
|
let selector: any = {};
|
||||||
|
|
||||||
|
if (filters?.uid) {
|
||||||
|
selector.uid = filters.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters?.markType) {
|
||||||
|
selector.markType = filters.markType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters?.tags && filters.tags.length > 0) {
|
||||||
|
selector.tags = { $elemMatch: { $in: filters.tags } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取总数
|
||||||
|
const countResult = await this.db.find({
|
||||||
|
selector,
|
||||||
|
fields: []
|
||||||
|
});
|
||||||
|
const total = countResult.docs.length;
|
||||||
|
|
||||||
|
// 计算分页
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
// 获取数据
|
||||||
|
const result = await this.db.find({
|
||||||
|
selector,
|
||||||
|
sort: [{ createdAt: 'desc' }],
|
||||||
|
skip,
|
||||||
|
limit
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
marks: result.docs.map(doc => docToMark(doc)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('分页获取 Marks 失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取统计信息
|
||||||
|
async getStats(): Promise<{
|
||||||
|
total: number;
|
||||||
|
byType: Record<string, number>;
|
||||||
|
byUser: Record<string, number>;
|
||||||
|
recentActivity: number;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const marks = await this.getAll();
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: marks.length,
|
||||||
|
byType: {} as Record<string, number>,
|
||||||
|
byUser: {} as Record<string, number>,
|
||||||
|
recentActivity: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// 统计按类型分组
|
||||||
|
marks.forEach(mark => {
|
||||||
|
if (mark.markType) {
|
||||||
|
stats.byType[mark.markType] = (stats.byType[mark.markType] || 0) + 1;
|
||||||
|
}
|
||||||
|
if (mark.uid) {
|
||||||
|
stats.byUser[mark.uid] = (stats.byUser[mark.uid] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 统计最近7天的活动
|
||||||
|
const weekAgo = new Date();
|
||||||
|
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||||
|
stats.recentActivity = marks.filter(mark =>
|
||||||
|
new Date(mark.updatedAt) > weekAgo
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取统计信息失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步数据库(用于远程同步)
|
||||||
|
async sync(remoteDB: string | PouchDB.Database): Promise<void> {
|
||||||
|
try {
|
||||||
|
const remote = typeof remoteDB === 'string' ? new PouchDB(remoteDB) : remoteDB;
|
||||||
|
|
||||||
|
await this.db.sync(remote).on('complete', () => {
|
||||||
|
console.log('同步完成');
|
||||||
|
}).on('error', (err) => {
|
||||||
|
console.error('同步错误:', err);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('同步失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理数据库
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.db.destroy();
|
||||||
|
this.db = createDB(this.db.name);
|
||||||
|
await this.createIndexes();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清理数据库失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成唯一ID
|
||||||
|
private generateId(): string {
|
||||||
|
return `mark_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭数据库连接
|
||||||
|
async close(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.db.close();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('关闭数据库失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建默认实例
|
||||||
|
export const markDB = new MarkDB();
|
||||||
|
|
||||||
|
// 初始化数据库
|
||||||
|
export const initMarkDB = async () => {
|
||||||
|
await markDB.createIndexes();
|
||||||
|
return markDB;
|
||||||
|
};
|
||||||
186
web/src/apps/muse/modules/mark-service.ts
Normal file
186
web/src/apps/muse/modules/mark-service.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { markDB, initMarkDB } from './db';
|
||||||
|
import { Mark } from './mark';
|
||||||
|
import { mockMarks } from '../base/mock/collection';
|
||||||
|
|
||||||
|
// Mark 服务类 - 提供业务逻辑层
|
||||||
|
export class MarkService {
|
||||||
|
private db = markDB;
|
||||||
|
|
||||||
|
// 初始化服务
|
||||||
|
async init() {
|
||||||
|
await initMarkDB();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的 Mark
|
||||||
|
async createMark(markData: Omit<Mark, 'id' | 'createdAt' | 'updatedAt'>): Promise<Mark> {
|
||||||
|
return await this.db.create(markData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 ID 获取 Mark
|
||||||
|
async getMark(id: string): Promise<Mark | null> {
|
||||||
|
return await this.db.getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有 Marks
|
||||||
|
async getAllMarks(): Promise<Mark[]> {
|
||||||
|
return await this.db.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按用户获取 Marks
|
||||||
|
async getMarksByUser(userId: string): Promise<Mark[]> {
|
||||||
|
return await this.db.getByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按类型获取 Marks
|
||||||
|
async getMarksByType(markType: string): Promise<Mark[]> {
|
||||||
|
return await this.db.getByType(markType as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按标签搜索 Marks
|
||||||
|
async getMarksByTag(tag: string): Promise<Mark[]> {
|
||||||
|
return await this.db.getByTag(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索 Marks
|
||||||
|
async searchMarks(query: string): Promise<Mark[]> {
|
||||||
|
return await this.db.search(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 Mark
|
||||||
|
async updateMark(id: string, updates: Partial<Omit<Mark, 'id' | 'createdAt'>>): Promise<Mark> {
|
||||||
|
return await this.db.update(id, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除 Mark
|
||||||
|
async deleteMark(id: string): Promise<boolean> {
|
||||||
|
return await this.db.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除 Marks
|
||||||
|
async deleteMultipleMarks(ids: string[]): Promise<boolean> {
|
||||||
|
return await this.db.deleteMultiple(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页获取 Marks
|
||||||
|
async getMarksPaginated(
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 10,
|
||||||
|
filters?: {
|
||||||
|
uid?: string;
|
||||||
|
markType?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return await this.db.getPaginated(page, limit, filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取统计信息
|
||||||
|
async getStats() {
|
||||||
|
return await this.db.getStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化示例数据
|
||||||
|
async initSampleData(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 检查是否已有数据
|
||||||
|
const existingMarks = await this.getAllMarks();
|
||||||
|
if (existingMarks.length === 0) {
|
||||||
|
// 添加示例数据
|
||||||
|
for (const mockMark of mockMarks) {
|
||||||
|
const { id, createdAt, updatedAt, ...markData } = mockMark;
|
||||||
|
await this.createMark(markData);
|
||||||
|
}
|
||||||
|
console.log(`已初始化 ${mockMarks.length} 条示例数据`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('初始化示例数据失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出数据
|
||||||
|
async exportData(): Promise<Mark[]> {
|
||||||
|
return await this.getAllMarks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入数据
|
||||||
|
async importData(marks: Mark[]): Promise<number> {
|
||||||
|
let importedCount = 0;
|
||||||
|
try {
|
||||||
|
for (const mark of marks) {
|
||||||
|
const { id, createdAt, updatedAt, ...markData } = mark;
|
||||||
|
await this.createMark(markData);
|
||||||
|
importedCount++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导入数据失败:', error);
|
||||||
|
}
|
||||||
|
return importedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建默认服务实例
|
||||||
|
export const markService = new MarkService();
|
||||||
|
|
||||||
|
// 使用示例函数
|
||||||
|
export const exampleUsage = async () => {
|
||||||
|
// 1. 初始化服务
|
||||||
|
await markService.init();
|
||||||
|
|
||||||
|
// 2. 初始化示例数据
|
||||||
|
await markService.initSampleData();
|
||||||
|
|
||||||
|
// 3. 创建新的 Mark
|
||||||
|
const newMark = await markService.createMark({
|
||||||
|
title: '我的第一个标记',
|
||||||
|
description: '这是一个测试标记',
|
||||||
|
markType: 'markdown',
|
||||||
|
tags: ['测试', '示例'],
|
||||||
|
data: {
|
||||||
|
md: '# 测试内容\n\n这是一个测试标记的内容。',
|
||||||
|
type: 'markdown'
|
||||||
|
},
|
||||||
|
uid: 'user123',
|
||||||
|
uname: '测试用户'
|
||||||
|
});
|
||||||
|
console.log('创建的标记:', newMark);
|
||||||
|
|
||||||
|
// 4. 获取所有标记
|
||||||
|
const allMarks = await markService.getAllMarks();
|
||||||
|
console.log('所有标记数量:', allMarks.length);
|
||||||
|
|
||||||
|
// 5. 搜索标记
|
||||||
|
const searchResults = await markService.searchMarks('测试');
|
||||||
|
console.log('搜索结果数量:', searchResults.length);
|
||||||
|
|
||||||
|
// 6. 按类型获取标记
|
||||||
|
const markdownMarks = await markService.getMarksByType('markdown');
|
||||||
|
console.log('Markdown 标记数量:', markdownMarks.length);
|
||||||
|
|
||||||
|
// 7. 分页获取标记
|
||||||
|
const paginatedResults = await markService.getMarksPaginated(1, 5);
|
||||||
|
console.log('分页结果:', {
|
||||||
|
currentPage: paginatedResults.page,
|
||||||
|
totalPages: paginatedResults.totalPages,
|
||||||
|
total: paginatedResults.total,
|
||||||
|
marksOnPage: paginatedResults.marks.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8. 获取统计信息
|
||||||
|
const stats = await markService.getStats();
|
||||||
|
console.log('统计信息:', stats);
|
||||||
|
|
||||||
|
// 9. 更新标记
|
||||||
|
if (newMark.id) {
|
||||||
|
const updatedMark = await markService.updateMark(newMark.id, {
|
||||||
|
title: '更新后的标题',
|
||||||
|
description: '更新后的描述'
|
||||||
|
});
|
||||||
|
console.log('更新后的标记:', updatedMark);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. 删除标记
|
||||||
|
if (newMark.id) {
|
||||||
|
const deleted = await markService.deleteMark(newMark.id);
|
||||||
|
console.log('删除结果:', deleted);
|
||||||
|
}
|
||||||
|
};
|
||||||
78
web/src/apps/muse/modules/mark.ts
Normal file
78
web/src/apps/muse/modules/mark.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
export type Mark<T = any> = {
|
||||||
|
/**
|
||||||
|
* 标记ID
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标题
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 描述
|
||||||
|
*/
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标签
|
||||||
|
*/
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记类型
|
||||||
|
*/
|
||||||
|
markType?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 封面
|
||||||
|
*/
|
||||||
|
cover?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 链接
|
||||||
|
*/
|
||||||
|
link?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 摘要
|
||||||
|
*/
|
||||||
|
summary?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 键
|
||||||
|
*/
|
||||||
|
key?: string;
|
||||||
|
data: T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附件列表
|
||||||
|
*/
|
||||||
|
fileList?: any[];
|
||||||
|
/**
|
||||||
|
* 创建人信息
|
||||||
|
*/
|
||||||
|
uname?: string;
|
||||||
|
/**
|
||||||
|
* 版本号
|
||||||
|
*/
|
||||||
|
version?: number;
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
createdAt: Date;
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
updatedAt: Date;
|
||||||
|
/**
|
||||||
|
* 标记时间
|
||||||
|
*/
|
||||||
|
markedAt?: Date;
|
||||||
|
uid?: string;
|
||||||
|
puid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureType = ['markdown', 'json', 'html', 'image', 'video', 'audio', 'code', 'link', 'file']
|
||||||
|
|
||||||
|
export type MarkEnsureType = typeof ensureType[number];
|
||||||
@@ -84,7 +84,7 @@ export const ChatInterface: React.FC = () => {
|
|||||||
<Bot className="w-6 h-6 text-white" />
|
<Bot className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-gray-900">AI 助手</h1>
|
<h1 className="text-xl font-semibold text-gray-900">智能工作台</h1>
|
||||||
<p className="text-sm text-gray-500">在线 · 随时为您服务</p>
|
<p className="text-sm text-gray-500">在线 · 随时为您服务</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user