# @zjlab-fe/data-hub-ui 组件库 Skill 参考文档

> **版本**：0.26.4 | **包名**：`@zjlab-fe/data-hub-ui` | **自动生成，请勿手动编辑**
>
> 本文档包含所有公开组件的 API 参考与使用说明，供 AI 编码助手作为 skill 参考。
> 安装：`npm install @zjlab-fe/data-hub-ui`

## 目录

- [SDK-modal](#SDK-modal)
- [apply-perm-modal（申请权限弹窗）](#apply-perm-modal)
- [auth-tag（权限标签）](#auth-tag)
- [bench-card（卡片）](#bench-card)
- [confirm-again（二次确认）](#confirm-again)
- [copy（复制）](#copy)
- [corpus-card](#corpus-card)
- [feature-card（功能点卡片）](#feature-card)
- [file-preview（文件预览）](#file-preview)
- [file-uploader（文件上传）](#file-uploader)
- [floating-label-input（浮动标签输入框）](#floating-label-input)
- [floating-layer（浮动操作框）](#floating-layer)
- [header（头部导航）](#header)
- [input-tag（标签/关键字输入）](#input-tag)
- [menu（菜单）](#menu)
- [model-card](#model-card)
- [notion-editor](#notion-editor)
- [operator-chain（卡片式操作链）](#operator-chain)
- [permission-editor（权限编辑器）](#permission-editor)
- [popover-select（带描述浮层下拉框）](#popover-select)
- [radio-card（单选卡片）](#radio-card)
- [section-heading（区块标题）](#section-heading)
- [status-tag（状态标签）](#status-tag)
- [tag-group-filter（标签组过滤器）](#tag-group-filter)
- [tag-view（标签视图）](#tag-view)
- [tip-tap（富文本编辑器）](#tip-tap)

---

## SDK-modal

**导入方式：**

```ts
import { SDKModal } from '@zjlab-fe/data-hub-ui';
```

用于展示 SDK 使用代码的弹窗组件。支持静态传入代码和异步加载两种方式，同时支持组件式和命令式两种调用形式。

### 代码演示

**示例代码（index.tsx）：**

```tsx
import { useState } from 'react';
import { Button, Space } from 'antd';
import SDKModal from '@/components/SDK-modal';

const MOCK_SDK_CODE = `import mktable

client = mktable.Client(
    host="https://api.example.com",
    token="your-access-token"
)

dataset = client.dataset.get("ds-xxxxxxxx")
df = dataset.to_dataframe()`;

function mockLoadSDK(): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => resolve(MOCK_SDK_CODE), 1200);
  });
}

function mockLoadSDKError(): Promise<string> {
  return new Promise((_, reject) => {
    setTimeout(() => reject(new Error('加载失败')), 1200);
  });
}

export default function Demo() {
  const [open, setOpen] = useState(false);
  const [openAsync, setOpenAsync] = useState(false);
  const [openAsyncError, setOpenAsyncError] = useState(false);

  const handleShowImperative = () => {
    SDKModal.show({
      title: '命令式 · 静态 SDK 代码',
      SDKCode: MOCK_SDK_CODE,
      documentLink: 'https://example.com/docs/sdk',
      onClose: () => console.log('命令式弹窗已关闭'),
    });
  };

  const handleShowImperativeAsync = () => {
    SDKModal.show({
      title: '命令式 · 异步加载 SDK 代码',
      loadSDK: mockLoadSDK,
      documentLink: 'https://example.com/docs/sdk',
      locale: 'en',
      onClose: () => console.log('命令式异步弹窗已关闭'),
    });
  };

  return (
    <div style={{ padding: 24 }}>
      # SDK Modal 使用示例

      ### 1. 组件式 · 静态 SDK 代码
      <p>直接传入 SDKCode，弹窗打开即展示，无需异步加载。</p>
      <Button onClick={() => setOpen(true)}>打开（静态）</Button>
      <SDKModal
        open={open}
        title="组件式 · Python SDK 下载代码"
        SDKCode={MOCK_SDK_CODE}
        documentLink="https://example.com/docs/sdk"
        onClose={() => setOpen(false)}
      />

      ### 2. 组件式 · 异步加载 SDK 代码
      <p>通过 loadSDK 异步获取代码，弹窗打开时展示 Loading 状态。</p>
      <Button onClick={() => setOpenAsync(true)}>打开（异步）</Button>
      <SDKModal
        open={openAsync}
        title="组件式 · 异步加载 SDK"
        loadSDK={mockLoadSDK}
        documentLink="https://example.com/docs/sdk"
        locale="zh"
        onClose={() => setOpenAsync(false)}
      />

      ### 3. 组件式 · 异步加载 SDK 代码（失败）
      <p>通过 loadSDK 异步获取代码，加载失败时展示错误信息。</p>
      <Button onClick={() => setOpenAsyncError(true)}>打开（异步）</Button>
      <SDKModal
        open={openAsyncError}
        title="组件式 · 异步加载 SDK"
        loadSDK={mockLoadSDKError}
        documentLink="https://example.com/docs/sdk"
        locale="zh"
        onClose={() => setOpenAsyncError(false)}
      />

      ### 3. 命令式 · 静态 SDK 代码
      <p>调用 SDKModal.show()，无需在 JSX 中声明，适合在事件回调中使用。</p>
      <Space>
        <Button onClick={handleShowImperative}>打开（静态）</Button>
        <Button onClick={handleShowImperativeAsync}>打开（异步 + 英文 locale）</Button>
      </Space>
    </div>
  );
}
```
### 调用方式

#### 组件式

```tsx
import SDKModal from '@/components/SDK-modal';

<SDKModal
  open={open}
  title="Python SDK 下载代码"
  SDKCode={code}
  documentLink="https://example.com/docs/sdk"
  onClose={() => setOpen(false)}
/>
```

#### 命令式

```tsx
import SDKModal from '@/components/SDK-modal';

SDKModal.show({
  title: 'Python SDK 下载代码',
  loadSDK: () => fetchSDKCode(),
  documentLink: 'https://example.com/docs/sdk',
  onClose: () => console.log('closed'),
});
```

### API

#### SDKModalProps

| 属性 | 说明 | 类型 | 默认值 |
|------|------|------|--------|
| open | 弹窗是否可见 | `boolean` | — |
| title | 弹窗标题 | `string` | — |
| onClose | 关闭回调 | `() => void` | — |
| SDKCode | 静态 SDK 代码字符串，与 `loadSDK` 二选一 | `string` | — |
| loadSDK | 异步获取 SDK 代码的函数，与 `SDKCode` 二选一 | `() => Promise<string>` | — |
| modalConfig | 覆盖默认 Modal 配置，与 antd ModalProps 一致 | `ModalProps` | — |
| documentLink | 文档链接地址，有值时在代码块下方展示链接 | `string` | — |
| locale | 语言，`'zh'` 中文 / `'en'` 英文 | `'zh' \| 'en'` | `'zh'` |

#### SDKModalConfig

`SDKModalConfig = Omit<SDKModalProps, 'open'>`，用于命令式调用 `SDKModal.show(config)`。

#### 内置语言包

| locale 值 | 语言 | confirm | documentPrefix | documentLinkText |
|-----------|------|---------|----------------|-----------------|
| `'zh'`（默认）| 中文 | 知道了 | 详情请查看 | 使用文档 |
| `'en'` | 英文 | Got it | For more details, see | Documentation |

```tsx
SDKModal.show({
  title: 'Python SDK',
  SDKCode: code,
  locale: 'en',
  onClose: () => {},
});
```

### 务必遵守以下事项，禁止逾越
1. `SDKCode` 和 `loadSDK`选择一项传入， 禁止同时传入。同时传入时，仅生效 `SDKCode`。

---

## apply-perm-modal（申请权限弹窗）

**导入方式：**

```ts
import { ApplyPermModal } from '@zjlab-fe/data-hub-ui';
```

antd modal二次封装，仅用于申请权限

### 代码演示

**示例代码（index.tsx）：**

```tsx
import React from 'react';
import { Button, Space } from 'antd';
import ApplyPermModal from '..';

export default function Demo() {
  const [open, setOpen] = React.useState(false);

  return (
    <>
      # 申请权限弹窗使用方法
      ### 1. 组件式
      <Button onClick={() => setOpen(true)}>打开</Button>
      <ApplyPermModal open={open} onCancel={() => setOpen(false)} />
      ### 2. 命令式
      <Space>
        <Button onClick={() => ApplyPermModal.confirm()}>打开（中文）</Button>
        <Button onClick={() => ApplyPermModal.confirm({ locale: 'en' })}>打开（英文）</Button>
      </Space>

      # 非弹窗形式展示申请权限内容
      <ApplyPermModal noModal />
    </>
  );
}
```
### API

| 参数 | 说明 | 类型 | 默认值 | 版本 | 必须 |
| --- | --- | --- | --- | --- | --- |
| locale    | 语言 | `'zh' \| 'en'` | `'zh'` | - | |
| noModal    | 是否直接展示申请权限内容，而非弹窗形式，默认以弹窗形式展示 | `boolean` | `false` | - | |
| open    | 对话框是否可见 | `boolean` | - | - | |
| onCancel | 取消时的回调 | `(e: React.MouseEvent<HTMLButtonElement>) => void;` | - | - | |

---

## auth-tag（权限标签）

**导入方式：**

```ts
import { AuthTag } from '@zjlab-fe/data-hub-ui';
```

> 该组件暂无 README 文档，以下为 demo 示例代码，可参考用法。

### 示例代码（index.tsx）

```tsx
import AuthTag from '@/components/auth-tag';
export default function Demo() {
  return (
    <>
      <AuthTag type="confidential">限制公开</AuthTag>
      <AuthTag type="public">公开</AuthTag>
      <AuthTag type="private">非公开</AuthTag>
    </>
  );
}
```

---

## bench-card（卡片）

**导入方式：**

```ts
import { BenchCard } from '@zjlab-fe/data-hub-ui';
```

提供两种 BenchCard 包括工作流式和组件式

### 代码演示

**示例代码（index.tsx）：**

```tsx
import BenchCard from '@/components/bench-card';
export default function Demo() {
  return (
    <>
      <BenchCard type="workflow">模板中心 -工作流-card</BenchCard>
      <BenchCard type="component">模板中心 -组件-card</BenchCard>
      <BenchCard type="workflow" size="small">
        工作空间 -工作流-card
      </BenchCard>
      <BenchCard type="component" size="small">
        工作空间 -组件-card
      </BenchCard>
      <BenchCard type="workflow" size="small" width={800} height={300}>
        工作空间 -工作流-card-可控制 长度
      </BenchCard>
    </>
  );
}
```
### API

| 参数 | 说明 | 类型 | 默认值 | 版本 | 必须 |
| --- | --- | --- | --- | --- | --- |
| type    | 类型 | `workflow \| component` | - | - |  是 |
| size    | 尺寸 | `small \| middle` | `middle` | -| 否 |
| children | 插槽 |`React.ReactNode` | - | - | - |
| width | 宽度 | `string \| number` | - | - | 否 |
| height | 高度 | `string \| number` | - | - | 否 |

---

## confirm-again（二次确认）

**导入方式：**

```ts
import { ConfirmAgain } from '@zjlab-fe/data-hub-ui';
```

antd modal二次封装，主要是覆盖了Modal部分默认样式，整体垂直居中展示

### 代码演示

**示例代码（index.tsx）：**

```tsx
import React, { useState } from 'react';
import ConfirmAgain from '@/components/confirm-again';
import { Button } from 'antd';

export default function Demo() {
  const [open, setOpen] = useState(false);
  const openModal = function () {
    setOpen(true);
  };
  const handleOk = function () {
    console.log('ok');
    setOpen(false);
  };
  const handleCancel = function () {
    console.log('cancel');
    setOpen(false);
  };
  const openFunModal = function () {
    ConfirmAgain.confirm({
      title: '命令式-二次确认弹窗',
      wrapClassName: 'test',
      content: <div style={{ width: 300 }}>子内容</div>,
      onOk: () => {
        console.log('ok');
      },
      onCancel: () => {
        console.log('cancel');
      },
      open: true,
    });
  };
  return (
    <>
      # 二次确认弹窗使用方法
      ### 1. 组件式
      <Button onClick={() => openModal()}>打开</Button>
      <ConfirmAgain
        title="组件式-二次确认弹窗"
        wrapClassName="test"
        open={open}
        onOk={handleOk}
        onCancel={handleCancel}
      >
        <div>子内容</div>
      </ConfirmAgain>
      ### 2. 命令式
      <Button onClick={() => openFunModal()}>打开</Button>
    </>
  );
}
```
### API

组件式API与antd modal API一致，函数命令式的参数与Modal.method()的参数一致

---

## copy（复制）

**导入方式：**

```ts
import { Copy, copy } from '@zjlab-fe/data-hub-ui';
```
```ts
// 类型导入
import type { ICopy } from '@zjlab-fe/data-hub-ui';
```

使用：`import { Copy } from '@zjlab-fe/data-hub-ui';`

### 何时使用

展示复制组件 or 复制文本到系统粘贴板

### 代码演示

**示例代码（index.tsx）：**

```tsx
import Copy, { copy } from '@/components/copy';
export default function Demo() {
  return (
    <>
      <p>方式一：传入待复制的文本</p>
      <Copy text="haha test" />
      <p>方式二：不传入待复制的文本，仅展示UI组件，在onClick回调中手动调用copy</p>
      <Copy
        onClick={() => {
          copy('haha test');
        }}
      />
    </>
  );
}
```
### API

| 参数 | 说明 | 类型 | 默认值 | 版本 | 必须 |
| --- | --- | --- | --- | --- | --- |
| text    | 待复制到粘贴板的文本，如果传入为null or undefined，将不会自动复制到粘贴板 | `string` | - | - | - |
| showFeedback | 是否展示复制成功后的反馈信息 | `boolean` | `true` | - | - |
| locale | 语言  | `zh \| en` | `zh` | - | - |
| onClick | 点击回调 | `() => void` | - | - | - |

#### copy

单独使用copy方法，用于复制文本

```js
import { copy } from '@zjlab-fe/data-hub-ui';
copy('test')
```

copy函数入参如下：

| 参数 | 说明 | 类型 | 默认值 | 版本 | 必须 |
| --- | --- | --- | --- | --- | --- |
| text    | 待复制到粘贴板的文本 | `string` | - | - | 是 |
| options | 其它选项 | `object` | - | - | - |

`options`具体属性如下：

| 属性名 | 说明 | 类型 | 默认值 | 版本 | 必须 |
| --- | --- | --- | --- | --- | --- |
| showFeedback | 是否展示复制成功后的反馈信息 | `boolean` | `true` | - | - |
| locale | 语言  | `zh \| en` | `zh` | - | - |

---

## corpus-card

**导入方式：**

```ts
import { CorpusCard } from '@zjlab-fe/data-hub-ui';
```

基于 React + Ant Design 实现的语料卡片组件，支持卡片点击激活、详情按钮、标签展示等功能，适用于语料库、模板列表等场景。

### 代码演示

**示例代码（index.tsx）：**

```tsx
import CorpusCard from '@/components/corpus-card';
import mockdata from './mockdata';
import { useState } from 'react';

export default function Demo() {
  const [activeCardId, setActiveCardId] = useState<number | null>(null);

  const onDetailClick = (id: number) => {
    console.log('详情点击', id);
  };

  const onCardClick = (id: number) => {
    console.log('卡片点击', id);
    setActiveCardId(id);
  };

  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '20px' }}>
      {mockdata.data.totalList.map((item) => (
        <CorpusCard
          card={item}
          key={item.id}
          isActive={activeCardId === item.id}
          onClick={onDetailClick}
          onCardClick={onCardClick}
          width={480}
        />
      ))}
    </div>
  );
}
```
### API

#### CorpusCard

| 参数        | 说明         | 类型                         | 默认值     | 必须 |
| --------- | ---------- | -------------------------- | ------- | -- |
| card       | 卡片数据       | `ProcessTemplateListItem`  | -       | 是  |
| isActive   | 卡片是否激活     | `boolean`                  | `false` | 否  |
| width      | 卡片宽度       | `number`                   | `375`   | 否  |
| onClick    | 详情按钮点击回调  | `(id: number) => void`     | -       | 否  |
| onCardClick | 卡片点击回调     | `(id: number) => void`     | -       | 否  |

### 接口定义

#### ProcessTemplateListItem 接口

卡片数据接口（前端只关注这几个就行）

| 属性                | 说明           | 类型                  | 必须 |
| ----------------- | ------------ | ------------------- | -- |
| id                | 卡片唯一标识       | `number`            | 是  |
| name              | 卡片标题         | `string`            | 是  |
| description       | 卡片描述         | `string`            | 是  |
| labels            | 标签列表         | `ProcessTemplateLabel[]` | 是  |
| labelTypeColorMap | 标签类型颜色映射     | `LabelTypeColor[]`  | 否  |

#### ProcessTemplateLabel 接口

标签数据接口

| 属性    | 说明    | 类型      | 必须 |
| ----- | ---- | ------- | -- |
| id    | 标签ID  | `number` | 是  |
| name  | 标签名称  | `string` | 是  |
| type  | 标签类型  | `number` | 是  |

#### LabelTypeColor 接口

标签类型颜色配置接口

| 属性          | 说明    | 类型      | 必须 |
| ----------- | ---- | ------- | -- |
| type        | 标签类型  | `number` | 是  |
| bgColor     | 背景颜色  | `string` | 是  |
| borderColor | 边框颜色  | `string` | 是  |
| textColor   | 文字颜色  | `string` | 是  |

### 使用说明

#### 1. 基础用法

```tsx
import CorpusCard from '@/components/corpus-card';

const cardData = {
  id: 1,
  name: '语料模板1',
  description: '这是一个示例语料模板描述',
  labels: [
    { id: 1, name: '数据处理', type: 1 },
    { id: 2, name: '文本分析', type: 2 },
  ],
};

const App = () => {
  const handleDetailClick = (id: number) => {
    console.log('详情点击', id);
  };

  const handleCardClick = (id: number) => {
    console.log('卡片点击', id);
  };

  return (
    <CorpusCard
      card={cardData}
      onClick={handleDetailClick}
      onCardClick={handleCardClick}
    />
  );
};
```

#### 2. 卡片激活状态

```tsx
import { useState } from 'react';
import CorpusCard from '@/components/corpus-card';

const App = () => {
  const [activeCardId, setActiveCardId] = useState<number | null>(null);

  const handleCardClick = (id: number) => {
    setActiveCardId(id);
  };

  return (
    <div>
      {cards.map((card) => (
        <CorpusCard
          key={card.id}
          card={card}
          isActive={activeCardId === card.id}
          onCardClick={handleCardClick}
        />
      ))}
    </div>
  );
};
```

#### 3. 自定义卡片宽度

```tsx
import CorpusCard from '@/components/corpus-card';

const App = () => {
  return (
    <CorpusCard
      card={cardData}
      width={400}
      onClick={handleDetailClick}
      onCardClick={handleCardClick}
    />
  );
};
```

#### 4. 网格布局（每行平铺三个卡片）

```tsx
import CorpusCard from '@/components/corpus-card';

const App = () => {
  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '20px' }}>
      {cards.map((card) => (
        <CorpusCard
          key={card.id}
          card={card}
          onClick={handleDetailClick}
          onCardClick={handleCardClick}
        />
      ))}
    </div>
  );
};
```

#### 5. 带标签颜色映射

```tsx
import CorpusCard from '@/components/corpus-card';

const cardData = {
  id: 1,
  name: '语料模板1',
  description: '这是一个示例语料模板描述',
  labels: [
    { id: 1, name: '数据处理', type: 1 },
    { id: 2, name: '文本分析', type: 2 },
  ],
  labelTypeColorMap: [
    { type: 1, bgColor: '#e6f7ff', borderColor: '#91d5ff', textColor: '#1890ff' },
    { type: 2, bgColor: '#f6ffed', borderColor: '#b7eb8f', textColor: '#52c41a' },
  ],
};

const App = () => {
  return (
    <CorpusCard
      card={cardData}
      onClick={handleDetailClick}
      onCardClick={handleCardClick}
    />
  );
};
```

### 注意事项

1. 卡片默认宽度为 375px，可通过 `width` 属性自定义
2. 激活状态下卡片会有蓝色边框和背景色渐变效果
3. 详情按钮点击不会触发卡片点击事件（已阻止事件冒泡）
4. 标签颜色可通过 `labelTypeColorMap` 自定义，未配置则使用默认样式
5. 卡片标题和描述支持文本溢出省略显示
6. 建议使用 CSS Grid 布局实现卡片网格排列，如每行平铺三个卡片

---

## feature-card（功能点卡片）

**导入方式：**

```ts
import { FeatureCard } from '@zjlab-fe/data-hub-ui';
```
```ts
// 类型导入
import type { FeatureCardProps, FeatureCardRootProps } from '@zjlab-fe/data-hub-ui';
```

用于展示功能点/卖点的卡片（icon + title + description）。

- 支持常规 props 方式
- 支持类似 shadcn/ui 的组合式用法（`FeatureCard.Root/Icon/Title/Description/Content`）
- 使用纯 CSS，并通过 `@scope` + 根类名做样式隔离

### 代码演示

**示例代码（index.tsx）：**

```tsx
import React from 'react';
import FeatureCard from '@/components/feature-card';
import { Card, RadioGroup } from '@/demo/shared';
import Moha from './moha';
import { ComparisonVsSemanticDom } from '@/demo/shared/ComparisonVsSemanticDom';
import './index.css';

type Align = 'left' | 'center' | 'right';
type Size = 'sm' | 'md' | 'lg';
type IconVariant = 'default' | 'full' | 'square';

function Controls({
  align,
  size,
  iconVariant,
  onChange,
}: {
  align: Align;
  size: Size;
  iconVariant: IconVariant;
  onChange: (next: { align?: Align; size?: Size; iconVariant?: IconVariant }) => void;
}) {
  return (
    <div style={{ display: 'grid', gap: 16 }}>
      <RadioGroup<Align>
        label="对齐方式 Align"
        value={align}
        options={[
          { value: 'left', label: 'left', badge: 'Default' },
          { value: 'center', label: 'center' },
          { value: 'right', label: 'right' },
        ]}
        onChange={(val) => onChange({ align: val })}
      />
      <RadioGroup<Size>
        label="尺寸 Size"
        value={size}
        options={[
          { value: 'sm', label: 'sm' },
          { value: 'md', label: 'md' },
          { value: 'lg', label: 'lg', badge: 'Default' },
        ]}
        onChange={(val) => onChange({ size: val })}
      />
      <RadioGroup<IconVariant>
        label="图标样式 Icon Variant"
        value={iconVariant}
        options={[
          { value: 'default', label: 'default' },
          { value: 'full', label: 'full' },
          { value: 'square', label: 'square' },
        ]}
        onChange={(val) => onChange({ iconVariant: val })}
      />
    </div>
  );
}

export default function Demo() {
  const [align, setAlign] = React.useState<Align>('left');
  const [size, setSize] = React.useState<Size>('lg');
  const [iconVariant, setIconVariant] = React.useState<IconVariant>('default');

  const shared = {
    icon: <Moha />,
    cardTitle: 'Too young, too simple, sometimes naïve',
    description: '你们问我支不支持，我说支持也不行，不支持也不行',
  } as const;

  return (
    <div style={{ padding: 24, display: 'grid', gap: 24, maxWidth: 1280, margin: '0 auto' }}>
      {/* Interactive Props Playground */}
      <Card
        title={<span>Interactive Playground</span>}
        sourceCode={`<div
  style={{
    padding: 24,
    display: 'grid',
    gap: 16,
    gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
  }}
>
  <FeatureCard
    icon={<Moha />}
    cardTitle="${shared.cardTitle}"
    description="${shared.description}"
    align="${align}"
    size="${size}"
    iconVariant="${iconVariant}"
  />
  <FeatureCard
    icon={<Moha />}
    cardTitle="${shared.cardTitle}"
    description="${shared.description}。${shared.description}。${shared.description}"
    align="${align}"
    size="${size}"
    iconVariant="${iconVariant}"
  />
  <FeatureCard.Root align="${align}" size="${size}" iconVariant="${iconVariant}">
    <FeatureCard.Icon>
      <Moha />
    </FeatureCard.Icon>
    <FeatureCard.Body>
      <FeatureCard.Title>${shared.cardTitle}</FeatureCard.Title>
      <FeatureCard.Description>${shared.description}</FeatureCard.Description>
    </FeatureCard.Body>
  </FeatureCard.Root>
</div>`}
      >
        <div style={{ display: 'grid', gap: 16 }}>
          <Controls
            align={align}
            size={size}
            iconVariant={iconVariant}
            onChange={(next) => {
              if (next.align) setAlign(next.align);
              if (next.size) setSize(next.size);
              if (next.iconVariant) setIconVariant(next.iconVariant);
            }}
          />

          <div
            style={{
              padding: 24,
              display: 'grid',
              gap: 16,
              gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
            }}
          >
            <FeatureCard
              icon={shared.icon}
              cardTitle={shared.cardTitle}
              description={shared.description}
              align={align}
              size={size}
              iconVariant={iconVariant}
            />
            <FeatureCard
              icon={shared.icon}
              cardTitle={shared.cardTitle}
              description={shared.description + '。' + shared.description + '。' + shared.description}
              align={align}
              size={size}
              iconVariant={iconVariant}
            />
            <FeatureCard.Root align={align} size={size} iconVariant={iconVariant}>
              <FeatureCard.Icon>
                <Moha />
              </FeatureCard.Icon>
              <FeatureCard.Body>
                <FeatureCard.Title>{shared.cardTitle}</FeatureCard.Title>
                <FeatureCard.Description>{shared.description}</FeatureCard.Description>
              </FeatureCard.Body>
            </FeatureCard.Root>
          </div>
        </div>
      </Card>

      <ComparisonVsSemanticDom />

      {/* Semantic API */}
      <Card
        title={<span>Semantic API</span>}
        sourceCode={`<FeatureCard
  icon={<Moha />}
  cardTitle="${shared.cardTitle}"
  description="${shared.description}"
  align="left"
  size="lg"
  classNames={{
    root: 'dhu-bg-gradient-to-br dhu-from-purple-50 dhu-to-blue-50 dhu-border-purple-200',
    icon: 'dhu-bg-gradient-to-br dhu-from-purple-500 dhu-to-blue-500 dhu-text-white',
    title: 'dhu-text-purple-900',
    description: 'dhu-text-purple-700',
  }}
/>`}
      >
        <FeatureCard
          icon={shared.icon}
          cardTitle={shared.cardTitle}
          description={shared.description}
          align="left"
          size="lg"
          classNames={{
            root: 'dhu-bg-gradient-to-br dhu-from-purple-50 dhu-to-blue-50 dhu-border-purple-200',
            icon: 'dhu-bg-gradient-to-br dhu-from-purple-500 dhu-to-blue-500 dhu-text-white',
            title: 'dhu-text-purple-900',
            description: 'dhu-text-purple-700',
          }}
        />
      </Card>

      {/* Composition API */}
      <Card
        title={<span>Composition API</span>}
        sourceCode={`<FeatureCard.Root align="left" size="lg">
  <FeatureCard.Icon>
    <Moha />
  </FeatureCard.Icon>
  <FeatureCard.Body>
    <FeatureCard.Title>Too young, too simple, sometimes naïve</FeatureCard.Title>
    <FeatureCard.Description>
      你们问我支不支持，我说支持也不行，不支持也不行
    </FeatureCard.Description>
  </FeatureCard.Body>
  <div style={{ 
    display: 'flex', 
    gap: 8, 
    alignItems: 'center',
    marginTop: 16,
    paddingTop: 16,
    borderTop: '1px solid #e5e7eb'
  }}>
    <span style={{
      padding: '4px 12px',
      background: '#f3f4f6',
      borderRadius: 12,
      fontSize: 12,
      color: '#6b7280'
    }}>
      Featured
    </span>
    <button style={{
      padding: '6px 16px',
      background: '#3b82f6',
      color: 'white',
      border: 'none',
      borderRadius: 6,
      fontSize: 14,
      cursor: 'pointer'
    }}>
      Learn More
    </button>
  </div>
</FeatureCard.Root>`}
      >
        <FeatureCard.Root align="left" size="lg">
          <FeatureCard.Icon>
            <Moha />
          </FeatureCard.Icon>
          <FeatureCard.Body>
            <FeatureCard.Title className="text-red">{shared.cardTitle}</FeatureCard.Title>
            <FeatureCard.Description>{shared.description}</FeatureCard.Description>
          </FeatureCard.Body>
          <div
            style={{
              display: 'flex',
              gap: 8,
              alignItems: 'center',
              marginTop: 16,
              paddingTop: 16,
              borderTop: '1px solid #e5e7eb',
            }}
          >
            <span
              style={{
                padding: '4px 12px',
                background: '#f3f4f6',
                borderRadius: 12,
                fontSize: 12,
                color: '#6b7280',
              }}
            >
              Featured
            </span>
            <button
              style={{
                padding: '6px 16px',
                background: '#3b82f6',
                color: 'white',
                border: 'none',
                borderRadius: 6,
                fontSize: 14,
                cursor: 'pointer',
              }}
            >
              Learn More
            </button>
          </div>
        </FeatureCard.Root>
      </Card>
    </div>
  );
}
```
### API

#### FeatureCard (默认导出)

| 参数 | 说明 | 类型 | 默认值 | 必须 |
| --- | --- | --- | --- | --- |
| icon | 图标区域内容 | `React.ReactNode` | - | 否 |
| cardTitle | 标题（避免与 HTML title 属性冲突） | `React.ReactNode` | - | 否 |
| description | 描述 | `React.ReactNode` | - | 否 |
| titleAs | 标题标签 | `keyof JSX.IntrinsicElements` | `'h3'` | 否 |
| className | 自定义类名 | `string` | - | 否 |

#### 组合式子组件

- `FeatureCard.Root`
- `FeatureCard.Header`
- `FeatureCard.Icon`
- `FeatureCard.Title`
- `FeatureCard.Description`
- `FeatureCard.Content`

子组件支持 `asChild`：传入自定义标签作为 children（需为单个 ReactElement）。

---

## file-preview（文件预览）

**导入方式：**

```ts
import { FilePreview } from '@zjlab-fe/data-hub-ui';
```
```ts
// 类型导入
import type { IFilePreviewProps } from '@zjlab-fe/data-hub-ui';
```

用于文件预览

### 支持的文件格式

包括：

- xlsx/csv
- json
- pdf
- jpg/jpeg/png
- md
- txt

### 代码演示

**示例代码（index.tsx）：**

```tsx
import FilePreview from '@/components/file-preview';
import { useState } from 'react';
import { Tabs } from 'antd';
import type { TabsProps } from 'antd';
export default function Demo() {
  const [show, setShow] = useState(true);

  const fileMap = {
    // 'xlsx/csv': 'https://haina-datahub.zero2x.org/ossRoute/frontend/resources/test/test.xlsx',
    'xlsx/csv': 'https://haina-datahub.zero2x.org/ossRoute/frontend/resources/test/method_Caledonian.csv',
    pdf: 'https://haina-datahub.zero2x.org/ossRoute/frontend/resources/test/1.pdf',
    image: 'https://haina-datahub.zero2x.org/ossRoute/frontend/resources/test/figure9.png',
    markdown: 'https://haina-datahub.zero2x.org/ossRoute/frontend/resources/test/formula.md',
    txt: 'https://haina-datahub.zero2x.org/ossRoute/frontend/resources/test/test.txt',
    json: 'https://haina-datahub.zero2x.org/ossRoute/frontend/resources/test/test.json',
    jsonl: 'https://haina-datahub.zero2x.org/ossRoute/frontend/test/ocr_test.jsonl',
    parquet: 'https://haina-datahub.zero2x.org/ossRoute/frontend/resources/test/test.parquet',
  };
  const items: TabsProps['items'] = [
    {
      key: 'table',
      label: 'table格式数据',
      children: (
        <div style={{ width: '80%', height: '500px' }}>
          <FilePreview
            openInModal={false}
            tableConfig={{
              rowCount: 100,
              maxLines: 2,
              header: [
                'id',
                'title',
                'title_new',
                'translation',
                'explanation',
                'classification_4',
                'explanation_4',
                'classification_14',
                'explanation_14',
                'ds',
              ],
              rows: [
                [
                  'aacid__upload_files_degruyter__20240526T001320Z__eTkNyDZB9Q8CsRz3SYorHg',
                  'DeGruyter Journals/Journal of Optical Communications/Volume 33 (2012)/Issue 4/006_An Approach of Multiple-Quality Segment Protection (10.1515_joc-2012-0059).pdf',
                  'degruyter journals/journal of optical communications/volume 33 (2012)/issue 4/006_an approach of multiple-quality segment protection (10.1515_joc-2012-0059).pdf',
                  'degruyter journals/journal of optical communications/volume 33 (2012)/issue 4/006_an approach of multiple-quality segment protection (10.1515_joc-2012-0059).pdf',
                  '标题看起来像一个学术期刊文章的文件名，包含具体的卷号、期号和文章标题，无实际意义，无法翻译，因此保留原样。',
                  '其他',
                  '该标题是一个学术期刊文章的引用，虽然涉及光学通信领域的专业内容，但作为一个单独的标题，它更适合作为学术文献而非教育或科普书籍。因此，归类为“其他”。',
                  '物理学',
                  '该标题涉及“optical communications（光通信）”，这是物理学中的一个分支领域，主要研究光信号的传输与处理。因此，归类为物理学。',
                  '2025-04-16',
                ],
                [
                  'aacid__upload_files_acm__20240525T232722Z__Es24jwWahzfMHbUzQdoBES',
                  'Curve Fitting with Conic Splines',
                  'curve fitting with conic splines',
                  '用圆锥样条进行曲线拟合',
                  '标题描述了技术内容，翻译为“用圆锥样条进行曲线拟合”准确且符合中文表达习惯。',
                  '专业教育',
                  '“curve fitting with conic splines” 是一个与数学和计算机科学相关的专业主题，通常在高等教育或专业培训中涉及。因此，它被归类为“专业教育”。',
                  '数学',
                  '“Curve fitting with conic splines”涉及数学中的曲线拟合和样条函数，这些是数学领域中的重要概念和方法，因此归类为数学。',
                  '2025-04-16',
                ],
                [
                  'aacid__upload_files_duxiu_main__20240526T021914Z__UXkiaAn54FAH4Yo3KGVyTY',
                  'v/djvu/33152228_普通物理力学_王正清高等教育.djvu',
                  'v/djvu/33152228_普通物理力学_王正清高等教育.djvu',
                  'v/djvu/33152228_普通物理力学_王正清高等教育.djvu',
                  '标题看起来像一个文件路径，包含文件名和扩展名，无实际意义，无法翻译，因此保留原样。',
                  '专业教育',
                  '标题中的“普通物理力学”和“高等教育”表明这是一本大学水平的物理教科书，适用于高等教育或职业培训，因此归类为“专业教育”。',
                  '物理学',
                  '标题中的“普通物理力学”明确指向物理学中的力学部分，属于物理学（物理学）的范畴。',
                  '2025-04-16',
                ],
                [
                  'aacid__upload_files_bpb9v_cadal__20240510T045544Z__R5oLfdRocAST7YfzYGKGRz',
                  'ca33_03/33087932_電測儀表初級工_山西省電力工業局編中國電力.djvu',
                  'ca33_03/33087932_電測儀表初級工_山西省電力工業局編中國電力.djvu',
                  'ca33_03/33087932_電測儀表初級工_山西省電力工業局編中國電力.djvu',
                  '标题看起来像一个文件名，包含中文和数字，无实际意义，无法翻译，因此保留原样。',
                  '专业教育',
                  '标题中的“電測儀表初級工”（初级电工仪表）和“山西省電力工業局編”（山西省电力工业局编）表明这是一本关于电工仪表的专业培训书籍，适用于职业培训或高等教育。因此，归类为“专业教育”。',
                  '工程学',
                  '标题中的“電測儀表初級工”涉及电气测量仪表的基础知识，属于工程学中的电气工程领域。因此，该书应归类为工程学。',
                  '2025-04-16',
                ],
                [
                  '28392707',
                  '职场女性健康新视角',
                  '职场女性健康新视角',
                  '职场女性健康新视角',
                  '输入已经是中文，无需翻译，直接保留。',
                  '专业教育',
                  '标题“职场女性健康新视角”涉及职业健康和女性健康，这通常属于专业教育范畴，特别是针对职场人士的健康管理和职业培训。因此，归类为“专业教育”。',
                  '医学',
                  '该书标题“职场女性健康新视角”涉及女性在职场中的健康问题，属于医学领域中的职业健康或女性健康研究，因此归类为医学。',
                  '2025-04-16',
                ],
                [
                  'xinandiquzaoleiz00shiz',
                  'xi nan di qu zao lei zi yuan kao cha zhuan ji 西南地区藻类资源考察专集',
                  'xi nan di qu zao lei zi yuan kao cha zhuan ji 西南地区藻类资源考察专集',
                  '西南地区藻类资源考察专集',
                  '输入已经是中文，无需翻译，直接保留。',
                  '科普知识',
                  '标题“西南地区藻类资源考察专集”涉及对特定地区的藻类资源进行科学考察和研究。虽然内容较为专业，但它面向的是普通读者和科普爱好者，而不是高等教育或职业培训。因此，归类为“科普知识”。',
                  '生物学',
                  '该书标题“西南地区藻类资源考察专集”涉及藻类资源的调查与研究，藻类属于生物学中的一个分支，因此归类为生物学。',
                  '2025-04-16',
                ],
                [
                  'aacid__upload_files_acm__20240525T230805Z__84PrZimQiEaA9wLTn8WK5W',
                  'Decision Procedures for Epistemic Logic  Exploiting Belief Bases',
                  'decision procedures for epistemic logic  exploiting belief bases',
                  '用于知识逻辑的决策程序：利用信念库',
                  '标题中的“decision procedures for epistemic logic”被翻译为“用于知识逻辑的决策程序”，准确传达了主题内容。“exploiting belief bases”则翻译为“利用信念库”，进一步明确了方法和手段。',
                  '专业教育',
                  '标题“decision procedures for epistemic logic exploiting belief bases”涉及逻辑学和认知科学的高级主题，通常在大学或研究生水平的课程中教授。因此，它属于“专业教育”类别。',
                  '其他',
                  '标题涉及“epistemic logic（认识逻辑）”和“belief bases（信念基础）”，这些内容属于哲学和逻辑学领域，不在给定的14个学术学科范围内，因此归类为“其他”。',
                  '2025-04-16',
                ],
                [
                  '27854311',
                  '正切、弧度、渐开线函数秒表',
                  '正切、弧度、渐开线函数秒表',
                  '正切、弧度、渐开线函数秒表',
                  '输入已经是中文，无需翻译，直接保留。',
                  '科普知识',
                  '标题中的“正切、弧度、渐开线函数”涉及数学和科学概念，但“秒表”表明这可能是一本介绍这些概念在实际应用中的书籍。整体来看，这本书更倾向于面向普通读者，提供科学知识的普及，因此归类为“科普知识”。',
                  '数学',
                  '标题中的“正切、弧度、渐开线函数”都是数学中的概念，特别是与三角函数和解析几何相关的内容。因此，该书应归类为数学。',
                  '2025-04-16',
                ],
                [
                  '31851026',
                  '黄家驷外科学 第8版 下册_吴孟超，吴在德主编2021年',
                  '黄家驷外科学 第8版 下册_吴孟超，吴在德主编2021年',
                  '黄家驷外科学 第8版 下册_吴孟超，吴在德主编2021年',
                  '输入已经是中文，无需翻译，直接保留。',
                  '专业教育',
                  '《黄家驷外科学》是一本经典的医学教科书，通常用于医学专业学生的高等教育或职业培训。因此，它属于“专业教育”类别。',
                  '医学',
                  '该书为《黄家驷外科学》的第8版，由吴孟超和吴在德主编，内容涉及外科医学，属于医学类。外科学是医学的一个重要分支，专注于外科手术和相关疾病的治疗。',
                  '2025-04-16',
                ],
                [
                  'aacid__upload_files_acm__20240525T223731Z__RhvjUeLyf5Ly67cbS2Dpob',
                  'Video enhancement leveraging high-quality depth maps',
                  'video enhancement leveraging high-quality depth maps',
                  '利用高质量深度图进行视频增强',
                  '标题描述了利用高质量深度图技术进行视频增强的内容，翻译为“利用高质量深度图进行视频增强”既准确又符合中文表达习惯。',
                  '专业教育',
                  '标题涉及视频增强和深度图技术，这些内容通常与高等教育或专业培训相关，特别是计算机科学和图像处理领域。因此，归类为“专业教育”。',
                  '计算机科学',
                  '该书标题涉及视频增强技术，利用高质量深度图进行处理，这属于计算机视觉和图像处理的范畴，是计算机科学的一个重要分支。因此，归类为计算机科学。',
                  '2025-04-16',
                ],
              ],
            }}
          />
        </div>
      ),
    },
    {
      key: 'xlsx/csv',
      label: 'xlsx/csv',
      children: (
        <div style={{ width: '80%', height: '500px' }}>
          <FilePreview
            openInModal={false}
            modalProps={{ show, onCancel: () => setShow(false) }}
            filePath={fileMap['xlsx/csv']}
          />
        </div>
      ),
    },
    {
      key: 'pdf',
      label: 'pdf',
      children: (
        <div style={{ width: '80%', height: '500px' }}>
          <FilePreview
            openInModal={false}
            modalProps={{ show, onCancel: () => setShow(false) }}
            filePath={fileMap['pdf']}
            httpHeaders={{
              Authorization: 'Bearer 123456',
            }}
          />
        </div>
      ),
    },
    {
      key: 'image',
      label: '图片',
      children: (
        <div style={{ width: '80%', height: '500px' }}>
          <FilePreview
            openInModal={false}
            modalProps={{ show, onCancel: () => setShow(false) }}
            filePath={fileMap['image']}
            httpHeaders={{
              Authorization: 'Bearer 123456',
            }}
          />
        </div>
      ),
    },
    {
      key: 'markdown',
      label: 'markdown',
      children: (
        <div style={{ width: '80%', height: '500px' }}>
          <FilePreview
            openInModal={false}
            modalProps={{ show, onCancel: () => setShow(false) }}
            filePath={fileMap['markdown']}
          />
        </div>
      ),
    },
    {
      key: 'txt',
      label: 'txt',
      children: (
        <div style={{ width: '80%', height: '500px' }}>
          <FilePreview
            openInModal={false}
            modalProps={{ show, onCancel: () => setShow(false) }}
            filePath={fileMap['txt']}
          />
        </div>
      ),
    },
    {
      key: 'jsonl',
      label: 'jsonl',
      children: (
        <div style={{ width: '80%', height: '500px' }}>
          <FilePreview
            openInModal={false}
            modalProps={{ show, onCancel: () => setShow(false) }}
            filePath={fileMap['jsonl']}
            // jsonConfig={{
            //   data: {
            //     name: 'John',
            //     age: 30,
            //   },
            // }}
          />
        </div>
      ),
    },
    {
      key: 'json',
      label: 'json',
      children: (
        <div style={{ width: '80%', height: '500px' }}>
          <FilePreview
            openInModal={false}
            modalProps={{ show, onCancel: () => setShow(false) }}
            filePath={fileMap['json']}
            // jsonConfig={{
            //   data: {
            //     name: 'John',
            //     age: 30,
            //   },
            // }}
          />
        </div>
      ),
    },
    {
      key: 'parquet',
      label: 'parquet',
      children: (
        <div style={{ width: '80%', height: '500px' }}>
          <FilePreview
            openInModal={false}
            modalProps={{ show, onCancel: () => setShow(false) }}
            filePath={fileMap['parquet']}
          />
        </div>
      ),
    },
  ];
  return <Tabs items={items} destroyInactiveTabPane />;
}
```
### API

| 参数 | 说明 | 类型 | 默认值 | 版本 | 必须 |
| --- | --- | --- | --- | --- | --- |
| filePath    | 文件url(从文件url可以判断文件类型) | `string` | - | - | filePath/tableConfig 二选一 |
| tableConfig | 使用方已获取预览数据，使用表格展示 | [tableConfig](#tableConfig) | - | - | filePath/tableConfig 二选一 |
| openInModal | 是否在[Modal](https://ant.design/components/modal-cn)组件中展示             | `boolean` | -  | - | 是 |
| modalProps | 如果在Modal中展示，需要进一步配置该属性|[modalProps](#modalProps)| - | - | |
| onError | 文件加载异常时的回调 | `(Error) => void` | - | - | |
| onSuccess | 文件加载成功时的回调 | `() => void` | - | - | |

#### modalProps

| 参数 | 说明 | 类型 | 默认值 | 版本 | 必须 |
| --- | --- | --- | --- | --- | --- |
| show    | 控制是否展示 | `boolean` | - | - | 是 |
| title | 标题          | `string` | -  | - |  |
| onCancel | Modal关闭时的回调 |`() => void`| - | - | 是|

#### tableConfig

| 参数 | 说明 | 类型 | 默认值 | 版本 | 必须 |
| --- | --- | --- | --- | --- | --- |
| header | 表头 | `string[]` | - | - | 是 |
| rows | 行数据    | `string[][]` | -  | - | 是 |
| rowCount | 数据总行数，如果传入，内部会判断`rows.length`和`rowCount`的大小，决定是否展示预览提示信息(请下载查看完整内容) | `number` | - | - | 否 |
| maxLines | 单元格内容最多显示多少行，默认固定显示2行，超出显示省略号 | `number` | - | - | 否 |

---

## file-uploader（文件上传）

**导入方式：**

```ts
// 该组件通过 export * 导出，具体导出项见下方文档
import { UploadProvider, FileUploader, useUpload /* ... */ } from '@zjlab-fe/data-hub-ui';
```

一个支持分片上传、进度追踪的文件上传组件。断点续传 is still in progress

### 目录

- [导出清单](#导出清单)
- [代码演示](#代码演示)
- [快速开始](#快速开始)
  - [基础用法](#基础用法)
  - [自定义 UI](#自定义-ui)
- [UploadProvider 详解](#uploadprovider-详解)
  - [它负责什么？](#它负责什么)
  - [放在哪里？](#放在哪里)
  - [配置项](#配置项)
  - [小贴士](#小贴士)
- [FileUploader 组件详解](#fileuploader-组件详解)
  - [Props](#props)
  - [Handlers（上传回调）](#handlers上传回调)
    - [HandlerContext（处理器上下文）](#HandlerContext处理器上下文)
  - [错误处理](#错误处理)
  - [自定义样式](#自定义样式)
  - [使用场景](#使用场景)
- [Hooks 详解](#hooks-详解)
  - [useUpload](#useupload)
  - [useUploadFiles](#useuploadfiles)
  - [useUploadOperations](#useuploadoperations)
  - [useUploadControl](#useuploadcontrol)
  - [useUploadBatch](#useuploadbatch)
  - [如何选择 Hook？](#如何选择-hook)
- [子组件详解](#子组件详解)
  - [UploaderDropZone](#uploaderdropzone)
  - [UploaderFileListLayout](#uploaderfilelistlayout)
  - [UploaderFileItem](#uploaderfileitem)
  - [组合使用完整示例](#组合使用完整示例)

### 导出清单

#### Provider

| 导出 | 说明 |
| --- | --- |
| `UploadProvider` | 状态管理 Provider，负责管理上传状态和全局配置。在 App 或页面根部包裹使用。 |

#### 组件

| 导出 | 说明 |
| --- | --- |
| `FileUploader` | 开箱即用的完整上传器，包含拖拽区和文件列表，文件选择后自动开始上传。 |
| `UploaderDropZone` | 拖拽上传区域，支持点击选择、拖拽文件、文件夹上传。 |
| `UploaderFileListLayout` | 文件列表容器，显示文件数量和空状态。 |
| `UploaderFileItem` | 单个文件项，展示文件名、状态、进度，以及暂停/继续/取消按钮。 |

#### Hooks

| 导出 | 说明 |
| --- | --- |
| `useUpload` | **主 Hook**，整合了下面所有 Hook 的功能，适合需要完整上传能力的场景。 |
| `useUploadFiles` | 只读文件状态。返回 `files`、`pendingFiles`、`uploadingFiles`、`successFiles`、`failedFiles`、`pausedFiles` 和 `getFileById`。 |
| `useUploadOperations` | 文件队列管理。提供 `addFiles`、`removeFile`、`clearSucceeded`、`clearAll`。 |
| `useUploadControl` | 单文件控制。提供 `start`、`pause`、`resume`、`cancel`。 |
| `useUploadBatch` | 批量操作。提供 `startAll`、`pauseAll`、`resumeAll`、`cancelAll`。 |

#### 类型

| 导出 | 说明 |
| --- | --- |
| `UploadFile` | 文件状态对象类型 |
| `UploadHandlers` | 上传回调处理器类型 |
| `HandlerContext` | 处理器上下文类型，用于在回调中获取最新的 files 和操作函数，避免闭包陷阱 |
| `UploadConfig` | Provider 配置类型 |
| `AddFilesOptions` | addFiles 选项类型 |

### 代码演示

**示例代码（index.tsx）：**

```tsx
import { FileUploader, UploaderDropZone, UploadProvider } from '../index';

function App() {
  return (
    <UploadProvider>
      <FileUploader
        classNames={{
          container: 'dark-mode',
        }}
        handlers={{
          async onGetUploadUrls(fileName) {
            console.log('Getting upload URLs for', fileName);
            return {
              success: true,
              uploadInfo: {
                uploadUrls: [],
                uploadId: '',
              },
            };
          },
          async onFileComplete(fileName) {
            console.log('Completing upload for', fileName);
            return {
              success: true,
            };
          },
          async onPartComplete(fileName, uploadId, partNum) {
            console.log(`Part ${partNum} completed for ${fileName}`);
            return {
              success: true,
            };
          },
          onError(file, error) {
            console.error('Upload error for file', file.fileName, error);
          },
        }}
      />
      <UploaderDropZone onDrop={() => {}} />
    </UploadProvider>
  );
}

export default App;
```
### 快速开始

#### 基础用法

最简单的方式是使用 `FileUploader` 组件，它已经帮你把拖拽区和文件列表都搞定了：

```tsx
import { UploadProvider, FileUploader } from '@/components/file-uploader';

function MyPage() {
  const handlers = {
    // 必填：获取上传地址
    onGetUploadUrls: async (fileName, partCount) => {
      const res = await api.getUploadUrls(fileName, partCount);
      return { success: true, uploadInfo: res };
    },
    // 必填：单个分片上传完成
    onPartComplete: async (fileName, uploadId, partNum) => {
      await api.onPartComplete(fileName, uploadId, partNum);
      return { success: true };
    },
    // 必填：整个文件上传完成
    onFileComplete: async (fileName, uploadId) => {
      await api.onFileComplete(fileName, uploadId);
      return { success: true };
    },
  };

  return (
    <UploadProvider>
      <FileUploader handlers={handlers} />
    </UploadProvider>
  );
}
```

#### 自定义 UI

如果你想自己控制 UI，可以直接用 Hook：

```tsx
import { UploadProvider, useUpload, UploaderDropZone } from '@/components/file-uploader';

function CustomUploader({ datasetId }: { datasetId: string }) {
  const { files, addFiles, start, pause, cancel } = useUpload();

  const handleDrop = (droppedFiles: File[]) => {
    addFiles(droppedFiles, {
      handlers: {
        onGetUploadUrls: (fileName, partCount) => api.getUploadUrls(datasetId, fileName, partCount),
        onPartComplete: (fileName, uploadId, partNum) => api.onPartComplete(datasetId, uploadId, partNum),
        onFileComplete: (fileName, uploadId) => api.onFileComplete(datasetId, uploadId),
      },
    });
  };

  return (
    <div>
      <UploaderDropZone onDrop={handleDrop} />
      <ul>
        {files.map((file) => (
          <li key={file.id}>
            {file.fileName} - {file.status} ({file.progress}%)
            <button onClick={() => start(file.id)}>开始</button>
            <button onClick={() => pause(file.id)}>暂停</button>
            <button onClick={() => cancel(file.id)}>取消</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

// 记得在外层包 Provider
function App() {
  return (
    <UploadProvider>
      <CustomUploader datasetId="123" />
    </UploadProvider>
  );
}
```

### UploadProvider 详解

`UploadProvider` 是整个上传系统的核心，所有上传组件和 Hook 都依赖它。如果你在没有 Provider 的情况下使用 Hook，会直接报错提醒你。

#### 它负责什么？

- **状态管理**：统一管理所有文件的上传状态（pending、uploading、paused、success、failed 等）
- **上传队列**：内置 QueueManager，自动控制并发上传，不用担心同时传太多文件把带宽打满
- **全局配置**：分片大小、并发数等配置在这里统一设置

#### 放在哪里？

建议放在 App 根组件或页面根部。同一个 Provider 下的所有上传组件会**共享状态**，这意味着：

```tsx
// ✅ 推荐：App 级别，整个应用共享上传状态
function App() {
  return (
    <UploadProvider>
      <Router>
        <Routes />
      </Router>
    </UploadProvider>
  );
}

// ✅ 也可以：页面级别，该页面内共享
function DatasetPage() {
  return (
    <UploadProvider>
      <DatasetUploader />
      <SomeOtherComponent /> {/* 这里也能通过 useUpload 访问上传状态 */}
    </UploadProvider>
  );
}

// ⚠️ 注意：多个 Provider 会隔离状态
<UploadProvider>
  <UploaderA /> {/* 这两个上传器的文件列表是分开的 */}
</UploadProvider>
<UploadProvider>
  <UploaderB /> {/* 互不影响 */}
</UploadProvider>
```

#### 配置项

通过 `config` prop 可以调整全局上传行为：

```tsx
<UploadProvider
  config={{
    maxChunkSize: 100 * 1024 * 1024,  // 最大分片大小，默认 100MB
    minChunkSize: 5 * 1024 * 1024,    // 最小分片大小，默认 5MB
    concurrentChunks: 2,               // 单文件并发分片数，默认 2
    concurrentFiles: 1,                // 同时上传文件数，默认 1
  }}
>
  <App />
</UploadProvider>
```

| 配置项 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `maxChunkSize` | `number` | `100MB` | 单个分片的最大大小 |
| `minChunkSize` | `number` | `5MB` | 单个分片的最小大小 |
| `concurrentChunks` | `number` | `2` | 每个文件同时上传的分片数 |
| `concurrentFiles` | `number` | `1` | 同时上传的文件数量 |

#### 小贴士

- 分片大小会根据文件大小自动计算，在 `minChunkSize` 和 `maxChunkSize` 之间动态调整
- `concurrentFiles` 设置为 1 时是串行上传，适合带宽有限或服务端有限制的场景
- 如果需要在不同页面使用完全独立的上传队列，可以用多个 Provider 隔离
- **并发数建议**：由于浏览器对同一域名有并发请求数限制（通常 6 个），建议 `concurrentChunks × concurrentFiles < 5`，给其他请求留点余量

### FileUploader 组件详解

`FileUploader` 是开箱即用的完整上传组件，内部集成了拖拽区（`UploaderDropZone`）和文件列表（`UploaderFileListLayout` + `UploaderFileItem`）。选择文件后会自动开始上传，适合大多数场景直接使用。

#### Props

| 属性 | 类型 | 默认值 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| `handlers` | `UploadHandlers` | - | ✅ | 上传生命周期回调，详见下方 Handlers 说明 |
| `accept` | `string` | `''` | | 允许的文件类型，如 `"image/*"` 或 `".pdf,.doc"` |
| `directory` | `boolean` | `false` | | 是否支持文件夹上传 |
| `maxFiles` | `number` | - | | 最大文件数量限制 |
| `maxSize` | `number` | `10GB` | | 单文件大小限制（字节） |
| `dragAreaDescription` | `ReactNode` | `'点击或拖拽上传文件'` | | 拖拽区提示文案 |
| `fileValidation` | `(file: File) => boolean \| Promise<boolean>` | - | | 自定义文件校验函数，在文件进入上传队列**之前**执行 |
| `onDropZoneError` | `(error: DropZoneError) => void` | - | | 文件进入上传队列**之前**的校验错误回调（如文件过大、数量超限等） |
| `classNames` | `object` | - | | 自定义样式类名 |

#### Handlers（上传回调）

`handlers` 是上传的核心配置，用于对接你的后端 API。

##### 必填回调

| 回调 | 参数 | 返回值 | 说明 |
| --- | --- | --- | --- |
| `onGetUploadUrls` | `(fileName, partCount)` | `Promise<GetUploadUrlsResult>` | 获取分片上传地址 |
| `onPartComplete` | `(fileName, uploadId, partNum)` | `Promise<CallbackResult>` | 单个分片上传完成后通知服务端 |
| `onFileComplete` | `(fileName, uploadId)` | `Promise<CallbackResult>` | 所有分片上传完成后通知服务端（通常触发合并） |

**返回值类型说明：**

```ts
// onGetUploadUrls 返回值
type GetUploadUrlsResult =
  | { success: false; reason?: string }
  | { success: true; uploadInfo: { uploadUrls: UploadUrl[]; uploadId: string } };

// uploadUrls 数组中每个元素的结构
interface UploadUrl {
  uploadUrl: string;  // 分片上传地址
  partNum: number;    // 分片序号
}

// onPartComplete / onFileComplete 返回值
type CallbackResult =
  | { success: true }
  | { success: false; reason?: string };
```

**示例：**

```tsx
const handlers = {
  onGetUploadUrls: async (fileName, partCount) => {
    const res = await api.getUploadUrls(fileName, partCount);
    // 返回成功
    return { success: true, uploadInfo: { uploadUrls: res.urls, uploadId: res.uploadId } };
    // 或返回失败
    // return { success: false, reason: '获取上传地址失败' };
  },

  onPartComplete: async (fileName, uploadId, partNum) => {
    await api.onPartComplete(fileName, uploadId, partNum);
    return { success: true };
  },

  onFileComplete: async (fileName, uploadId) => {
    await api.onFileComplete(fileName, uploadId);
    return { success: true };
  },
};
```

##### 可选回调

| 回调 | 参数 | 返回值 | 说明 |
| --- | --- | --- | --- |
| `onBeforeUpload` | `(file: UploadFile, ctx: HandlerContext)` | `Promise<CallbackResult>` | 上传前校验，返回 `{ success: false }` 可取消上传 |
| `onUploadStart` | `(file: UploadFile, ctx: HandlerContext)` | `void` | 开始上传时触发（获取到上传地址后） |
| `onProgress` | `(file: UploadFile, progress: number, ctx: HandlerContext)` | `void` | 进度更新（0-100） |
| `onPause` | `(file: UploadFile, ctx: HandlerContext)` | `void` | 暂停时触发 |
| `onResume` | `(file: UploadFile, ctx: HandlerContext)` | `void` | 恢复时触发 |
| `onUploadSuccess` | `(file: UploadFile, ctx: HandlerContext)` | `void` | 上传成功时触发 |
| `onUploadCancel` | `(fileName, uploadId)` | `Promise<CallbackResult>` | 取消上传，通知服务端清理资源 |
| `onDeleteFile` | `(file: UploadFile, ctx: HandlerContext)` | `Promise<CallbackResult>` | 从队列中删除文件时触发 |
| `onRetry` | `(file: UploadFile, retryType: 'smart' \| 'full', ctx: HandlerContext)` | `void` | 重试上传时触发 |
| `onError` | `(file: UploadFile, error: Error, ctx: HandlerContext)` | `void` | 上传出错时触发 |

##### HandlerContext（处理器上下文）

> **设计初衷**：在用户触发上传的那一刻，捕获当时的状态（如当前选中的文件夹），
> 每个文件都有自己专属的 handlers
>
> 但在实际场景中，你可能希望拿到某些最新的数据
>
> 以下是**临时解决方案**，我们会在后续版本中探索更优雅的解决方案。

> ⚠️ **重要提示：避免闭包陷阱！**
>
> 由于 React 的闭包特性，在 handlers 中直接使用 `files`、`removeFile` 等变量会导致**值过期**问题。
> 这些值在 handler 注册时被"捕获"，之后即使状态更新，handler 中的值也不会更新。

**错误示例：**

```tsx
// ❌ 错误：files 和 removeFile 是注册时的旧值，永远不会更新！
const { files, removeFile } = useUpload();

addFiles(selectedFiles, {
  getHandlers: () => ({
    onUploadSuccess: (file) => {
      console.log(files.length);  // 始终是 0 或旧值
      removeFile(file.id);        // 使用的是旧版本的 removeFile
    }
  })
});
```

**正确示例：**

```tsx
// ✅ 正确：使用 ctx 参数获取最新值
addFiles(selectedFiles, {
  getHandlers: () => ({
    onUploadSuccess: (file, ctx) => {
      console.log(ctx.files.length);  // 始终是最新值
      ctx.removeFile(file.id);        // 使用最新版本的 removeFile
    },
    onError: (file, error, ctx) => {
      console.log('上传失败，剩余文件:', ctx.files.length);
      // 可以使用 ctx.start, ctx.pause, ctx.resume, ctx.cancel 等
    }
  })
});
```

**HandlerContext 结构：**

```ts
interface HandlerContext {
  files: UploadFile[];                           // 当前所有文件（始终最新）
  removeFile: (id: string) => Promise<void>;     // 移除文件
  start: (id: string) => Promise<void>;          // 开始上传
  pause: (id: string) => void;                   // 暂停上传
  resume: (id: string) => Promise<void>;         // 恢复上传
  cancel: (id: string) => void;                  // 取消上传
  getFileById: (id: string) => UploadFile | undefined;  // 获取指定文件
}
```

##### 处理自定义状态的闭包问题

`HandlerContext` 只能解决上传组件内部状态的闭包问题。如果你在 handler 中使用了**自己组件的状态**，同样会遇到闭包陷阱：

```tsx
// ❌ 错误：selectedFolder 也会过期！
const [selectedFolder, setSelectedFolder] = useState('folder-1');

addFiles(files, {
  getHandlers: () => ({
    onUploadSuccess: (file, ctx) => {
      api.moveToFolder(file.id, selectedFolder);  // selectedFolder 永远是 'folder-1'
    }
  })
});
```

**临时解决方案：使用 useRef 保存最新值**

```tsx
const [selectedFolder, setSelectedFolder] = useState('folder-1');
const selectedFolderRef = useRef(selectedFolder);

// 保持 ref 始终是最新值
useEffect(() => {
  selectedFolderRef.current = selectedFolder;
}, [selectedFolder]);

addFiles(files, {
  getHandlers: () => ({
    onUploadSuccess: (file, ctx) => {
      api.moveToFolder(file.id, selectedFolderRef.current);  // ✅ 始终是最新值
    }
  })
});
```

#### 错误处理

当文件校验失败时（比如文件太大、数量超限），会触发 `onDropZoneError`：

```tsx
<FileUploader
  handlers={handlers}
  maxSize={100 * 1024 * 1024} // 100MB
  maxFiles={10}
  onDropZoneError={(error) => {
    // error.type: 'FILE_TOO_LARGE' | 'TOO_MANY_FILES' | 'INVALID_TYPE' | 'DROP_FAILED'
    // error.message: 错误描述
    // error.rejectedFiles: 被拒绝的文件列表
    message.error(error.message);
  }}
/>
```

#### 自定义样式

通过 `classNames` 可以覆盖各部分的样式, 基本包含了组件中出现所有html元素：

```tsx
<FileUploader
  handlers={handlers}
  classNames={{
    container: 'my-uploader',           // 最外层容器
    dragZone: {
      container: 'my-drop-zone',        // 拖拽区容器
      dragArea: 'my-drag-area',         // 拖拽区域
      dragging: 'my-dragging',          // 拖拽中状态
      disabled: 'my-disabled',          // 禁用状态
      icon: 'my-icon',                  // 图标
      description: 'my-description',    // 提示文字
      input: 'my-input',                // 文件输入框
    },
    fileList: {
      container: 'my-file-list',        // 文件列表容器
      title: 'my-file-list-title',      // 文件列表标题
      listWrapper: 'my-list-wrapper',   // 文件列表包装器
      empty: 'my-empty-state',          // 空状态
    },
    fileItem: {
      container: 'my-file-item',        // 单个文件项
      info: 'my-file-info',             // 文件信息区域
      icon: 'my-file-icon',             // 文件图标
      name: 'my-file-name',             // 文件名
      relativePath: 'my-relative-path', // 相对路径
      status: 'my-status',              // 状态区域
      statusText: 'my-status-text',     // 状态文字
      progress: 'my-progress',          // 进度条
      pauseBtn: 'my-pause-btn',         // 暂停按钮
      resumeBtn: 'my-resume-btn',       // 继续按钮
      closeBtn: 'my-close-btn',         // 关闭按钮
    },
  }}
/>
```

#### 使用场景

```tsx
// 场景 1：基础上传
<FileUploader handlers={handlers} />

// 场景 2：只允许上传图片，最多 5 张
<FileUploader
  handlers={handlers}
  accept="image/*"
  maxFiles={5}
  directory={false}
/>

// 场景 3：大文件上传，限制 50GB
<FileUploader
  handlers={handlers}
  maxSize={50 * 1024 * 1024 * 1024}
  dragAreaDescription="支持上传最大 50GB 的文件"
/>

// 场景 4：自定义校验（只允许特定命名格式）
<FileUploader
  handlers={handlers}
  fileValidation={(file) => /^data_\d+\.csv$/.test(file.name)}
  onDropZoneError={(e) => message.error('文件名必须是 data_数字.csv 格式')}
/>
```

### Hooks 详解

如果你需要自定义 UI 或更细粒度的控制，可以直接使用 Hooks。我们提供了 5 个 Hook，按需选用：

#### useUpload

**主 Hook**，整合了下面 4 个 Hook 的所有功能。如果你不确定用哪个，直接用这个就对了。

```tsx
const {
  // 来自 useUploadFiles
  files,
  getFileById,
  pendingFiles,
  uploadingFiles,
  successFiles,
  failedFiles,
  pausedFiles,

  // 来自 useUploadOperations
  addFiles,
  removeFile,
  clearSucceeded,
  clearAll,

  // 来自 useUploadControl
  start,
  pause,
  resume,
  cancel,

  // 来自 useUploadBatch
  startAll,
  pauseAll,
  resumeAll,
  cancelAll,
} = useUpload();
```

---

#### useUploadFiles

只读文件状态，适合只需要展示文件列表、不需要操作的场景。

```tsx
const {
  files,           // UploadFile[] - 所有文件
  getFileById,     // (id: string) => UploadFile | undefined
  pendingFiles,    // UploadFile[] - 等待上传的文件
  uploadingFiles,  // UploadFile[] - 正在上传的文件
  successFiles,  // UploadFile[] - 上传完成的文件
  failedFiles,     // UploadFile[] - 上传失败的文件
  pausedFiles,     // UploadFile[] - 已暂停的文件
} = useUploadFiles();
```

**UploadFile 结构：**

```ts
interface UploadFile {
  id: string;              // 文件唯一标识
  file: File;              // 原始 File 对象
  fileName: string;        // 文件名
  fileSize: number;        // 文件大小（字节）
  status: UploadStatusType; // 状态：pending | preparing | uploading | paused | merging | success | failed | cancelled
  progress: number;        // 上传进度（0-100）
  uploadId: string;        // 服务端返回的上传 ID
  chunks: ChunkState[];    // 分片状态列表
  error?: string;          // 错误信息
  createdAt: number;       // 添加时间戳
}
```

---

#### useUploadOperations

文件队列管理，用于添加、删除、清理文件。

```tsx
const {
  addFiles,        // 添加文件到队列
  removeFile,      // 移除单个文件
  clearSucceeded,  // 清理所有已完成的文件
  clearAll,        // 清理所有文件
} = useUploadOperations();
```

**addFiles 用法：**

```tsx
// 方式 1：所有文件使用相同的 handlers
addFiles(fileList, {
  handlers: {
    onGetUploadUrls: ...,
    onPartComplete: ...,
    onFileComplete: ...,
  },
});

// 方式 2：每个文件使用不同的 handlers（比如上传到不同目录）
addFiles(fileList, {
  getHandlers: (file) => ({
    onGetUploadUrls: (fileName, partCount) =>
      api.getUploadUrls(getFolderIdFromFileName(file.name), fileName, partCount),
    onPartComplete: ...,
    onFileComplete: ...,
  }),
});
```

**其他方法：**

```tsx
removeFile(fileId);   // 移除文件，会触发 handlers.onDeleteFile
clearSucceeded();     // 清理所有 status === 'success' 的文件
clearAll();           // 清理所有文件（不管状态）
```

---

#### useUploadControl

单文件控制，用于控制单个文件的上传流程。

```tsx
const {
  start,   // (id: string) => Promise<void> - 开始上传
  pause,   // (id: string) => void - 暂停上传
  resume,  // (id: string) => Promise<void> - 恢复上传
  cancel,  // (id: string) => void - 取消上传
} = useUploadControl();
```

**示例：**

```tsx
function FileActions({ file }: { file: UploadFile }) {
  const { start, pause, resume, cancel } = useUploadControl();

  return (
    <div>
      {file.status === 'pending' && (
        <button onClick={() => start(file.id)}>开始</button>
      )}
      {file.status === 'uploading' && (
        <button onClick={() => pause(file.id)}>暂停</button>
      )}
      {file.status === 'paused' && (
        <button onClick={() => resume(file.id)}>继续</button>
      )}
      {['pending', 'uploading', 'paused'].includes(file.status) && (
        <button onClick={() => cancel(file.id)}>取消</button>
      )}
    </div>
  );
}
```

---

#### useUploadBatch

批量操作，一次控制所有文件。

```tsx
const {
  startAll,   // () => Promise<void> - 开始所有 pending 文件
  pauseAll,   // () => void - 暂停所有 uploading 文件
  resumeAll,  // () => Promise<void> - 恢复所有 paused 文件
  cancelAll,  // () => void - 取消所有未完成的文件
} = useUploadBatch();
```

**示例：**

```tsx
function BatchControls() {
  const { pendingFiles, uploadingFiles, pausedFiles } = useUploadFiles();
  const { startAll, pauseAll, resumeAll, cancelAll } = useUploadBatch();

  return (
    <div>
      {pendingFiles.length > 0 && (
        <button onClick={startAll}>全部开始 ({pendingFiles.length})</button>
      )}
      {uploadingFiles.length > 0 && (
        <button onClick={pauseAll}>全部暂停 ({uploadingFiles.length})</button>
      )}
      {pausedFiles.length > 0 && (
        <button onClick={resumeAll}>全部继续 ({pausedFiles.length})</button>
      )}
      <button onClick={cancelAll}>全部取消</button>
    </div>
  );
}
```

---

#### 如何选择 Hook？

| 场景 | 推荐 Hook |
| --- | --- |
| 完整功能，不想分开引入 | `useUpload` |
| 只需要展示文件列表 | `useUploadFiles` |
| 只需要添加/删除文件 | `useUploadOperations` |
| 只需要控制单个文件 | `useUploadControl` |
| 只需要批量操作 | `useUploadBatch` |
| 组合使用，按需引入 | 分别引入需要的 Hook |

### 子组件详解

如果你不想用 `Uploader` 一体化组件，可以自由组合这三个子组件来构建自定义 UI。

#### UploaderDropZone

拖拽上传区域，支持点击选择、拖拽文件、文件夹上传。这是一个纯 UI 组件，不依赖上传状态。

##### Props

| 属性 | 类型 | 默认值 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| `onDrop` | `(files: File[]) => void` | - | ✅ | 文件选择/拖入后的回调 |
| `accept` | `string` | `''` | | 允许的文件类型，如 `"image/*"` 或 `".pdf,.doc"` |
| `maxSize` | `number` | - | | 单文件大小限制（字节） |
| `maxFiles` | `number` | - | | 最大文件数量 |
| `directory` | `boolean` | `false` | | 是否支持文件夹上传 |
| `disabled` | `boolean` | `false` | | 禁用状态 |
| `dragAreaDescription` | `ReactNode` | `'点击或拖拽上传文件'` | | 提示文案 |
| `icon` | `ReactNode \| string` | - | | 自定义图标（组件或图片路径） |
| `fileValidation` | `(file: File) => boolean \| Promise<boolean>` | - | | 自定义文件校验 |
| `onError` | `(error: DropZoneError) => void` | - | | 校验失败回调 |
| `classNames` | `UploaderDropZoneClassNames` | - | | 自定义样式 |

##### classNames 结构

```ts
interface UploaderDropZoneClassNames {
  container?: string;    // 容器
  dragArea?: string;     // 拖拽区域
  dragging?: string;     // 拖拽中状态
  disabled?: string;     // 禁用状态
  icon?: string;         // 图标
  description?: string;  // 提示文字
  input?: string;        // 隐藏的 input
}
```

##### 示例

```tsx
import { UploaderDropZone } from '@/components/file-uploader';

function MyDropZone() {
  const handleDrop = (files: File[]) => {
    console.log('选中的文件:', files);
    // 通常这里调用 addFiles
  };

  return (
    <UploaderDropZone
      onDrop={handleDrop}
      accept="image/*"
      maxSize={10 * 1024 * 1024}
      dragAreaDescription="拖入图片或点击上传"
      onError={(error) => console.error(error.message)}
    />
  );
}
```

---

#### UploaderFileListLayout

文件列表容器，显示文件数量标题和空状态。内部使用 `useUploadFiles` 获取文件列表长度。

##### Props

| 属性 | 类型 | 默认值 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| `children` | `ReactNode` | - | ✅ | 子元素（通常是 `UploaderFileItem` 列表） |
| `emptyText` | `string` | `'暂无文件'` | | 空状态提示文案 |
| `classNames` | `FileListClassNames` | - | | 自定义样式 |

##### classNames 结构

```ts
interface FileListClassNames {
  container?: string;  // 列表容器
  empty?: string;      // 空状态容器
}
```

##### 示例

```tsx
import { UploaderFileListLayout, UploaderFileItem, useUploadFiles } from '@/components/file-uploader';

function MyFileList() {
  const { files } = useUploadFiles();

  return (
    <UploaderFileListLayout emptyText="还没有上传任何文件">
      {files.map((file) => (
        <UploaderFileItem key={file.id} file={file} />
      ))}
    </UploaderFileListLayout>
  );
}
```

---

#### UploaderFileItem

单个文件项，显示文件名、状态、进度圈、操作按钮。这是一个受控组件，需要传入 `file` 和操作回调。

##### Props

| 属性 | 类型 | 默认值 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| `file` | `UploadFile` | - | ✅ | 文件对象 |
| `onCancel` | `(id: string) => void` | - | | 取消上传回调 |
| `onRemove` | `(id: string) => void` | - | | 移除文件回调（用于已完成/失败/已取消的文件） |
| `onPause` | `(id: string) => void` | - | | 暂停上传回调 |
| `onResume` | `(id: string) => void` | - | | 恢复上传回调 |
| `classNames` | `UploaderFileItemClassNames` | - | | 自定义样式 |

##### classNames 结构

```ts
interface UploaderFileItemClassNames {
  container?: string;   // 根容器
  info?: string;        // 文件信息区域
  icon?: string;        // 文件图标
  name?: string;        // 文件名
  status?: string;      // 状态区域
  statusText?: string;  // 状态文字
  progress?: string;    // 进度圈容器
  pauseBtn?: string;    // 暂停按钮
  resumeBtn?: string;   // 继续按钮
  closeBtn?: string;    // 关闭/取消按钮
}
```

##### 状态显示逻辑

- **关闭按钮**：始终显示。已完成/失败/已取消时调用 `onRemove`，其他状态调用 `onCancel`
- **暂停按钮**：上传中且传入了 `onPause` 时显示
- **继续按钮**：已暂停且传入了 `onResume` 时显示

##### 示例

```tsx
import { UploaderFileItem, useUploadFiles, useUploadControl } from '@/components/file-uploader';

function MyFileItem({ file }: { file: UploadFile }) {
  const { pause, resume, cancel } = useUploadControl();
  const { removeFile } = useUploadOperations();

  return (
    <UploaderFileItem
      file={file}
      onPause={pause}
      onResume={resume}
      onCancel={cancel}
      onRemove={removeFile}
    />
  );
}
```

---

#### 组合使用完整示例

```tsx
import {
  UploadProvider,
  UploaderDropZone,
  UploaderFileListLayout,
  UploaderFileItem,
  useUpload,
} from '@/components/file-uploader';

function CustomUploader({ datasetId }: { datasetId: string }) {
  const { files, addFiles, pause, resume, cancel, removeFile } = useUpload();

  const handlers = {
    onGetUploadUrls: (fileName, partCount) => api.getUploadUrls(datasetId, fileName, partCount),
    onPartComplete: (fileName, uploadId, partNum) => api.onPartComplete(datasetId, uploadId, partNum),
    onFileComplete: (fileName, uploadId) => api.onFileComplete(datasetId, uploadId),
  };

  return (
    <div className="my-uploader">
      {/* 拖拽区 */}
      <UploaderDropZone
        onDrop={(droppedFiles) => addFiles(droppedFiles, { handlers })}
        accept="image/*,.pdf"
        maxSize={100 * 1024 * 1024}
        directory
      />

      {/* 文件列表 */}
      <UploaderFileListLayout emptyText="拖入文件开始上传">
        {files.map((file) => (
          <UploaderFileItem
            key={file.id}
            file={file}
            onPause={pause}
            onResume={resume}
            onCancel={cancel}
            onRemove={removeFile}
          />
        ))}
      </UploaderFileListLayout>
    </div>
  );
}

// 使用
function App() {
  return (
    <UploadProvider>
      <CustomUploader datasetId="123" />
    </UploadProvider>
  );
}
```

---

## floating-label-input（浮动标签输入框）

**导入方式：**

```ts
import { FloatingLabelInput } from '@zjlab-fe/data-hub-ui';
```

使用：`import { FloatingLabelInput } from '@zjlab-fe/data-hub-ui';`

### 代码演示

**示例代码（index.tsx）：**

```tsx
import React from 'react';
import FloatingLabelInput from '../index';
import { Button, Checkbox, Form, Input } from 'antd';
import type { FormProps } from 'antd';
import * as styles from '../index.module.scss';

type FieldType = {
  testtest?: string;
  passwor2d?: string;
  remember?: string;
};

export default function Demo() {
  const onFinish: FormProps<FieldType>['onFinish'] = (values) => {
    console.log('Success:', values);
  };

  const onFinishFailed: FormProps<FieldType>['onFinishFailed'] = (errorInfo) => {
    console.log('Failed:', errorInfo);
  };
  return (
    <>
      ### 普通使用
      <FloatingLabelInput
        placeholder="请输入"
        onChange={(e) => {
          console.log(e.target.value);
        }}
        onBlur={(e) => {
          console.log('onBlur', e.target.value);
        }}
      ></FloatingLabelInput>
      <br />
      ### 在form表单里使用
      <Form
        name="basic"
        style={{ maxWidth: 600 }}
        initialValues={{ remember: true }}
        onFinish={onFinish}
        onFinishFailed={onFinishFailed}
        autoComplete="off"
      >
        <Form.Item<FieldType>
          label="Testtest"
          name="testtest"
          rules={[{ required: true, message: 'Please input your username!' }]}
        >
          <FloatingLabelInput
            placeholder="请输入"
            onChange={(e) => {
              console.log(e.target.value);
            }}
            onBlur={(e) => {
              console.log('onBlur', e.target.value);
            }}
            wrapperStyle={{
              width: 200,
            }}
          ></FloatingLabelInput>
        </Form.Item>

        <Form.Item<FieldType>
          label="Passwo2rd"
          name="passwor2d"
          rules={[{ required: true, message: 'Please input your password!' }]}
        >
          <FloatingLabelInput
            placeholder="请输入"
            onChange={(e) => {
              console.log(e.target.value);
            }}
            onBlur={(e) => {
              console.log('onBlur', e.target.value);
            }}
            wrapperStyle={{
              width: 200,
            }}
          ></FloatingLabelInput>
        </Form.Item>

        <Form.Item<FieldType> name="remember" valuePropName="checked" label={null}>
          <Checkbox>Remember me</Checkbox>
        </Form.Item>

        <Form.Item label={null}>
          <Button type="primary" htmlType="submit">
            Submit
          </Button>
        </Form.Item>
      </Form>
      <br />
      ### 设置inline
      <FloatingLabelInput
        placeholder="请输入"
        wrapperStyle={{
          width: 200,
        }}
        inline
      ></FloatingLabelInput>
      <FloatingLabelInput
        placeholder="请输入"
        wrapperStyle={{
          width: 200,
        }}
        inline
      ></FloatingLabelInput>
      <br />
      ### 设置wrapperClassName
      <FloatingLabelInput
        placeholder="请输入"
        wrapperClassName={styles['slide-input-wrapper']}
      ></FloatingLabelInput>
    </>
  );
}
```
### API

| 参数 | 说明 | 类型 | 默认值 | 版本 | 必须 |
| --- | --- | --- | --- | --- | --- |
| id | 绑定在输入框根节点的id| `string` | - | - | 否 |
| placeholder | 输入框提示文字 | `string` | - | - | |
| inline | 是否显示为行内元素 | `boolean` | `false` | - | |
| wrapperStyle | 包装器的样式 | `React.CSSProperties` | `{}` | - | |
| wrapperClassName | 包装器的类名 | `string` | `''` | - | |
| onFocus | 输入框获得焦点时的回调 | `(e: React.FocusEvent<HTMLInputElement>) => void` | - | - | |
| onBlur | 输入框失去焦点时的回调 | `(e: React.FocusEvent<HTMLInputElement>) => void` | - | - | |
| 其他属性 | 支持原生Input组件的其他所有属性 | - | - | - | |

---

## floating-layer（浮动操作框）

**导入方式：**

```ts
import { FloatingLayer } from '@zjlab-fe/data-hub-ui';
```

### 代码演示

**示例代码（index.tsx）：**

```tsx
import { useState } from 'react';
import FloatingLayer from '@/components/floating-layer';
import { Button } from 'antd';

export default function Demo() {
  const [open, setOpen] = useState(false);
  const openFloat = function () {
    setOpen(true);
  };
  const onClose = () => {
    setOpen(false);
  };
  return (
    <>
      <Button onClick={() => openFloat()}>打开浮层</Button>
      <FloatingLayer visiable={open} onClose={onClose}>
        <div>这里是插槽</div>
      </FloatingLayer>
    </>
  );
}
```
### API



| 参数 | 说明 | 类型 | 默认值 | 版本 | 必须 |
| --- | --- | --- | --- | --- | --- |
| visiable    | 展示浮层 | `boolean` | - | - | 是 |
| onClose | 关闭浮层时的回调 | `() => void` | - | - | 是|
| children | 浮层内子节点 | `React.ReactNode` | - | - | |
| className | 如传入，将添加到根节点上 | `string` | - | >= 0.11.2| |
| style | 如传入，将添加到根节点上 | `React.CSSProperties` | - | >= 0.11.2 | |

---

## header（头部导航）

**导入方式：**

```ts
import { Header } from '@zjlab-fe/data-hub-ui';
```

> 该组件暂无 README 文档，以下为 demo 示例代码，可参考用法。

### 示例代码（index.tsx）

```tsx
import React from 'react';
import Header from '@/components/header';

export default function Demo() {
  return <Header showLogin />;
}
```

---

## input-tag（标签/关键字输入）

**导入方式：**

```ts
import { InputTag } from '@zjlab-fe/data-hub-ui';
```
```ts
// 类型导入
import type { IProps as InputTagProps } from '@zjlab-fe/data-hub-ui';
```

用于输入多个标签/关键字，回车将输入内容添加为标签或者关键字

### 代码演示

#### 示例1

期望传回字符串

**示例代码（index.tsx）：**

```tsx
import React, { useState } from 'react';
import InputTag, { IProps } from '@/components/input-tag';
export default function Demo() {
  const [value, setValue] = useState('abc;def');
  const onChange: IProps['onChange'] = (v) => {
    console.log('+++ value', v);
    setValue(v as string);
  };
  return <InputTag valueType="string" value={value} onChange={onChange} maxLength={20} placeholder="标签" />;
}
```
#### 示例2

期望传回数组

**示例代码（index2.tsx）：**

```tsx
import React, { useState } from 'react';
import InputTag, { IProps } from '@/components/input-tag';
export default function Demo() {
  const [value, setValue] = useState(['abc', 'def']);
  const onChange: IProps['onChange'] = (v) => {
    console.log('+++ value', v);
    setValue(v as string[]);
  };
  return <InputTag valueType="array" value={value} onChange={onChange} maxLength={20} placeholder="标签" />;
}
```
### API

| 参数 | 说明 | 类型 | 默认值 | 版本 | 必须 |
| --- | --- | --- | --- | --- | --- |
| valueType | 值变更回调时，期望传回的值的类型，如果期望传回字符串，则传回的值是英文分号分隔的字符串 | `array \| string` | - | - | 是 |
| value | 当前值 | `string[] \| string \| undefined` | | | |
| onChange | 内容变化时的回调 | `(value: string[] \| string \| undefined) => void` | | |
| placeholder | 占位内容 | `string` | 请输入关键字 | | |
| maxLength | 可输入的字符个数 | `number` | | | |
| size | 组件大小 | `large \| middle \| small` | middle | | |
| disabled | 是否禁止操作 | `boolean` | `false` | | |
| deduplicated | 是否去除重复 | `boolean` | `true` | | |

---

## menu（菜单）

**导入方式：**

```ts
import { Menu } from '@zjlab-fe/data-hub-ui';
```

基于 React + Ant Design 实现的后台系统左侧可折叠侧边栏菜单组件，支持路由菜单、外链菜单、自动路由匹配选中、折叠/展开切换、折叠态 Tooltip 提示，适配企业级后台布局规范。

### 代码演示

**示例代码（index.tsx）：**

```tsx
import { useState } from 'react';
import { DatabaseOutlined, TagsOutlined, FilterOutlined } from '@ant-design/icons';
import Menu from '../index';
import { MenuItem } from '../index';

import { Layout, theme } from 'antd';
const { Content } = Layout;

const menus: MenuItem[] = [
  {
    label: '数据管理',
    key: 'data-management',
    icon: <DatabaseOutlined />,
    children: [
      {
        label: '数据集',
        key: 'data-management-dataset',
        icon: <DatabaseOutlined />,
      },
    ],
  },
  {
    label: '数据加工',
    key: 'data-processing',
    children: [
      {
        label: '语料生产服务',
        key: 'data-processing-corpus',
        icon: <TagsOutlined />,
      },
      {
        label: '语料生产服务',
        key: '_blank',
        type: 'link',
        icon: <FilterOutlined />,
      },
      {
        label: '可视化工作流',
        key: 'data-processing-workflow',
        icon: <FilterOutlined />,
      },
    ],
  },
  {
    label: '特色工具',
    key: 'feature-tools',
    children: [
      {
        label: '网页语料生产',
        key: 'feature-tools-web',
        icon: <TagsOutlined />,
      },
      {
        label: '文档语料生产',
        key: 'feature-tools-doc',
        icon: <TagsOutlined />,
      },
    ],
  },
  {
    label: '数据标注',
    key: 'data-labeling',
    children: [
      {
        label: '文档标注',
        key: 'data-labeling-doc',
        icon: <TagsOutlined />,
      },
    ],
  },
  {
    label: '模型服务',
    key: 'model-service',
    children: [
      {
        label: '模型训练',
        key: 'model-service-train',
        icon: <FilterOutlined />,
      },
      {
        label: '模型部署',
        key: 'model-service-deploy',
        icon: <FilterOutlined />,
      },
      {
        label: '模型管理',
        key: 'model-service-manage',
        icon: <FilterOutlined />,
      },
    ],
  },
];
export default function Demo() {
  const [collapsed, setCollapsed] = useState(false);
  const [selectedKeys, setSelectedKeys] = useState<string[]>(['data-management-dataset']);

  const {
    token: { colorBgContainer, borderRadiusLG },
  } = theme.useToken();

  const handleMenuClick = (item: MenuItem) => {
    console.log('当前点击的菜单', item);
    const key = item.key;
    setSelectedKeys([key]);
  };

  const handleCollapse = (collapsed: boolean) => {
    setCollapsed(collapsed);
    console.log('当前菜单是否收起', collapsed);
  };

  return (
    <Layout style={{ minHeight: '100vh' }}>
      <Menu
        menus={menus}
        onClick={handleMenuClick}
        onCollapse={handleCollapse}
        collapsed={collapsed}
        selectedKeys={selectedKeys}
      />
      <Layout>
        <Content
          style={{
            margin: '24px 16px',
            padding: 24,
            minHeight: 280,
            background: colorBgContainer,
            borderRadius: borderRadiusLG,
          }}
        >
          <p>当前选中的菜单: {selectedKeys || '无'}</p>
          <p>菜单状态: {collapsed ? '收起' : '展开'}</p>
          <p>右侧内容区域，会根据菜单的展开/收起自动调整宽度</p>
        </Content>
      </Layout>
    </Layout>
  );
}
```
### API

#### Menu

| 参数          | 说明                    | 类型                              | 默认值     | 必须 |
| ----------- | --------------------- | ------------------------------- | ------- | -- |
| menus       | 菜单配置数据，支持一级分组 + 二级子菜单 | `MenuItem[]`                    | -       | 是  |
| collapsed   | 侧边栏是否折叠（受控属性）         | `boolean`                       | `false` | 否  |
| onCollapse  | 折叠状态切换的回调函数           | `(collapsed: boolean) => void`  | -       | 否  |
| onClick     | 菜单点击时的回调函数            | `(item: MenuItem) => void`      | -       | 否  |
| onSelect    | 菜单选中项变化的回调函数          | `(key: string \| null) => void` | -       | 否  |

### 接口定义

#### MenuType 类型

菜单类型枚举

| 取值        | 说明             |
| --------- | -------------- |
| `default` | 默认路由菜单（点击跳转路由） |
| `link`    | 外链菜单（点击不跳转路由）  |

#### MenuItem 接口

菜单项配置接口

| 属性         | 说明            | 类型                | 必须             |
| ---------- | ------------- | ----------------- | -------------- |
| label      | 菜单显示文本        | `string`          | 是              |
| key        | 菜单唯一标识/路由跳转路径 | `string`          | 是              |
| type       | 菜单类型          | `MenuType`        | 否（默认`default`） |
| icon       | 菜单默认图标        | `React.ReactNode` | 否              |
| activeicon | 菜单激活态图标（预留）   | `React.ReactNode` | 否              |
| children   | 二级子菜单列表       | `MenuItem[]`      | 否              |

### 使用说明

#### 1. 基础用法（路由菜单）

```tsx
import Menu from '@/components/menu';
import { HomeOutlined, SettingOutlined } from '@ant-design/icons';

// 重要：组件仅生效于菜单第二层级，一级菜单仅作为分组，不支持点击、激活等交互
const menus = [
  {
    label: '数据管理',
    children: [
      { label: '用户管理', key: '/data/user', icon: <SettingOutlined /> },
      { label: '角色管理', key: '/data/role', icon: <SettingOutlined /> },
    ],
  },
  {
    label: '系统管理',
    children: [
      { label: '用户管理', key: '/system/user', icon: <SettingOutlined /> },
      { label: '角色管理', key: '/system/role', icon: <SettingOutlined /> },
    ],
  },
];

const App = () => {
  return <Menu menus={menus} />;
};
```

#### 2. 受控折叠用法

```tsx
import { useState } from 'react';
import Menu from '@/components/menu';

const App = () => {
  const [collapsed, setCollapsed] = useState(false);

  return (
    <Menu
      menus={menus}
      collapsed={collapsed}
      onCollapse={(val) => setCollapsed(val)}
    />
  );
};
```

#### 3. 外链菜单用法

```tsx
const menus = [
  {
    label: '帮助中心',
    // 外链菜单必须配置 type="link"，否则会触发路由跳转
    key: 'https://www.xxx.com/help',
    type: 'link', 
  },
];

const handleMenuClick = (item) => {
  if (item.type === 'link') {
    window.open(item.key, '_blank');
  }
};

// 外链菜单无激活状态，需通过 onClick 回调自行处理跳转逻辑
<Menu menus={menus} onClick={handleMenuClick} />;
```

#### 4. 完整回调用法

```tsx
const App = () => {
  const handleMenuClick = (item) => {
    console.log('点击菜单：', item);
  };

  const handleSelect = (key) => {
    console.log('选中菜单key：', key);
  };

  return (
    <Menu
      menus={menus}
      onClick={handleMenuClick}
      onSelect={handleSelect}
    />
  );
};
```

### 注意事项

1. 组件仅对菜单第二层级生效，一级菜单仅作为分组展示，配置 key、icon、路由等属性均无效
2. 外链菜单必须显式配置 type='link'，配置后点击不会触发内部路由跳转
3. 外链菜单无激活高亮状态，仅作为普通点击项使用
4. 组件需配合 Ant Design Layout 布局使用，默认支持响应式布局，适配不同屏幕尺寸

---

## model-card

**导入方式：**

```ts
import { ModelCard } from '@zjlab-fe/data-hub-ui';
```
```ts
// 类型导入
import type { ModelCardProps } from '@zjlab-fe/data-hub-ui';
```

用于展示模型信息，包括模型名称、提供方、描述、标签、支持训练、支持部署、支持评测、图标、评分、操作按钮等。

### 代码演示

#### 示例

展示模型信息

**示例代码（index.tsx）：**

```tsx
import ModelCard from '../index';

const cardList = [
  {
    id: 1,
    name: '模型1',
    provider: '模型提供方1',
    description: '这是一个模型1的描述',
    tags: ['标签1', '标签2'],
    supportTraining: true,
    supportDeployment: true,
    supportEvaluation: true,
    image: 'https://haina-datahub.zero2x.org/ossRoute/frontend/resources/modelicon/default.png',
    stars: 4,
    onTrain: () => {},
    onDeploy: () => {},
    onEvaluate: () => {},
    onExperience: () => {},
  },
  {
    id: 2,
    name: '模型2',
    provider: '模型提供方2',
    description: '这是一个模型2的描述',
    tags: ['标签3', '标签4'],
    supportTraining: true,
    supportDeployment: true,
    supportEvaluation: true,
    image: 'https://haina-datahub.zero2x.org/ossRoute/frontend/resources/modelicon/default.png',
    stars: 3,
    onTrain: () => {},
    onDeploy: () => {},
    onEvaluate: () => {},
    onExperience: () => {},
  },
];

export default function Demo() {
  return (
    <div>
      {cardList.map((item) => (
        <ModelCard key={item.id} {...item} />
      ))}
    </div>
  );
}
```
### API

| 参数 | 说明 | 类型 | 默认值 | 必须 |
| --- | --- | --- | --- | --- |
| `id` | number | 主键ID | 是 |
| `name` | string | 模型名称 | 是 |
| `provider` | string | 提供方 | 是 |
| `description` | string | 模型描述（一句话） | 是 |
| `tags` | string[] | 模型标签 | 是 |
| `supportTraining` | Boolean | 是否支持模型训练 | 是 |
| `supportDeployment` | Boolean | 是否支持模型部署 | 是 |
| `supportEvaluation` | Boolean | 是否支持模型评测 | 是 |
| `image` | string | 模型图标URL | 否 |
| `stars` | number | 模型评分 | 是 |
| `onTrain` | () => void | 模型训练操作回调 | 是 |
| `onDeploy` | () => void | 模型部署操作回调 | 是 |
| `onEvaluate` | () => void | 模型评测操作回调 | 是 |
| `onExperience` | () => void | 模型体验操作回调 | 是 |

---

## notion-editor

Notion 风格的富文本编辑器，基于 BlockNote 实现。支持标题、段落、图片、文件、表格、待办列表、代码块等多种内容类型，支持 Markdown/HTML 粘贴，适用于文档编辑、知识库内容录入等场景。

### 代码演示

#### 基础用法

支持图片/文件上传、初始化内容、获取/重置内容

**示例代码（index.tsx）：**

```tsx
import React, { useRef } from 'react';
import NotionEditor, { NotionEditorHandle, Block, PartialBlock } from '../index';
import axios from 'axios';
import { Button, Flex } from 'antd';
export default function NotionEditorDemo() {
  const editorRef = useRef<NotionEditorHandle>(null);
  const onFileUpload: (file: File) => Promise<string> = async (file) => {
    // 1. 获取预签名URL
    const { data } = await axios.get('/api/fes/docs/resource/getUploadUrl', {
      params: { id: 'c7fe927b-0045-4b36-8b3e-e16259876e18', fileName: file.name },
      headers: {
        Authorization:
          'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGFpbXMiOnsiYWNjb3VudF90eXBlIjoiMSIsInVzZXJfaWQiOiIxODIxMDA0MTkyMzI4ODQ3MzYwIiwidXNlcl9uYW1lIjoid21zdGVzdDEiLCJhY2NvdW50X3N0YXR1cyI6IjAifSwiZXhwIjoxNzgxMTYxNjk0fQ.E7zDJ_piFjAHeCfyq9lkudHyoP6LFinYdsSCbAlJy2k',
      },
    });
    const { uploadUrl, fileUrl } = data.data;

    // 2. 上传
    await axios.put(uploadUrl, file, {
      headers: { 'Content-Type': file.type || 'application/octet-stream' },
    });

    return fileUrl;
  };

  const onImageUploadByUrl: (url: string) => Promise<string> = async (url) => {
    const { data } = await axios.post(
      '/api/fes/docs/resource/uploadImageByUrl',
      {
        imageUrl: url,
        id: 'c7fe927b-0045-4b36-8b3e-e16259876e18',
      },
      {
        headers: {
          Authorization:
            'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGFpbXMiOnsiYWNjb3VudF90eXBlIjoiMSIsInVzZXJfaWQiOiIxODIxMDA0MTkyMzI4ODQ3MzYwIiwidXNlcl9uYW1lIjoid21zdGVzdDEiLCJhY2NvdW50X3N0YXR1cyI6IjAifSwiZXhwIjoxNzgxMTYxNjk0fQ.E7zDJ_piFjAHeCfyq9lkudHyoP6LFinYdsSCbAlJy2k',
        },
      },
    );
    return data?.data?.url || data?.url || (typeof data?.data === 'string' ? data.data : '');
  };

  const onChange = (content: Block[]) => {
    console.log('+++ content:', content);
  };

  const initContent: PartialBlock[] = [
    {
      id: '088a4484-f40b-4c17-92b2-b7c7e89d8d06',
      type: 'heading',
      props: {
        backgroundColor: 'default',
        textColor: 'default',
        textAlignment: 'left',
        level: 1,
        isToggleable: false,
      },
      content: [
        {
          type: 'text',
          text: '这是标题',
          styles: {},
        },
      ],
      children: [],
    },
    {
      id: '90dd94e1-a217-4dd3-9406-d225e3ab3a38',
      type: 'paragraph',
      props: {
        backgroundColor: 'default',
        textColor: 'default',
        textAlignment: 'left',
      },
      content: [
        {
          type: 'text',
          text: '这是内容',
          styles: {},
        },
      ],
      children: [],
    },
    {
      id: '0e537fae-38d8-4d47-b645-bcdea812f04a',
      type: 'image',
      props: {
        textAlignment: 'left',
        backgroundColor: 'default',
        name: 'Snipaste_2025-12-11_10-50-04.png',
        url: 'https://haina-datahub.zero2x.org/ossRoute/frontend/resources/public/attachments/test/0d4169ec-390e-482f-b367-75abdb1ad310.png',
        caption: '',
        showPreview: true,
      },
      children: [],
    },
    {
      id: 'd029cee5-443a-4422-b3b8-f7fef48f8aa4',
      type: 'file',
      props: {
        name: '立体化学-0.pdf',
        url: 'https://haina-datahub.zero2x.org/ossRoute/frontend/resources/public/attachments/test/11a32231-887e-4610-91dc-83bcd78224b0.pdf',
        caption: '',
        backgroundColor: 'default',
      },
      children: [],
    },
    {
      id: 'e0b7af21-7bc8-4e04-8959-c217673e1b95',
      type: 'paragraph',
      props: {
        backgroundColor: 'default',
        textColor: 'default',
        textAlignment: 'left',
      },
      content: [],
      children: [],
    },
  ];
  return (
    <div style={{ height: '800px', overflow: 'auto' }}>
      <Flex gap={24} style={{ padding: 24 }}>
        <Button
          type="primary"
          onClick={() => {
            console.log(editorRef.current?.getContent());
          }}
        >
          获取内容(查看控制台)
        </Button>
        <Button
          type="primary"
          onClick={() => {
            console.log(
              editorRef.current?.setContent([
                {
                  id: '088a4484-f40b-4c17-92b2-b7c7e89d8d06',
                  type: 'heading',
                  props: {
                    backgroundColor: 'default',
                    textColor: 'default',
                    textAlignment: 'left',
                    level: 1,
                    isToggleable: false,
                  },
                  content: [
                    {
                      type: 'text',
                      text: '这是重置后的标题',
                      styles: {},
                    },
                  ],
                  children: [],
                },
                {
                  id: '90dd94e1-a217-4dd3-9406-d225e3ab3a38',
                  type: 'paragraph',
                  props: {
                    backgroundColor: 'default',
                    textColor: 'default',
                    textAlignment: 'left',
                  },
                  content: [
                    {
                      type: 'text',
                      text: '这是重置后的内容',
                      styles: {},
                    },
                  ],
                  children: [],
                },
                {
                  id: 'd029cee5-443a-4422-b3b8-f7fef48f8aa4',
                  type: 'paragraph',
                  props: {
                    backgroundColor: 'default',
                    textColor: 'default',
                    textAlignment: 'left',
                  },
                  content: [],
                  children: [],
                },
              ]),
            );
          }}
        >
          重置内容
        </Button>
      </Flex>

      <NotionEditor
        ref={editorRef}
        docId="test"
        onImageUpload={onFileUpload}
        onImageUploadByUrl={onImageUploadByUrl}
        onFileUpload={onFileUpload}
        initContent={initContent}
        editable
        onChange={onChange}
      />
    </div>
  );
}
```
### API

#### NotionEditorHandle

| 方法 | 说明 | 类型 |
| --- | --- | --- |
| getContent | 获取编辑器当前所有 block 内容 | `() => Block[]` |
| setContent | 设置编辑器内容（替换全部） | `(content: Block[]) => void` |
| focus | 聚焦编辑器 | `() => void` |
| isEmpty | 编辑器是否为空 | `() => boolean` |

#### NotionEditorProps

| 参数 | 说明 | 类型 | 默认值 | 版本 | 必须 |
| --- | --- | --- | --- | --- | --- |
| initContent | 初始内容 | `PartialBlock[]` | - | | |
| editable | 是否可编辑 | `boolean` | `false` | | |
| className | 自定义类名 | `string` | `''` | | |
| docId | 粘贴 copy JSON 时用于替换附件 URL 中文档 ID 的值 | `string` | - | | |
| onChange | 编辑器内容变化时的回调 | `(blocks: Block[]) => void` | - | | |
| onImageUpload | 图片上传处理函数，返回图片访问 URL | `(file: File) => Promise<string>` | - | | |
| onImageUploadByUrl | 通过 URL 上传图片的处理函数（用于解析 Markdown 中的图片链接） | `(url: string) => Promise<string>` | - | | |
| onFileUpload | 文件上传处理函数，返回文件访问 URL | `(file: File) => Promise<string>` | - | | |

#### 注意

- `onImageUpload` 和 `onFileUpload` 未传入时，上传的文件会使用 `URL.createObjectURL` 生成本地临时 URL
- 编辑器内置中文语言包，粘贴时自动识别 Markdown 或 HTML 格式
- 自定义文件块渲染，点击文件块可在新窗口打开文件
- 支持表格单元格拆分（`splitCells: true`）

---

## operator-chain（卡片式操作链）

**导入方式：**

```ts
import { OperatorChain } from '@zjlab-fe/data-hub-ui';
```
```ts
// 类型导入
import type { OperatorChainProps, SingleCardItem, OperatorChainRef } from '@zjlab-fe/data-hub-ui';
```

用于展示和管理算子流程的链式组件
支持数据导入导出节点
支持是否可编辑模式
符合团队样式规范
不支持样式定制

### 代码演示

**示例代码（index.tsx）：**

```tsx
import { Button, Flex, message } from 'antd';
import OperatorChain, { OperatorChainRef, SingleCardItem } from '../index';
import { useRef } from 'react';

const cards = [
  {
    id: '1',
    title: 'Operator 1',
    description:
      '这里随便写一句长文本，测试一下是否能正常显示，要很长很长很长，超过2行，是否能正常显示省略号。',
  },
  {
    id: '2',
    title: 'Operator 2',
    description: 'Description 2',
  },
  {
    id: '3',
    title: 'Operator 3',
    description: 'Description 3',
  },
];
const memu = [
  {
    id: '1',
    title: 'Operator 1',
    description: 'Description 1',
    type: '数据清洗算子',
    typeId: 1,
  },
  {
    id: '2',
    title: 'Operator 2',
    description: 'Description 2',
    type: '特征提取算子',
    typeId: 2,
  },
  {
    id: '3',
    title: 'Operator 3',
    description: 'Description 3',
    type: '数据聚合算子',
    typeId: 3,
  },
  {
    id: '4',
    title: 'Operator 4',
    description: 'Description 4',
    type: '数据过滤算子',
    typeId: 4,
  },
  {
    id: '5',
    title: 'Operator 5',
    description: 'Description 5',
    type: '格式转换算子',
    typeId: 5,
  },
  {
    id: '6',
    title: 'Operator 6',
    description: 'Description 6',
    type: '数据清洗算子',
    typeId: 1,
  },
  {
    id: '7',
    title: 'Operator 7',
    description: 'Description 7',
    type: '特征提取算子',
    typeId: 2,
  },
  {
    id: '8',
    title: 'Operator 8',
    description: 'Description 8',
    type: '数据聚合算子',
    typeId: 3,
  },
  {
    id: '9',
    title: 'Operator 9',
    description: 'Description 9',
    type: '数据过滤算子',
    typeId: 4,
  },
  {
    id: '10',
    title: 'Operator 10',
    description: 'Description 10',
    type: '格式转换算子',
    typeId: 5,
  },
  {
    id: '11',
    title: 'Operator 11',
    description: 'Description 11',
    type: '数据清洗算子',
    typeId: 1,
  },
  {
    id: '12',
    title: 'Operator 12',
    description: 'Description 12',
    type: '特征提取算子',
    typeId: 2,
  },
  {
    id: '13',
    title: 'Operator 13',
    description: 'Description 13',
    type: '数据聚合算子',
    typeId: 3,
  },
  {
    id: '14',
    title: 'Operator 14',
    description: 'Description 14',
    type: '数据过滤算子',
    typeId: 4,
  },
  {
    id: '15',
    title: 'Operator 15',
    description: 'Description 15',
    type: '格式转换算子',
    typeId: 5,
  },
  {
    id: '16',
    title: 'Operator 16',
    description: 'Description 16',
    type: '数据清洗算子',
    typeId: 1,
  },
  {
    id: '17',
    title: 'Operator 17',
    description: 'Description 17',
    type: '特征提取算子',
    typeId: 2,
  },
  {
    id: '18',
    title: 'Operator 18',
    description: 'Description 18',
    type: '数据聚合算子',
    typeId: 3,
  },
  {
    id: '19',
    title: 'Operator 19',
    description: 'Description 19',
    type: '数据过滤算子',
    typeId: 4,
  },
  {
    id: '20',
    title: 'Operator 20',
    description: 'Description 20',
    type: '格式转换算子',
    typeId: 5,
  },
];

export default function Demo() {
  const chainRef = useRef<OperatorChainRef>(null);

  const handleClick = (id: string) => {
    message.info(`当前选择的卡片id: ${id}`);
  };

  const handleCardsChange = (latestCards: SingleCardItem[]) => {
    console.log('子组件最新的cards：', latestCards);
  };

  const handleActive = (id: string | null) => {
    console.log('子组件最新的激活卡片id：', id);
  };

  return (
    <div>
      <div style={{ display: 'flex', gap: 100, width: '100%' }}>
        <div>
          ### dataIO 模式 
          #### 输入数据集/ 输出数据集 卡片id 固定为 IMPORT/ EXPORT

          <OperatorChain
            type="dataIO"
            defaultValues={cards}
            menu={memu}
            onChange={handleCardsChange}
            onClick={handleClick}
            onActive={handleActive}
            isEdit={true}
            ref={chainRef} // 绑定ref
          />

          <Flex align="center" gap={5}>
            <Button type="primary" onClick={() => console.log('获取cards：', chainRef.current?.getCards())}>
              获取组件当前状态
            </Button>
            <Button
              type="primary"
              onClick={() => console.log('获取当前激活卡片id：', chainRef.current?.getActiveCardId())}
            >
              获取当前激活卡片id
            </Button>
          </Flex>
        </div>

        <div>
          ### 默认可编辑模式
          <OperatorChain
            defaultValues={cards}
            menu={memu}
            onChange={handleCardsChange}
            onClick={handleClick}
            isEdit={true}
          />
        </div>

        <div>
          ### 禁止编辑模式
          <OperatorChain defaultValues={cards} menu={memu} isEdit={false} />
        </div>
      </div>
    </div>
  );
}
```
### API

#### OperatorChain

| 参数 | 说明 | 类型 | 默认值 | 必须 |
| --- | --- | --- | --- | --- |
| type | 组件类型 | `'default' \| 'dataIO'` | `'default'` | 否 |
| isEdit | 是否处于编辑模式 | `boolean` | `false` | 否 |
| defaultValues | 默认卡片数据 | `SingleCardItem[]` | `[]` | 否 |
| menu | 可选的算子菜单 | `SingleCardItem[]` | `[]` | 否 |
| onClick | 点击卡片时的回调 | `(id: string) => void` | - | 否 |
| onChange | 卡片数据变化时的回调 | `(values: SingleCardItem[]) => void` | - | 否 |
| onActive | 激活卡片变化时的回调 | `(id: string \| null) => void` | - | 否 |


### 接口定义

#### SingleCardItem 接口

| 属性 | 说明 | 类型 | 必须 |
| --- | --- | --- | --- |
| id | 卡片唯一标识 | `string` | 是 |
| title | 卡片标题 | `string` | 否 |
| description | 卡片描述 | `string` | 否 |
| type | 算子类型 | `string` | 否 |
| typeId | 算子类型id | `number` | 否 |

#### OperatorChainRef 接口

| 属性 | 说明 | 类型 |
| --- | --- | --- |
| getCards | 获取当前所有卡片数据 | `() => SingleCardItem[]` |
| getActiveCardId | 获取当前激活卡片id | `() => string \| null` |

### 使用说明

#### 1. 基础用法（dataIO 模式）

```tsx
import { OperatorChain } from '@/components/operator-chain';

const defaultValues = [
  { id: '1', title: '数据清洗', description: '清洗原始数据' ,type: '数据处理', typeId: 1},
  { id: '2', title: '数据转换', description: '转换数据格式' ,type: '数据转换', typeId: 2},
  { id: '3', title: '数据聚合', description: '聚合统计数据' ,type: '数据聚合', typeId: 3},
];

<OperatorChain 
  defaultValues={defaultValues}
  onClick={(id) => console.log('点击了:', id)}
  onChange={(values) => console.log('当前数据:', values)}
/>
```

#### 2. 编辑模式用法

```tsx
import { OperatorChain } from '@/components/operator-chain';

const menu = [
  { id: 'filter', title: '数据过滤', description: '过滤符合条件的数据' },
  { id: 'transform', title: '数据转换', description: '转换数据格式' },
  { id: 'aggregate', title: '数据聚合', description: '聚合统计数据' },
];

<OperatorChain 
  isEdit={true}
  menu={menu}
  defaultValues={[{ id: '1', title: '初始算子', description: '默认算子' }]}
  onChange={(values) => console.log('当前数据:', values)}
/>
```

#### 3. 使用 ref 获取数据

```tsx
import { useRef } from 'react';
import { OperatorChain, OperatorChainRef } from '@/components/operator-chain';

const operatorRef = useRef<OperatorChainRef>(null);

const handleGetData = () => {
  const cards = operatorRef.current?.getCards();
  console.log('当前卡片数据:', cards);
};

<OperatorChain 
  ref={operatorRef}
  defaultValues={[{ id: '1', title: '示例算子' }]}
/>
```

#### 4. 自定义下拉菜单样式

```tsx
import { OperatorChain } from '@/components/operator-chain';

const menu = [
  { id: 'filter1', title: '数据过滤1', description: '过滤符合条件的数据', type: '数据处理', typeId: 1 },
  { id: 'filter2', title: '数据过滤2', description: '过滤符合条件的数据', type: '数据处理', typeId: 1 },
  { id: 'transform1', title: '数据转换1', description: '转换数据格式', type: '数据转换', typeId: 2 },
  { id: 'transform2', title: '数据转换2', description: '转换数据格式', type: '数据转换', typeId: 2 },
  { id: 'aggregate1', title: '数据聚合1', description: '聚合统计数据', type: '数据聚合', typeId: 3 },
];

<OperatorChain 
  isEdit={true}
  menu={menu}
  dropdownStyle={{
    width: '250px',
    maxHeight: '300px',
    overflowY: 'auto'
  }}
  defaultValues={[{ id: '1', title: '初始算子', description: '默认算子' }]}
/>
```

#### 5. 使用带有类型信息的菜单数据

```tsx
import { OperatorChain } from '@/components/operator-chain';

const menu = [
  { id: 'filter1', title: '数据过滤1', description: '过滤符合条件的数据', type: '数据处理', typeId: 1 },
  { id: 'filter2', title: '数据过滤2', description: '过滤符合条件的数据', type: '数据处理', typeId: 1 },
  { id: 'transform1', title: '数据转换1', description: '转换数据格式', type: '数据转换', typeId: 2 },
  { id: 'transform2', title: '数据转换2', description: '转换数据格式', type: '数据转换', typeId: 2 },
  { id: 'aggregate1', title: '数据聚合1', description: '聚合统计数据', type: '数据聚合', typeId: 3 },
];

<OperatorChain 
  isEdit={true}
  menu={menu}
  defaultValues={[{ id: '1', title: '初始算子', description: '默认算子' }]}
/>
```

### 注意事项

1. `dataIO` 模式下，组件会自动在首尾添加 `IMPORT` 和 `EXPORT` 节点
2. `default` 模式下，不会自动添加导入导出节点
3. 编辑模式下需要传入 `menu` 属性才能使用添加功能
4. 非编辑模式下，添加按钮和删除按钮不会显示
5. 当 `menu` 数据中包含 `type` 和 `typeId` 字段时，下拉菜单会显示类型筛选功能
6. 下拉菜单支持按算子类型和标题进行搜索过滤

---

## permission-editor（权限编辑器）

**导入方式：**

```ts
import { PermissionEditor, PermissionEditModal, PermissionViewPopover } from '@zjlab-fe/data-hub-ui';
```

一个用于编辑用户权限的 React 组件，支持分层级的权限选择，包含父级权限和子级权限的联动逻辑。

### 功能特性

- 🏷️ **分层级权限管理**：支持父子级权限结构
- 🔒 **只读权限控制**：部分权限可设为只读状态
- 🔄 **智能联动**：父子级权限自动联动选择
- ✏️ **可编辑控制**：支持整体编辑状态的控制
- 📱 **响应式布局**：自适应不同屏幕尺寸

### 使用方法

#### 基础用法

```tsx
import React, { useState } from 'react';
import PermissionEditor from './components/permission-editor/permissionEditor';

const App = () => {
  const [permissions, setPermissions] = useState([
    {
      module: 'workbench',
      grentedList: ['1', '3'], // 部分子权限被选中
    },
    {
      module: 'agentic',
      grentedList: ['13'], 
    },
  ]);

  const handlePermissionChange = (newPermissions) => {
    console.log('权限变更:', newPermissions);
    setPermissions(newPermissions);
  };

  return (
    <div style={{ padding: '20px' }}>
      ## 用户权限设置
      <PermissionEditor
        initialPermissions={permissions}
        onChange={handlePermissionChange}
        editable={true}
      />
    </div>
  );
};

export default App;
```

#### 只读模式

```tsx
<PermissionEditor
  initialPermissions={userPermissions}
  editable={false}
/>
```
### 代码演示

**示例代码（index.tsx）：**

```tsx
import PermissionEditor from '@/components/permission-editor';
import { PermissionEditModal, PermissionViewPopover } from '@/components/permission-editor';
import React, { useState } from 'react';
import { Button } from 'antd';

export default function Demo() {
  const [permissionEditShow, setPermissionEditShow] = useState<boolean>(false);

  return (
    <>
      <div style={{ marginBottom: 30 }}>
        ### 权限编辑(PermissionEditor)
        <div style={{ width: 500, border: '1px solid #171313', marginBottom: 10, padding: 10 }}>
          <PermissionEditor
            initialPermissions={[
              {
                module: 'agentic',
                grentedList: ['13'],
              },
              {
                module: 'workbench',
                grentedList: ['1', '3'],
              },
            ]}
            onChange={(permissions) => {
              console.log('selected permissions: ', permissions);
            }}
          />
        </div>
      </div>
      <div style={{ marginBottom: 30 }}>
        ### 权限查看气泡卡片(PermissionViewPopover)
        <PermissionViewPopover
          initialPermissions={[
            {
              module: 'workbench',
              grentedList: ['1', '3'],
            },
          ]}
        >
          <Button type="primary">Hover me</Button>
        </PermissionViewPopover>
      </div>
      <div style={{ marginBottom: 30 }}>
        ### 权限编辑弹窗(PermissionEditModal)
        <Button type="primary" onClick={() => setPermissionEditShow(true)}>
          Click me
        </Button>
      </div>
      <PermissionEditModal
        open={permissionEditShow}
        onCancel={() => {
          setPermissionEditShow(false);
        }}
        onSave={(permissions) => {
          console.log('saved permissions: ', permissions);
          setPermissionEditShow(false);
        }}
        initialPermissions={[
          {
            module: 'agentic',
            grentedList: [],
          },
          {
            module: 'workbench',
            grentedList: ['1', '3'],
          },
        ]}
      />
    </>
  );
}
```
### API

#### Props

| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `initialPermissions` | `PermissionModule[]` | `[]` | 初始权限数据 |
| `onChange` | `(permissions: PermissionModule[]) => void` | - | 权限变化时的回调函数 |
| `editable` | `boolean` | `true` | 是否可编辑，为 `false` 时组件为只读状态 |

### 🗂 数据结构

#### PermissionModule 接口

```typescript
interface PermissionModule {
  module: string;        // 权限模块标识符
  grentedList: string[]; // 已授权的子权限列表
}
```

#### 权限配置结构
如需修改页面中的权限内容，如新增或删减，请修改以下配置内容
```typescript
const permissionTags = [
  {
    label: '智能体权限',    // 显示名称
    value: 'agentic',      // 权限标识符
    readOnly: false,       // 是否只读
    children: [],          // 子权限（空数组表示无子权限）
  },
  {
    label: '工作台权限',
    value: 'workbench',
    readOnly: false,
    children: [
      {
        label: '可视化工作流',
        value: '1',
        readOnly: false,
      },
      {
        label: 'Notebook',
        value: '2',
        readOnly: true,     // 只读权限，用户无法修改
      },
      // ... 更多子权限
    ],
  },
];
```

#### 数据示例

```typescript
// 输入数据格式
const initialPermissions = [
  {
    module: 'workbench',
    grentedList: ['1', '3', '4'], // 选中了部分子权限
  },
  {
    module: 'agentic',
    grentedList: [], // 无子权限的模块，父级被选中
  },
];

// 输出数据格式（onChange 回调）
const outputPermissions = [
  {
    module: 'workbench',
    grentedList: ['1', '3', '4', '5'], // 新增了权限 '5'
  },
  {
    module: 'agentic',
    grentedList: [], // 依然保持为无子权限状态
  },
];
```

### 🔄 交互逻辑

#### 父子级联动规则

1. **选中父级权限**：
   - 自动选中所有非只读的子级权限
   - 只读子权限保持原状态不变

2. **取消父级权限**：
   - 自动取消所有子级权限（包括只读）
   - 该模块从权限列表中移除

3. **选中子级权限**：
   - 如果所有可选子级都被选中，自动选中父级
   - 如果部分子级被选中，父级保持未选中状态

4. **取消子级权限**：
   - 如果父级原本被选中，自动取消父级选中状态
   - 如果所有子级都被取消，该模块从权限列表中移除

### 相关组件

#### PermissionEditModal 权限编辑弹窗

基于 PermissionEditor 构建的模态框组件，提供完整的权限编辑界面。

##### 功能特性

- 📋 **模态框形式**：以弹窗方式展示权限编辑器
- 💾 **确认/取消操作**：提供保存和取消按钮
- 🔄 **状态管理**：内置权限变更的状态管理

##### 使用方法

```tsx
import React, { useState } from 'react';
import { Button } from 'antd';
import PermissionEditModal from '../permissionEditModal';

const UserManagement = () => {
  const [modalVisible, setModalVisible] = useState(false);
  const [userPermissions, setUserPermissions] = useState([
    {
      module: 'workbench',
      grentedList: ['1', '3'],
    }
  ]);

  const handleSave = (permissions) => {
    setUserPermissions(permissions);
    setModalVisible(false);
    console.log('保存权限:', permissions);
  };

  const handleCancel = () => {
    setModalVisible(false);
  };

  return (
    <>
      <Button onClick={() => setModalVisible(true)}>
        编辑权限
      </Button>
      
      <PermissionEditModal
        open={modalVisible}
        initialPermissions={userPermissions}
        onSave={handleSave}
        onCancel={handleCancel}
      />
    </>
  );
};
```

##### API

| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `open` | `boolean` | `false` | 控制弹窗显示/隐藏 |
| `initialPermissions` | `PermissionModule[]` | `[]` | 初始权限列表 |
| `onSave` | `(permissions: PermissionModule[]) => void` | - | 保存权限时的回调 |
| `onCancel` | `() => void` | - | 取消编辑时的回调 |

---

#### PermissionViewPopover 权限查看气泡卡片

用于展示用户权限的轻量级气泡组件，支持快速查看权限详情。

##### 功能特性

- 👁️ **只读展示**：以只读模式展示用户权限
- 🎈 **气泡形式**：轻量级的气泡卡片展示
- 📱 **紧凑布局**：适合在列表、表格中使用

##### 使用方法

```tsx
import React from 'react';
import { Button, Tag } from 'antd';
import PermissionViewPopover from '../permissionViewPopover';

const UserList = () => {
  const userPermissions = [
    {
      module: 'workbench',
      grentedList: ['1', '3', '4'],
    },
    {
      module: 'agentic',
      grentedList: [],
    }
  ];

  return (
    <div>
      {/* 按钮触发方式 */}
      <PermissionViewPopover permissions={userPermissions}>
        <Button type="link">查看权限</Button>
      </PermissionViewPopover>

      {/* 标签触发方式 */}
      <PermissionViewPopover permissions={userPermissions}>
        <Tag color="blue">权限详情</Tag>
      </PermissionViewPopover>
    </div>
  );
};
```

##### API

| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `initialPermissions` | `PermissionModule[]` | `[]` | 要展示的权限列表 |
| `children` | `React.ReactNode` | - | 触发元素 |
| `title` | `string` | `"权限详情"` | 气泡标题 |

---

## popover-select（带描述浮层下拉框）

**导入方式：**

```ts
import { PopoverSelect } from '@zjlab-fe/data-hub-ui';
```

使用：`import { PopoverSelect } from '@zjlab-fe/data-hub-ui';` 



### 代码演示 


**示例代码（index.tsx）：**

```tsx
import React from 'react';
import PopoverSelect from '../index';
import { Form, Button } from 'antd';

const mockData = [
  {
    value: '24',
    label: 'EmmaE',
    desc: (
      <div>
        ### 产品设计部门
        <p>产品设计部门负责用户界面设计，确保产品符合用户需求和使用习惯。</p>
        <span>联系电话：13800000000</span>
      </div>
    ),
  },
  { value: '56', label: 'Liam', desc: '后端开发工程师，擅长Java技术栈' },
  { value: '18', label: 'Olivia', desc: '市场部专员，负责品牌推广' },
  { value: '73', label: 'Noah', desc: '数据分析师，专注用户行为研究' },
  { value: '39', label: 'Ava', desc: '人力资源主管，负责招聘与培训' },
  { value: '82', label: 'Elijah', desc: '前端开发，精通React框架' },
  { value: '45', label: 'Sophia', desc: '内容编辑，擅长文案创作' },
  { value: '67', label: 'Oliver', desc: 'DevOps工程师，负责系统部署' },
  { value: '29', label: 'Isabella', desc: '客服经理，客户关系维护' },
  { value: '91', label: 'William', desc: '产品经理，主导产品路线规划' },
  { value: '53', label: 'Mia', desc: '财务专员，负责账目管理' },
  { value: '37', label: 'James', desc: 'UI设计师，专注用户体验优化' },
  { value: '78', label: 'Charlotte', desc: '测试工程师，保障产品质量' },
  { value: '61', label: 'Benjamin', desc: '运维工程师，系统稳定性保障' },
  { value: '42', label: 'Amelia', desc: '销售代表，负责客户拓展' },
  { value: '89', label: 'Lucas', desc: '数据库管理员，数据安全维护' },
  { value: '33', label: 'Harper', desc: '行政专员，办公室日常管理' },
  { value: '59', label: 'Alexander', desc: '算法工程师，优化搜索推荐' },
  { value: '74', label: 'Abigail', desc: '新媒体运营，社交媒体管理' },
  { value: '26', label: 'Ethan', desc: '架构师，系统架构设计' },
];

export default function Demo() {
  return (
    <>
      ### 基础使用
      <PopoverSelect options={mockData} />
      <br />
      ### 行内元素
      <PopoverSelect options={mockData} style={{ width: 400 }} inline />
      <PopoverSelect options={mockData} style={{ width: 400 }} inline />
      <br />
      ### 分组
      <PopoverSelect
        options={[
          {
            label: <span>manager</span>,
            title: 'manager',
            options: [
              { label: <span>Jack</span>, value: 'Jack', desc: 'manager' },
              { label: <span>Lucy</span>, value: 'Lucy', desc: 'manager' },
            ],
          },
          {
            label: <span>engineer</span>,
            title: 'engineer',
            options: [
              { label: <span>Chloe</span>, value: 'Chloe', desc: 'engineer' },
              { label: <span>Lucas</span>, value: 'Lucas', desc: 'engineer' },
            ],
          },
        ]}
        style={{ width: 400 }}
      />
      <br />
      ### 表单元素
      <Form>
        <Form.Item name="select" label="选择" rules={[{ required: true, message: '请选择' }]}>
          <PopoverSelect options={mockData} />
        </Form.Item>
        <Form.Item>
          <Button type="primary" htmlType="submit">
            提交
          </Button>
        </Form.Item>
      </Form>
    </>
  );
}
```
### API 


| 参数 | 说明 | 类型 | 默认值 | 版本 | 必须 | 
| --- | --- | --- | --- | --- | --- | 
| id | 绑定select根节点的id | `string` | - | - | - | 
| options | 选项数组，每个选项可以包含 desc 属性用于显示描述文本 | `Array<DefaultOptionType & { desc?: React.ReactNode }>` | - | - | ✓ | 
| inline | 是否显示为行内元素 | `boolean` | `false` | - | | 
| wrapperStyle | 包装器的样式 | `React.CSSProperties` | `{}` | - | | 
| wrapperClassName | 包装器的类名 | `string` | `''` | - | | 
| style | 选择框的样式 | `React.CSSProperties` | `{ width: 200 }` | - | | 
| 其他属性 | 支持原生 Select 组件的其他所有属性 (optionRender除外)| - | - | - | |

---

## radio-card（单选卡片）

**导入方式：**

```ts
import { RadioCard } from '@zjlab-fe/data-hub-ui';
```
```ts
// 类型导入
import type { RadioCardProps, RadioOption } from '@zjlab-fe/data-hub-ui';
```

对antd radio的二次封装
单选卡片
符合团队样式规范

### 代码演示

**示例代码（index.tsx）：**

```tsx
import { useState } from 'react';
import RadioCard from '../index';

function Home() {
  const [value, setValue] = useState<number>(1);
  const [value2, setValue2] = useState<number>(2);
  const [value3, setValue3] = useState<number>(2);

  const onChange = (value: number | string) => {
    console.log('radio checked', value);
    setValue(Number(value));
  };

  const onChange2 = (value: number | string) => {
    console.log('radio checked with content', value);
    setValue2(Number(value));
  };
  const onChange3 = (value: number | string) => {
    console.log('radio checked with mixed', value);
    setValue3(Number(value));
  };

  const options = [
    {
      value: 1,
      title: 'SFT 微调训练',
      desc: '有监督微调，增强方指令跟踪的能力，提供全参和高效训练方指令跟踪的能力',
    },
    {
      value: 2,
      title: 'CPT 继续预训练',
      desc: '通过无标注数据进行无监督继续预训练，强化或新增模型特定能力',
    },
    {
      value: 3,
      title: 'TEST 继续预训练',
      desc: '通过无标注数据进行无监督继续预训练，强化或新增模型特定能力',
    },
  ];

  const optionsWithContent = [
    {
      value: 1,
      content: (
        <div style={{ padding: '10px' }}>
          ### 自定义内容 1
          <p style={{ margin: '10px 0 0 0', fontSize: '12px', color: '#666' }}>
            这是一个完全自定义的内容区域，可以包含任何 React 组件
          </p>
          <div style={{ marginTop: '10px', display: 'flex', gap: '8px' }}>
            <span
              style={{ padding: '4px 8px', background: '#f0f0f0', borderRadius: '4px', fontSize: '12px' }}
            >
              Tag 1
            </span>
            <span
              style={{ padding: '4px 8px', background: '#f0f0f0', borderRadius: '4px', fontSize: '12px' }}
            >
              Tag 2
            </span>
          </div>
        </div>
      ),
    },
    {
      value: 2,
      content: (
        <div style={{ padding: '10px' }}>
          ### 自定义内容 2
          <p style={{ margin: '10px 0 0 0', fontSize: '12px', color: '#666' }}>
            第2个自定义内容，展示更复杂的布局
          </p>
          <div style={{ marginTop: '10px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
            <div
              style={{
                padding: '6px',
                background: '#f6ffed',
                border: '1px solid #b7eb8f',
                borderRadius: '4px',
              }}
            >
              <div style={{ fontSize: '10px', color: '#52c41a' }}>特性 A</div>
              <div style={{ fontSize: '14px', fontWeight: 'bold' }}>100%</div>
            </div>
            <div
              style={{
                padding: '6px',
                background: '#fff7e6',
                border: '1px solid #ffd591',
                borderRadius: '4px',
              }}
            >
              <div style={{ fontSize: '10px', color: '#fa8c16' }}>特性 B</div>
              <div style={{ fontSize: '14px', fontWeight: 'bold' }}>85%</div>
            </div>
          </div>
        </div>
      ),
    },
  ];

  return (
    <div style={{ padding: '20px' }}>
      ## 用法一：使用 title 和 desc及其内置样式
      <RadioCard options={options} value={value} onChange={onChange} />

      ## 用法二：使用 content传入ReactNode，自定义卡片
      <RadioCard options={optionsWithContent} value={value2} onChange={onChange2} />

      ## 用法三：混合使用（部分使用 content，部分使用 title/desc）
      <RadioCard
        options={[...options.slice(0, 1), ...optionsWithContent.slice(1), ...options.slice(2, 3)]}
        value={value3}
        onChange={onChange3}
      />
    </div>
  );
}

export default Home;
```
### API

#### RadioCard

| 参数 | 说明 | 类型 | 默认值 | 必须 |
| --- | --- | --- | --- | --- |
| options | 选项数组 | `RadioOption[]` | - | 是 |
| value | 当前选中的值 | `number \| string` | - | 否 |
| onChange | 值变化时的回调函数 | `(value: number \| string) => void` | - | 否 |

### 接口定义

#### RadioOption 类型（联合类型）
`RadioOption` 是一个联合类型，必须是 `TitleRadioOption` 或 `ContentRadioOption` 中的一种：

```typescript
type RadioOption = TitleRadioOption | ContentRadioOption;
```

#### TitleRadioOption 接口（使用标题）
| 属性 | 说明 | 类型 | 必须 |
| --- | --- | --- | --- |
| value | 选项的值 | `number \| string` | 是 |
| title | 选项的标题 | `string` | 是 |
| desc | 选项的描述 | `string` | 否 |
| content | **禁止使用** | `never` | - |

#### ContentRadioOption 接口（使用自定义内容）
| 属性 | 说明 | 类型 | 必须 |
| --- | --- | --- | --- |
| value | 选项的值 | `number \| string` | 是 |
| content | 自定义内容 | `React.ReactNode` | 是 |
| desc | 选项的描述 | `string` | 否 |
| title | **禁止使用** | `never` | - |

### 使用说明

#### 1. 传统用法（使用 title 和 desc）
```tsx
import { RadioCard, TitleRadioOption } from '@/components/radio-card';

const options: TitleRadioOption[] = [
  {
    value: 1,
    title: '选项一',
    desc: '选项一的描述',
  },
  {
    value: 2,
    title: '选项二',
    desc: '选项二的描述',
  },
];

<RadioCard options={options} value={value} onChange={onChange} />
```

#### 2. 自定义内容用法（使用 content）
```tsx
import { RadioCard, ContentRadioOption } from '@/components/radio-card';

const options: ContentRadioOption[] = [
  {
    value: 1,
    content: (
      <div>
        ### 自定义标题
        <p>自定义描述内容</p>
      </div>
    ),
  },
  {
    value: 2,
    content: (
      <div>
        ### 另一个自定义标题
        <p>另一个自定义描述</p>
      </div>
    ),
  },
];

<RadioCard options={options} value={value} onChange={onChange} />
```

#### 3. 混合用法（TypeScript 自动推断）
```tsx
import { RadioCard } from '@/components/radio-card';

const options = [
  {
    value: 1,
    title: '传统选项',
    desc: '使用 title 和 desc',
  },
  {
    value: 2,
    content: <div>自定义内容选项</div>,
  },
] as const; 

<RadioCard options={options} value={value} onChange={onChange} />
```

### 注意事项

1. 每个选项必须提供 `title` 或 `content` 中的一个（不能两者都没有）
2. `title` 和 `content` 不能同时存在（互斥）

---

## section-heading（区块标题）

**导入方式：**

```ts
import { SectionHeading } from '@zjlab-fe/data-hub-ui';
```
```ts
// 类型导入
import type { SectionHeadingProps, SectionHeadingRootProps } from '@zjlab-fe/data-hub-ui';
```

用于页面区块标题（kicker + title + description）。

- 支持常规 props 方式
- 支持类似 shadcn/ui 的组合式用法（`SectionHeading.Root/Title/Description`）

### 代码演示

**示例代码（index.tsx）：**

```tsx
import React, { type JSX } from 'react';
import SectionHeading from '@/components/section-heading';
import { Card, RadioGroup } from '@/demo/shared';
import { ComparisonVsSemanticDom } from '../../../demo/shared/ComparisonVsSemanticDom';

type Align = 'left' | 'center' | 'right';
type Size = 'sm' | 'md' | 'lg';
type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4';

function Controls({
  align,
  size,
  titleAs,
  onChange,
}: {
  align: Align;
  size: Size;
  titleAs: HeadingTag;
  onChange: (next: { align?: Align; size?: Size; titleAs?: HeadingTag }) => void;
}) {
  return (
    <div style={{ display: 'grid', gap: 16 }}>
      <RadioGroup<Align>
        label="对齐方式 Align"
        value={align}
        options={[
          { value: 'left', label: 'left' },
          { value: 'center', label: 'center', badge: 'Default' },
          { value: 'right', label: 'right' },
        ]}
        onChange={(val) => onChange({ align: val })}
      />
      <RadioGroup<Size>
        label="尺寸 Size"
        value={size}
        options={[
          { value: 'sm', label: 'sm' },
          { value: 'md', label: 'md' },
          { value: 'lg', label: 'lg', badge: 'Default' },
        ]}
        onChange={(val) => onChange({ size: val })}
      />
      <RadioGroup<HeadingTag>
        label="标题标签 Title Tag"
        value={titleAs}
        options={[
          { value: 'h1', label: 'h1' },
          { value: 'h2', label: 'h2', badge: 'Default' },
          { value: 'h3', label: 'h3' },
          { value: 'h4', label: 'h4' },
        ]}
        onChange={(val) => onChange({ titleAs: val })}
      />
    </div>
  );
}

export default function Demo() {
  const [align, setAlign] = React.useState<Align>('center');
  const [size, setSize] = React.useState<Size>('lg');
  const [titleAs, setTitleAs] = React.useState<HeadingTag>('h2');

  const shared = {
    kicker: '你们呀，不要想搞个大新闻',
    mainTitle: 'Too young, too simple, sometimes naïve',
    description: '你们问我支不支持，我说支持也不行，不支持也不行',
  } as const;

  return (
    <div style={{ padding: 24, display: 'grid', gap: 24, maxWidth: 1280, margin: '0 auto' }}>
      {/* Interactive Props Playground */}
      <Card
        title={<span>Interactive Playground</span>}
        sourceCode={`<SectionHeading
  kicker="你们呀，不要想搞个大新闻"
  mainTitle="Too young, too simple, sometimes naïve"
  description="你们问我支不支持，我说支持也不行，不支持也不行"
  align="${align}"
  size="${size}"
  titleAs="${titleAs}"
/>`}
      >
        <div style={{ display: 'grid', gap: 16 }}>
          <Controls
            align={align}
            size={size}
            titleAs={titleAs}
            onChange={(next) => {
              if (next.align) setAlign(next.align);
              if (next.size) setSize(next.size);
              if (next.titleAs) setTitleAs(next.titleAs);
            }}
          />

          <SectionHeading
            kicker={shared.kicker}
            mainTitle={shared.mainTitle}
            description={shared.description}
            align={align}
            size={size}
            titleAs={titleAs as keyof JSX.IntrinsicElements}
          />
        </div>
      </Card>

      {/* Comparison Card */}
      <ComparisonVsSemanticDom />

      {/* Semantic Dom - Simple Props API */}
      <Card
        title={
          <div style={{ display: 'grid', gap: 4 }}>
            <span>Semantic Dom</span>
            <span style={{ fontSize: 14, fontWeight: 400, color: '#6b7280' }}>
              Simple and powerful. Use this when you don&apos;t need to customize the DOM structure.
            </span>
          </div>
        }
        sourceCode={`// With custom styles
<SectionHeading
  kicker="你们呀，不要想搞个大新闻"
  mainTitle="Too young, too simple, sometimes naïve"
  description="你们问我支不支持，我说支持也不行，不支持也不行"
  align="left"
  size="lg"
  titleAs="h2"
  styles={{
    root: {
      background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
      padding: '32px',
      borderRadius: '12px',
      color: 'white',
    },
    kicker: {
      color: '#fbbf24',
      fontWeight: 600,
      textTransform: 'uppercase',
      letterSpacing: '1px',
    },
    title: {
      color: 'white',
      textShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
    },
    description: {
      color: 'rgba(255, 255, 255, 0.9)',
      lineHeight: 1.6,
    },
  }}
/>`}
      >
        <SectionHeading
          kicker={shared.kicker}
          mainTitle={shared.mainTitle}
          description={shared.description}
          align="left"
          size="lg"
          titleAs="h2"
          styles={{
            root: {
              background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
              padding: '32px',
              borderRadius: '12px',
              color: 'white',
            },
            kicker: {
              color: '#fbbf24',
              fontWeight: 600,
              textTransform: 'uppercase',
              letterSpacing: '1px',
            },
            title: {
              color: 'white',
              textShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
            },
            description: {
              color: 'rgba(255, 255, 255, 0.9)',
              lineHeight: 1.6,
            },
          }}
        />
      </Card>

      {/* Composition - Flexible DOM Structure */}
      <Card
        title={
          <div style={{ display: 'grid', gap: 4 }}>
            <span>Composition</span>
            <span style={{ fontSize: 14, fontWeight: 400, color: '#6b7280' }}>
              Use this when you need to add extra DOM elements or customize the structure. Note: Semantic Dom
              and Composition cannot be used together — choose one approach.
            </span>
          </div>
        }
        sourceCode={`<SectionHeading.Root align="left" size="md">
  <SectionHeading.Kicker>你们呀，不要想搞个大新闻</SectionHeading.Kicker>
  {/* Extra DOM element between kicker and title */}
  <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
    <span style={{ fontSize: 16 }}>🎯</span>
    <span
      style={{
        fontSize: 11,
        padding: '2px 8px',
        background: '#dbeafe',
        color: '#1e40af',
        borderRadius: 12,
        fontWeight: 600,
      }}
    >
      NEW
    </span>
  </div>
  <SectionHeading.Title asChild>
    ### Too young, too simple, sometimes naïve
  </SectionHeading.Title>
  {/* Extra DOM element between title and description */}
  <hr style={{ margin: '12px 0', border: 'none', borderTop: '1px solid #e5e7eb' }} />
  <SectionHeading.Description>
    你们问我支不支持，我说支持也不行，不支持也不行
  </SectionHeading.Description>
</SectionHeading.Root>`}
      >
        <SectionHeading.Root align="left" size="md">
          <SectionHeading.Kicker>{shared.kicker}</SectionHeading.Kicker>
          {/* Extra DOM element between kicker and title */}
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
            <span style={{ fontSize: 16 }}>🎯</span>
            <span
              style={{
                fontSize: 11,
                padding: '2px 8px',
                background: '#dbeafe',
                color: '#1e40af',
                borderRadius: 12,
                fontWeight: 600,
              }}
            >
              NEW
            </span>
          </div>
          <SectionHeading.Title asChild>
            ### {shared.mainTitle}
          </SectionHeading.Title>
          {/* Extra DOM element between title and description */}
          <hr style={{ margin: '12px 0', border: 'none', borderTop: '1px solid #e5e7eb' }} />
          <SectionHeading.Description>{shared.description}</SectionHeading.Description>
        </SectionHeading.Root>
      </Card>
    </div>
  );
}
```
### API

#### SectionHeading (默认导出)

| 参数 | 说明 | 类型 | 默认值 | 必须 |
| --- | --- | --- | --- | --- |
| kicker | 上方小标题 | `React.ReactNode` | - | 否 |
| title | 主标题 | `React.ReactNode` | - | 否 |
| description | 描述 | `React.ReactNode` | - | 否 |
| align | 对齐方式 | `'left' \| 'center' \| 'right'` | `'left'` | 否 |
| size | 标题尺寸 | `'sm' \| 'md' \| 'lg'` | `'lg'` | 否 |
| titleAs | 标题标签 | `keyof JSX.IntrinsicElements` | `'h2'` | 否 |
| className | 自定义类名 | `string` | - | 否 |

#### 组合式子组件

- `SectionHeading.Root`
- `SectionHeading.Kicker`
- `SectionHeading.Title`
- `SectionHeading.Description`

子组件支持 `asChild`：传入自定义标签作为 children（需为单个 ReactElement）。

---

## status-tag（状态标签）

**导入方式：**

```ts
import { StatusTag } from '@zjlab-fe/data-hub-ui';
```

状态标签组件，用于展示运行、成功、失败等状态信息。支持四种视觉样式，覆盖表格、表单、卡片、标题栏等常见使用场景。

### 何时使用

- 需要在列表、表格中展示任务或数据状态
- 需要在表单、详情页中以标签形式展示状态
- 需要在卡片、面板中展示轻量状态标识
- 需要在标题栏中展示带圆点的状态标识

### 样式变体

`variant` 按标签自身的视觉属性命名，而非使用场景：

| variant | 说明 | 典型场景 |
| --- | --- | --- |
| `filled` | 浅色填充底，无边框 | 卡片、面板 |
| `bordered` | 带边框的标签 | 表单页、详情页 |
| `dot` | 圆点 + 文字，无背景 | 表格列 |
| `dotFilled` | 圆点 + 浅色填充底 + 描边，中性文字色 | 标题栏 |

### 状态类型

| type | 说明 |
| --- | --- |
| `running` | 进行中 |
| `success` | 成功 / 完成 |
| `failed` | 失败 |
| `stopped` | 已终止 / 已停止 |
| `waiting` | 等待中 / 排队中，视觉与 `stopped` 一致 |
| `warning` | 警告 / 冲突 |

### 代码演示

**示例代码（index.tsx）：**

```tsx
import StatusTag from '@/components/status-tag';

const statusList = [
  { type: 'running' as const, label: '过程中' },
  { type: 'success' as const, label: '完成' },
  { type: 'failed' as const, label: '失败' },
  { type: 'stopped' as const, label: '已终止' },
  { type: 'waiting' as const, label: '等待中' },
  { type: 'warning' as const, label: '冲突' },
];

const variantList = [
  { variant: 'dot' as const, title: '点状（表格中使用）' },
  { variant: 'bordered' as const, title: '带边框（表单页使用）' },
  { variant: 'filled' as const, title: '填充底（卡片中使用）' },
  { variant: 'dotFilled' as const, title: '圆点填充底（标题栏使用）' },
];

export default function Demo() {
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 24, padding: 24 }}>
      {variantList.map(({ variant, title }) => (
        <section key={variant}>
          #### {title}
          <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center' }}>
            {statusList.map(({ type, label }) => (
              <StatusTag key={`${variant}-${type}`} type={type} variant={variant}>
                {label}
              </StatusTag>
            ))}
          </div>
        </section>
      ))}
    </div>
  );
}
```
### API

#### StatusTag

| 参数 | 说明 | 类型 | 默认值 | 必须 |
| --- | --- | --- | --- | --- |
| type | 状态类型 | `'running' \| 'success' \| 'failed' \| 'stopped' \| 'waiting' \| 'warning'` | `'running'` | 否 |
| variant | 视觉样式 | `'filled' \| 'bordered' \| 'dot' \| 'dotFilled'` | `'filled'` | 否 |
| className | 自定义类名 | `string` | - | 否 |
| children | 标签文案 | `React.ReactNode` | - | 否 |

### 使用示例

```tsx
import { StatusTag } from '@zjlab-fe/data-hub-ui';

// 填充底（默认）
<StatusTag type="running">运行中</StatusTag>
<StatusTag type="success">运行成功</StatusTag>
<StatusTag type="failed">运行失败</StatusTag>
<StatusTag type="stopped">已终止</StatusTag>
<StatusTag type="waiting">等待中</StatusTag>
<StatusTag type="warning">冲突</StatusTag>

// 点状，适合表格列
<StatusTag type="success" variant="dot">完成</StatusTag>
<StatusTag type="failed" variant="dot">失败</StatusTag>

// 带边框，适合表单页
<StatusTag type="running" variant="bordered">过程中</StatusTag>
<StatusTag type="warning" variant="bordered">冲突</StatusTag>

// 圆点填充底，适合标题栏
<StatusTag type="running" variant="dotFilled">处理中</StatusTag>
<StatusTag type="waiting" variant="dotFilled">排队中</StatusTag>
```

### 注意事项

1. **文案自定义**：状态文案通过 `children` 传入，组件不内置文案映射，便于适配不同业务语义（如「运行中」/「过程中」）。
2. **样式预设**：颜色、背景、边框等样式由 `type` + `variant` 组合决定，无需手动传色值。
3. **默认样式**：未传 `variant` 时默认为 `filled`，与卡片场景的设计稿一致。
4. **waiting 与 stopped**：两种状态语义不同，但视觉样式保持一致。
5. **dotFilled 文字色**：标题栏样式下文字固定为中性色，状态差异体现在圆点和底色上。

---

## tag-group-filter（标签组过滤器）

**导入方式：**

```ts
import { TagGroupFilter } from '@zjlab-fe/data-hub-ui';
```

标签分组筛选组件，支持多组标签的分类筛选和数据体量范围筛选。

#### 特性

- **分组展示**：支持多个标签组的折叠/展开
- **多选筛选**：每组标签支持多选，独立管理选中状态
- **数据体量筛选**：内置数据大小范围筛选器（KB/MB/GB/TB）
- **智能布局**：集成 TagView 组件，自动处理标签溢出
- **清空功能**：支持快速清空单组或全部选中项
- **响应式折叠**：记住各组折叠状态，自动展开所有组

### 代码演示

**示例代码（index.tsx）：**

```tsx
import React, { useState } from 'react';
import TagGroupFilter from '@/components/tag-group-filter';
import { Card } from '@/demo/shared';

export default function Demo() {
  // 基础标签分组数据
  const [tagSelected1, setTagSelected1] = useState<Record<string, string[]>>({
    category: [],
    type: [],
  });

  const basicTagGroupDatas = [
    {
      key: 'category',
      title: '分类',
      children: [
        {
          children: [
            { value: 'ai', name: '人工智能' },
            { value: 'ml', name: '机器学习' },
            { value: 'dl', name: '深度学习' },
            { value: 'nlp', name: '自然语言处理' },
            { value: 'cv', name: '计算机视觉' },
            { value: 'dm', name: '数据挖掘' },
          ],
        },
      ],
    },
    {
      key: 'type',
      title: '类型',
      children: [
        {
          children: [
            { value: 'image', name: '图像' },
            { value: 'text', name: '文本' },
            { value: 'audio', name: '音频' },
            { value: 'video', name: '视频' },
            { value: 'structured', name: '结构化数据' },
          ],
        },
      ],
    },
  ];

  // 带子标题的标签分组数据
  const [tagSelected2, setTagSelected2] = useState<Record<string, string[]>>({
    domain: [],
    industry: [],
  });

  const subTitleTagGroupDatas = [
    {
      key: 'domain',
      title: '技术领域',
      children: [
        {
          subTitle: '热门领域',
          children: [
            { value: 'llm', name: '大语言模型' },
            { value: 'aigc', name: 'AIGC' },
            { value: 'multimodal', name: '多模态' },
          ],
        },
        {
          subTitle: '传统领域',
          children: [
            { value: 'search', name: '搜索引擎' },
            { value: 'recommendation', name: '推荐系统' },
            { value: 'ads', name: '广告投放' },
          ],
        },
      ],
    },
    {
      key: 'industry',
      title: '行业应用',
      children: [
        {
          children: [
            { value: 'finance', name: '金融' },
            { value: 'healthcare', name: '医疗' },
            { value: 'education', name: '教育' },
            { value: 'retail', name: '零售' },
            { value: 'manufacturing', name: '制造' },
          ],
        },
      ],
    },
  ];

  // 带数据体量筛选
  const [tagSelected3, setTagSelected3] = useState<Record<string, string[]>>({
    source: [],
  });

  const [dataSizeValue, setDataSizeValue] = useState<string[]>(['', '']);

  const dataSizeTagGroupDatas = [
    {
      key: 'source',
      title: '数据来源',
      children: [
        {
          children: [
            { value: 'public', name: '公开数据集' },
            { value: 'private', name: '私有数据' },
            { value: 'synthetic', name: '合成数据' },
            { value: 'user-generated', name: '用户生成内容' },
          ],
        },
      ],
    },
  ];

  return (
    <div style={{ padding: 24, display: 'grid', gap: 24, maxWidth: 1200, margin: '0 auto' }}>
      {/* 基础用法 */}
      <Card title="基础用法 - Basic Usage">
        <p style={{ marginBottom: 16, color: '#666' }}>展示多个标签分组，支持折叠/展开和多选</p>
        <div style={{ marginBottom: 12, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
          <strong>当前选中：</strong>
          <pre style={{ margin: '8px 0 0', fontSize: 12 }}>{JSON.stringify(tagSelected1, null, 2)}</pre>
        </div>
        <div style={{ width: 350, border: '1px solid #e8e8e8', borderRadius: 4, padding: 12 }}>
          <TagGroupFilter
            tagGroupDatas={basicTagGroupDatas}
            tagSelected={tagSelected1}
            onTagChange={setTagSelected1}
          />
        </div>
      </Card>

      {/* 带子标题 */}
      <Card title="带子标题 - With Subtitles">
        <p style={{ marginBottom: 16, color: '#666' }}>每个分组可以包含多个子分组，用子标题区分</p>
        <div style={{ marginBottom: 12, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
          <strong>当前选中：</strong>
          <pre style={{ margin: '8px 0 0', fontSize: 12 }}>{JSON.stringify(tagSelected2, null, 2)}</pre>
        </div>
        <div style={{ width: 350, border: '1px solid #e8e8e8', borderRadius: 4, padding: 12 }}>
          <TagGroupFilter
            tagGroupDatas={subTitleTagGroupDatas}
            tagSelected={tagSelected2}
            onTagChange={setTagSelected2}
          />
        </div>
      </Card>

      {/* 带数据体量筛选 */}
      <Card title="带数据体量筛选 - With Data Size Filter">
        <p style={{ marginBottom: 16, color: '#666' }}>启用数据体量范围筛选器，支持 KB/MB/GB/TB 单位</p>
        <div style={{ marginBottom: 12, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
          <strong>标签选中：</strong>
          <pre style={{ margin: '8px 0 0', fontSize: 12 }}>{JSON.stringify(tagSelected3, null, 2)}</pre>
          <strong style={{ marginTop: 8, display: 'block' }}>数据体量范围：</strong>
          <span style={{ fontSize: 12 }}>
            {dataSizeValue[0] || '最小值'} - {dataSizeValue[1] || '最大值'}
          </span>
        </div>
        <div style={{ width: 350, border: '1px solid #e8e8e8', borderRadius: 4, padding: 12 }}>
          <TagGroupFilter
            tagGroupDatas={dataSizeTagGroupDatas}
            tagSelected={tagSelected3}
            onTagChange={setTagSelected3}
            needDataSizeFilter={true}
            dataSizeValue={dataSizeValue}
            onDataSizeChange={setDataSizeValue}
          />
        </div>
      </Card>

      {/* 综合示例 */}
      <Card title="综合示例 - Comprehensive Example">
        <p style={{ marginBottom: 16, color: '#666' }}>结合多种功能的完整示例</p>
        <div style={{ width: 350, border: '1px solid #e8e8e8', borderRadius: 4, padding: 12 }}>
          <TagGroupFilter
            tagGroupDatas={[
              ...basicTagGroupDatas,
              {
                key: 'format',
                title: '格式',
                children: [
                  {
                    children: [
                      { value: 'json', name: 'JSON' },
                      { value: 'csv', name: 'CSV' },
                      { value: 'parquet', name: 'Parquet' },
                      { value: 'tfrecord', name: 'TFRecord' },
                    ],
                  },
                ],
              },
            ]}
            tagSelected={tagSelected1}
            onTagChange={setTagSelected1}
            needDataSizeFilter={true}
            dataSizeValue={dataSizeValue}
            onDataSizeChange={setDataSizeValue}
          />
        </div>
      </Card>
    </div>
  );
}
```
### API

#### TagGroupFilter

| 参数 | 说明 | 类型 | 默认值 | 必须 |
| --- | --- | --- | --- | --- |
| tagGroupDatas | 标签分组数据 | `ITagGroupDataItem[]` | - | 是 |
| tagSelected | 已选中的标签（按组 key 索引） | `Record<string, string[]>` | - | 是 |
| needDataSizeFilter | 是否显示数据体量筛选器 | `boolean` | `false` | 否 |
| isEn | 是否为英文环境 | `boolean` | `false` | 否 |
| dataSizeValue | 数据体量筛选值 `[min, max]` | `string[]` | - | 否 |
| tagClassName | 自定义标签类名 | `{ default?: string; checked?: string }` | - | 否 |
| onTagChange | 标签选中变化回调 | `(tagSelected: Record<string, string[]>) => void` | - | 是 |
| onDataSizeChange | 数据体量筛选变化回调 | `(value: string[]) => void` | - | 否 |

#### 数据结构

##### ITagGroupDataItem

| 参数 | 说明 | 类型 |
| --- | --- | --- |
| key | 分组的唯一标识 | `string` |
| title | 分组标题 | `string` |
| children | 标签列表项 | `ITagListItem[]` |

##### ITagListItem

| 参数 | 说明 | 类型 |
| --- | --- | --- |
| subTitle | 子标题（可选） | `string` |
| children | 标签选项列表 | `{ value: string; name: string }[]` |

#### 使用示例

```tsx
import TagGroupFilter from '@zjlab-fe/data-hub-ui/components/tag-group-filter';
import { useState } from 'react';

// 基础用法
const [tagSelected, setTagSelected] = useState<Record<string, string[]>>({
  category: [],
  type: [],
});

const tagGroupDatas = [
  {
    key: 'category',
    title: '分类',
    children: [
      {
        children: [
          { value: 'ai', name: '人工智能' },
          { value: 'ml', name: '机器学习' },
          { value: 'dl', name: '深度学习' },
        ],
      },
    ],
  },
  {
    key: 'type',
    title: '类型',
    children: [
      {
        children: [
          { value: 'image', name: '图像' },
          { value: 'text', name: '文本' },
          { value: 'audio', name: '音频' },
        ],
      },
    ],
  },
];

<TagGroupFilter
  tagGroupDatas={tagGroupDatas}
  tagSelected={tagSelected}
  onTagChange={setTagSelected}
/>;

// 带数据体量筛选
const [dataSizeValue, setDataSizeValue] = useState<string[]>(['', '']);

<TagGroupFilter
  tagGroupDatas={tagGroupDatas}
  tagSelected={tagSelected}
  onTagChange={setTagSelected}
  needDataSizeFilter={true}
  dataSizeValue={dataSizeValue}
  onDataSizeChange={setDataSizeValue}
/>;

// 带子标题的分组
const tagGroupDatasWithSubTitle = [
  {
    key: 'domain',
    title: '领域',
    children: [
      {
        subTitle: '热门领域',
        children: [
          { value: 'cv', name: '计算机视觉' },
          { value: 'nlp', name: '自然语言处理' },
        ],
      },
      {
        subTitle: '其他领域',
        children: [
          { value: 'speech', name: '语音识别' },
          { value: 'robot', name: '机器人' },
        ],
      },
    ],
  },
];
```

#### 注意事项

1. **数据体量筛选位置**：当 `needDataSizeFilter` 为 true 时，数据体量筛选器会自动插入到第三个位置（索引 2）
2. **选中状态管理**：`tagSelected` 是一个对象，key 为分组的 key，value 为该组选中的标签值数组
3. **折叠状态**：组件会自动展开所有分组，用户可手动折叠/展开
4. **清空功能**：点击分组标题旁的清空图标可清空该组所有选中项
6. **数据体量格式**：`dataSizeValue` 数组格式为 `["{value}{unit}", "{value}{unit}"]`，如 `["100kb", "5gb"]`
7. **单位转换**：数据体量筛选器内部会将所有值转换为 MB 进行比较，确保范围有效性

---

## tag-view（标签视图）

**导入方式：**

```ts
import { TagView } from '@zjlab-fe/data-hub-ui';
```

智能标签展示组件，支持多选、溢出处理、高亮标记等功能。

#### 特性

- **智能溢出处理**：自动计算容器宽度，支持多行显示
- **两种溢出模式**：Tooltip 提示模式或展开/收起模式
- **支持选择**：可选中标签，支持自定义选中/未选中样式
- **高亮标记**：支持 `<mark>` 标签高亮显示部分内容
- **响应式**：使用 ResizeObserver 监听容器尺寸变化

### 代码演示

**示例代码（index.tsx）：**

```tsx
import React, { useState } from 'react';
import TagView from '@/components/tag-view';
import { Card, RadioGroup } from '@/demo/shared';

export default function Demo() {
  const [selected, setSelected] = useState<string[]>([]);
  const [maxLine, setMaxLine] = useState<number>(1);

  // 基础标签数据
  const basicTags = [
    { value: 'tag1', name: '人工智能' },
    { value: 'tag2', name: '机器学习' },
    { value: 'tag3', name: '深度学习' },
    { value: 'tag4', name: '自然语言处理' },
    { value: 'tag5', name: '计算机视觉' },
    { value: 'tag6', name: '数据挖掘' },
    { value: 'tag7', name: '大数据分析' },
    { value: 'tag8', name: '云计算' },
    { value: 'tag9', name: '边缘计算' },
    { value: 'tag10', name: '物联网' },
  ];

  // 带高亮标记的标签数据
  const highlightTags = [
    { value: 'tag1', name: '<mark>AI</mark>助手' },
    { value: 'tag2', name: '<mark>智能</mark>推荐' },
    { value: 'tag3', name: '<mark>自动</mark>化测试' },
    { value: 'tag4', name: '<mark>实时</mark>分析' },
    { value: 'tag5', name: '<mark>分布式</mark>计算' },
  ];

  return (
    <div style={{ padding: 24, display: 'grid', gap: 24, maxWidth: 1200, margin: '0 auto' }}>
      {/* 基础用法 */}
      <Card title="基础用法 - Basic Usage">
        <p style={{ marginBottom: 16, color: '#666' }}>展示一组标签，支持自动换行和溢出处理</p>
        <TagView list={basicTags} maxLine={2} />
      </Card>

      {/* 带选择功能 */}
      <Card title="带选择功能 - With Selection">
        <p style={{ marginBottom: 16, color: '#666' }}>点击标签进行选中/取消选中，已选中的标签会高亮显示</p>
        <div style={{ marginBottom: 12 }}>
          <strong>已选中：</strong>
          {selected.length > 0 ? selected.join(', ') : '无'}
        </div>
        <TagView list={basicTags} selected={selected} onChange={setSelected} maxLine={2} />
      </Card>

      {/* 高亮标记 */}
      <Card title="高亮标记 - Highlight Marks">
        <p style={{ marginBottom: 16, color: '#666' }}>支持使用 &lt;mark&gt; 标签高亮显示部分内容</p>
        <TagView list={highlightTags} maxLine={2} />
      </Card>

      {/* 溢出模式 */}
      <Card title="溢出模式 - Overflow Mode">
        <div style={{ display: 'grid', gap: 16, marginBottom: 20 }}>
          <RadioGroup<string>
            label="最大行数 Max Line"
            value={String(maxLine)}
            options={[
              { value: '1', label: '1 行' },
              { value: '2', label: '2 行' },
              { value: '3', label: '3 行' },
            ]}
            onChange={(val) => setMaxLine(Number(val))}
          />
        </div>
        <div style={{ border: '1px solid #e8e8e8', padding: 16, borderRadius: 4, width: '300px' }}>
          <TagView
            list={basicTags}
            maxLine={maxLine}
            needTooltip={true}
            selected={selected}
            onChange={setSelected}
          />
        </div>
      </Card>
    </div>
  );
}
```
### API

#### TagView

| 参数 | 说明 | 类型 | 默认值 | 必须 |
| --- | --- | --- | --- | --- |
| list | 标签列表数据 | `{ value: string; name: string }[]` | `[]` | 是 |
| maxLine | 最多显示行数 | `number` | `1` | 否 |
| needTooltip | 省略部分是否使用 Tooltip，false 则使用展开/收起模式 | `boolean` | `false` | 否 |
| selected | 已选中的标签值数组 | `string[]` | - | 否 |
| style | 自定义样式 | `React.CSSProperties` | - | 否 |
| onChange | 选中状态变化回调 | `(selected: string[]) => void` | - | 否 |
| tagClassName | 自定义标签类名 | `{ default?: string; checked?: string }` | - | 否 |

#### 数据结构

##### list 项

| 参数 | 说明 | 类型 |
| --- | --- | --- |
| value | 标签的唯一标识值 | `string` |
| name | 标签显示的名称，支持 `<mark>` 高亮 | `string` |

#### 使用示例

```tsx
import TagView from '@zjlab-fe/data-hub-ui/components/tag-view';

// 基础用法
<TagView
  list={[
    { value: 'tag1', name: '标签1' },
    { value: 'tag2', name: '标签2' },
    { value: 'tag3', name: '标签3' },
  ]}
/>

// 带选择功能
const [selected, setSelected] = useState<string[]>([]);

<TagView
  list={tags}
  selected={selected}
  onChange={setSelected}
  maxLine={2}
  tagClassName={{
    default: 'custom-default-class',
    checked: 'custom-checked-class',
  }}
/>

// 使用 Tooltip 模式处理溢出
<TagView
  list={tags}
  maxLine={1}
  needTooltip={true}
/>

// 带高亮标记
<TagView
  list={[
    { value: 'tag1', name: '<mark>关键</mark>标签1' },
    { value: 'tag2', name: '普通标签2' },
  ]}
/>
```

#### 注意事项

1. **空数据处理**：当 `list` 为空数组时，组件不会渲染任何内容
2. **高亮标记**：`name` 字段支持 `<mark>` 和 `</mark>` 标签包裹需要高亮的文本
3. **响应式更新**：组件会自动监听容器尺寸变化并重新计算布局
4. **选择模式**：只有传入 `selected` 属性时，标签才可点击选择
5. **溢出计算**：基于实际 DOM 元素宽度进行精确计算，确保布局准确

---

## tip-tap（富文本编辑器）

**导入方式：**

```ts
import { Tiptap, TipTapReader } from '@zjlab-fe/data-hub-ui';
```

tip-tap 是一个基于 [TipTap](https://tiptap.dev/) 和 React 构建的强大富文本编辑器组件。当需要提供富文本编辑功能时使用，支持文本格式化、图片插入、表格编辑、Markdown 支持等功能。组件提供了可编辑的编辑器（TipTapEditor）和只读的阅读器（TipTapReader）两种模式。

### 主要特性

1. **文本格式化**：支持粗体、斜体、下划线、删除线
2. **标题支持**：支持 H1、H2、H3、H4 标题
3. **列表**：支持有序列表和无序列表
4. **文本对齐**：支持左对齐、居中、右对齐
5. **颜色**：支持文本颜色和背景高亮颜色
6. **链接**：支持插入和编辑超链接
7. **代码**：支持行内代码和代码块（带语法高亮）
8. **引用**：支持引用块
9. **图片**：支持上传和插入图片（base64 支持）
10. **表格**：支持插入和编辑可调整大小的表格（带表头）
11. **Markdown**：完整的 Markdown 支持，粘贴时自动检测
12. **工具栏**：顶部工具栏提供所有格式化选项
13. **气泡菜单**：选中文本时出现的上下文菜单
14. **浮动菜单**：空行时出现的快速插入菜单
15. **多种格式**：支持获取 HTML、JSON 或 Markdown 格式的内容
16. **Ref API**：提供命令式 API 用于程序化控制

### 代码演示

**示例代码（index.tsx）：**

```tsx
import { useRef, useState } from 'react';
import TipTapEditor, { TipTapReader } from '../index';
import type { TipTapEditorHandle } from '../editor';

const initialContent = `
<!--- this is file is already parsed --># 数据简介
<p>先进星载热辐射和反射辐射计 (ASTER) 是一种多光谱成像仪，于 1999 年 12 月搭载于 NASA 的 Terra 航天器上。ASTER 可以收集从可见光到热红外的 14 个光谱带的数据，每个景覆盖 60 x 60 千米的区域。该数据集包含全球范围内2000年3月4日至2007年12月31日的 ASTER L1T 数据，L1T 数据由 L1A 通过重采样生成，且已完成大气校正、辐射定标及精准地形校正，包括三个可见光及近红外波段、六个短波红外波段和五个热红外波段。</p>
## 波段说明
<table width="375.75" border="0" cellpadding="0" cellspacing="0" style="width:375.75pt;border-collapse:collapse;table-layout:fixed;">
   <colgroup><col width="134.15" style="mso-width-source:userset;mso-width-alt:6541;">
   <col width="73.30" style="mso-width-source:userset;mso-width-alt:3574;">
   <col width="89.15" style="mso-width-source:userset;mso-width-alt:4346;">
   <col width="79.15" style="mso-width-source:userset;mso-width-alt:3859;">
   </colgroup><tbody><tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl65" height="34.75" width="134.15" style="height:34.75pt;width:134.15pt;" x:str="">谱段</td>
    <td class="xl66" width="73.30" style="width:73.30pt;" x:str="">波段名称</td>
    <td class="xl66" width="89.15" style="width:89.15pt;" x:str="">波长范围</td>
    <td class="xl66" width="79.15" style="width:79.15pt;" x:str="">空间分辨率</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl67" height="104.25" rowspan="3" style="height:104.25pt;border-right:.5pt solid windowtext;border-bottom:.5pt solid windowtext;" x:str="">可见光近红外（VNIR）</td>
    <td class="xl68" x:str="">Band 1</td>
    <td class="xl69" x:str="">0.520-0.600µm</td>
    <td class="xl68" x:str="">15 m</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl68" x:str="">Band 2</td>
    <td class="xl69" x:str="">0.630-0.690µm</td>
    <td class="xl68" x:str="">15 m</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl68" x:str="">Band 3</td>
    <td class="xl69" x:str="">0.780-0.860µm</td>
    <td class="xl68" x:str="">15 m</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl67" height="208.50" rowspan="6" style="height:208.50pt;border-right:.5pt solid windowtext;border-bottom:.5pt solid windowtext;" x:str="">短波红外（SWIR）</td>
    <td class="xl68" x:str="">Band 4</td>
    <td class="xl69" x:str="">1.600-1.700µm</td>
    <td class="xl68" x:str="">30 m</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl68" x:str="">Band 5</td>
    <td class="xl69" x:str="">2.145-2.185µm</td>
    <td class="xl68" x:str="">30 m</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl68" x:str="">Band 6</td>
    <td class="xl69" x:str="">2.185-2.225µm</td>
    <td class="xl68" x:str="">30 m</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl68" x:str="">Band 7</td>
    <td class="xl69" x:str="">2.235-2.285µm</td>
    <td class="xl68" x:str="">30 m</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl68" x:str="">Band 8</td>
    <td class="xl69" x:str="">2.295-2.365µm</td>
    <td class="xl68" x:str="">30 m</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl68" x:str="">Band 9</td>
    <td class="xl69" x:str="">2.360-2.430µm</td>
    <td class="xl68" x:str="">30 m</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl67" height="173.75" rowspan="5" style="height:173.75pt;border-right:.5pt solid windowtext;border-bottom:.5pt solid windowtext;" x:str="">热红外（TIR）</td>
    <td class="xl68" x:str="">Band 10</td>
    <td class="xl69" x:str="">8.125-8.475µm</td>
    <td class="xl68" x:str="">90 m</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl68" x:str="">Band 11</td>
    <td class="xl69" x:str="">8.475-8.825µm</td>
    <td class="xl68" x:str="">90 m</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl68" x:str="">Band 12</td>
    <td class="xl69" x:str="">8.925-9.275µm</td>
    <td class="xl68" x:str="">90 m</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl68" x:str="">Band 13</td>
    <td class="xl69" x:str="">10.250-10.950µm</td>
    <td class="xl68" x:str="">90 m</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl68" x:str="">Band 14</td>
    <td class="xl69" x:str="">10.950-11.650µm</td>
    <td class="xl68" x:str="">90 m</td>
   </tr>
   <!--[if supportMisalignedColumns]-->
    <tr width="0" style="display:none;">
     <td width="134" style="width:134;"></td>
     <td width="73" style="width:73;"></td>
     <td width="89" style="width:89;"></td>
     <td width="79" style="width:79;"></td>
    </tr>
   <!--[endif]-->
  </tbody></table>

## 数据属性
<table width="667.35" border="0" cellpadding="0" cellspacing="0" style="width:667.35pt;border-collapse:collapse;table-layout:fixed;">
   <colgroup><col width="214.10" style="mso-width-source:userset;mso-width-alt:10439;">
   <col width="132.45" style="mso-width-source:userset;mso-width-alt:6458;">
   <col width="320.80" style="mso-width-source:userset;mso-width-alt:15642;">
   </colgroup><tbody><tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl65" height="34.75" width="214.10" style="height:34.75pt;width:214.10pt;" x:str="">字段名称</td>
    <td class="xl65" width="132.45" style="width:132.45pt;" x:str="">数据类型</td>
    <td class="xl65" width="320.80" style="width:320.80pt;" x:str="">说明</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Granule_UR</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">数据 UR 或唯一标识符</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Short_Name</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">数据集缩写</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Version_ID</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">数据集版本</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">File_Name</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">文件名</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">File_Size</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">每一种文件大小，以字节为单位</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Reprocessing_Actual</td>
    <td class="xl66" x:str="">Datetime</td>
    <td class="xl66" x:str="">实际重新处理的次数</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Granule_ID</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">产品命名，即 L 1 T 数据名称</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Day_Night_Flag</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">白天还是夜晚的标志，这里是“Day”，表示这是白天的数据</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Time_of_Day</td>
    <td class="xl66" x:str="">Datetime</td>
    <td class="xl66" x:str="">观测时间</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Calendar_Date</td>
    <td class="xl66" x:str="">Datetime</td>
    <td class="xl66" x:str="">观测日期</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Point_Longitude</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">某一景地理范围的左上/右上/右下/左下角点经度</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Point_Latitude</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">某一景地理范围的左上/右上/右下/左下角点纬度</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">QA_Percent_Missing_Data</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">存储缺失数据占全部数据百分比</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">QA_Percent_Out_of_Bounds_Data</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">存储错误数据占全部数据百分比</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">QA_Percent_Interpolated_Data</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">内插数据百分比</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">QA_Percent_Cloud_Cover</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">云覆盖百分比</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Automatic_Qualit_Flag</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">自动质量检查</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">ASTER_Map_Projection</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">地图投影方法</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Scene_Cloud_Coverage</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">场景云覆盖率</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Upper_Left_Quad_Cloud_Coverage</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">左上角云覆盖率</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Upper_Right_Quad_Cloud_Coverage</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">右上角云覆盖率</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Lower_Left_Quad_Cloud_Coverage</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">左下角云覆盖率</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Lower_Right_Quad_Cloud_Coverage</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">右下角云覆盖率</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">VNIR 1_Observation_Mode</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">VNIR 1 观测模式</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">VNIR 2_Observation_Mode</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">VNIR 2 观测模式</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">SWIR_Observation_Mode</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">SWIR 观测模式</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">TIR_Observation_Mode</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">TIR 观测模式</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Band 1_Available</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">波段 1 可用</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Band 2_Available</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">波段 2 可用</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Band 3_Available</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">波段 3 可用</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Band 4_Available</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">波段 4 可用</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Band 5_Available</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">波段 5 可用</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Band 6_Available</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">波段 6 可用</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Band 7_Available</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">波段 7 可用</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Band 8_Available</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">波段 8 可用</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Band 9_Available</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">波段 9 可用</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Band 10_Available</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">波段 10 可用</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Band 11_Available</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">波段 11 可用</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Band 12_Available</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">波段 12 可用</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Band 13_Available</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">波段 13 可用</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Band 14_Available</td>
    <td class="xl66" x:str="">String</td>
    <td class="xl66" x:str="">波段 14 可用</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Solar_Azimuth_Angle</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">太阳方位角</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">Solar_Elevation_Angle</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">太阳仰角</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">ASTER_Map_Orientation_Angle</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">地图定向角</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">ASTER_VNIR_Pointing_Angle</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">VNIR 指向角</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">ASTER_SWIR_Pointing_Angle</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">SWIR 指向角</td>
   </tr>
   <tr height="34.75" style="height:34.75pt;mso-height-source:userset;mso-height-alt:695;">
    <td class="xl66" height="34.75" style="height:34.75pt;" x:str="">ASTER_TIR_Pointing_Angle</td>
    <td class="xl66" x:str="">Double</td>
    <td class="xl66" x:str="">TIR 指向角</td>
   </tr>
   <!--[if supportMisalignedColumns]-->
    <tr width="0" style="display:none;">
     <td width="214" style="width:214;"></td>
     <td width="132" style="width:132;"></td>
     <td width="321" style="width:321;"></td>
    </tr>
   <!--[endif]-->
  </tbody></table>

## API 参考
<p>方式一：使用 OSS Browser 进行数据访问，参数如下：
Endpoint =<a href="http://oss-cn-hangzhou-zjy-d01-a.ops.cloud.zhejianglab.com/">http://oss-cn-hangzhou-zjy-d01-a.ops.cloud.zhejianglab.com/</a>
预设OSS路径：oss://geocloud/aster
AccessKeyId和AccessKeySecret请联系管理员获取，<a href="mailto:ysx@zhejianglab.com">ysx@zhejianglab.com</a></p>
<p>方式二：使用OSS Python SDK进行数据访问
1、鉴权及初始化</p>
<pre><code class="language-python">import oss2 auth = oss2.Auth(&#39;&lt;AccessKey_ID&gt;&#39;, &#39;&lt;AccessKey_Secret&gt;&#39;) 
## endpoint 区域 例如 region = &#39;cn-hangzhou&#39; 
region = &#39;&lt;region&gt;&#39; 
bucket = oss2.Bucket(auth, &#39;&lt;Endpoint&gt;&#39;, &#39;&lt;Bucket_Name&gt;&#39;,region=region)</code></pre>
<p>2、查询文件 key bucket 文件列表，读取文件，下载文件</p>
<pre><code class="language-python">from itertools import islice

#### 查询文件对应的key 查询列表 这里只展示10条
for b in islice(oss2.ObjectIterator(bucket), 10):
    print(b.key)
## 可以根据查询的列表中的key查询对应文件
key = &#39;&lt;FILE_KEY&gt;&#39;
#### 获取文件
file = bucket.get_object(key)
print(file.read())

## 下载文件 Local_file_DIR 包括文件存储路径以及存储对应的文件名和后缀 例如/home/opt/xxx.xml
bucket.get_object_to_file(key,&#39;&lt;LOCAL_FILE_DIR_WITH_FILE_SUFFIX&gt;&#39;)</code></pre>

`;

export default function TipTapEditorDemo() {
  const editorRef = useRef<TipTapEditorHandle>(null);
  const [mode, setMode] = useState<'edit' | 'read'>('edit');
  const [savedContent, setSavedContent] = useState(initialContent);

  const handleSave = () => {
    const content = editorRef.current?.getContent() || '';
    setSavedContent(content);
    alert('内容已保存！');
  };

  const handleClear = () => {
    editorRef.current?.setContent('');
  };

  return (
    <div
      style={{
        minHeight: '100vh',
        background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
        padding: '40px 20px',
      }}
    >
      <div
        style={{
          maxWidth: '1000px',
          margin: '0 auto',
        }}
      >
        {/* Header */}
        <div
          style={{
            textAlign: 'center',
            marginBottom: '32px',
            color: 'white',
          }}
        >
          <h1
            style={{
              fontSize: '2.5rem',
              fontWeight: 700,
              marginBottom: '8px',
              textShadow: '0 2px 4px rgba(0,0,0,0.2)',
            }}
          >
            TipTap Editor
          </h1>
          <p
            style={{
              fontSize: '1.1rem',
              opacity: 0.9,
            }}
          >
            下一代富文本编辑器
          </p>
        </div>

        {/* Controls */}
        <div
          style={{
            display: 'flex',
            justifyContent: 'center',
            gap: '12px',
            marginBottom: '24px',
          }}
        >
          <button
            onClick={() => setMode('edit')}
            style={{
              padding: '10px 24px',
              border: 'none',
              borderRadius: '8px',
              background: mode === 'edit' ? 'white' : 'rgba(255,255,255,0.2)',
              color: mode === 'edit' ? '#6366f1' : 'white',
              fontWeight: 600,
              cursor: 'pointer',
              transition: 'all 0.2s',
            }}
          >
            编辑模式
          </button>
          <button
            onClick={() => setMode('read')}
            style={{
              padding: '10px 24px',
              border: 'none',
              borderRadius: '8px',
              background: mode === 'read' ? 'white' : 'rgba(255,255,255,0.2)',
              color: mode === 'read' ? '#6366f1' : 'white',
              fontWeight: 600,
              cursor: 'pointer',
              transition: 'all 0.2s',
            }}
          >
            阅读模式
          </button>
        </div>

        {/* Editor Container */}
        <div
          style={{
            background: 'white',
            borderRadius: '16px',
            boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
          }}
        >
          {mode === 'edit' ? (
            <>
              <TipTapEditor
                ref={editorRef}
                content={savedContent}
                placeholder="开始创作你的内容..."
                onChange={(html) => {
                  console.log('Content changed:', html.slice(0, 100) + '...');
                }}
              />

              {/* Action Buttons */}
              <div
                style={{
                  display: 'flex',
                  justifyContent: 'flex-end',
                  gap: '12px',
                  padding: '16px 24px',
                  borderTop: '1px solid #e2e8f0',
                  background: '#f8fafc',
                }}
              >
                <button
                  onClick={handleClear}
                  style={{
                    padding: '10px 20px',
                    border: '1px solid #e2e8f0',
                    borderRadius: '8px',
                    background: 'white',
                    color: '#64748b',
                    fontWeight: 500,
                    cursor: 'pointer',
                    transition: 'all 0.2s',
                  }}
                >
                  清空内容
                </button>
                <button
                  onClick={handleSave}
                  style={{
                    padding: '10px 24px',
                    border: 'none',
                    borderRadius: '8px',
                    background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
                    color: 'white',
                    fontWeight: 600,
                    cursor: 'pointer',
                    transition: 'all 0.2s',
                    boxShadow: '0 4px 14px rgba(99, 102, 241, 0.4)',
                  }}
                >
                  保存内容
                </button>
              </div>
            </>
          ) : (
            <div style={{ padding: '24px' }}>
              <TipTapReader content={savedContent} />
            </div>
          )}
        </div>

        {/* Footer */}
        <div
          style={{
            textAlign: 'center',
            marginTop: '32px',
            color: 'rgba(255,255,255,0.7)',
            fontSize: '14px',
          }}
        >
          Built with TipTap & React
        </div>
      </div>
    </div>
  );
}
```
### 使用方法

#### 基本用法

##### TipTapEditor

```tsx
import { useRef } from 'react';
import TipTapEditor, { type TipTapEditorHandle } from '@/components/tip-tap';

function MyComponent() {
  const editorRef = useRef<TipTapEditorHandle>(null);

  const handleSave = () => {
    const content = editorRef.current?.getContent();
    console.log('保存的内容:', content);
  };

  return (
    <TipTapEditor
      ref={editorRef}
      content="<p>初始内容</p>"
      placeholder="开始输入内容..."
      onChange={(html) => {
        console.log('内容变化:', html);
      }}
    />
  );
}
```

##### TipTapReader

```tsx
import { TipTapReader } from '@/components/tip-tap';

function MyComponent() {
  const content = '# 标题<p>一些内容</p>';

  return (
    <TipTapReader content={content} />
  );
}
```

### API

#### TipTapEditor

| 参数 | 说明 | 类型 | 默认值 | 版本 | 必须 |
| --- | --- | --- | --- | --- | --- |
| content | 初始 HTML 内容 | `string` | `''` | - | 否 |
| placeholder | 编辑器为空时的占位文本 | `string` | `'开始输入内容...'` | - | 否 |
| editable | 是否可编辑 | `boolean` | `true` | - | 否 |
| autofocus | 是否自动聚焦 | `boolean` | `false` | - | 否 |
| className | 编辑器容器的额外 CSS 类名 | `string` | `''` | - | 否 |
| toolbarClassName | 工具栏的额外 CSS 类名 | `string` | `''` | - | 否 |
| onChange | 内容变化时的回调函数 | `(html: string) => void` | - | - | 否 |

#### TipTapEditor Ref 方法

通过 `ref` 可以访问以下方法：

| 方法 | 说明 | 返回值 |
| --- | --- | --- |
| getContent | 获取 HTML 格式的内容 | `string` |
| getJSON | 获取 JSON 格式的内容 | `Record<string, unknown>` |
| getMarkdown | 获取 Markdown 格式的内容 | `string` |
| setContent | 设置 HTML 内容 | `void` |
| setMarkdown | 设置 Markdown 内容 | `void` |
| focus | 聚焦编辑器 | `void` |
| isEmpty | 检查编辑器是否为空 | `boolean` |

#### TipTapReader

| 参数 | 说明 | 类型 | 默认值 | 版本 | 必须 |
| --- | --- | --- | --- | --- | --- |
| content | 要显示的 HTML 内容 | `string` | - | - | 是 |
| className | 额外的 CSS 类名 | `string` | `''` | - | 否 |

### 高级用法

#### 程序化内容操作

```tsx
const editorRef = useRef<TipTapEditorHandle>(null);

// 获取不同格式的内容
const html = editorRef.current?.getContent();
const json = editorRef.current?.getJSON();
const markdown = editorRef.current?.getMarkdown();

// 设置内容
editorRef.current?.setContent('<p>新内容</p>');
editorRef.current?.setMarkdown('# 标题\n\n内容');

// 检查是否为空
const isEmpty = editorRef.current?.isEmpty();

// 聚焦编辑器
editorRef.current?.focus();
```

#### 在编辑和只读模式之间切换

```tsx
const [isEditing, setIsEditing] = useState(true);
const [content, setContent] = useState('');

return (
  <>
    {isEditing ? (
      <TipTapEditor
        content={content}
        onChange={setContent}
      />
    ) : (
      <TipTapReader content={content} />
    )}
    <button onClick={() => setIsEditing(!isEditing)}>
      {isEditing ? '查看' : '编辑'}
    </button>
  </>
);
```

#### 只读编辑器

```tsx
<TipTapEditor
  content={content}
  editable={false}
/>
```

### 注意事项

1. 图片上传支持 base64 格式，大图片可能会导致性能问题，建议使用图片上传服务。

2. 使用 `getContent()` 获取的内容是 HTML 格式，可以直接保存到数据库或用于显示。

3. 使用 `getMarkdown()` 可以获取 Markdown 格式的内容，便于版本控制和纯文本编辑。

4. 组件支持粘贴 Markdown 内容，会自动转换为富文本格式。

5. 通过 CSS 变量可以自定义样式，相关变量定义在 `styles/variables.css` 中。

### 内部实现

tip-tap 组件内部使用了以下关键技术和方法：

1. **TipTap 编辑器核心**：基于 `@tiptap/react` 构建，使用 `useEditor` hook 管理编辑器实例。

2. **扩展系统**：通过 TipTap 扩展系统实现各种功能：
   - StarterKit：核心编辑功能
   - TextAlign：文本对齐
   - Highlight：文本高亮
   - Color：文本颜色
   - Image：图片插入
   - Table：表格支持
   - CodeBlockLowlight：语法高亮的代码块
   - Markdown：Markdown 解析和序列化

3. **菜单系统**：
   - 工具栏（Toolbar）：顶部固定工具栏
   - 气泡菜单（BubbleMenu）：选中文本时显示
   - 浮动菜单（FloatingMenu）：空行时显示

4. **样式系统**：使用 CSS 模块化，包含：
   - `editor.css`：编辑器样式
   - `content.css`：内容样式
   - `toolbar.css`：工具栏样式
   - `menus.css`：菜单样式
   - `animations.css`：动画样式
   - `variables.css`：CSS 变量

5. **Ref API**：通过 `useImperativeHandle` 暴露命令式 API，方便外部程序化控制编辑器。

通过这些实现，tip-tap 组件提供了强大的富文本编辑功能，同时保持了良好的用户体验和性能。

---
