diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65dbced..6353b54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: lucide-react: specifier: ^0.545.0 version: 0.545.0(react@19.2.0) + marked: + specifier: ^16.4.1 + version: 16.4.1 nanoid: specifier: ^5.1.6 version: 5.1.6 @@ -132,6 +135,9 @@ importers: react-toastify: specifier: ^11.0.5 version: 11.0.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-virtualized: + specifier: ^9.22.6 + version: 9.22.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) sigma: specifier: ^3.0.2 version: 3.0.2(graphology-types@0.24.8) @@ -163,6 +169,9 @@ importers: '@types/react-dom': specifier: ^19.2.2 version: 19.2.2(@types/react@19.2.2) + '@types/react-virtualized': + specifier: ^9.22.3 + version: 9.22.3 '@types/three': specifier: ^0.180.0 version: 0.180.0 @@ -1105,11 +1114,17 @@ packages: '@types/pouchdb@6.4.2': resolution: {integrity: sha512-YsI47rASdtzR+3V3JE2UKY58snhm0AglHBpyckQBkRYoCbTvGagXHtV0x5n8nzN04jQmvTG+Sm85cIzKT3KXBA==} + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/react-dom@19.2.2': resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==} peerDependencies: '@types/react': ^19.2.0 + '@types/react-virtualized@9.22.3': + resolution: {integrity: sha512-UKRWeBIrECaKhE4O//TSFhlgwntMwyiEIOA7WZoVkr52Jahv0dH6YIOorqc358N2V3oKFclsq5XxPmx2PiYB5A==} + '@types/react@19.2.2': resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==} @@ -1319,6 +1334,10 @@ packages: resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} engines: {node: '>=0.8'} + clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1428,6 +1447,9 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -1966,6 +1988,10 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -1998,6 +2024,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@16.4.1: + resolution: {integrity: sha512-ntROs7RaN3EvWfy3EZi14H4YxmT6A5YvywfhO+0pm+cH/dnSQRmdAmoFIc3B9aiwTehyk7pESH4ofyBY+V5hZg==} + engines: {node: '>= 20'} + hasBin: true + mdast-util-definitions@6.0.0: resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} @@ -2258,6 +2289,10 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + ofetch@1.4.1: resolution: {integrity: sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==} @@ -2402,6 +2437,9 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-information@6.5.0: resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} @@ -2456,6 +2494,12 @@ packages: typescript: optional: true + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-lifecycles-compat@3.0.4: + resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -2472,6 +2516,12 @@ packages: react: ^18 || ^19 react-dom: ^18 || ^19 + react-virtualized@9.22.6: + resolution: {integrity: sha512-U5j7KuUQt3AaMatlMJ0UJddqSiX+Km0YJxSqbAzIiGw5EmNz0khMyqP2hzgu4+QUtm+QPIrxzUX4raJxmVJnHg==} + peerDependencies: + react: ^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react@19.2.0: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} @@ -4040,10 +4090,17 @@ snapshots: '@types/pouchdb-node': 6.1.7 '@types/pouchdb-replication': 6.4.7 + '@types/prop-types@15.7.15': {} + '@types/react-dom@19.2.2(@types/react@19.2.2)': dependencies: '@types/react': 19.2.2 + '@types/react-virtualized@9.22.3': + dependencies: + '@types/prop-types': 15.7.15 + '@types/react': 19.2.2 + '@types/react@19.2.2': dependencies: csstype: 3.1.3 @@ -4339,6 +4396,8 @@ snapshots: clone@2.1.2: {} + clsx@1.2.1: {} + clsx@2.1.1: {} collapse-white-space@2.1.0: {} @@ -4419,6 +4478,11 @@ snapshots: dlv@1.1.3: {} + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.28.4 + csstype: 3.1.3 + dotenv@16.6.1: {} dotenv@17.2.3: {} @@ -5015,6 +5079,10 @@ snapshots: longest-streak@3.1.0: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lru-cache@10.4.3: {} lru-cache@5.1.1: @@ -5045,6 +5113,8 @@ snapshots: markdown-table@3.0.4: {} + marked@16.4.1: {} + mdast-util-definitions@6.0.0: dependencies: '@types/mdast': 4.0.4 @@ -5548,6 +5618,8 @@ snapshots: normalize-path@3.0.0: {} + object-assign@4.1.1: {} + ofetch@1.4.1: dependencies: destr: 2.0.5 @@ -5759,6 +5831,12 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + property-information@6.5.0: {} property-information@7.1.0: {} @@ -5809,6 +5887,10 @@ snapshots: react-dom: 19.2.0(react@19.2.0) typescript: 5.9.3 + react-is@16.13.1: {} + + react-lifecycles-compat@3.0.4: {} + react-refresh@0.17.0: {} react-resizable-panels@3.0.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): @@ -5822,6 +5904,17 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + react-virtualized@9.22.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@babel/runtime': 7.28.4 + clsx: 1.2.1 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-lifecycles-compat: 3.0.4 + react@19.2.0: {} readable-stream@0.0.4: {} diff --git a/web/package.json b/web/package.json index eb3c9ee..c242a07 100644 --- a/web/package.json +++ b/web/package.json @@ -36,6 +36,7 @@ "highlight.js": "^11.11.1", "lodash-es": "^4.17.21", "lucide-react": "^0.545.0", + "marked": "^16.4.1", "nanoid": "^5.1.6", "pocketbase": "^0.26.2", "pouchdb-adapter-memory": "^9.0.0", @@ -44,6 +45,7 @@ "react-dom": "^19.2.0", "react-resizable-panels": "^3.0.6", "react-toastify": "^11.0.5", + "react-virtualized": "^9.22.6", "sigma": "^3.0.2", "tailwind-merge": "^3.3.1", "three": "^0.180.0", @@ -59,6 +61,7 @@ "@types/pouchdb-browser": "^6.1.5", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", + "@types/react-virtualized": "^9.22.3", "@types/three": "^0.180.0", "@vitejs/plugin-basic-ssl": "^2.1.0", "dotenv": "^17.2.3", diff --git a/web/src/apps/muse/base/card/MarkDetailList.css b/web/src/apps/muse/base/card/MarkDetailList.css new file mode 100644 index 0000000..59f7ca5 --- /dev/null +++ b/web/src/apps/muse/base/card/MarkDetailList.css @@ -0,0 +1,46 @@ +/* MarkDetailList 组件样式 */ + +/* 虚拟化列表容器 */ +.ReactVirtualized__List { + outline: none; +} + +/* 列表行容器 */ +.ReactVirtualized__List .ReactVirtualized__Grid__innerScrollContainer > div { + box-sizing: border-box; +} + +/* 防止内容溢出 */ +.mark-detail-row { + overflow: hidden; + box-sizing: border-box; +} + +/* 卡片样式优化 */ +.mark-detail-card { + margin-bottom: 8px; + box-sizing: border-box; + position: relative; + z-index: 1; +} + +/* 确保图片不会影响布局 */ +.mark-detail-card img { + flex-shrink: 0; +} + +/* 代码块样式优化 */ +.mark-detail-card pre { + word-wrap: break-word; + white-space: pre-wrap; +} + +/* 链接样式 */ +.mark-detail-card a { + word-break: break-all; +} + +/* 标签容器 */ +.mark-detail-tags { + min-height: 24px; +} diff --git a/web/src/apps/muse/base/card/MarkDetailList.tsx b/web/src/apps/muse/base/card/MarkDetailList.tsx new file mode 100644 index 0000000..f4b3ee2 --- /dev/null +++ b/web/src/apps/muse/base/card/MarkDetailList.tsx @@ -0,0 +1,364 @@ + +import React, { useState, useMemo } from 'react'; +import { AutoSizer, List } from 'react-virtualized'; +import './MarkDetailList.css'; +export type MarkShow = { + id: string; + title?: string; + description?: string; + tags?: string[]; + markType?: string; + cover?: string; + link?: string; + summary?: string; + key?: string; + data: any; + createdAt?: string; + updatedAt?: string; + markedAt?: Date; +} + +export type SimpleMarkShow = { + id: string; + title?: string; + description?: string; + tags?: string[]; + cover?: string; + link?: string; + summary?: string; +} + +interface MarkDetailProps { + data: MarkShow[]; +} + +export const MarkDetailList: React.FC = ({ data = [] }) => { + const [showAll, setShowAll] = useState(false); + + // 根据显示模式过滤数据 + const displayData = useMemo(() => { + if (showAll) { + // 显示所有字段 + return data; + } else { + // 仅显示 SimpleMarkShow 字段 + return data.map(item => ({ + id: item.id, + title: item.title, + description: item.description, + tags: item.tags, + cover: item.cover, + link: item.link, + summary: item.summary + } as SimpleMarkShow)); + } + }, [data, showAll]); + + // 计算行高 - 根据视图模式计算固定高度 + const getRowHeight = () => { + return showAll ? 400 : 240; + }; + + // 渲染单个简化项目 + const renderSimpleItem = (item: SimpleMarkShow) => { + return ( +
+
+
+
+ +

{item.id}

+
+
+ +

{item.title || '-'}

+
+
+ +
+ +

{item.description || '-'}

+
+ +
+
+ +
{renderTags(item.tags)}
+
+
+ +

{item.summary || '-'}

+
+
+ +
+
+ +
+ {item.cover ? ( + 封面 { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + ) : ( + - + )} +
+
+
+ +
+ {item.link ? ( + + {item.link} + + ) : ( + - + )} +
+
+
+
+
+ ); + }; + + // 渲染单个完整项目 + const renderFullItem = (item: MarkShow) => { + return ( +
+
+
+
+ +

{item.id}

+
+
+ +

{item.title || '-'}

+
+
+ +
{renderMarkType(item.markType)}
+
+
+ +
+ +

{item.description || '-'}

+
+ +
+
+ +
{renderTags(item.tags)}
+
+
+ +

{item.summary || '-'}

+
+
+ +
+
+ +

{item.key || '-'}

+
+
+ +

{formatDate(item.createdAt)}

+
+
+ +

{formatDate(item.updatedAt)}

+
+
+ +
+
+ +

{formatDate(item.markedAt)}

+
+
+ +
+ {item.cover ? ( + 封面 { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + ) : ( + - + )} +
+
+
+ +
+ +
+ {item.link ? ( + + {item.link} + + ) : ( + - + )} +
+
+ +
+ +
+
+                {JSON.stringify(item.data, null, 2)}
+              
+
+
+
+
+ ); + }; + + // 行渲染函数 + const rowRenderer = ({ index, key, style }: any) => { + const item = displayData[index]; + + return ( +
+
+ {showAll ? renderFullItem(item as MarkShow) : renderSimpleItem(item as SimpleMarkShow)} +
+
+ ); + }; + + // 格式化日期显示 + const formatDate = (date: string | Date | undefined) => { + if (!date) return '-'; + return new Date(date).toLocaleString('zh-CN'); + }; + + // 渲染标签 + const renderTags = (tags?: string[]) => { + if (!tags || tags.length === 0) return '-'; + return ( +
+ {tags.map((tag, index) => ( + + {tag} + + ))} +
+ ); + }; + + // 渲染类型徽章 + const renderMarkType = (markType?: string) => { + if (!markType) return '-'; + + const typeColors: Record = { + markdown: 'bg-green-100 text-green-800', + json: 'bg-yellow-100 text-yellow-800', + html: 'bg-orange-100 text-orange-800', + image: 'bg-purple-100 text-purple-800', + video: 'bg-red-100 text-red-800', + audio: 'bg-pink-100 text-pink-800', + code: 'bg-gray-100 text-gray-800', + link: 'bg-blue-100 text-blue-800', + file: 'bg-indigo-100 text-indigo-800', + }; + + const colorClass = typeColors[markType] || 'bg-gray-100 text-gray-800'; + + return ( + + {markType} + + ); + }; + + // 渲染虚拟滚动列表 + const renderVirtualizedList = () => { + return ( + + {({ height, width }) => ( + + )} + + ); + }; + + return ( +
+ {/* 头部控制区域 */} +
+
+

+ SimpleMark 信息显示 +

+
+ + 显示模式: + + +
+ 共 {data.length} 条记录 +
+
+
+
+ + {/* 数据显示区域 */} +
+ {data.length === 0 ? ( +
+
+
📝
+

暂无数据

+
+
+ ) : ( + renderVirtualizedList() + )} +
+
+ ); +}; diff --git a/web/src/apps/muse/base/docs/README.md b/web/src/apps/muse/base/docs/README.md new file mode 100644 index 0000000..576e72b --- /dev/null +++ b/web/src/apps/muse/base/docs/README.md @@ -0,0 +1,191 @@ +# 文档组件 (DocsComponent) + +一个现代化的左侧导航右侧内容的文档显示组件,支持多种内容类型和优雅的样式设计。 + +## 特性 + +- 🎨 **现代化设计**: 清新的UI设计,优雅的色彩搭配 +- 📱 **响应式布局**: 支持桌面和移动端适配 +- 📝 **Markdown支持**: 使用 marked 库,支持 GitHub Flavored Markdown +- 🔄 **多内容类型**: 支持Markdown、代码、JSON、图片等多种类型 +- ⚡ **流畅交互**: 带加载状态和平滑过渡动画 +- 🏷️ **标签系统**: 支持文档标签和分类 +- 🔍 **清晰导航**: 左侧树形导航,快速定位文档 +- ✨ **丰富语法**: 支持表格、任务列表、代码高亮等 GFM 特性 + +## 使用方法 + +### 基础用法 + +```tsx +import React from 'react'; +import { DocsComponent } from './docs'; +import { mockMarks } from './mock/collection'; + +function App() { + return ( +
+ +
+ ); +} +``` + +### 自定义数据 + +```tsx +import { DocsComponent, Mark } from './docs'; + +const customDocs: Mark[] = [ + { + id: '1', + title: '快速开始', + description: '了解如何快速开始使用我们的产品', + tags: ['入门', '指南'], + markType: 'markdown', + data: { + content: `# 快速开始 + +这里是文档内容...` + }, + createdAt: new Date(), + updatedAt: new Date() + } +]; + +function App() { + return ; +} +``` + +## 数据结构 + +组件接受一个 `Mark[]` 类型的数据源,每个Mark对象包含: + +```typescript +type Mark = { + id: string; // 唯一标识 + title?: string; // 文档标题 + description?: string; // 文档描述 + tags?: string[]; // 标签数组 + markType?: string; // 内容类型 + data: any; // 内容数据 + createdAt: Date; // 创建时间 + updatedAt: Date; // 更新时间 + // ... 其他字段 +} +``` + +## 支持的内容类型 + +### Markdown +```typescript +{ + markType: 'markdown', + data: { + content: '# 标题\n\n这是Markdown内容...' + } +} +``` + +### 代码 +```typescript +{ + markType: 'code', + data: { + code: 'const hello = "world";', + language: 'javascript' + } +} +``` + +### JSON数据 +```typescript +{ + markType: 'json', + data: { + // 任何JSON数据 + } +} +``` + +### 图片 +```typescript +{ + markType: 'image', + data: { + src: 'https://example.com/image.jpg', + alt: '图片描述' + } +} +``` + +## 样式定制 + +组件使用CSS类名,你可以通过覆盖这些类名来定制样式: + +```css +/* 主容器 */ +.docs-container { } + +/* 导航区域 */ +.docs-nav { } +.docs-nav-item { } +.docs-nav-link { } + +/* 内容区域 */ +.docs-content { } +.docs-content-header { } +.docs-content-body { } + +/* Markdown内容 */ +.docs-markdown { } +``` + +## 组件API + +### Props + +| 属性 | 类型 | 默认值 | 描述 | +|------|------|--------|------| +| dataSource | Mark[] | [] | 文档数据源 | + +### 导出组件 + +- `DocsComponent`: 主要的文档组件 +- `App`: DocsComponent的别名,保持向后兼容 + +## 示例 + +查看 `example.tsx` 文件获取完整的使用示例。 + +## 开发 + +```bash +# 安装依赖 +npm install marked +# 或 +pnpm install marked +``` + +组件使用 `marked` 库进行 Markdown 渲染,支持: + +- ✅ GitHub Flavored Markdown (GFM) +- ✅ 表格语法 +- ✅ 任务列表 +- ✅ 代码块语法高亮 +- ✅ 自动链接识别 +- ✅ 删除线语法 + +## 注意事项 + +1. 确保容器有足够的高度(建议设置为 `100vh`) +2. 组件会自动选中第一个文档项 +3. 支持键盘导航和无障碍访问 +4. 在移动端会自动调整为上下布局 + +## 更新日志 + +- v1.0.0: 初始版本,支持基础的文档显示功能 +- 支持Markdown、代码、JSON、图片等内容类型 +- 响应式设计和现代化UI \ No newline at end of file diff --git a/web/src/apps/muse/base/docs/docs.css b/web/src/apps/muse/base/docs/docs.css new file mode 100644 index 0000000..1b2fe6a --- /dev/null +++ b/web/src/apps/muse/base/docs/docs.css @@ -0,0 +1,542 @@ +/* 文档组件样式 */ +.docs-container { + display: flex; + height: 100%; + background: #f8fafc; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + +/* 左侧导航区域 */ +.docs-nav { + width: 280px; + background: #ffffff; + border-right: 1px solid #e2e8f0; + flex-shrink: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.docs-nav-header { + padding: 20px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; + position: sticky; + top: 0; + z-index: 10; + flex-shrink: 0; +} + +.docs-nav-title { + font-size: 18px; + font-weight: 600; + color: #1e293b; + margin: 0; +} + +.docs-nav-list { + list-style: none; + padding: 0; + margin: 0; + flex: 1; + overflow-y: auto; +} + +.docs-nav-item { + border-bottom: 1px solid #f1f5f9; + transition: all 0.2s ease; +} + +.docs-nav-item:last-child { + border-bottom: none; +} + +.docs-nav-link { + display: block; + padding: 16px 20px; + color: #64748b; + text-decoration: none; + transition: all 0.2s ease; + cursor: pointer; + border-left: 3px solid transparent; +} + +.docs-nav-link:hover { + background: #f8fafc; + color: #334155; + border-left-color: #e2e8f0; +} + +.docs-nav-link.active { + background: #eff6ff; + color: #2563eb; + border-left-color: #2563eb; + font-weight: 500; +} + +.docs-nav-link-title { + font-size: 14px; + font-weight: 500; + margin-bottom: 4px; + line-height: 1.4; +} + +.docs-nav-link-desc { + font-size: 12px; + color: #94a3b8; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; +} + +.docs-nav-link.active .docs-nav-link-desc { + color: #60a5fa; +} + +/* 右侧内容区域 */ +.docs-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.docs-content-header { + padding: 20px 32px; + background: #ffffff; + border-bottom: 1px solid #e2e8f0; + flex-shrink: 0; +} + +.docs-content-title { + font-size: 24px; + font-weight: 700; + color: #1e293b; + margin: 0 0 8px 0; + line-height: 1.3; +} + +.docs-content-meta { + display: flex; + gap: 16px; + align-items: center; + margin-top: 8px; +} + +.docs-content-tag { + display: inline-flex; + align-items: center; + padding: 4px 8px; + background: #f1f5f9; + color: #475569; + border-radius: 6px; + font-size: 12px; + font-weight: 500; +} + +.docs-content-date { + font-size: 12px; + color: #94a3b8; +} + +.docs-content-body { + flex: 1; + padding: 32px; + overflow-y: auto; + background: #ffffff; +} + +.docs-content-body.empty { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + color: #94a3b8; +} + +.docs-empty-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.docs-empty-text { + font-size: 16px; + text-align: center; + line-height: 1.5; +} + +/* Markdown内容样式 */ +.docs-markdown { + max-width: none; + color: #374151; + line-height: 1.7; +} + +.docs-markdown h1, +.docs-markdown h2, +.docs-markdown h3, +.docs-markdown h4, +.docs-markdown h5, +.docs-markdown h6 { + color: #111827; + font-weight: 600; + margin: 24px 0 16px 0; + line-height: 1.25; +} + +.docs-markdown h1 { + font-size: 32px; + border-bottom: 1px solid #e5e7eb; + padding-bottom: 12px; +} + +.docs-markdown h2 { + font-size: 24px; + border-bottom: 1px solid #f3f4f6; + padding-bottom: 8px; +} + +.docs-markdown h3 { + font-size: 20px; +} + +.docs-markdown h4 { + font-size: 16px; +} + +.docs-markdown p { + margin: 16px 0; + line-height: 1.7; +} + +.docs-markdown ul, +.docs-markdown ol { + margin: 16px 0; + padding-left: 24px; +} + +.docs-markdown li { + margin: 8px 0; + line-height: 1.6; +} + +.docs-markdown blockquote { + margin: 16px 0; + padding: 16px 20px; + background: #f9fafb; + border-left: 4px solid #d1d5db; + color: #6b7280; + font-style: italic; +} + +.docs-markdown code { + background: #f3f4f6; + color: #e11d48; + padding: 2px 6px; + border-radius: 4px; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 0.875em; +} + +.docs-markdown pre { + background: #1f2937; + color: #f9fafb; + padding: 20px; + border-radius: 8px; + overflow-x: auto; + margin: 16px 0; +} + +.docs-markdown pre code { + background: none; + color: inherit; + padding: 0; + border-radius: 0; +} + +.docs-markdown a { + color: #2563eb; + text-decoration: none; +} + +.docs-markdown a:hover { + text-decoration: underline; +} + +.docs-markdown img { + max-width: 100%; + height: auto; + border-radius: 8px; + margin: 16px 0; +} + +.docs-markdown table { + width: 100%; + border-collapse: collapse; + margin: 16px 0; +} + +.docs-markdown th, +.docs-markdown td { + border: 1px solid #e5e7eb; + padding: 12px; + text-align: left; +} + +.docs-markdown th { + background: #f9fafb; + font-weight: 600; +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .docs-container { + flex-direction: column; + height: auto; + } + + .docs-nav { + width: 100%; + max-height: 300px; + display: flex; + flex-direction: column; + } + + .docs-nav-header { + position: sticky; + top: 0; + z-index: 10; + } + + .docs-nav-list { + flex: 1; + overflow-y: auto; + } + + .docs-content-header { + padding: 16px 20px; + } + + .docs-content-body { + padding: 20px; + } + + .docs-content-title { + font-size: 20px; + } +} + +/* 滚动条样式 */ +.docs-nav-list::-webkit-scrollbar, +.docs-content-body::-webkit-scrollbar { + width: 6px; +} + +.docs-nav-list::-webkit-scrollbar-track, +.docs-content-body::-webkit-scrollbar-track { + background: #f1f5f9; +} + +.docs-nav-list::-webkit-scrollbar-thumb, +.docs-content-body::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 3px; +} + +.docs-nav-list::-webkit-scrollbar-thumb:hover, +.docs-content-body::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +/* 不同内容类型的样式 */ +.docs-json-content { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 20px; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 14px; + line-height: 1.5; + color: #374151; + overflow-x: auto; +} + +.docs-code-content { + background: #1e293b; + color: #f1f5f9; + border-radius: 8px; + padding: 20px; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 14px; + line-height: 1.6; + overflow-x: auto; + margin: 16px 0; +} + +.docs-code-content code { + background: none; + color: inherit; + padding: 0; + border-radius: 0; +} + +.docs-image-content { + text-align: center; + margin: 20px 0; +} + +.docs-image-content img { + max-width: 100%; + height: auto; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.docs-default-content { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 20px; +} + +.docs-default-content pre { + background: none; + color: #374151; + padding: 0; + margin: 0; + font-size: 14px; + line-height: 1.5; +} + +/* 内容区域优化 */ +.docs-content-body .docs-markdown h1:first-child { + margin-top: 0; +} + +.docs-content-body .docs-markdown h1:last-child, +.docs-content-body .docs-markdown h2:last-child, +.docs-content-body .docs-markdown h3:last-child, +.docs-content-body .docs-markdown p:last-child { + margin-bottom: 0; +} + +/* 导航项类型标识 */ +.docs-nav-item::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 0; + background: #2563eb; + transition: height 0.2s ease; +} + +.docs-nav-item { + position: relative; +} + +.docs-nav-link.active ~ ::before, +.docs-nav-item:has(.docs-nav-link.active)::before { + height: 100%; +} + +/* 标签优化 */ +.docs-content-tag.type-markdown { + background: #dbeafe; + color: #1e40af; +} + +.docs-content-tag.type-code { + background: #f3e8ff; + color: #7c3aed; +} + +.docs-content-tag.type-json { + background: #ecfdf5; + color: #059669; +} + +.docs-content-tag.type-image { + background: #fef3c7; + color: #d97706; +} + +/* 加载状态 */ +.docs-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + color: #94a3b8; +} + +.docs-loading-spinner { + width: 20px; + height: 20px; + border: 2px solid #e2e8f0; + border-top: 2px solid #2563eb; + border-radius: 50%; + animation: docs-spin 1s linear infinite; + margin-right: 12px; +} + +@keyframes docs-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* 搜索和过滤功能样式 */ +.docs-nav-search { + padding: 16px 20px; + border-bottom: 1px solid #e2e8f0; + background: #ffffff; + position: sticky; + top: 0; + z-index: 10; + flex-shrink: 0; +} + +.docs-nav-search-input { + width: 100%; + padding: 8px 12px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 14px; + transition: border-color 0.2s ease; +} + +.docs-nav-search-input:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.docs-nav-search-input::placeholder { + color: #9ca3af; +} + +/* 内容区域的打印样式 */ +@media print { + .docs-nav { + display: none; + } + + .docs-content { + width: 100%; + } + + .docs-content-body { + padding: 0; + } + + .docs-markdown { + color: #000; + } +} \ No newline at end of file diff --git a/web/src/apps/muse/base/docs/example.tsx b/web/src/apps/muse/base/docs/example.tsx new file mode 100644 index 0000000..1ca26c8 --- /dev/null +++ b/web/src/apps/muse/base/docs/example.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import { DocsComponent } from './index'; +import { mockMarks, generateMarkWithType } from '../mock/collection'; + +// 创建一些示例文档数据 +const createSampleDocs = () => { + const markdownDoc = generateMarkWithType('markdown'); + markdownDoc.title = '项目介绍'; + markdownDoc.description = '了解我们的项目背景和目标'; + markdownDoc.tags = ['介绍', '项目']; + markdownDoc.data = { + content: `# 欢迎使用文档系统 + +这是一个现代化的文档展示系统,现在使用 **marked** 库进行 Markdown 渲染。 + +## 主要功能 + +- **响应式设计**: 适配各种屏幕尺寸 +- **左侧导航**: 清晰的文档结构 +- **Markdown支持**: 使用 marked 库,支持 GitHub Flavored Markdown +- **多种内容类型**: 支持文本、代码、图片等多种内容 + +## 技术特性 + +### 样式设计 +使用了现代化的CSS设计,包括: + +1. 清新的配色方案 +2. 优雅的阴影和边框 +3. 流畅的过渡动画 + +### 功能特性 + +> **提示**: 这是一个引用块,展示了 marked 的渲染能力 + +- [x] 点击导航自动切换内容 +- [x] 加载状态提示 +- [x] 标签和日期显示 +- [ ] 响应式布局 + +## 代码示例 + +### TypeScript 代码 + +\`\`\`typescript +import { DocsComponent } from './docs'; +import { mockMarks } from './mock/collection'; + +const App: React.FC = () => { + return ; +}; +\`\`\` + +### 内联代码 + +使用 \`marked\` 库可以更好地处理 Markdown 语法。 + +## 表格支持 + +| 功能 | 状态 | 描述 | +|------|------|------| +| Markdown | ✅ | 完整支持 | +| 代码高亮 | ✅ | 语法高亮 | +| 表格 | ✅ | GFM 表格 | +| 任务列表 | ✅ | 支持复选框 | + +## 链接和图片 + +- 外部链接: [GitHub](https://github.com) +- 图片支持: ![示例](https://via.placeholder.com/150) + +--- + +希望你喜欢这个使用 **marked** 的文档系统!🎉` + }; + + const apiDoc = generateMarkWithType('markdown'); + apiDoc.title = 'API 文档'; + apiDoc.description = 'API接口使用说明和示例'; + apiDoc.tags = ['API', '开发']; + apiDoc.data = { + content: `# API 文档 + +## 🔐 用户认证 + +### 登录接口 + +**请求地址**: \`POST /api/auth/login\` + +**请求参数**: + +| 参数 | 类型 | 必填 | 描述 | +|------|------|------|------| +| username | string | ✅ | 用户名 | +| password | string | ✅ | 密码 | + +**响应示例**: + +\`\`\`json +{ + "code": 200, + "message": "success", + "data": { + "token": "eyJhbGciOiJIUzI1NiIs...", + "user": { + "id": 1, + "username": "admin", + "email": "admin@example.com" + } + } +} +\`\`\` + +## 📊 数据操作 + +### 获取列表 + +**请求地址**: \`GET /api/data/list\` + +**查询参数**: + +- \`page\`: 页码 (默认: 1) +- \`size\`: 每页数量 (默认: 10) +- \`keyword\`: 搜索关键词 + +### 创建数据 + +**请求地址**: \`POST /api/data/create\` + +**请求体**: + +\`\`\`json +{ + "title": "标题", + "content": "内容", + "tags": ["tag1", "tag2"] +} +\`\`\` + +## ⚠️ 错误码 + +| 错误码 | 描述 | 解决方案 | +|--------|------|----------| +| 400 | 请求参数错误 | 检查请求参数格式 | +| 401 | 未授权 | 重新登录获取 token | +| 403 | 禁止访问 | 检查用户权限 | +| 500 | 服务器错误 | 联系技术支持 | + +> **注意**: 所有 API 请求都需要在 Header 中包含 \`Authorization: Bearer \` + +更多 API 详情请参考 [完整文档](https://docs.example.com) 📖` + }; + + const codeDoc = generateMarkWithType('code'); + codeDoc.title = '代码示例'; + codeDoc.description = '常用的代码片段和最佳实践'; + codeDoc.tags = ['代码', '示例']; + codeDoc.data = { + code: `// React Hook 示例 +import { useState, useEffect, useCallback } from 'react'; + +const useDocuments = (initialData = []) => { + const [docs, setDocs] = useState(initialData); + const [loading, setLoading] = useState(false); + const [selectedId, setSelectedId] = useState(null); + + // 获取文档列表 + const fetchDocs = useCallback(async () => { + setLoading(true); + try { + const response = await fetch('/api/docs'); + const data = await response.json(); + setDocs(data); + } catch (error) { + console.error('Failed to fetch docs:', error); + } finally { + setLoading(false); + } + }, []); + + // 选择文档 + const selectDoc = useCallback((id) => { + setSelectedId(id); + }, []); + + useEffect(() => { + fetchDocs(); + }, [fetchDocs]); + + return { + docs, + loading, + selectedId, + selectDoc, + refetch: fetchDocs + }; +}; + +export default useDocuments;`, + language: 'typescript' + }; + + const configDoc = generateMarkWithType('json'); + configDoc.title = '配置说明'; + configDoc.description = '系统配置项说明和默认值'; + configDoc.tags = ['配置', '设置']; + + return [markdownDoc, apiDoc, codeDoc, configDoc]; +}; + +// 示例组件 +export const DocsExample: React.FC = () => { + const sampleDocs = createSampleDocs(); + + return ( +
+ +
+ ); +}; + +export default DocsExample; \ No newline at end of file diff --git a/web/src/apps/muse/base/docs/index.tsx b/web/src/apps/muse/base/docs/index.tsx new file mode 100644 index 0000000..08c8a74 --- /dev/null +++ b/web/src/apps/muse/base/docs/index.tsx @@ -0,0 +1,222 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { marked } from 'marked'; +import dayjs from 'dayjs'; +import { Mark } from '../mock/collection'; +import './docs.css'; + +type Props = { + dataSource?: Mark[]; +} + +// 配置 marked 选项 +marked.setOptions({ + breaks: true, + gfm: true, +}); + +// Markdown渲染组件 +const MarkdownRenderer: React.FC<{ content: string }> = ({ content }) => { + const [htmlContent, setHtmlContent] = useState(''); + + useEffect(() => { + const renderMarkdown = async () => { + try { + const html = await marked(content); + setHtmlContent(html); + } catch (error) { + console.error('Markdown rendering error:', error); + const errorMessage = error instanceof Error ? error.message : '未知错误'; + setHtmlContent(`

渲染错误: ${errorMessage}

`); + } + }; + + renderMarkdown(); + }, [content]); + + return ( +
+ ); +}; + +// 内容渲染组件 +const ContentRenderer: React.FC<{ mark: Mark }> = ({ mark }) => { + const renderContent = () => { + if (!mark.data) { + return
暂无内容
; + } + if (mark.description) { + return ; + } + + // 根据markType渲染不同类型的内容 + switch (mark.markType) { + case 'markdown': + if (mark.data.content) { + return ; + } + break; + case 'json': + return ( +
+            {JSON.stringify(mark.data, null, 2)}
+          
+ ); + case 'code': + return ( +
+            {mark.data.code || JSON.stringify(mark.data, null, 2)}
+          
+ ); + case 'image': + if (mark.data.src) { + return ( +
+ {mark.data.alt +
+ ); + } + break; + default: + // 对于其他类型,尝试显示内容字段 + if (mark.data.content) { + return ; + } + // 如果没有内容字段,显示JSON格式 + return ( +
+
{JSON.stringify(mark.data, null, 2)}
+
+ ); + } + + return
无法显示此类型的内容
; + }; + + return <>{renderContent()}; +}; + +// 主要的Docs组件 +export const DocsComponent: React.FC = ({ dataSource = [] }) => { + const [selectedMarkId, setSelectedMarkId] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // 过滤和处理数据源 + const validMarks = useMemo(() => { + return dataSource.filter(mark => mark.title || mark.description); + }, [dataSource]); + + // 获取当前选中的Mark + const selectedMark = useMemo(() => { + return validMarks.find(mark => mark.id === selectedMarkId) || null; + }, [validMarks, selectedMarkId]); + + // 默认选中第一项 + useEffect(() => { + if (validMarks.length > 0 && !selectedMarkId) { + setSelectedMarkId(validMarks[0].id); + } + }, [validMarks, selectedMarkId]); + + // 处理导航项点击 + const handleNavItemClick = (markId: string) => { + if (markId !== selectedMarkId) { + setIsLoading(true); + setSelectedMarkId(markId); + + // 模拟加载时间 + setTimeout(() => { + setIsLoading(false); + }, 200); + } + }; + + // 格式化日期 + const formatDate = (date: Date) => { + return dayjs(date).format('YYYY年MM月DD日'); + }; + + return ( +
+ {/* 左侧导航 */} + + + {/* 右侧内容 */} +
+ {selectedMark ? ( + <> + {/* 内容标题栏 */} +
+

+ {selectedMark.title || '未命名文档'} +

+
+ {selectedMark.tags && selectedMark.tags.map((tag, index) => ( + + {tag} + + ))} + + {formatDate(selectedMark.updatedAt)} + +
+
+ + {/* 内容主体 */} +
+ {isLoading ? ( +
+
+ 加载中... +
+ ) : ( + + )} +
+ + ) : ( +
+
📄
+
+ {validMarks.length === 0 ? '暂无文档可显示' : '请选择一个文档查看'} +
+
+ )} +
+
+ ); +}; + +// 兼容性导出 +export const App = DocsComponent; \ No newline at end of file diff --git a/web/src/apps/muse/base/index.tsx b/web/src/apps/muse/base/index.tsx index 987b58a..6ad92d2 100644 --- a/web/src/apps/muse/base/index.tsx +++ b/web/src/apps/muse/base/index.tsx @@ -2,22 +2,29 @@ import { useEffect, useState } from "react"; import { Base } from "./table/index"; import { markService } from "../modules/mark-service"; import { SigmaGraph } from "./graph/sigma/index"; +import { DocsComponent } from "./docs"; +import { MarkDetailList } from "./card/MarkDetailList"; + const tabs = [ { key: 'table', title: '表格' }, + { + key: 'card', + title: '卡片' + }, { key: 'graph', title: '关系图' }, - { - key: 'world', - title: '世界' - }, { key: 'docs', title: '文档' + }, + { + key: 'world', + title: '世界' } ]; @@ -41,18 +48,16 @@ export const BaseApp = () => {
); + case 'card': + return ; + case 'docs': + return ; case 'world': return (
世界模块暂未实现
); - case 'docs': - return ( -
- 文档模块暂未实现 -
- ); default: return null; } @@ -67,9 +72,9 @@ export const BaseApp = () => { + ))} + + + )} + + ); + }; + if (loading) { return (
@@ -110,11 +139,11 @@ export const Table: React.FC = ({ } const selectedKeys = rowSelection?.selectedRowKeys || []; - const isAllSelected = paginatedData.length > 0 && paginatedData.every(item => selectedKeys.includes(item.id)); + const isAllSelected = displayData.length > 0 && displayData.every(item => selectedKeys.includes(item.id)); const isIndeterminate = selectedKeys.length > 0 && !isAllSelected; return ( -
+
{/* 表格工具栏 */} {rowSelection && selectedKeys.length > 0 && (
@@ -134,173 +163,73 @@ export const Table: React.FC = ({ {/* 表格 */}
- - - - {rowSelection && ( - - )} - {columns.map(column => ( - - ))} - {actions && actions.length > 0 && ( - - )} - - - - {paginatedData.map((record, index) => ( - - {rowSelection && ( - - )} - {columns.map(column => ( - - ))} - {actions && actions.length > 0 && ( - - )} - + )} + + ))} - -
- { - if (input) input.indeterminate = isIndeterminate; - }} - onChange={(e) => handleSelectAll(e.target.checked)} - /> - -
- {column.title} - {column.sortable && ( -
handleSort(column.dataIndex)} - > - - -
- )} -
-
操作
- handleRowSelect(record, e.target.checked)} - disabled={rowSelection.getCheckboxProps?.(record)?.disabled} - /> - - {column.render - ? column.render(getNestedValue(record, column.dataIndex), record, index) - : getNestedValue(record, column.dataIndex) - } - -
- {actions.map(action => ( - - ))} + {/* 固定表头 */} +
+
+ {rowSelection && ( +
+ { + if (input) input.indeterminate = isIndeterminate; + }} + onChange={(e) => handleSelectAll(e.target.checked)} + /> +
+ )} + {columns.map(column => ( +
+
+ {column.title} + {column.sortable && ( +
handleSort(column.dataIndex)} + > + +
-
- - {paginatedData.length === 0 && ( -
-
📭
-

暂无数据

-
- )} -
- - {/* 分页 */} - {pagination && ( -
-
- {pagination.showTotal && pagination.showTotal( - pagination.total, - [ - (currentPage - 1) * pagination.pageSize + 1, - Math.min(currentPage * pagination.pageSize, pagination.total) - ] + {actions && actions.length > 0 && ( +
操作
)}
-
- - -
- {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 ( - - {showEllipsis && ...} - - - ); - }) - } -
- - -
- - {pagination.showSizeChanger && ( -
- +
+ + {/* 虚拟滚动内容区域 */} +
+ {displayData.length > 0 ? ( + + {({ height, width }) => ( + + )} + + ) : ( +
+
📭
+

暂无数据

)}
- )} +
); }; \ No newline at end of file diff --git a/web/src/apps/muse/base/table/index.tsx b/web/src/apps/muse/base/table/index.tsx index 0cdca4b..4483a98 100644 --- a/web/src/apps/muse/base/table/index.tsx +++ b/web/src/apps/muse/base/table/index.tsx @@ -1,4 +1,5 @@ import React, { useState, useMemo, useEffect } from 'react'; +import { Eye, Edit, Trash2 } from 'lucide-react'; import { Table } from './Table'; import { DetailModal } from './DetailModal'; import { Mark } from '../mock/collection'; @@ -10,8 +11,6 @@ type Props = { export const Base = (props: Props) => { const { dataSource = [] } = props; const [selectedRowKeys, setSelectedRowKeys] = useState([]); - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(10); const [data, setData] = useState([]); const [detailModalVisible, setDetailModalVisible] = useState(false); const [currentRecord, setCurrentRecord] = useState(null); @@ -127,16 +126,15 @@ export const Base = (props: Props) => { { key: 'view', label: '详情', - type: 'primary', - icon: '👁', + icon: , onClick: (record: Mark) => { handleViewDetail(record); } }, { key: 'edit', + icon: , label: '编辑', - icon: '✏️', onClick: (record: Mark) => { handleEdit(record); } @@ -144,8 +142,7 @@ export const Base = (props: Props) => { { key: 'delete', label: '删除', - type: 'danger', - icon: '🗑️', + icon: , onClick: (record: Mark) => { handleDelete(record); } @@ -222,72 +219,75 @@ export const Base = (props: Props) => { 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); - } + // 虚拟滚动配置 + const virtualScrollConfig = { + rowHeight: 60, // 因为有两行内容,需要更高的行高 + // 移除固定高度,让表格自适应容器高度 }; return ( -
-
-

数据管理表格

-

- 支持多选、排序、分页等功能的数据表格示例 -

-
- - {selectedRowKeys.length > 0 && ( -
- 已选择 {selectedRowKeys.length} 项 - +
+
+ {/* 固定头部区域 */} +
+

数据管理表格

+

+ 支持多选、排序、虚拟滚动等功能的数据表格示例 +

+
+
+ {/* 批量操作条 */} + {selectedRowKeys.length > 0 && ( +
+ 已选择 {selectedRowKeys.length} 项 + +
+ )}
- )} - { - setSelectedRowKeys(keys); - } - }} - pagination={paginationConfig} - onSort={handleSort} - /> + {/* 表格容器 - 占据剩余空间并支持滚动 */} +
+
{ + setSelectedRowKeys(keys); + } + }} + virtualScroll={virtualScrollConfig} + onSort={handleSort} + /> + - { - setDetailModalVisible(false); - setCurrentRecord(null); - }} - /> + { + setDetailModalVisible(false); + setCurrentRecord(null); + }} + /> + ); }; \ No newline at end of file diff --git a/web/src/apps/muse/base/table/table.css b/web/src/apps/muse/base/table/table.css index eaa24cd..abee090 100644 --- a/web/src/apps/muse/base/table/table.css +++ b/web/src/apps/muse/base/table/table.css @@ -4,6 +4,10 @@ border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); overflow: hidden; + height: calc(100% - 24px); /* 距离底部保持24px的间距 */ + margin-bottom: 24px; /* 底部间距 */ + display: flex; + flex-direction: column; } /* 工具栏 */ @@ -14,6 +18,7 @@ padding: 16px; background: #f5f5f5; border-bottom: 1px solid #e8e8e8; + flex-shrink: 0; /* 工具栏不压缩,保持固定高度 */ } .selected-info { @@ -26,37 +31,83 @@ gap: 8px; } -/* 表格主体 */ +/* 表格主体 - 虚拟滚动布局 */ .table-wrapper { - overflow-x: auto; + display: flex; + flex-direction: column; + flex: 1; /* 占满剩余空间 */ + height: 100%; /* 占满父容器高度 */ + min-height: 200px; /* 最小高度,避免过小 */ + overflow: hidden; /* 防止溢出 */ } -.data-table { - width: 100%; - border-collapse: collapse; - font-size: 14px; -} - -.data-table th, -.data-table td { - padding: 12px 16px; - text-align: left; +/* 固定表头容器 */ +.table-header-wrapper { + flex-shrink: 0; border-bottom: 1px solid #e8e8e8; + background: #fafafa; + overflow: hidden; } -.data-table th { - background: #fafafa; +.table-header-row { + display: flex; + align-items: center; +} + +.table-header-cell { + display: flex; + align-items: center; + padding: 12px 16px; + border-right: 1px solid #e8e8e8; font-weight: 600; color: #333; - position: sticky; - top: 0; - z-index: 10; + background: #fafafa; } -.data-table tbody tr:hover { +.table-header-cell:last-child { + border-right: none; +} + +.table-header-cell.sortable { + cursor: pointer; + user-select: none; +} + +/* 虚拟滚动内容区域 */ +.table-body-wrapper { + flex: 1; + overflow: hidden; + background: #fff; + min-height: 0; /* 重要:允许flex子项收缩 */ +} + +/* 虚拟行样式 */ +.virtual-row { + display: flex; + align-items: center; + border-bottom: 1px solid #e8e8e8; + background: #fff; + transition: background-color 0.2s; +} + +.virtual-row:hover { background: #f5f5f5; } +.table-cell { + display: flex; + align-items: center; + padding: 12px 16px; + border-right: 1px solid #e8e8e8; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.table-cell:last-child { + border-right: none; +} + /* 表头 */ .table-header { display: flex; @@ -103,66 +154,220 @@ /* 操作列 */ .actions-column { - width: 200px; + width: auto; + min-width: 120px; text-align: right; + padding: 8px 16px; } .action-buttons { + position: relative; display: flex; - gap: 8px; + gap: 6px; justify-content: flex-end; + align-items: center; + flex-wrap: wrap; } /* 按钮样式 */ .btn { + position: relative; display: inline-flex; align-items: center; + justify-content: center; gap: 4px; - padding: 6px 12px; + padding: 8px 12px; + min-width: 32px; + height: 32px; border: 1px solid #d9d9d9; - border-radius: 4px; + border-radius: 6px; background: #fff; color: #333; font-size: 12px; + font-weight: 500; cursor: pointer; - transition: all 0.2s; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); text-decoration: none; + user-select: none; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + outline: none; } -.btn:hover { +.btn:hover:not(:disabled) { border-color: #40a9ff; color: #40a9ff; + transform: translateY(-1px); + box-shadow: 0 4px 8px 0 rgba(64, 169, 255, 0.15); +} + +.btn:focus:not(:disabled) { + border-color: #40a9ff; + box-shadow: 0 0 0 2px rgba(64, 169, 255, 0.2); +} + +.btn:active:not(:disabled) { + transform: translateY(0); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1); } .btn:disabled { opacity: 0.5; cursor: not-allowed; + transform: none !important; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important; } .btn-primary { - background: #1890ff; + background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); border-color: #1890ff; color: #fff; } .btn-primary:hover:not(:disabled) { - background: #40a9ff; + background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%); border-color: #40a9ff; + color: #fff; + box-shadow: 0 4px 12px 0 rgba(24, 144, 255, 0.3); +} + +.btn-primary:focus:not(:disabled) { + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3); } .btn-danger { - background: #ff4d4f; + background: linear-gradient(135deg, #ff4d4f 0%, #f5222d 100%); border-color: #ff4d4f; color: #fff; } .btn-danger:hover:not(:disabled) { - background: #ff7875; + background: linear-gradient(135deg, #ff7875 0%, #ff4d4f 100%); border-color: #ff7875; + color: #fff; + box-shadow: 0 4px 12px 0 rgba(255, 77, 79, 0.3); +} + +.btn-danger:focus:not(:disabled) { + box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.3); +} + +.btn-success { + background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%); + border-color: #52c41a; + color: #fff; +} + +.btn-success:hover:not(:disabled) { + background: linear-gradient(135deg, #73d13d 0%, #52c41a 100%); + border-color: #73d13d; + color: #fff; + box-shadow: 0 4px 12px 0 rgba(82, 196, 26, 0.3); +} + +.btn-success:focus:not(:disabled) { + box-shadow: 0 0 0 2px rgba(82, 196, 26, 0.3); +} + +.btn-warning { + background: linear-gradient(135deg, #faad14 0%, #d48806 100%); + border-color: #faad14; + color: #fff; +} + +.btn-warning:hover:not(:disabled) { + background: linear-gradient(135deg, #ffc53d 0%, #faad14 100%); + border-color: #ffc53d; + color: #fff; + box-shadow: 0 4px 12px 0 rgba(250, 173, 20, 0.3); +} + +.btn-warning:focus:not(:disabled) { + box-shadow: 0 0 0 2px rgba(250, 173, 20, 0.3); } .btn-icon { + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; +} + +/* Tooltip 样式 */ +.tooltip { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 8px; + padding: 6px 10px; + background: rgba(0, 0, 0, 0.85); + color: #fff; font-size: 12px; + font-weight: 400; + line-height: 1.4; + border-radius: 4px; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.tooltip::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 4px solid transparent; + border-top-color: rgba(0, 0, 0, 0.85); +} + +.btn:hover .tooltip { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(-4px); +} + +/* 确保tooltip在不同位置的按钮中都能正确显示 */ +.action-buttons { + position: relative; +} + +/* 按钮尺寸变体 */ +.btn-small { + padding: 4px 8px; + min-width: 24px; + height: 24px; + font-size: 11px; +} + +.btn-small .btn-icon { + font-size: 12px; +} + +.btn-medium { + padding: 8px 12px; + min-width: 32px; + height: 32px; + font-size: 12px; +} + +.btn-medium .btn-icon { + font-size: 14px; +} + +.btn-large { + padding: 12px 16px; + min-width: 40px; + height: 40px; + font-size: 14px; +} + +.btn-large .btn-icon { + font-size: 16px; } /* 空状态 */ @@ -202,73 +407,6 @@ 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 { @@ -281,32 +419,62 @@ 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; + .table-wrapper { + min-height: 300px; /* 移动端最小高度,仍然使用flex: 1占满剩余空间 */ } .action-buttons { - flex-direction: column; + flex-direction: row; gap: 4px; + justify-content: flex-end; } .actions-column { - width: 120px; + min-width: 80px; + padding: 6px 12px; } .btn { + font-size: 11px; + padding: 6px 10px; + min-width: 28px; + height: 28px; + } + + .btn-icon { + font-size: 12px; + } + + /* 移动端tooltip调整 */ + .tooltip { font-size: 11px; padding: 4px 8px; + margin-bottom: 6px; + } + + .tooltip::after { + border-width: 3px; + } + + /* 移动端按钮尺寸调整 */ + .btn-small { + padding: 3px 6px; + min-width: 20px; + height: 20px; + font-size: 10px; + } + + .btn-medium { + padding: 6px 10px; + min-width: 28px; + height: 28px; + font-size: 11px; + } + + .btn-large { + padding: 8px 12px; + min-width: 32px; + height: 32px; + font-size: 12px; } } \ No newline at end of file diff --git a/web/src/apps/muse/base/table/types.ts b/web/src/apps/muse/base/table/types.ts index e166291..4b830cc 100644 --- a/web/src/apps/muse/base/table/types.ts +++ b/web/src/apps/muse/base/table/types.ts @@ -19,25 +19,22 @@ export interface RowSelection { 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 VirtualScrollConfig { + rowHeight?: number; // 行高度,默认 48px + height?: number; // 表格容器高度,默认 400px } // 表格操作按钮类型 export interface ActionButton { key: string; label: string; - type?: 'primary' | 'default' | 'danger'; + className?: string; icon?: React.ReactNode; onClick: (record: Mark) => void; disabled?: (record: Mark) => boolean; + tooltip?: string; // 可选的自定义tooltip文本 + size?: 'small' | 'medium' | 'large'; // 按钮尺寸 } // 表格属性 @@ -46,7 +43,7 @@ export interface TableProps { columns: TableColumn[]; loading?: boolean; rowSelection?: RowSelection; - pagination?: PaginationConfig | false; + virtualScroll?: VirtualScrollConfig; actions?: ActionButton[]; onSort?: (field: string, order: 'asc' | 'desc' | null) => void; } diff --git a/web/src/apps/muse/components/README.md b/web/src/apps/muse/components/README.md new file mode 100644 index 0000000..cef238c --- /dev/null +++ b/web/src/apps/muse/components/README.md @@ -0,0 +1,201 @@ +# MarkDetail 组件功能说明 + +## 概述 + +`MarkDetail` 组件是一个用于显示 SimpleMark 信息的 React 组件,支持两种显示模式: +1. **简化模式** - 仅显示 `SimpleMarkShow` 类型的基础字段 +2. **完整模式** - 显示 `MarkShow` 类型的所有字段 + +## 功能特性 + +### ✨ 主要功能 + +- 🔄 **切换显示模式**:通过复选框切换显示所有字段或仅基础字段 +- 📊 **数据过滤**:根据用户选择动态过滤显示的数据字段 +- 🎨 **美观界面**:现代化的卡片式布局,响应式设计 +- 🏷️ **标签系统**:彩色标签显示,支持多标签 +- 🖼️ **图片展示**:支持封面图片显示和错误处理 +- 🔗 **链接跳转**:可点击的外部链接 +- 📅 **时间格式化**:友好的时间显示格式 + +### 🎯 类型定义 + +#### MarkShow (完整类型) +```typescript +export type MarkShow = { + id: string; + title?: string; + description?: string; + tags?: string[]; + markType?: string; + cover?: string; + link?: string; + summary?: string; + key?: string; + data: any; + createdAt?: string; + updatedAt?: string; + markedAt?: Date; +} +``` + +#### SimpleMarkShow (简化类型) +```typescript +export type SimpleMarkShow = { + id: string; + title?: string; + description?: string; + tags?: string[]; + cover?: string; + link?: string; + summary?: string; +} +``` + +### 🔄 显示模式 + +#### 简化模式(默认) +仅显示 `SimpleMarkShow` 的字段: +- ID +- 标题 +- 描述 +- 标签 +- 封面 +- 链接 +- 摘要 + +#### 完整模式 +显示 `MarkShow` 的所有字段: +- 简化模式的所有字段 +- 标记类型(带颜色标识) +- 键值 +- 创建时间 +- 更新时间 +- 标记时间 +- 数据内容(JSON 格式) + +## 使用方法 + +### 基础使用 + +```tsx +import { MarkDetail, MarkShow } from './components/MarkDetail'; + +const data: MarkShow[] = [ + { + id: '1', + title: '示例标题', + description: '示例描述', + tags: ['React', 'TypeScript'], + // ... 其他字段 + } +]; + +function App() { + return ( +
+ +
+ ); +} +``` + +### 在主应用中使用 + +组件已集成到主应用的 "Mark详情" 标签页中: + +1. 启动应用后,点击 "Mark详情" 标签 +2. 使用右上角的复选框切换显示模式: + - ☐ 仅显示基础字段(SimpleMarkShow) + - ☑️ 显示全部字段(MarkShow) + +## 界面说明 + +### 头部控制区域 +- **标题**:显示组件名称 +- **切换开关**:复选框用于切换显示模式 +- **记录统计**:显示当前数据记录总数 + +### 数据显示区域 +- **卡片布局**:每条记录显示为一个独立的卡片 +- **响应式网格**:字段按网格布局,适配不同屏幕尺寸 +- **空状态处理**:无数据时显示友好的空状态提示 + +### 字段显示特性 + +#### 标签显示 +- 蓝色背景的小标签 +- 支持多标签横向排列 +- 自动换行处理 + +#### 类型标识 +不同的 `markType` 显示不同颜色: +- `markdown` - 绿色 +- `json` - 黄色 +- `html` - 橙色 +- `image` - 紫色 +- `video` - 红色 +- `audio` - 粉色 +- `code` - 灰色 +- `link` - 蓝色 +- `file` - 靛蓝色 + +#### 图片处理 +- 封面图片自动缩放为 64x64 像素 +- 图片加载失败时自动隐藏 +- 圆角样式处理 + +#### 链接处理 +- 外部链接在新标签页打开 +- 蓝色下划线样式 +- 悬停效果 + +#### 时间格式化 +- 使用 `toLocaleString('zh-CN')` 格式化 +- 支持中文本地化显示 +- 未设置时显示 "-" + +## 样式系统 + +使用 Tailwind CSS 构建,主要样式类: + +### 布局类 +- `h-full flex flex-col` - 全高度弹性布局 +- `grid grid-cols-1 md:grid-cols-2 gap-4` - 响应式网格 + +### 卡片样式 +- `border border-gray-200 rounded-lg p-4 bg-white shadow-sm` + +### 文本样式 +- `text-sm font-medium text-gray-600` - 标签文本 +- `text-sm text-gray-800` - 内容文本 + +### 交互样式 +- `hover:text-blue-800` - 悬停效果 +- `focus:ring-blue-500` - 聚焦状态 + +## 扩展性 + +组件设计考虑了扩展性: + +1. **类型安全**:完整的 TypeScript 类型定义 +2. **可配置**:通过 props 传入数据和配置 +3. **可定制**:基于 Tailwind CSS,易于定制样式 +4. **响应式**:适配不同屏幕尺寸 +5. **可维护**:清晰的代码结构和注释 + +## 注意事项 + +1. **数据类型**:确保传入的数据符合 `MarkShow` 类型定义 +2. **图片链接**:封面图片需要是有效的 URL +3. **外部链接**:确保链接格式正确,组件会在新标签页打开 +4. **性能**:大量数据时建议使用虚拟滚动或分页 +5. **容器高度**:建议为组件容器设置明确的高度 + +## 更新日志 + +- v1.0.0 (2024-10-21) + - 初始版本发布 + - 支持简化/完整两种显示模式 + - 响应式布局和现代化UI设计 + - 完整的类型安全支持 \ No newline at end of file diff --git a/web/src/apps/muse/index.tsx b/web/src/apps/muse/index.tsx index 0e71c2e..e70b661 100644 --- a/web/src/apps/muse/index.tsx +++ b/web/src/apps/muse/index.tsx @@ -1,12 +1,12 @@ -import { ToastContainer } from 'react-toastify'; +import { ToastContainer, toast } from 'react-toastify'; import { AuthProvider } from '../login/AuthProvider'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { VadVoice } from './videos/modules/VadVoice.tsx'; import { ChatInterface } from './prompts/index.tsx'; import { BaseApp } from './base/index.tsx'; -import { exampleUsage } from './modules/mark-service.ts'; +import { exampleUsage, markService } from './modules/mark-service.ts'; const LeftPanel = () => { return ( @@ -47,6 +47,64 @@ export const MuseApp = () => { const [showRightPanel, setShowRightPanel] = useState(true); const [showLeftPanel, setShowLeftPanel] = useState(true); const [showCenterPanel, setShowCenterPanel] = useState(true); + const fileInputRef = useRef(null); + + // 导出数据库 + const handleExportDB = async () => { + try { + const filename = `marks_backup_${new Date().toISOString().split('T')[0]}.json`; + await markService.exportToFile(filename); + toast.success('数据库导出成功!'); + } catch (error) { + console.error('导出失败:', error); + toast.error('导出失败: ' + (error as Error).message); + } + }; + + // 触发导入文件选择 + const handleImportDB = () => { + fileInputRef.current?.click(); + }; + + // 处理文件选择并导入 + const handleFileImport = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + const result = await markService.importFromFile(file); + toast.success( + `导入完成!成功: ${result.success}条,失败: ${result.failed}条,总计: ${result.total}条` + ); + } catch (error) { + console.error('导入失败:', error); + toast.error('导入失败: ' + (error as Error).message); + } + + // 重置文件输入 + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + // 删除数据库 + const handleDeleteDB = async () => { + if (!window.confirm('确定要删除所有数据吗?此操作无法撤销!')) { + return; + } + + try { + const success = await markService.clearDatabase(); + if (success) { + toast.success('数据库已清空!'); + } else { + toast.error('清空数据库失败!'); + } + } catch (error) { + console.error('删除失败:', error); + toast.error('删除失败: ' + (error as Error).message); + } + }; return (
@@ -85,11 +143,32 @@ export const MuseApp = () => { }}> 初始化DB - + + + {/* 隐藏的文件输入元素 */} +
diff --git a/web/src/apps/muse/modules/db.ts b/web/src/apps/muse/modules/db.ts index 29a552e..bfc4198 100644 --- a/web/src/apps/muse/modules/db.ts +++ b/web/src/apps/muse/modules/db.ts @@ -27,12 +27,32 @@ export class MarkDB { this.db = createDB(dbName); } + // 检查是否支持 find 方法 + private supportsFindAPI(): boolean { + return typeof this.db.find === 'function'; + } + + // 回退方案:使用 allDocs 过滤数据 + private async fallbackFind(filterFn: (doc: Mark) => boolean): Promise { + const allDocs = await this.getAll(); + return allDocs.filter(filterFn).sort((a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + } + // 创建索引以支持查询 async createIndexes() { if (!this.db) { throw new Error('数据库未初始化'); } + // 检查是否支持 createIndex 方法 (需要 pouchdb-find 插件) + if (typeof this.db.createIndex !== 'function') { + console.warn('PouchDB Find plugin not available. Skipping index creation.'); + console.warn('Some query features may not work optimally without indexes.'); + return; + } + try { // PouchDB 创建索引的正确方式 const indexes = [ @@ -64,7 +84,8 @@ export class MarkDB { console.log('索引初始化完成'); } catch (error) { console.error('创建索引失败:', error); - throw error; + // 不再抛出错误,而是警告用户 + console.warn('索引创建失败,但数据库可以继续使用(性能可能受影响)'); } } @@ -123,14 +144,18 @@ export class MarkDB { // 按用户 ID 获取 Marks async getByUserId(uid: string): Promise { try { - const result = await this.db.find({ - selector: { - uid: uid - }, - sort: [{ createdAt: 'desc' }] - }); - - return result.docs.map(doc => docToMark(doc)); + if (this.supportsFindAPI()) { + const result = await this.db.find({ + selector: { + uid: uid + }, + sort: [{ createdAt: 'desc' }] + }); + return result.docs.map(doc => docToMark(doc)); + } else { + // 回退方案:使用 allDocs 过滤 + return await this.fallbackFind((mark: Mark) => mark.uid === uid); + } } catch (error) { console.error('按用户获取 Marks 失败:', error); throw error; @@ -140,14 +165,18 @@ export class MarkDB { // 按类型获取 Marks async getByType(markType: MarkEnsureType): Promise { try { - const result = await this.db.find({ - selector: { - markType: markType - }, - sort: [{ createdAt: 'desc' }] - }); - - return result.docs.map(doc => docToMark(doc)); + if (this.supportsFindAPI()) { + const result = await this.db.find({ + selector: { + markType: markType + }, + sort: [{ createdAt: 'desc' }] + }); + return result.docs.map(doc => docToMark(doc)); + } else { + // 回退方案:使用 allDocs 过滤 + return await this.fallbackFind((mark: Mark) => mark.markType === markType); + } } catch (error) { console.error('按类型获取 Marks 失败:', error); throw error; @@ -157,14 +186,20 @@ export class MarkDB { // 按标签搜索 Marks async getByTag(tag: string): Promise { try { - const result = await this.db.find({ - selector: { - tags: { $elemMatch: { $eq: tag } } - }, - sort: [{ createdAt: 'desc' }] - }); - - return result.docs.map(doc => docToMark(doc)); + if (this.supportsFindAPI()) { + const result = await this.db.find({ + selector: { + tags: { $elemMatch: { $eq: tag } } + }, + sort: [{ createdAt: 'desc' }] + }); + return result.docs.map(doc => docToMark(doc)); + } else { + // 回退方案:使用 allDocs 过滤 + return await this.fallbackFind((mark: Mark) => + Boolean(mark.tags && mark.tags.includes(tag)) + ); + } } catch (error) { console.error('按标签获取 Marks 失败:', error); throw error; @@ -174,18 +209,30 @@ export class MarkDB { // 搜索 Marks(按标题或描述) async search(query: string): Promise { 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)); + if (this.supportsFindAPI()) { + const result = await this.db.find({ + selector: { + $or: [ + { title: { $regex: query, $options: 'i' } }, + { description: { $regex: query, $options: 'i' } }, + { summary: { $regex: query, $options: 'i' } } + ] + }, + sort: [{ createdAt: 'desc' }] + }); + return result.docs.map(doc => docToMark(doc)); + } else { + // 回退方案:使用 allDocs 过滤,简单的字符串匹配 + const lowerQuery = query.toLowerCase(); + return await this.fallbackFind((mark: Mark) => { + const title = mark.title?.toLowerCase() || ''; + const description = mark.description?.toLowerCase() || ''; + const summary = mark.summary?.toLowerCase() || ''; + return title.includes(lowerQuery) || + description.includes(lowerQuery) || + summary.includes(lowerQuery); + }); + } } catch (error) { console.error('搜索 Marks 失败:', error); throw error; @@ -256,47 +303,91 @@ export class MarkDB { totalPages: number; }> { try { - // 构建查询选择器 - let selector: any = {}; + if (this.supportsFindAPI()) { + // 使用 find API + let selector: any = {}; - if (filters?.uid) { - selector.uid = filters.uid; + if (filters?.uid) { + selector.uid = filters.uid; + } + + if (filters?.markType) { + selector.markType = filters.markType; + } + + if (filters?.tags && filters.tags.length > 0) { + selector.tags = { $elemMatch: { $in: filters.tags } }; + } + + // 获取总数 + const countResult = await this.db.find({ + selector, + fields: [] + }); + const total = countResult.docs.length; + + // 计算分页 + const skip = (page - 1) * limit; + const totalPages = Math.ceil(total / limit); + + // 获取数据 + const result = await this.db.find({ + selector, + sort: [{ createdAt: 'desc' }], + skip, + limit + }); + + return { + marks: result.docs.map(doc => docToMark(doc)), + total, + page, + limit, + totalPages + }; + } else { + // 回退方案:获取所有数据后在内存中分页 + let allMarks = await this.getAll(); + + // 应用过滤器 + if (filters) { + allMarks = allMarks.filter(mark => { + let matches = true; + + if (filters.uid && mark.uid !== filters.uid) { + matches = false; + } + + if (filters.markType && mark.markType !== filters.markType) { + matches = false; + } + + if (filters.tags && filters.tags.length > 0) { + const hasMatchingTag = filters.tags.some(tag => + mark.tags && mark.tags.includes(tag) + ); + if (!hasMatchingTag) { + matches = false; + } + } + + return matches; + }); + } + + const total = allMarks.length; + const totalPages = Math.ceil(total / limit); + const skip = (page - 1) * limit; + const marks = allMarks.slice(skip, skip + limit); + + return { + marks, + total, + page, + limit, + totalPages + }; } - - 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; @@ -363,8 +454,9 @@ export class MarkDB { // 清理数据库 async clear(): Promise { try { + const dbName = this.db.name; await this.db.destroy(); - this.db = createDB(this.db.name); + this.db = createDB(dbName); await this.createIndexes(); } catch (error) { console.error('清理数据库失败:', error); diff --git a/web/src/apps/muse/modules/mark-service.ts b/web/src/apps/muse/modules/mark-service.ts index db57cf8..f4f8f42 100644 --- a/web/src/apps/muse/modules/mark-service.ts +++ b/web/src/apps/muse/modules/mark-service.ts @@ -1,6 +1,5 @@ import { markDB, initMarkDB } from './db'; import { Mark } from './mark'; -import { mockMarks } from '../base/mock/collection'; // Mark 服务类 - 提供业务逻辑层 export class MarkService { @@ -79,24 +78,6 @@ export class MarkService { return await this.db.getStats(); } - // 初始化示例数据 - async initSampleData(): Promise { - 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 { return await this.getAllMarks(); @@ -116,6 +97,103 @@ export class MarkService { } return importedCount; } + + // 导出数据到文件 + async exportToFile(filename: string = 'marks_backup.json'): Promise { + try { + const marks = await this.exportData(); + const exportData = { + version: '1.0', + exportTime: new Date().toISOString(), + totalCount: marks.length, + marks: marks + }; + + const jsonData = JSON.stringify(exportData, null, 2); + const blob = new Blob([jsonData], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + // 创建下载链接 + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + console.log(`成功导出 ${marks.length} 条记录到文件: ${filename}`); + } catch (error) { + console.error('导出文件失败:', error); + throw new Error('导出文件失败: ' + (error as Error).message); + } + } + + // 从文件导入数据 + async importFromFile(file: File): Promise<{ success: number; failed: number; total: number }> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = async (e) => { + try { + const content = e.target?.result as string; + const importData = JSON.parse(content); + + // 验证数据格式 + if (!importData.marks || !Array.isArray(importData.marks)) { + throw new Error('无效的数据格式:缺少marks数组'); + } + + let successCount = 0; + let failedCount = 0; + const totalCount = importData.marks.length; + + // 批量导入数据 + for (const mark of importData.marks) { + try { + // 移除ID相关字段,让系统重新生成 + const { id, createdAt, updatedAt, _id, _rev, ...markData } = mark; + await this.createMark(markData); + successCount++; + } catch (error) { + console.warn('导入单条记录失败:', error); + failedCount++; + } + } + + const result = { + success: successCount, + failed: failedCount, + total: totalCount + }; + + console.log(`导入完成: 成功${successCount}条,失败${failedCount}条,总计${totalCount}条`); + resolve(result); + } catch (error) { + console.error('解析导入文件失败:', error); + reject(new Error('解析导入文件失败: ' + (error as Error).message)); + } + }; + + reader.onerror = () => { + reject(new Error('读取文件失败')); + }; + + reader.readAsText(file); + }); + } + + // 清空数据库 + async clearDatabase(): Promise { + try { + await this.db.clear(); + console.log('数据库已清空'); + return true; + } catch (error) { + console.error('清空数据库失败:', error); + return false; + } + } } // 创建默认服务实例 @@ -126,24 +204,6 @@ 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); @@ -169,18 +229,4 @@ export const exampleUsage = async () => { 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); - } }; \ No newline at end of file diff --git a/web/src/apps/muse/modules/speak.ts b/web/src/apps/muse/modules/speak.ts new file mode 100644 index 0000000..f5733c2 --- /dev/null +++ b/web/src/apps/muse/modules/speak.ts @@ -0,0 +1,12 @@ +type Speak = { + id: string; + no: number; // 序号, 当天的序号 + file?: string; // base64 编码的音频文件 + text?: string; // 文字内容,识别的内容 + timestamp: Date; // 生成时间戳 + day: number; // 365天中的第几天 + duration: number; // 音频时长,单位秒 + speaker?: string; // 说话人 + type?: 'merge' | 'normal'; // 语音类型,默认录制或者合并的 +} +