add card
This commit is contained in:
93
pnpm-lock.yaml
generated
93
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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",
|
||||
|
||||
46
web/src/apps/muse/base/card/MarkDetailList.css
Normal file
46
web/src/apps/muse/base/card/MarkDetailList.css
Normal 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;
|
||||
}
|
||||
364
web/src/apps/muse/base/card/MarkDetailList.tsx
Normal file
364
web/src/apps/muse/base/card/MarkDetailList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
191
web/src/apps/muse/base/docs/README.md
Normal file
191
web/src/apps/muse/base/docs/README.md
Normal 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
|
||||
542
web/src/apps/muse/base/docs/docs.css
Normal file
542
web/src/apps/muse/base/docs/docs.css
Normal 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;
|
||||
}
|
||||
}
|
||||
222
web/src/apps/muse/base/docs/example.tsx
Normal file
222
web/src/apps/muse/base/docs/example.tsx
Normal 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)
|
||||
- 图片支持: 
|
||||
|
||||
---
|
||||
|
||||
希望你喜欢这个使用 **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;
|
||||
222
web/src/apps/muse/base/docs/index.tsx
Normal file
222
web/src/apps/muse/base/docs/index.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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. **防抖**:搜索和过滤功能添加防抖处理
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
201
web/src/apps/muse/components/README.md
Normal file
201
web/src/apps/muse/components/README.md
Normal 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设计
|
||||
- 完整的类型安全支持
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
12
web/src/apps/muse/modules/speak.ts
Normal file
12
web/src/apps/muse/modules/speak.ts
Normal 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'; // 语音类型,默认录制或者合并的
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user