This commit is contained in:
2025-10-21 03:26:49 +08:00
parent b5430eb8d0
commit df859762ad
19 changed files with 2750 additions and 542 deletions

93
pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = () => {
<SigmaGraph dataSource={dataSource} />
</div>
);
case 'card':
return <MarkDetailList data={dataSource} />;
case 'docs':
return <DocsComponent dataSource={dataSource} />;
case 'world':
return (
<div className="flex items-center justify-center h-96 text-gray-500">
</div>
);
case 'docs':
return (
<div className="flex items-center justify-center h-96 text-gray-500">
</div>
);
default:
return null;
}
@@ -67,9 +72,9 @@ export const BaseApp = () => {
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`py-2 px-1 border-b-2 font-medium text-sm ${activeTab === tab.key
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
className={`py-2 px-4 border-b-2 font-medium text-sm transition-all duration-200 ease-in-out transform cursor-pointer ${activeTab === tab.key
? 'border-blue-500 text-blue-600 bg-blue-50'
: 'border-transparent text-gray-500'
}`}
>
{tab.title}
@@ -79,7 +84,7 @@ export const BaseApp = () => {
</div>
{/* Tab 内容区域 */}
<div className="flex-1">
<div className="flex-1 h-full">
{renderContent()}
</div>
</div>

View File

@@ -1,6 +1,6 @@
# 数据管理表格组件
这是一个功能完整的React表格组件支持多选、排序、分页、操作等功能并集成了Mock数据。
这是一个功能完整的React表格组件支持多选、排序、虚拟滚动、操作等功能并集成了Mock数据。
## 功能特性
@@ -21,11 +21,11 @@
- 升序/降序/取消排序
- 排序状态可视化指示
4. **分页功能**
- 支持页码切换
- 可调整每页显示数量10/20/50/100条
- 显示总数和当前范围
- 快速跳转页码
4. **虚拟滚动功能**
- 基于 react-virtualized 实现高性能虚拟滚动
- 支持大量数据展示而不影响性能
- 可配置行高和表格高度
- 固定表头,滚动内容区域
5. **操作功能**
- 详情查看(弹窗形式)
@@ -148,23 +148,19 @@ base/table/
}
```
### 修改分页配置
调整 `paginationConfig` 对象的属性:
### 修改虚拟滚动配置
调整 `virtualScrollConfig` 对象的属性:
```tsx
const paginationConfig = {
current: currentPage,
pageSize: pageSize,
total: data.length,
showSizeChanger: true,
showQuickJumper: true,
// 其他配置...
const virtualScrollConfig = {
rowHeight: 60, // 行高度
height: 600 // 表格容器高度
};
```
## 性能优化
1. **虚拟**对于大量数据,可以考虑实现虚拟滚动
1. **虚拟滚动**已实现基于 react-virtualized 的虚拟滚动,支持大量数据高性能展示
2. **懒加载**:支持服务端分页和按需加载
3. **缓存**:实现数据缓存机制
4. **防抖**:搜索和过滤功能添加防抖处理

View File

@@ -1,19 +1,24 @@
import React, { useState, useMemo } from 'react';
import { AutoSizer, List } from 'react-virtualized';
import { Mark } from '../mock/collection';
import { TableProps, SortState } from './types';
import './table.css';
// 虚拟滚动常量
const DEFAULT_ROW_HEIGHT = 48; // 每行高度
const HEADER_HEIGHT = 48; // 表头高度
export const Table: React.FC<TableProps> = ({
data,
columns,
loading = false,
rowSelection,
pagination,
virtualScroll,
actions,
onSort
}) => {
const rowHeight = virtualScroll?.rowHeight || DEFAULT_ROW_HEIGHT;
const [sortState, setSortState] = useState<SortState>({ field: null, order: null });
const [currentPage, setCurrentPage] = useState(1);
// 处理排序
const handleSort = (field: string) => {
@@ -46,38 +51,16 @@ export const Table: React.FC<TableProps> = ({
});
}, [data, sortState]);
// 分页数据
const paginatedData = useMemo(() => {
if (!pagination) return sortedData;
const start = (currentPage - 1) * pagination.pageSize;
const end = start + pagination.pageSize;
return sortedData.slice(start, end);
}, [sortedData, currentPage, pagination]);
// 处理分页变化
const handlePageChange = (page: number) => {
setCurrentPage(page);
if (pagination && typeof pagination === 'object') {
pagination.onChange?.(page, pagination.pageSize);
}
};
// 处理页大小变化
const handlePageSizeChange = (pageSize: number) => {
setCurrentPage(1);
if (pagination && typeof pagination === 'object') {
pagination.onChange?.(1, pageSize);
}
};
// 当前显示的数据(移除分页,直接使用排序后的数据
const displayData = sortedData;
// 全选/取消全选
const handleSelectAll = (checked: boolean) => {
if (!rowSelection) return;
const allKeys = paginatedData.map(item => item.id);
const allKeys = displayData.map(item => item.id);
const selectedKeys = checked ? allKeys : [];
const selectedRows = checked ? paginatedData : [];
const selectedRows = checked ? displayData : [];
rowSelection.onChange?.(selectedKeys, selectedRows);
};
@@ -100,6 +83,52 @@ export const Table: React.FC<TableProps> = ({
return path.split('.').reduce((o, p) => o?.[p], obj);
};
// 渲染虚拟滚动行
const rowRenderer = ({ index, key, style }: any) => {
const record = displayData[index];
return (
<div key={key} style={style} className="table-row virtual-row">
{rowSelection && (
<div className="selection-column">
<input
type="checkbox"
checked={selectedKeys.includes(record.id)}
onChange={(e) => handleRowSelect(record, e.target.checked)}
disabled={rowSelection.getCheckboxProps?.(record)?.disabled}
/>
</div>
)}
{columns.map(column => (
<div key={column.key} className="table-cell" style={{ width: column.width }}>
{column.render
? column.render(getNestedValue(record, column.dataIndex), record, index)
: getNestedValue(record, column.dataIndex)
}
</div>
))}
{actions && actions.length > 0 && (
<div className="actions-column">
<div className="action-buttons">
{actions.map(action => (
<button
key={action.key}
className={action.className}
onClick={() => action.onClick(record)}
disabled={action.disabled?.(record)}
aria-label={action.tooltip || action.label}
>
{action.icon && <span className="btn-icon">{action.icon}</span>}
<span className="tooltip">{action.tooltip || action.label}</span>
</button>
))}
</div>
</div>
)}
</div>
);
};
if (loading) {
return (
<div className="table-loading">
@@ -110,11 +139,11 @@ export const Table: React.FC<TableProps> = ({
}
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 (
<div className="table-container">
<div className="table-container ">
{/* 表格工具栏 */}
{rowSelection && selectedKeys.length > 0 && (
<div className="table-toolbar">
@@ -134,173 +163,73 @@ export const Table: React.FC<TableProps> = ({
{/* 表格 */}
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
{rowSelection && (
<th className="selection-column">
<input
type="checkbox"
checked={isAllSelected}
ref={input => {
if (input) input.indeterminate = isIndeterminate;
}}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</th>
)}
{columns.map(column => (
<th
key={column.key}
style={{ width: column.width }}
className={column.sortable ? 'sortable' : ''}
>
<div className="table-header">
<span>{column.title}</span>
{column.sortable && (
<div
className="sort-indicators"
onClick={() => handleSort(column.dataIndex)}
>
<span className={`sort-arrow sort-up ${
sortState.field === column.dataIndex && sortState.order === 'asc' ? 'active' : ''
}`}></span>
<span className={`sort-arrow sort-down ${
sortState.field === column.dataIndex && sortState.order === 'desc' ? 'active' : ''
}`}></span>
</div>
)}
</div>
</th>
))}
{actions && actions.length > 0 && (
<th className="actions-column"></th>
)}
</tr>
</thead>
<tbody>
{paginatedData.map((record, index) => (
<tr key={record.id} className="table-row">
{rowSelection && (
<td className="selection-column">
<input
type="checkbox"
checked={selectedKeys.includes(record.id)}
onChange={(e) => handleRowSelect(record, e.target.checked)}
disabled={rowSelection.getCheckboxProps?.(record)?.disabled}
/>
</td>
)}
{columns.map(column => (
<td key={column.key}>
{column.render
? column.render(getNestedValue(record, column.dataIndex), record, index)
: getNestedValue(record, column.dataIndex)
}
</td>
))}
{actions && actions.length > 0 && (
<td className="actions-column">
<div className="action-buttons">
{actions.map(action => (
<button
key={action.key}
className={`btn btn-${action.type || 'default'}`}
onClick={() => action.onClick(record)}
disabled={action.disabled?.(record)}
title={action.label}
>
{action.icon && <span className="btn-icon">{action.icon}</span>}
{action.label}
</button>
))}
{/* 固定表头 */}
<div className="table-header-wrapper" style={{ height: HEADER_HEIGHT }}>
<div className="table-header-row">
{rowSelection && (
<div className="selection-column">
<input
type="checkbox"
checked={isAllSelected}
ref={input => {
if (input) input.indeterminate = isIndeterminate;
}}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</div>
)}
{columns.map(column => (
<div
key={column.key}
style={{ width: column.width }}
className={`table-header-cell ${column.sortable ? 'sortable' : ''}`}
>
<div className="table-header">
<span>{column.title}</span>
{column.sortable && (
<div
className="sort-indicators"
onClick={() => handleSort(column.dataIndex)}
>
<span className={`sort-arrow sort-up ${
sortState.field === column.dataIndex && sortState.order === 'asc' ? 'active' : ''
}`}></span>
<span className={`sort-arrow sort-down ${
sortState.field === column.dataIndex && sortState.order === 'desc' ? 'active' : ''
}`}></span>
</div>
</td>
)}
</tr>
)}
</div>
</div>
))}
</tbody>
</table>
{paginatedData.length === 0 && (
<div className="empty-state">
<div className="empty-icon">📭</div>
<p></p>
</div>
)}
</div>
{/* 分页 */}
{pagination && (
<div className="pagination-wrapper">
<div className="pagination-info">
{pagination.showTotal && pagination.showTotal(
pagination.total,
[
(currentPage - 1) * pagination.pageSize + 1,
Math.min(currentPage * pagination.pageSize, pagination.total)
]
{actions && actions.length > 0 && (
<div className="actions-column"></div>
)}
</div>
<div className="pagination-controls">
<button
className="btn btn-default"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage <= 1}
>
</button>
<div className="page-numbers">
{Array.from({ length: Math.ceil(pagination.total / pagination.pageSize) })
.map((_, i) => i + 1)
.filter(page => {
const distance = Math.abs(page - currentPage);
return distance === 0 || distance <= 2 || page === 1 || page === Math.ceil(pagination.total / pagination.pageSize);
})
.map((page, index, pages) => {
const prevPage = pages[index - 1];
const showEllipsis = prevPage && page - prevPage > 1;
return (
<React.Fragment key={page}>
{showEllipsis && <span className="page-ellipsis">...</span>}
<button
className={`btn page-btn ${currentPage === page ? 'active' : ''}`}
onClick={() => handlePageChange(page)}
>
{page}
</button>
</React.Fragment>
);
})
}
</div>
<button
className="btn btn-default"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= Math.ceil(pagination.total / pagination.pageSize)}
>
</button>
</div>
{pagination.showSizeChanger && (
<div className="page-size-selector">
<select
value={pagination.pageSize}
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
>
<option value={10}>10/</option>
<option value={20}>20/</option>
<option value={50}>50/</option>
<option value={100}>100/</option>
</select>
</div>
{/* 虚拟滚动内容区域 */}
<div className="table-body-wrapper">
{displayData.length > 0 ? (
<AutoSizer>
{({ height, width }) => (
<List
height={height}
width={width}
rowCount={displayData.length}
rowHeight={rowHeight}
rowRenderer={rowRenderer}
/>
)}
</AutoSizer>
) : (
<div className="empty-state">
<div className="empty-icon">📭</div>
<p></p>
</div>
)}
</div>
)}
</div>
</div>
);
};

View File

@@ -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<React.Key[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [data, setData] = useState<Mark[]>([]);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [currentRecord, setCurrentRecord] = useState<Mark | null>(null);
@@ -127,16 +126,15 @@ export const Base = (props: Props) => {
{
key: 'view',
label: '详情',
type: 'primary',
icon: '👁',
icon: <Eye className="w-4 h-4 text-blue-600 hover:text-blue-700 cursor-pointer transition-colors" />,
onClick: (record: Mark) => {
handleViewDetail(record);
}
},
{
key: 'edit',
icon: <Edit className="w-4 h-4 text-green-600 hover:text-green-700 cursor-pointer transition-colors" />,
label: '编辑',
icon: '✏️',
onClick: (record: Mark) => {
handleEdit(record);
}
@@ -144,8 +142,7 @@ export const Base = (props: Props) => {
{
key: 'delete',
label: '删除',
type: 'danger',
icon: '🗑️',
icon: <Trash2 className="w-4 h-4 text-red-600 hover:text-red-700 cursor-pointer transition-colors" />,
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 (
<div style={{ padding: '24px' }}>
<div style={{ marginBottom: '16px' }}>
<h2></h2>
<p style={{ color: '#666', margin: '8px 0' }}>
</p>
</div>
{selectedRowKeys.length > 0 && (
<div style={{
marginBottom: '16px',
padding: '12px',
backgroundColor: '#e6f7ff',
borderRadius: '4px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<span> {selectedRowKeys.length} </span>
<button
className="btn btn-danger"
onClick={handleBatchDelete}
>
</button>
<div className='h-full flex flex-col'>
<div className='flex flex-col h-full' style={{ padding: '24px' }}>
{/* 固定头部区域 */}
<div style={{ marginBottom: '16px', flexShrink: 0 }}>
<h2 style={{ margin: '0 0 8px 0' }}></h2>
<p style={{ color: '#666', margin: '0' }}>
</p>
</div>
<div>
{/* 批量操作条 */}
{selectedRowKeys.length > 0 && (
<div style={{
marginBottom: '16px',
padding: '12px',
backgroundColor: '#e6f7ff',
borderRadius: '4px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0
}}>
<span> {selectedRowKeys.length} </span>
<button
className="btn btn-danger"
onClick={handleBatchDelete}
>
</button>
</div>
)}
</div>
)}
<Table
data={data}
columns={columns}
actions={actions}
rowSelection={{
selectedRowKeys,
onChange: (keys, rows) => {
setSelectedRowKeys(keys);
}
}}
pagination={paginationConfig}
onSort={handleSort}
/>
{/* 表格容器 - 占据剩余空间并支持滚动 */}
<div style={{
flex: 1,
minHeight: 0 // 重要允许flex容器收缩
}}>
<Table
data={data}
columns={columns}
actions={actions}
rowSelection={{
selectedRowKeys,
onChange: (keys, rows) => {
setSelectedRowKeys(keys);
}
}}
virtualScroll={virtualScrollConfig}
onSort={handleSort}
/>
</div>
<DetailModal
visible={detailModalVisible}
data={currentRecord}
onClose={() => {
setDetailModalVisible(false);
setCurrentRecord(null);
}}
/>
<DetailModal
visible={detailModalVisible}
data={currentRecord}
onClose={() => {
setDetailModalVisible(false);
setCurrentRecord(null);
}}
/>
</div>
</div>
);
};

View File

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

View File

@@ -19,25 +19,22 @@ export interface RowSelection<T = any> {
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<Mark>[];
loading?: boolean;
rowSelection?: RowSelection<Mark>;
pagination?: PaginationConfig | false;
virtualScroll?: VirtualScrollConfig;
actions?: ActionButton[];
onSort?: (field: string, order: 'asc' | 'desc' | null) => void;
}

View File

@@ -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 (
<div className="h-screen">
<MarkDetail data={data} />
</div>
);
}
```
### 在主应用中使用
组件已集成到主应用的 "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设计
- 完整的类型安全支持

View File

@@ -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<HTMLInputElement>(null);
// 导出数据库
const handleExportDB = async () => {
try {
const filename = `marks_backup_${new Date().toISOString().split('T')[0]}.json`;
await markService.exportToFile(filename);
toast.success('数据库导出成功!');
} catch (error) {
console.error('导出失败:', error);
toast.error('导出失败: ' + (error as Error).message);
}
};
// 触发导入文件选择
const handleImportDB = () => {
fileInputRef.current?.click();
};
// 处理文件选择并导入
const handleFileImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const result = await markService.importFromFile(file);
toast.success(
`导入完成!成功: ${result.success}条,失败: ${result.failed}条,总计: ${result.total}`
);
} catch (error) {
console.error('导入失败:', error);
toast.error('导入失败: ' + (error as Error).message);
}
// 重置文件输入
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// 删除数据库
const handleDeleteDB = async () => {
if (!window.confirm('确定要删除所有数据吗?此操作无法撤销!')) {
return;
}
try {
const success = await markService.clearDatabase();
if (success) {
toast.success('数据库已清空!');
} else {
toast.error('清空数据库失败!');
}
} catch (error) {
console.error('删除失败:', error);
toast.error('删除失败: ' + (error as Error).message);
}
};
return (
<div className="h-screen flex flex-col">
@@ -85,11 +143,32 @@ export const MuseApp = () => {
}}>
DB
</button>
<button className="px-3 py-1 rounded text-sm bg-red-500 text-white hover:bg-red-600" onClick={() => {
// 删除DB的逻辑
}}>
<button
className="px-3 py-1 rounded text-sm bg-blue-500 text-white hover:bg-blue-600"
onClick={handleExportDB}
>
DB
</button>
<button
className="px-3 py-1 rounded text-sm bg-purple-500 text-white hover:bg-purple-600"
onClick={handleImportDB}
>
DB
</button>
<button
className="px-3 py-1 rounded text-sm bg-red-500 text-white hover:bg-red-600"
onClick={handleDeleteDB}
>
DB
</button>
{/* 隐藏的文件输入元素 */}
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileImport}
style={{ display: 'none' }}
/>
</div>
</div>

View File

@@ -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<Mark[]> {
const allDocs = await this.getAll();
return allDocs.filter(filterFn).sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
// 创建索引以支持查询
async createIndexes() {
if (!this.db) {
throw new Error('数据库未初始化');
}
// 检查是否支持 createIndex 方法 (需要 pouchdb-find 插件)
if (typeof this.db.createIndex !== 'function') {
console.warn('PouchDB Find plugin not available. Skipping index creation.');
console.warn('Some query features may not work optimally without indexes.');
return;
}
try {
// PouchDB 创建索引的正确方式
const indexes = [
@@ -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<Mark[]> {
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<Mark[]> {
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<Mark[]> {
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<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));
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<void> {
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);

View File

@@ -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<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();
@@ -116,6 +97,103 @@ export class MarkService {
}
return importedCount;
}
// 导出数据到文件
async exportToFile(filename: string = 'marks_backup.json'): Promise<void> {
try {
const marks = await this.exportData();
const exportData = {
version: '1.0',
exportTime: new Date().toISOString(),
totalCount: marks.length,
marks: marks
};
const jsonData = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
// 创建下载链接
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
console.log(`成功导出 ${marks.length} 条记录到文件: ${filename}`);
} catch (error) {
console.error('导出文件失败:', error);
throw new Error('导出文件失败: ' + (error as Error).message);
}
}
// 从文件导入数据
async importFromFile(file: File): Promise<{ success: number; failed: number; total: number }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target?.result as string;
const importData = JSON.parse(content);
// 验证数据格式
if (!importData.marks || !Array.isArray(importData.marks)) {
throw new Error('无效的数据格式缺少marks数组');
}
let successCount = 0;
let failedCount = 0;
const totalCount = importData.marks.length;
// 批量导入数据
for (const mark of importData.marks) {
try {
// 移除ID相关字段让系统重新生成
const { id, createdAt, updatedAt, _id, _rev, ...markData } = mark;
await this.createMark(markData);
successCount++;
} catch (error) {
console.warn('导入单条记录失败:', error);
failedCount++;
}
}
const result = {
success: successCount,
failed: failedCount,
total: totalCount
};
console.log(`导入完成: 成功${successCount}条,失败${failedCount}条,总计${totalCount}`);
resolve(result);
} catch (error) {
console.error('解析导入文件失败:', error);
reject(new Error('解析导入文件失败: ' + (error as Error).message));
}
};
reader.onerror = () => {
reject(new Error('读取文件失败'));
};
reader.readAsText(file);
});
}
// 清空数据库
async clearDatabase(): Promise<boolean> {
try {
await this.db.clear();
console.log('数据库已清空');
return true;
} catch (error) {
console.error('清空数据库失败:', error);
return false;
}
}
}
// 创建默认服务实例
@@ -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);
}
};

View File

@@ -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'; // 语音类型,默认录制或者合并的
}