committed by
GitHub
222 changed files with 8522 additions and 5049 deletions
@ -0,0 +1,85 @@ |
|||||
|
import { faker } from '@faker-js/faker'; |
||||
|
import { eventHandler, getQuery } from 'h3'; |
||||
|
import { verifyAccessToken } from '~/utils/jwt-utils'; |
||||
|
import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response'; |
||||
|
|
||||
|
const formatterCN = new Intl.DateTimeFormat('zh-CN', { |
||||
|
timeZone: 'Asia/Shanghai', |
||||
|
year: 'numeric', |
||||
|
month: '2-digit', |
||||
|
day: '2-digit', |
||||
|
hour: '2-digit', |
||||
|
minute: '2-digit', |
||||
|
second: '2-digit', |
||||
|
}); |
||||
|
|
||||
|
function generateMockDataList(count: number) { |
||||
|
const dataList = []; |
||||
|
|
||||
|
for (let i = 0; i < count; i++) { |
||||
|
const dataItem: Record<string, any> = { |
||||
|
id: faker.string.uuid(), |
||||
|
name: faker.commerce.product(), |
||||
|
status: faker.helpers.arrayElement([0, 1]), |
||||
|
createTime: formatterCN.format( |
||||
|
faker.date.between({ from: '2022-01-01', to: '2025-01-01' }), |
||||
|
), |
||||
|
deptId: faker.string.uuid(), |
||||
|
remark: faker.lorem.sentence(), |
||||
|
}; |
||||
|
|
||||
|
dataList.push(dataItem); |
||||
|
} |
||||
|
|
||||
|
return dataList; |
||||
|
} |
||||
|
|
||||
|
const mockData = generateMockDataList(100); |
||||
|
|
||||
|
export default eventHandler(async (event) => { |
||||
|
const userinfo = verifyAccessToken(event); |
||||
|
if (!userinfo) { |
||||
|
return unAuthorizedResponse(event); |
||||
|
} |
||||
|
|
||||
|
const { |
||||
|
page = 1, |
||||
|
pageSize = 20, |
||||
|
name, |
||||
|
id, |
||||
|
remark, |
||||
|
startTime, |
||||
|
endTime, |
||||
|
deptId, |
||||
|
status, |
||||
|
} = getQuery(event); |
||||
|
let listData = structuredClone(mockData); |
||||
|
if (name) { |
||||
|
listData = listData.filter((item) => |
||||
|
item.name.toLowerCase().includes(String(name).toLowerCase()), |
||||
|
); |
||||
|
} |
||||
|
if (id) { |
||||
|
listData = listData.filter((item) => |
||||
|
item.id.toLowerCase().includes(String(id).toLowerCase()), |
||||
|
); |
||||
|
} |
||||
|
if (remark) { |
||||
|
listData = listData.filter((item) => |
||||
|
item.remark?.toLowerCase()?.includes(String(remark).toLowerCase()), |
||||
|
); |
||||
|
} |
||||
|
if (startTime) { |
||||
|
listData = listData.filter((item) => item.createTime >= startTime); |
||||
|
} |
||||
|
if (endTime) { |
||||
|
listData = listData.filter((item) => item.createTime <= endTime); |
||||
|
} |
||||
|
if (['0', '1'].includes(status as string)) { |
||||
|
listData = listData.filter((item) => item.status === Number(status)); |
||||
|
} |
||||
|
if (deptId) { |
||||
|
listData = listData.filter((item) => item.deptId === deptId); |
||||
|
} |
||||
|
return usePageResponseSuccess(page as string, pageSize as string, listData); |
||||
|
}); |
||||
@ -0,0 +1,2 @@ |
|||||
|
import { register } from 'node:module'; |
||||
|
register('./dayjs-resolve-hook.mjs', import.meta.url); |
||||
@ -0,0 +1,10 @@ |
|||||
|
const DAYJS_SUBPATH_RE = /^dayjs\/(plugin|locale)\/([^./]+)$/; |
||||
|
|
||||
|
/** @type {import('node:module').ResolveHook} */ |
||||
|
export async function resolve(specifier, context, nextResolve) { |
||||
|
const match = specifier.match(DAYJS_SUBPATH_RE); |
||||
|
if (match) { |
||||
|
return nextResolve(`${specifier}.js`, context); |
||||
|
} |
||||
|
return nextResolve(specifier, context); |
||||
|
} |
||||
@ -0,0 +1,172 @@ |
|||||
|
--- |
||||
|
outline: deep |
||||
|
--- |
||||
|
|
||||
|
# Vben Cropper 图片裁剪 |
||||
|
|
||||
|
`VCropper` 是一个纯原生实现的图片裁剪组件,支持自由比例和固定比例裁剪,可通过方法调用获取裁剪后的图片。 |
||||
|
|
||||
|
> 如果文档内没有参数说明,可以尝试在在线示例内寻找 |
||||
|
|
||||
|
::: info 写在前面 |
||||
|
|
||||
|
如果你觉得现有组件的封装不够理想,或者不完全符合你的需求,可以直接使用原生组件,亦或亲手封装一个适合的组件。框架提供的组件并非束缚,使用与否,完全取决于你的需求与自由。 |
||||
|
|
||||
|
::: |
||||
|
|
||||
|
## 基础用法 |
||||
|
|
||||
|
最基本的图片裁剪,支持自由比例调整。 |
||||
|
|
||||
|
<DemoPreview dir="demos/vben-cropper/basic" /> |
||||
|
|
||||
|
## 固定比例裁剪 |
||||
|
|
||||
|
通过 `aspectRatio` 属性设置裁剪比例,格式为 `"宽:高"`,如 `"1:1"`、`"16:9"`、`"3:4"` 等。 |
||||
|
|
||||
|
<DemoPreview dir="demos/vben-cropper/aspect-ratio" /> |
||||
|
|
||||
|
## API |
||||
|
|
||||
|
### Props |
||||
|
|
||||
|
| 属性名 | 描述 | 类型 | 默认值 | |
||||
|
| ------------- | ------------------------------------- | -------- | ------ | |
||||
|
| `img` | 图片地址(必填) | `string` | - | |
||||
|
| `width` | 容器宽度 | `number` | `500` | |
||||
|
| `height` | 容器高度 | `number` | `400` | |
||||
|
| `aspectRatio` | 裁剪比例,格式如 `"1:1"`、`"16:9"` 等 | `string` | - | |
||||
|
|
||||
|
### Methods |
||||
|
|
||||
|
通过 `ref` 调用组件方法: |
||||
|
|
||||
|
```vue |
||||
|
<script setup lang="ts"> |
||||
|
import { ref } from 'vue'; |
||||
|
import { VCropper } from '@vben/common-ui'; |
||||
|
|
||||
|
const cropperRef = ref<InstanceType<typeof VCropper>>(); |
||||
|
|
||||
|
const handleCrop = async () => { |
||||
|
const result = await cropperRef.value?.getCropImage(); |
||||
|
// result 为 Blob 或 base64 字符串 |
||||
|
}; |
||||
|
</script> |
||||
|
``` |
||||
|
|
||||
|
#### getCropImage |
||||
|
|
||||
|
裁剪并获取图片。 |
||||
|
|
||||
|
```ts |
||||
|
interface GetCropImageOptions { |
||||
|
/** 输出图片格式 */ |
||||
|
format?: 'image/jpeg' | 'image/png'; |
||||
|
/** 压缩质量(0-1),仅对 jpeg 格式有效 */ |
||||
|
quality?: number; |
||||
|
/** 输出类型 */ |
||||
|
outputType?: 'base64' | 'blob'; |
||||
|
/** 目标宽度(可选,不传则为原始裁剪宽度) */ |
||||
|
targetWidth?: number; |
||||
|
/** 目标高度(可选,不传则为原始裁剪高度) */ |
||||
|
targetHeight?: number; |
||||
|
} |
||||
|
|
||||
|
getCropImage( |
||||
|
format?: 'image/jpeg' | 'image/png', |
||||
|
quality?: number, |
||||
|
outputType?: 'base64' | 'blob', |
||||
|
targetWidth?: number, |
||||
|
targetHeight?: number, |
||||
|
): Promise<Blob | string | undefined> |
||||
|
``` |
||||
|
|
||||
|
**参数说明:** |
||||
|
|
||||
|
| 参数 | 类型 | 默认值 | 描述 | |
||||
|
| --- | --- | --- | --- | |
||||
|
| `format` | `'image/jpeg' \| 'image/png'` | `'image/png'` | 输出图片格式 | |
||||
|
| `quality` | `number` | `0.92` | 压缩质量(0-1),仅 jpeg 有效 | |
||||
|
| `outputType` | `'base64' \| 'blob'` | `'blob'` | 输出类型,base64 字符串或 Blob 对象 | |
||||
|
| `targetWidth` | `number` | - | 目标宽度,不传则使用原始裁剪宽度 | |
||||
|
| `targetHeight` | `number` | - | 目标高度,不传则使用原始裁剪高度 | |
||||
|
|
||||
|
## 功能特性 |
||||
|
|
||||
|
### 裁剪操作 |
||||
|
|
||||
|
- **拖拽移动** - 拖拽裁剪框中心区域移动裁剪位置 |
||||
|
- **边角调整** - 拖拽四角调整裁剪框大小 |
||||
|
- **边缘调整** - 拖拽四边中点调整单边 |
||||
|
|
||||
|
### 比例控制 |
||||
|
|
||||
|
- **自由比例** - 不设置 `aspectRatio` 时,可自由调整任意比例 |
||||
|
- **固定比例** - 设置 `aspectRatio` 后,裁剪框始终保持设定比例 |
||||
|
|
||||
|
### 高清屏适配 |
||||
|
|
||||
|
组件自动适配 Retina 等高清屏幕,保证输出图片清晰无模糊。 |
||||
|
|
||||
|
### 图片适配 |
||||
|
|
||||
|
- 图片自动等比缩放以完整显示在容器内 |
||||
|
- 支持本地图片和网络图片 |
||||
|
- 网络图片需目标服务端支持 CORS 才能导出裁剪结果 |
||||
|
|
||||
|
## 使用示例 |
||||
|
|
||||
|
```vue |
||||
|
<script setup lang="ts"> |
||||
|
import { ref } from 'vue'; |
||||
|
import { VCropper } from '@vben/common-ui'; |
||||
|
|
||||
|
const cropperRef = ref<InstanceType<typeof VCropper>>(); |
||||
|
const imageUrl = ref('https://example.com/image.jpg'); |
||||
|
const croppedImage = ref(''); |
||||
|
|
||||
|
// 获取裁剪后的 Blob 对象 |
||||
|
const handleCropBlob = async () => { |
||||
|
const blob = await cropperRef.value?.getCropImage('image/jpeg', 0.9, 'blob'); |
||||
|
if (blob instanceof Blob) { |
||||
|
// 上传到服务器或创建预览URL |
||||
|
const url = URL.createObjectURL(blob); |
||||
|
croppedImage.value = url; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// 获取裁剪后的 base64 字符串 |
||||
|
const handleCropBase64 = async () => { |
||||
|
const base64 = await cropperRef.value?.getCropImage('image/png', 1, 'base64'); |
||||
|
if (typeof base64 === 'string') { |
||||
|
croppedImage.value = base64; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// 导出指定尺寸 |
||||
|
const handleCropWithSize = async () => { |
||||
|
const blob = await cropperRef.value?.getCropImage( |
||||
|
'image/jpeg', |
||||
|
0.9, |
||||
|
'blob', |
||||
|
200, // 目标宽度 |
||||
|
200, // 目标高度 |
||||
|
); |
||||
|
}; |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div> |
||||
|
<VCropper |
||||
|
ref="cropperRef" |
||||
|
:img="imageUrl" |
||||
|
:width="500" |
||||
|
:height="400" |
||||
|
aspect-ratio="1:1" |
||||
|
/> |
||||
|
<button @click="handleCropBlob">裁剪</button> |
||||
|
<img v-if="croppedImage" :src="croppedImage" /> |
||||
|
</div> |
||||
|
</template> |
||||
|
``` |
||||
@ -0,0 +1,199 @@ |
|||||
|
--- |
||||
|
outline: deep |
||||
|
--- |
||||
|
|
||||
|
# Vben Tiptap 富文本编辑器 |
||||
|
|
||||
|
基于 [Tiptap](https://tiptap.dev/) 构建的富文本编辑器组件,支持丰富的文本格式化、图片插入、图片上传等功能。 |
||||
|
|
||||
|
> 如果文档内没有参数说明,可以尝试在在线示例内寻找 |
||||
|
|
||||
|
::: info 写在前面 |
||||
|
|
||||
|
如果你觉得现有组件的封装不够理想,或者不完全符合你的需求,可以直接使用原生组件,亦或亲手封装一个适合的组件。框架提供的组件并非束缚,使用与否,完全取决于你的需求与自由。 |
||||
|
|
||||
|
::: |
||||
|
|
||||
|
## 基础用法 |
||||
|
|
||||
|
<DemoPreview dir="demos/vben-tiptap/basic" /> |
||||
|
|
||||
|
## 组件列表 |
||||
|
|
||||
|
### VbenTiptap |
||||
|
|
||||
|
富文本编辑器主组件。 |
||||
|
|
||||
|
### VbenTiptapPreview |
||||
|
|
||||
|
富文本内容预览组件,用于只读展示编辑器内容。 |
||||
|
|
||||
|
## API |
||||
|
|
||||
|
### Props |
||||
|
|
||||
|
| 属性名 | 说明 | 类型 | 默认值 | |
||||
|
| --- | --- | --- | --- | |
||||
|
| `modelValue` (v-model) | 编辑器内容(HTML字符串) | `string` | `''` | |
||||
|
| `editable` | 是否可编辑 | `boolean` | `true` | |
||||
|
| `toolbar` | 是否显示工具栏 | `boolean` | `true` | |
||||
|
| `previewable` | 是否显示预览按钮 | `boolean` | `true` | |
||||
|
| `placeholder` | 占位提示文本 | `string` | - | |
||||
|
| `minHeight` | 最小高度 | `number \| string` | `240` | |
||||
|
| `maxHeight` | 最大高度 | `number \| string` | `400` | |
||||
|
| `extensions` | 自定义 Tiptap 扩展配置 | `Extensions` | - | |
||||
|
| `imageUpload` | 图片上传配置 | `ImageUploadOptions` | - | |
||||
|
|
||||
|
### Events |
||||
|
|
||||
|
| 事件名 | 说明 | 参数类型 | |
||||
|
| -------- | -------------- | ----------------------- | |
||||
|
| `change` | 内容变化时触发 | `VbenTiptapChangeEvent` | |
||||
|
|
||||
|
#### VbenTiptapChangeEvent |
||||
|
|
||||
|
```ts |
||||
|
interface VbenTiptapChangeEvent { |
||||
|
html: string; // HTML 内容 |
||||
|
json: JSONContent; // JSON 结构内容 |
||||
|
text: string; // 纯文本内容 |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### ImageUploadOptions |
||||
|
|
||||
|
图片上传配置项: |
||||
|
|
||||
|
```ts |
||||
|
interface ImageUploadOptions { |
||||
|
/** 允许的文件类型,默认 'image/*' */ |
||||
|
accept?: string; |
||||
|
/** 最大文件大小(字节),默认 5MB */ |
||||
|
maxSize?: number; |
||||
|
/** 上传失败回调,未提供时使用 alert 弹窗提示 */ |
||||
|
onUploadError?: (error: unknown) => void; |
||||
|
/** 上传函数,返回图片 URL */ |
||||
|
upload: ( |
||||
|
file: File, |
||||
|
onProgress?: (percent: number) => void, |
||||
|
) => Promise<string>; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### VbenTiptapPreview Props |
||||
|
|
||||
|
| 属性名 | 说明 | 类型 | 默认值 | |
||||
|
| ----------- | ------------------ | ------------------ | ------ | |
||||
|
| `content` | 要预览的 HTML 内容 | `string` | `''` | |
||||
|
| `minHeight` | 最小高度 | `number \| string` | `160` | |
||||
|
| `class` | 自定义类名 | `any` | - | |
||||
|
|
||||
|
## 工具栏功能 |
||||
|
|
||||
|
编辑器工具栏提供以下功能: |
||||
|
|
||||
|
### 格式化 |
||||
|
|
||||
|
- **撤销/重做** - 撤销或重做编辑操作 |
||||
|
- **清除格式** - 清除选中文本的所有格式 |
||||
|
- **粗体** - 加粗文本 |
||||
|
- **斜体** - 斜体文本 |
||||
|
- **下划线** - 下划线文本 |
||||
|
- **删除线** - 删除线文本 |
||||
|
- **行内代码** - 行内代码标记 |
||||
|
|
||||
|
### 结构 |
||||
|
|
||||
|
- **标题** - 段落、H1-H4 标题切换 |
||||
|
- **有序列表** - 有序编号列表 |
||||
|
- **无序列表** - 无序符号列表 |
||||
|
- **引用块** - 引用块样式 |
||||
|
- **代码块** - 多行代码块 |
||||
|
|
||||
|
### 链接与图片 |
||||
|
|
||||
|
- **插入链接** - 插入或编辑超链接 |
||||
|
- **移除链接** - 移除选中文本的链接 |
||||
|
- **插入图片** - 通过 URL 插入图片 |
||||
|
|
||||
|
### 样式 |
||||
|
|
||||
|
- **文字颜色** - 设置文字颜色(预设色板) |
||||
|
- **背景颜色** - 设置文字背景高亮颜色 |
||||
|
|
||||
|
### 对齐 |
||||
|
|
||||
|
- **左对齐** - 文本左对齐 |
||||
|
- **居中对齐** - 文本居中对齐 |
||||
|
- **右对齐** - 文本右对齐 |
||||
|
|
||||
|
### 其他 |
||||
|
|
||||
|
- **预览** - 在弹窗中预览编辑内容 |
||||
|
|
||||
|
## 图片上传 |
||||
|
|
||||
|
<DemoPreview dir="demos/vben-tiptap/image-upload" /> |
||||
|
|
||||
|
当配置 `imageUpload` 时,工具栏的图片按钮会变为下拉菜单,包含「本地上传」和「URL 插入」两个选项。 |
||||
|
|
||||
|
### 上传方式 |
||||
|
|
||||
|
支持三种图片上传方式: |
||||
|
|
||||
|
1. **文件选择** - 点击工具栏本地上传按钮 |
||||
|
2. **拖拽上传** - 直接拖拽图片到编辑器区域 |
||||
|
3. **粘贴上传** - 粘贴图片到编辑器 |
||||
|
|
||||
|
### 上传进度显示 |
||||
|
|
||||
|
上传过程中会显示: |
||||
|
|
||||
|
- **加载指示器** - 旋转动画指示上传进行中 |
||||
|
- **进度条** - 当上传函数提供 `onProgress` 回调时,显示进度条 |
||||
|
|
||||
|
### 文件校验 |
||||
|
|
||||
|
- `accept` - 指定允许的文件类型(MIME类型) |
||||
|
- `maxSize` - 最大文件大小限制(字节) |
||||
|
- 校验失败时会触发 `onUploadError` 回调或默认 alert 提示 |
||||
|
|
||||
|
::: warning 注意事项 |
||||
|
|
||||
|
- 仅支持单张图片上传,多图拖拽/粘贴时会提示并仅处理第一张 |
||||
|
- 上传中不要保存编辑器内容(`getHTML()`),因为此时图片 URL 为临时 blob URL |
||||
|
- 自定义 `extensions` 时,图片上传功能将不显示(因为可能缺少 uploadImage 命令) |
||||
|
|
||||
|
::: |
||||
|
|
||||
|
## 自定义扩展 |
||||
|
|
||||
|
通过 `extensions` 属性可以传入自定义的 Tiptap 扩展配置: |
||||
|
|
||||
|
```vue |
||||
|
<script setup lang="ts"> |
||||
|
import { VbenTiptap } from '@vben/plugins/tiptap'; |
||||
|
import StarterKit from '@tiptap/starter-kit'; |
||||
|
import Underline from '@tiptap/extension-underline'; |
||||
|
|
||||
|
const extensions = [ |
||||
|
StarterKit, |
||||
|
Underline, |
||||
|
// 其他扩展... |
||||
|
]; |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<VbenTiptap v-model="content" :extensions="extensions" /> |
||||
|
</template> |
||||
|
``` |
||||
|
|
||||
|
::: warning 自定义扩展注意事项 |
||||
|
|
||||
|
使用自定义 `extensions` 时: |
||||
|
|
||||
|
- 默认扩展配置将不会生效 |
||||
|
- 图片上传功能不可用(工具栏不显示上传选项) |
||||
|
- 需自行配置所需的编辑器功能 |
||||
|
|
||||
|
::: |
||||
@ -0,0 +1,102 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { onBeforeUnmount, ref } from 'vue'; |
||||
|
|
||||
|
import { VCropper } from '@vben/common-ui'; |
||||
|
|
||||
|
const cropperRef = ref<InstanceType<typeof VCropper>>(); |
||||
|
const aspectRatio = ref('1:1'); |
||||
|
const imageUrl = ref('https://picsum.photos/seed/cropper-ratio/800/600'); |
||||
|
const croppedImage = ref(''); |
||||
|
|
||||
|
const aspectOptions = [ |
||||
|
{ label: '1:1 (正方形)', value: '1:1' }, |
||||
|
{ label: '16:9 (宽屏)', value: '16:9' }, |
||||
|
{ label: '4:3 (标准)', value: '4:3' }, |
||||
|
{ label: '3:4 (竖版)', value: '3:4' }, |
||||
|
{ label: '3:2 (照片)', value: '3:2' }, |
||||
|
]; |
||||
|
|
||||
|
// 释放旧的 object URL 以避免内存泄漏 |
||||
|
const revokeCroppedImage = () => { |
||||
|
if (croppedImage.value?.startsWith('blob:')) { |
||||
|
URL.revokeObjectURL(croppedImage.value); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handleCrop = async () => { |
||||
|
const blob = await cropperRef.value?.getCropImage('image/jpeg', 0.9, 'blob'); |
||||
|
if (blob instanceof Blob) { |
||||
|
// 释放旧的 URL |
||||
|
revokeCroppedImage(); |
||||
|
croppedImage.value = URL.createObjectURL(blob); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handleReset = () => { |
||||
|
// 释放 URL |
||||
|
revokeCroppedImage(); |
||||
|
croppedImage.value = ''; |
||||
|
imageUrl.value = `https://picsum.photos/seed/cropper-${Date.now()}/800/600`; |
||||
|
}; |
||||
|
|
||||
|
// 组件卸载时清理 |
||||
|
onBeforeUnmount(() => { |
||||
|
revokeCroppedImage(); |
||||
|
}); |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div> |
||||
|
<div class="mb-4"> |
||||
|
<label class="text-sm text-gray-500 mr-2">选择比例:</label> |
||||
|
<select v-model="aspectRatio" class="px-3 py-1 border rounded text-sm"> |
||||
|
<option |
||||
|
v-for="option in aspectOptions" |
||||
|
:key="option.value" |
||||
|
:value="option.value" |
||||
|
> |
||||
|
{{ option.label }} |
||||
|
</option> |
||||
|
</select> |
||||
|
</div> |
||||
|
|
||||
|
<VCropper |
||||
|
ref="cropperRef" |
||||
|
:img="imageUrl" |
||||
|
:width="500" |
||||
|
:height="300" |
||||
|
:aspect-ratio="aspectRatio" |
||||
|
/> |
||||
|
|
||||
|
<div class="mt-4 flex gap-2"> |
||||
|
<button |
||||
|
class="px-4 py-2 bg-blue-500 rounded hover:bg-blue-600" |
||||
|
@click="handleCrop" |
||||
|
> |
||||
|
裁剪图片 |
||||
|
</button> |
||||
|
<button |
||||
|
class="px-4 py-2 bg-gray-500 rounded hover:bg-gray-600" |
||||
|
@click="handleReset" |
||||
|
> |
||||
|
重置 |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="croppedImage" class="mt-4"> |
||||
|
<p class="text-sm text-gray-500 mb-2"> |
||||
|
裁剪结果 (比例: {{ aspectRatio }}): |
||||
|
</p> |
||||
|
<img :src="croppedImage" class="max-w-full rounded border" /> |
||||
|
</div> |
||||
|
|
||||
|
<div class="mt-4"> |
||||
|
<p class="text-sm text-gray-500">提示:</p> |
||||
|
<ul class="mt-2 text-xs text-gray-400 list-disc pl-4"> |
||||
|
<li>设置固定比例后,裁剪框始终维持该比例</li> |
||||
|
<li>切换比例会自动重新计算裁剪框大小</li> |
||||
|
<li>比例格式为 "宽:高",如 "16:9"</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
@ -0,0 +1,70 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { onBeforeUnmount, ref } from 'vue'; |
||||
|
|
||||
|
import { VCropper } from '@vben/common-ui'; |
||||
|
|
||||
|
const cropperRef = ref<InstanceType<typeof VCropper>>(); |
||||
|
const imageUrl = ref('https://picsum.photos/seed/cropper-demo/800/600'); |
||||
|
const croppedImage = ref(''); |
||||
|
|
||||
|
// 释放旧的 object URL 以避免内存泄漏 |
||||
|
const revokeCroppedImage = () => { |
||||
|
if (croppedImage.value?.startsWith('blob:')) { |
||||
|
URL.revokeObjectURL(croppedImage.value); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handleCrop = async () => { |
||||
|
const blob = await cropperRef.value?.getCropImage('image/jpeg', 0.9, 'blob'); |
||||
|
if (blob instanceof Blob) { |
||||
|
// 释放旧的 URL |
||||
|
revokeCroppedImage(); |
||||
|
croppedImage.value = URL.createObjectURL(blob); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handleReset = () => { |
||||
|
// 释放 URL |
||||
|
revokeCroppedImage(); |
||||
|
croppedImage.value = ''; |
||||
|
// 重新加载图片以重置裁剪框 |
||||
|
imageUrl.value = `https://picsum.photos/seed/cropper-${Date.now()}/800/600`; |
||||
|
}; |
||||
|
|
||||
|
// 组件卸载时清理 |
||||
|
onBeforeUnmount(() => { |
||||
|
revokeCroppedImage(); |
||||
|
}); |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div> |
||||
|
<VCropper ref="cropperRef" :img="imageUrl" :width="500" :height="300" /> |
||||
|
<div class="mt-4 flex gap-2"> |
||||
|
<button |
||||
|
class="px-4 py-2 bg-blue-500 rounded hover:bg-blue-600" |
||||
|
@click="handleCrop" |
||||
|
> |
||||
|
裁剪图片 |
||||
|
</button> |
||||
|
<button |
||||
|
class="px-4 py-2 bg-gray-500 rounded hover:bg-gray-600" |
||||
|
@click="handleReset" |
||||
|
> |
||||
|
重置 |
||||
|
</button> |
||||
|
</div> |
||||
|
<div v-if="croppedImage" class="mt-4"> |
||||
|
<p class="text-sm text-gray-500 mb-2">裁剪结果:</p> |
||||
|
<img :src="croppedImage" class="max-w-full rounded border" /> |
||||
|
</div> |
||||
|
<div class="mt-4"> |
||||
|
<p class="text-sm text-gray-500">提示:</p> |
||||
|
<ul class="mt-2 text-xs text-gray-400 list-disc pl-4"> |
||||
|
<li>拖拽裁剪框中心区域可移动裁剪位置</li> |
||||
|
<li>拖拽四角或四边可调整裁剪框大小</li> |
||||
|
<li>默认为自由比例,可调整为任意比例</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
@ -0,0 +1,19 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { ref } from 'vue'; |
||||
|
|
||||
|
import { VbenTiptap } from '@vben/plugins/tiptap'; |
||||
|
|
||||
|
const content = ref('<p>开始编辑你的内容...</p>'); |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div> |
||||
|
<VbenTiptap v-model="content" /> |
||||
|
<div class="mt-4"> |
||||
|
<p class="text-sm text-gray-500">当前内容:</p> |
||||
|
<pre class="mt-2 p-2 bg-gray-100 rounded text-xs overflow-auto max-h-40"> |
||||
|
{{ content }} |
||||
|
</pre> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
@ -0,0 +1,45 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { ref } from 'vue'; |
||||
|
|
||||
|
import { type ImageUploadOptions, VbenTiptap } from '@vben/plugins/tiptap'; |
||||
|
|
||||
|
const content = ref(''); |
||||
|
|
||||
|
// Mock upload function with progress simulation |
||||
|
const imageUpload: ImageUploadOptions = { |
||||
|
accept: 'image/jpeg,image/png,image/gif,image/webp', |
||||
|
maxSize: 5 * 1024 * 1024, // 5MB |
||||
|
upload: async (_file, onProgress) => { |
||||
|
// Simulate upload progress |
||||
|
for (let i = 0; i <= 100; i += 10) { |
||||
|
await new Promise((resolve) => setTimeout(resolve, 100)); |
||||
|
onProgress?.(i); |
||||
|
} |
||||
|
|
||||
|
// Return a mock image URL (using picsum for demo) |
||||
|
return `https://picsum.photos/seed/${Date.now()}/800/400`; |
||||
|
}, |
||||
|
onUploadError: (error) => { |
||||
|
console.error('Upload error:', error); |
||||
|
}, |
||||
|
}; |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div> |
||||
|
<VbenTiptap |
||||
|
v-model="content" |
||||
|
:image-upload="imageUpload" |
||||
|
placeholder="尝试拖拽或粘贴图片..." |
||||
|
/> |
||||
|
<div class="mt-4"> |
||||
|
<p class="text-sm text-gray-500">提示:</p> |
||||
|
<ul class="mt-2 text-xs text-gray-400 list-disc pl-4"> |
||||
|
<li>点击工具栏图片按钮可选择本地上传或 URL 插入</li> |
||||
|
<li>拖拽图片到编辑器区域可直接上传</li> |
||||
|
<li>粘贴图片也会触发上传</li> |
||||
|
<li>上传过程中会显示进度条</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
@ -0,0 +1,159 @@ |
|||||
|
--- |
||||
|
outline: deep |
||||
|
--- |
||||
|
|
||||
|
# Vben Cropper Image Cropping |
||||
|
|
||||
|
`VCropper` is a pure native image cropping component that supports both free and fixed aspect ratio cropping, with method-based access to cropped results. |
||||
|
|
||||
|
> If some details are not obvious from the docs, check the live demos as well. |
||||
|
|
||||
|
::: info Note |
||||
|
|
||||
|
If you feel the current component implementation doesn't meet your needs, you can use native components directly or create your own component. The components provided by the framework are not constraints - use them at your discretion. |
||||
|
|
||||
|
::: |
||||
|
|
||||
|
## Basic Usage |
||||
|
|
||||
|
Basic image cropping with free aspect ratio adjustment. |
||||
|
|
||||
|
<DemoPreview dir="demos/vben-cropper/basic" /> |
||||
|
|
||||
|
## Fixed Aspect Ratio |
||||
|
|
||||
|
Set the cropping ratio via the `aspectRatio` prop. The format is `"width:height"`, e.g. `"1:1"`, `"16:9"`, `"3:4"`. |
||||
|
|
||||
|
<DemoPreview dir="demos/vben-cropper/aspect-ratio" /> |
||||
|
|
||||
|
## API |
||||
|
|
||||
|
### Props |
||||
|
|
||||
|
| Property | Description | Type | Default | |
||||
|
| ------------- | ---------------------------------- | -------- | ------- | |
||||
|
| `img` | Image URL (required) | `string` | - | |
||||
|
| `width` | Container width | `number` | `500` | |
||||
|
| `height` | Container height | `number` | `400` | |
||||
|
| `aspectRatio` | Crop ratio, e.g. `"1:1"`, `"16:9"` | `string` | - | |
||||
|
|
||||
|
### Methods |
||||
|
|
||||
|
Call component methods via `ref`: |
||||
|
|
||||
|
```vue |
||||
|
<script setup lang="ts"> |
||||
|
import { ref } from 'vue'; |
||||
|
import { VCropper } from '@vben/common-ui'; |
||||
|
|
||||
|
const cropperRef = ref<InstanceType<typeof VCropper>>(); |
||||
|
|
||||
|
const handleCrop = async () => { |
||||
|
const result = await cropperRef.value?.getCropImage(); |
||||
|
// result is a Blob or base64 string |
||||
|
}; |
||||
|
</script> |
||||
|
``` |
||||
|
|
||||
|
#### getCropImage |
||||
|
|
||||
|
Crop and retrieve the image. |
||||
|
|
||||
|
```ts |
||||
|
getCropImage( |
||||
|
format?: 'image/jpeg' | 'image/png', |
||||
|
quality?: number, |
||||
|
outputType?: 'base64' | 'blob', |
||||
|
targetWidth?: number, |
||||
|
targetHeight?: number, |
||||
|
): Promise<Blob | string | undefined> |
||||
|
``` |
||||
|
|
||||
|
**Parameters:** |
||||
|
|
||||
|
| Parameter | Type | Default | Description | |
||||
|
| --- | --- | --- | --- | |
||||
|
| `format` | `'image/jpeg' \| 'image/png'` | `'image/png'` | Output image format | |
||||
|
| `quality` | `number` | `0.92` | Compression quality (0-1), only effective for jpeg | |
||||
|
| `outputType` | `'base64' \| 'blob'` | `'blob'` | Output type, base64 string or Blob object | |
||||
|
| `targetWidth` | `number` | - | Target width, defaults to original crop width if omitted | |
||||
|
| `targetHeight` | `number` | - | Target height, defaults to original crop height if omitted | |
||||
|
|
||||
|
## Features |
||||
|
|
||||
|
### Cropping Operations |
||||
|
|
||||
|
- **Drag to Move** - Drag the center area of the crop box to move its position |
||||
|
- **Corner Resize** - Drag the four corners to resize the crop box |
||||
|
- **Edge Resize** - Drag the midpoints of edges to adjust a single side |
||||
|
|
||||
|
### Aspect Ratio Control |
||||
|
|
||||
|
- **Free Ratio** - Without `aspectRatio`, adjust the crop box to any ratio |
||||
|
- **Fixed Ratio** - With `aspectRatio` set, the crop box maintains the specified ratio |
||||
|
|
||||
|
### HiDPI Support |
||||
|
|
||||
|
The component automatically adapts to Retina and other high-DPI screens, ensuring crisp output images. |
||||
|
|
||||
|
### Image Fitting |
||||
|
|
||||
|
- Images are automatically scaled to fit within the container |
||||
|
- Supports both local and remote images |
||||
|
- Remote images require CORS support from the server to export cropped results |
||||
|
|
||||
|
## Usage Example |
||||
|
|
||||
|
```vue |
||||
|
<script setup lang="ts"> |
||||
|
import { ref } from 'vue'; |
||||
|
import { VCropper } from '@vben/common-ui'; |
||||
|
|
||||
|
const cropperRef = ref<InstanceType<typeof VCropper>>(); |
||||
|
const imageUrl = ref('https://example.com/image.jpg'); |
||||
|
const croppedImage = ref(''); |
||||
|
|
||||
|
// Get cropped Blob |
||||
|
const handleCropBlob = async () => { |
||||
|
const blob = await cropperRef.value?.getCropImage('image/jpeg', 0.9, 'blob'); |
||||
|
if (blob instanceof Blob) { |
||||
|
// Upload to server or create preview URL |
||||
|
const url = URL.createObjectURL(blob); |
||||
|
croppedImage.value = url; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// Get cropped base64 string |
||||
|
const handleCropBase64 = async () => { |
||||
|
const base64 = await cropperRef.value?.getCropImage('image/png', 1, 'base64'); |
||||
|
if (typeof base64 === 'string') { |
||||
|
croppedImage.value = base64; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// Export with specific dimensions |
||||
|
const handleCropWithSize = async () => { |
||||
|
const blob = await cropperRef.value?.getCropImage( |
||||
|
'image/jpeg', |
||||
|
0.9, |
||||
|
'blob', |
||||
|
200, // target width |
||||
|
200, // target height |
||||
|
); |
||||
|
}; |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div> |
||||
|
<VCropper |
||||
|
ref="cropperRef" |
||||
|
:img="imageUrl" |
||||
|
:width="500" |
||||
|
:height="400" |
||||
|
aspect-ratio="1:1" |
||||
|
/> |
||||
|
<button @click="handleCropBlob">Crop</button> |
||||
|
<img v-if="croppedImage" :src="croppedImage" /> |
||||
|
</div> |
||||
|
</template> |
||||
|
``` |
||||
@ -0,0 +1,199 @@ |
|||||
|
--- |
||||
|
outline: deep |
||||
|
--- |
||||
|
|
||||
|
# Vben Tiptap Rich Text Editor |
||||
|
|
||||
|
A rich text editor component built on [Tiptap](https://tiptap.dev/), supporting rich text formatting, image insertion, and image upload features. |
||||
|
|
||||
|
> If some details are not obvious from the docs, check the live demos as well. |
||||
|
|
||||
|
::: info Note |
||||
|
|
||||
|
If you feel the current component implementation doesn't meet your needs, you can use native components directly or create your own component. The components provided by the framework are not constraints - use them at your discretion. |
||||
|
|
||||
|
::: |
||||
|
|
||||
|
## Basic Usage |
||||
|
|
||||
|
<DemoPreview dir="demos/vben-tiptap/basic" /> |
||||
|
|
||||
|
## Component List |
||||
|
|
||||
|
### VbenTiptap |
||||
|
|
||||
|
Main rich text editor component. |
||||
|
|
||||
|
### VbenTiptapPreview |
||||
|
|
||||
|
Read-only preview component for displaying editor content. |
||||
|
|
||||
|
## API |
||||
|
|
||||
|
### Props |
||||
|
|
||||
|
| Property | Description | Type | Default | |
||||
|
| --- | --- | --- | --- | |
||||
|
| `modelValue` (v-model) | Editor content (HTML string) | `string` | `''` | |
||||
|
| `editable` | Whether the editor is editable | `boolean` | `true` | |
||||
|
| `toolbar` | Whether to show the toolbar | `boolean` | `true` | |
||||
|
| `previewable` | Whether to show the preview button | `boolean` | `true` | |
||||
|
| `placeholder` | Placeholder text | `string` | - | |
||||
|
| `minHeight` | Minimum height | `number \| string` | `240` | |
||||
|
| `maxHeight` | Maximum height | `number \| string` | `400` | |
||||
|
| `extensions` | Custom Tiptap extensions | `Extensions` | - | |
||||
|
| `imageUpload` | Image upload configuration | `ImageUploadOptions` | - | |
||||
|
|
||||
|
### Events |
||||
|
|
||||
|
| Event | Description | Parameters | |
||||
|
| -------- | ------------------------------ | ----------------------- | |
||||
|
| `change` | Triggered when content changes | `VbenTiptapChangeEvent` | |
||||
|
|
||||
|
#### VbenTiptapChangeEvent |
||||
|
|
||||
|
```ts |
||||
|
interface VbenTiptapChangeEvent { |
||||
|
html: string; // HTML content |
||||
|
json: JSONContent; // JSON structure |
||||
|
text: string; // Plain text content |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### ImageUploadOptions |
||||
|
|
||||
|
Image upload configuration: |
||||
|
|
||||
|
```ts |
||||
|
interface ImageUploadOptions { |
||||
|
/** Allowed file types, default 'image/*' */ |
||||
|
accept?: string; |
||||
|
/** Max file size in bytes, default 5MB */ |
||||
|
maxSize?: number; |
||||
|
/** Upload error callback, uses alert if not provided */ |
||||
|
onUploadError?: (error: unknown) => void; |
||||
|
/** Upload function, returns image URL */ |
||||
|
upload: ( |
||||
|
file: File, |
||||
|
onProgress?: (percent: number) => void, |
||||
|
) => Promise<string>; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### VbenTiptapPreview Props |
||||
|
|
||||
|
| Property | Description | Type | Default | |
||||
|
| ----------- | ----------------------- | ------------------ | ------- | |
||||
|
| `content` | HTML content to preview | `string` | `''` | |
||||
|
| `minHeight` | Minimum height | `number \| string` | `160` | |
||||
|
| `class` | Custom class name | `any` | - | |
||||
|
|
||||
|
## Toolbar Features |
||||
|
|
||||
|
The editor toolbar provides the following features: |
||||
|
|
||||
|
### Formatting |
||||
|
|
||||
|
- **Undo/Redo** - Undo or redo editing operations |
||||
|
- **Clear Formatting** - Remove all formatting from selected text |
||||
|
- **Bold** - Bold text |
||||
|
- **Italic** - Italic text |
||||
|
- **Underline** - Underline text |
||||
|
- **Strikethrough** - Strikethrough text |
||||
|
- **Inline Code** - Inline code mark |
||||
|
|
||||
|
### Structure |
||||
|
|
||||
|
- **Headings** - Paragraph, H1-H4 heading switching |
||||
|
- **Ordered List** - Numbered list |
||||
|
- **Bullet List** - Bulleted list |
||||
|
- **Blockquote** - Quote block style |
||||
|
- **Code Block** - Multi-line code block |
||||
|
|
||||
|
### Links & Images |
||||
|
|
||||
|
- **Insert Link** - Insert or edit hyperlinks |
||||
|
- **Remove Link** - Remove link from selected text |
||||
|
- **Insert Image** - Insert image via URL |
||||
|
|
||||
|
### Style |
||||
|
|
||||
|
- **Text Color** - Set text color (preset palette) |
||||
|
- **Highlight Color** - Set text background highlight color |
||||
|
|
||||
|
### Alignment |
||||
|
|
||||
|
- **Align Left** - Left align text |
||||
|
- **Align Center** - Center align text |
||||
|
- **Align Right** - Right align text |
||||
|
|
||||
|
### Other |
||||
|
|
||||
|
- **Preview** - Preview content in a modal |
||||
|
|
||||
|
## Image Upload |
||||
|
|
||||
|
<DemoPreview dir="demos/vben-tiptap/image-upload" /> |
||||
|
|
||||
|
When `imageUpload` is configured, the toolbar image button becomes a dropdown menu with "Upload" and "URL" options. |
||||
|
|
||||
|
### Upload Methods |
||||
|
|
||||
|
Three image upload methods are supported: |
||||
|
|
||||
|
1. **File Selection** - Click the upload button in toolbar |
||||
|
2. **Drag & Drop** - Drag images directly into the editor |
||||
|
3. **Paste** - Paste images into the editor |
||||
|
|
||||
|
### Upload Progress Display |
||||
|
|
||||
|
During upload: |
||||
|
|
||||
|
- **Loading Indicator** - Spinner animation indicating upload in progress |
||||
|
- **Progress Bar** - Shows progress bar when upload function provides `onProgress` callback |
||||
|
|
||||
|
### File Validation |
||||
|
|
||||
|
- `accept` - Specify allowed file types (MIME types) |
||||
|
- `maxSize` - Maximum file size limit (bytes) |
||||
|
- Validation failure triggers `onUploadError` callback or default alert |
||||
|
|
||||
|
::: warning Important Notes |
||||
|
|
||||
|
- Only single image upload is supported; multi-image drag/paste will show a prompt and process only the first image |
||||
|
- Do not save editor content (`getHTML()`) during upload as image URLs are temporary blob URLs |
||||
|
- When using custom `extensions`, the image upload feature will not be available (toolbar won't show upload option) |
||||
|
|
||||
|
::: |
||||
|
|
||||
|
## Custom Extensions |
||||
|
|
||||
|
Pass custom Tiptap extension configurations via the `extensions` property: |
||||
|
|
||||
|
```vue |
||||
|
<script setup lang="ts"> |
||||
|
import { VbenTiptap } from '@vben/plugins/tiptap'; |
||||
|
import StarterKit from '@tiptap/starter-kit'; |
||||
|
import Underline from '@tiptap/extension-underline'; |
||||
|
|
||||
|
const extensions = [ |
||||
|
StarterKit, |
||||
|
Underline, |
||||
|
// Other extensions... |
||||
|
]; |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<VbenTiptap v-model="content" :extensions="extensions" /> |
||||
|
</template> |
||||
|
``` |
||||
|
|
||||
|
::: warning Custom Extensions Note |
||||
|
|
||||
|
When using custom `extensions`: |
||||
|
|
||||
|
- Default extension configuration will not take effect |
||||
|
- Image upload feature is not available (toolbar won't show upload option) |
||||
|
- You need to configure all required editor features yourself |
||||
|
|
||||
|
::: |
||||
@ -0,0 +1,70 @@ |
|||||
|
import type { Plugin } from 'vite'; |
||||
|
|
||||
|
function viteDayjsPlugin(): Plugin { |
||||
|
return { |
||||
|
name: 'vite-dayjs-plugin', |
||||
|
enforce: 'pre', |
||||
|
async resolveId(source, importer, options) { |
||||
|
// 1) 已经使用了 dayjs/esm 的不处理
|
||||
|
if (source.startsWith('dayjs/esm')) return null; |
||||
|
|
||||
|
// 2) 根入口:dayjs -> dayjs/esm
|
||||
|
if (source === 'dayjs') { |
||||
|
return await this.resolve('dayjs/esm', importer, { |
||||
|
skipSelf: true, |
||||
|
...options, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 3) 插件入口的多种写法
|
||||
|
// - dayjs/plugin/xxx.js -> dayjs/esm/plugin/xxx/index.js
|
||||
|
// - dayjs/plugin/xxx -> dayjs/esm/plugin/xxx
|
||||
|
const pluginWithJs = source.match(/^dayjs\/plugin\/([^/]+)\.js$/); |
||||
|
if (pluginWithJs) { |
||||
|
const target = `dayjs/esm/plugin/${pluginWithJs[1]}/index.js`; |
||||
|
return await this.resolve(target, importer, { |
||||
|
skipSelf: true, |
||||
|
...options, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
const pluginBare = source.match(/^dayjs\/plugin\/([^/]+)$/); |
||||
|
if (pluginBare) { |
||||
|
const target = `dayjs/esm/plugin/${pluginBare[1]}`; |
||||
|
return await this.resolve(target, importer, { |
||||
|
skipSelf: true, |
||||
|
...options, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 4) 处理多语言包
|
||||
|
// - dayjs/locale/xxx.js -> dayjs/esm/locale/xxx.js
|
||||
|
const localeWithJs = source.match(/^dayjs\/locale\/([^/]+)\.js$/); |
||||
|
if (localeWithJs) { |
||||
|
const target = `dayjs/esm/locale/${localeWithJs[1]}.js`; |
||||
|
return await this.resolve(target, importer, { |
||||
|
skipSelf: true, |
||||
|
...options, |
||||
|
}); |
||||
|
} |
||||
|
const localeBare = source.match(/^dayjs\/locale\/([^/]+)$/); |
||||
|
if (localeBare) { |
||||
|
const target = `dayjs/esm/locale/${localeBare[1]}`; |
||||
|
return await this.resolve(target, importer, { |
||||
|
skipSelf: true, |
||||
|
...options, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
return null; |
||||
|
}, |
||||
|
config() { |
||||
|
return { |
||||
|
optimizeDeps: { |
||||
|
exclude: ['dayjs'], |
||||
|
}, |
||||
|
}; |
||||
|
}, |
||||
|
}; |
||||
|
} |
||||
|
export { viteDayjsPlugin }; |
||||
@ -0,0 +1,418 @@ |
|||||
|
# Cache 模块 |
||||
|
|
||||
|
基于**策略模式**的异步存储管理方案,支持多种存储后端(localStorage、IndexedDB、Memory),提供统一的 API 接口。 |
||||
|
|
||||
|
## 架构设计 |
||||
|
|
||||
|
```shell |
||||
|
┌───────────────────────────────────────────────┐ |
||||
|
│ StorageManager │ |
||||
|
│ ┌─────────────┐ ┌───────────────────────┐ │ |
||||
|
│ │ Prefix 隔离 │ │ TTL 过期管理 │ │ |
||||
|
│ └─────────────┘ └───────────────────────┘ │ |
||||
|
├───────────────────────────────────────────────┤ |
||||
|
│ IStorageDriver │ |
||||
|
├──────────┬─────────────────┬──────────────────┤ |
||||
|
│ Local │ IndexedDB │ Memory │ |
||||
|
│ Storage │ Driver │ Driver │ |
||||
|
│ Driver │ │ │ |
||||
|
└──────────┴─────────────────┴──────────────────┘ |
||||
|
``` |
||||
|
|
||||
|
**分层职责:** |
||||
|
|
||||
|
| 层级 | 职责 | |
||||
|
| ---------------- | -------------------------------------------- | |
||||
|
| `StorageManager` | 命名空间前缀隔离、TTL 过期检查、统一对外 API | |
||||
|
| `IStorageDriver` | 纯粹的 KV 存取抽象接口 | |
||||
|
| 各 Driver 实现 | 对接具体存储引擎,不感知前缀和 TTL | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 快速开始 |
||||
|
|
||||
|
### 基本使用(默认 localStorage) |
||||
|
|
||||
|
```typescript |
||||
|
import { StorageManager } from '@vben-core/shared/cache'; |
||||
|
|
||||
|
const cache = new StorageManager({ prefix: 'myapp' }); |
||||
|
// 使用 IndexedDB |
||||
|
//new StorageManager({ driver: new IndexedDBDriver(), prefix: 'app' }); |
||||
|
|
||||
|
// 使用 sessionStorage |
||||
|
//new StorageManager({ driver: new LocalStorageDriver({ storageType: 'sessionStorage' }), prefix: 'app' }); |
||||
|
|
||||
|
// 测试环境 |
||||
|
//new StorageManager({ driver: new MemoryStorageDriver(), prefix: 'test' }); |
||||
|
|
||||
|
// 存储数据 |
||||
|
await cache.setItem('user', { name: '张三', age: 28 }); |
||||
|
|
||||
|
// 读取数据 |
||||
|
const user = await cache.getItem('user'); |
||||
|
// => { name: '张三', age: 28 } |
||||
|
|
||||
|
// 带默认值读取 |
||||
|
const settings = await cache.getItem('settings', { theme: 'light' }); |
||||
|
// 如果不存在,返回 { theme: 'light' } |
||||
|
|
||||
|
// 删除数据 |
||||
|
await cache.removeItem('user'); |
||||
|
|
||||
|
// 清除当前前缀下所有数据 |
||||
|
await cache.clear(); |
||||
|
``` |
||||
|
|
||||
|
### 带 TTL 过期 |
||||
|
|
||||
|
```typescript |
||||
|
const cache = new StorageManager({ prefix: 'session' }); |
||||
|
|
||||
|
// 设置 5 分钟后过期(TTL 单位为毫秒) |
||||
|
await cache.setItem('token', 'abc123', 5 * 60 * 1000); |
||||
|
|
||||
|
// 5 分钟内可以正常读取 |
||||
|
const token = await cache.getItem('token'); |
||||
|
// => 'abc123' |
||||
|
|
||||
|
// 5 分钟后自动返回 null(惰性删除) |
||||
|
const expiredToken = await cache.getItem('token'); |
||||
|
// => null |
||||
|
|
||||
|
// 主动清理所有过期项 |
||||
|
await cache.clearExpiredItems(); |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 存储驱动 |
||||
|
|
||||
|
### LocalStorageDriver(默认) |
||||
|
|
||||
|
基于浏览器 `localStorage` 或 `sessionStorage`,数据持久化存储。 |
||||
|
|
||||
|
```typescript |
||||
|
import { LocalStorageDriver, StorageManager } from '@vben-core/shared/cache'; |
||||
|
|
||||
|
// 使用 localStorage(默认) |
||||
|
const cache = new StorageManager({ |
||||
|
driver: new LocalStorageDriver(), |
||||
|
prefix: 'app', |
||||
|
}); |
||||
|
|
||||
|
// 使用 sessionStorage |
||||
|
const sessionCache = new StorageManager({ |
||||
|
driver: new LocalStorageDriver({ storageType: 'sessionStorage' }), |
||||
|
prefix: 'app', |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
**特点:** |
||||
|
|
||||
|
- 同步 API 用 async 包装,保持接口统一 |
||||
|
- 自动处理 JSON 序列化/反序列化 |
||||
|
- 数据损坏时自动清除并返回 null |
||||
|
- 存储上限约 5-10MB(视浏览器而定) |
||||
|
|
||||
|
**适用场景:** 用户偏好设置、小型配置数据、Token 存储 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### IndexedDBDriver |
||||
|
|
||||
|
基于浏览器 IndexedDB,支持大容量结构化数据存储。 |
||||
|
|
||||
|
```typescript |
||||
|
import {IndexedDBDriver, StorageManager} from '@vben-core/shared/cache'; |
||||
|
|
||||
|
const cache = new StorageManager({ |
||||
|
driver: new IndexedDBDriver({ |
||||
|
dbName: 'my-app-db', // 数据库名称,默认 'vben-storage' |
||||
|
dbVersion: 1, // 数据库版本,默认 1 |
||||
|
storeName: 'cache-store', // 对象存储名称,默认 'kv-store' |
||||
|
}), |
||||
|
prefix: 'data', |
||||
|
}); |
||||
|
|
||||
|
// 存储大量数据 |
||||
|
await cache.setItem('table-data', largeDataArray); |
||||
|
|
||||
|
// 存储二进制友好的结构(IndexedDB 原生支持) |
||||
|
await cache.setItem('config', { |
||||
|
columns: [...], |
||||
|
filters: [...], |
||||
|
pagination: {page: 1, size: 20}, |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
**特点:** |
||||
|
|
||||
|
- 懒初始化:首次操作时自动打开数据库,无需手动调用 `init()` |
||||
|
- 存储容量大(通常数百 MB 到 GB 级别) |
||||
|
- 支持结构化克隆(可存储 Date、RegExp、Blob 等复杂类型) |
||||
|
- 天然异步,不阻塞主线程 |
||||
|
|
||||
|
**适用场景:** 离线数据缓存、大型表格数据、文件/图片缓存、复杂业务数据 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### MemoryStorageDriver |
||||
|
|
||||
|
基于内存 Map,数据不持久化,页面刷新即丢失。 |
||||
|
|
||||
|
```typescript |
||||
|
import { MemoryStorageDriver, StorageManager } from '@vben-core/shared/cache'; |
||||
|
|
||||
|
const cache = new StorageManager({ |
||||
|
driver: new MemoryStorageDriver(), |
||||
|
prefix: 'test', |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
**特点:** |
||||
|
|
||||
|
- 读写速度最快 |
||||
|
- 无浏览器 API 依赖 |
||||
|
- 数据随页面生命周期销毁 |
||||
|
|
||||
|
**适用场景:** 单元测试、SSR 服务端渲染、临时运行时缓存 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## API 参考 |
||||
|
|
||||
|
### StorageManager |
||||
|
|
||||
|
#### 构造函数 |
||||
|
|
||||
|
```typescript |
||||
|
new StorageManager(options?: StorageManagerOptions) |
||||
|
``` |
||||
|
|
||||
|
| 参数 | 类型 | 默认值 | 说明 | |
||||
|
| --- | --- | --- | --- | |
||||
|
| `driver` | `IStorageDriver` | `new LocalStorageDriver()` | 存储驱动实例 | |
||||
|
| `prefix` | `string` | `''` | 键前缀,用于命名空间隔离 | |
||||
|
|
||||
|
#### 方法 |
||||
|
|
||||
|
| 方法 | 签名 | 说明 | |
||||
|
| --- | --- | --- | |
||||
|
| `getItem` | `getItem<T>(key: string, defaultValue?: T \| null): Promise<T \| null>` | 获取存储项,过期或不存在返回默认值 | |
||||
|
| `setItem` | `setItem<T>(key: string, value: T, ttl?: number): Promise<void>` | 设置存储项,可选 TTL(毫秒) | |
||||
|
| `removeItem` | `removeItem(key: string): Promise<void>` | 删除指定存储项 | |
||||
|
| `clear` | `clear(): Promise<void>` | 清除当前前缀下所有存储项 | |
||||
|
| `clearExpiredItems` | `clearExpiredItems(): Promise<void>` | 主动清理所有过期项 | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### IStorageDriver 接口 |
||||
|
|
||||
|
自定义驱动需要实现此接口: |
||||
|
|
||||
|
```typescript |
||||
|
interface IStorageDriver { |
||||
|
clear(): Promise<void>; |
||||
|
|
||||
|
getItem<T>(key: string): Promise<null | T>; |
||||
|
|
||||
|
keys(): Promise<string[]>; |
||||
|
|
||||
|
removeItem(key: string): Promise<void>; |
||||
|
|
||||
|
setItem<T>(key: string, value: T): Promise<void>; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 高级用法 |
||||
|
|
||||
|
### 自定义 Driver |
||||
|
|
||||
|
```typescript |
||||
|
import type { IStorageDriver } from '@vben-core/shared/cache'; |
||||
|
|
||||
|
class CookieStorageDriver implements IStorageDriver { |
||||
|
async getItem<T>(key: string): Promise<null | T> { |
||||
|
const value = getCookie(key); |
||||
|
return value ? JSON.parse(value) : null; |
||||
|
} |
||||
|
|
||||
|
async setItem<T>(key: string, value: T): Promise<void> { |
||||
|
setCookie(key, JSON.stringify(value)); |
||||
|
} |
||||
|
|
||||
|
async removeItem(key: string): Promise<void> { |
||||
|
deleteCookie(key); |
||||
|
} |
||||
|
|
||||
|
async clear(): Promise<void> { |
||||
|
clearAllCookies(); |
||||
|
} |
||||
|
|
||||
|
async keys(): Promise<string[]> { |
||||
|
return getAllCookieNames(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 使用自定义 Driver |
||||
|
const cache = new StorageManager({ |
||||
|
driver: new CookieStorageDriver(), |
||||
|
prefix: 'ck', |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
### 根据环境动态选择 Driver |
||||
|
|
||||
|
```typescript |
||||
|
import { |
||||
|
IndexedDBDriver, |
||||
|
LocalStorageDriver, |
||||
|
MemoryStorageDriver, |
||||
|
StorageManager, |
||||
|
} from '@vben-core/shared/cache'; |
||||
|
|
||||
|
function createStorageManager(prefix: string) { |
||||
|
// SSR 环境使用内存驱动 |
||||
|
if (typeof window === 'undefined') { |
||||
|
return new StorageManager({ |
||||
|
driver: new MemoryStorageDriver(), |
||||
|
prefix, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 大数据场景使用 IndexedDB |
||||
|
if (needsLargeStorage()) { |
||||
|
return new StorageManager({ |
||||
|
driver: new IndexedDBDriver({ dbName: `${prefix}-db` }), |
||||
|
prefix, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 默认使用 localStorage |
||||
|
return new StorageManager({ prefix }); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 命名空间隔离 |
||||
|
|
||||
|
```typescript |
||||
|
// 不同模块使用不同前缀,互不干扰 |
||||
|
const userCache = new StorageManager({ prefix: 'user' }); |
||||
|
const configCache = new StorageManager({ prefix: 'config' }); |
||||
|
|
||||
|
await userCache.setItem('profile', { name: '张三' }); |
||||
|
await configCache.setItem('profile', { theme: 'dark' }); |
||||
|
|
||||
|
// 各自独立 |
||||
|
await userCache.getItem('profile'); // => { name: '张三' } |
||||
|
await configCache.getItem('profile'); // => { theme: 'dark' } |
||||
|
|
||||
|
// 只清除 user 前缀的数据 |
||||
|
await userCache.clear(); |
||||
|
await configCache.getItem('profile'); // => { theme: 'dark' }(不受影响) |
||||
|
``` |
||||
|
|
||||
|
### 定时清理过期数据 |
||||
|
|
||||
|
```typescript |
||||
|
const cache = new StorageManager({ prefix: 'app' }); |
||||
|
|
||||
|
// 应用启动时清理一次 |
||||
|
await cache.clearExpiredItems(); |
||||
|
|
||||
|
// 或者定时清理(每 10 分钟) |
||||
|
setInterval( |
||||
|
async () => { |
||||
|
await cache.clearExpiredItems(); |
||||
|
}, |
||||
|
10 * 60 * 1000, |
||||
|
); |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 数据存储格式 |
||||
|
|
||||
|
`StorageManager` 在 Driver 层存储的数据结构为: |
||||
|
|
||||
|
```typescript |
||||
|
interface StorageItem<T> { |
||||
|
expiry?: number; // 过期时间戳(毫秒),undefined 表示永不过期 |
||||
|
value: T; // 实际业务数据 |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
实际存储的 key 格式为:`{prefix}-{key}` |
||||
|
|
||||
|
例如 `prefix = 'app'`,`key = 'user'`,则实际存储键为 `app-user`。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 过期策略 |
||||
|
|
||||
|
采用**惰性删除 + 主动清理**双重策略: |
||||
|
|
||||
|
| 策略 | 触发时机 | 说明 | |
||||
|
| --- | --- | --- | |
||||
|
| 惰性删除 | 调用 `getItem` 时 | 读取时检查过期,过期则删除并返回默认值 | |
||||
|
| 主动清理 | 调用 `clearExpiredItems` 时 | 遍历所有带前缀的 key,删除已过期项 | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 各 Driver 对比 |
||||
|
|
||||
|
| 特性 | LocalStorageDriver | IndexedDBDriver | MemoryStorageDriver | |
||||
|
| ---------- | ------------------- | ---------------- | ------------------- | |
||||
|
| 持久化 | ✅ | ✅ | ❌ | |
||||
|
| 容量 | 5-10 MB | 数百 MB+ | 受内存限制 | |
||||
|
| 速度 | 快(同步) | 中等(异步 I/O) | 最快 | |
||||
|
| 数据类型 | 仅 JSON 可序列化 | 结构化克隆 | 任意 JS 对象 | |
||||
|
| 浏览器支持 | 所有现代浏览器 | 所有现代浏览器 | 任意环境 | |
||||
|
| 阻塞主线程 | 是 | 否 | 否 | |
||||
|
| 适用场景 | 配置、Token、小数据 | 离线缓存、大数据 | 测试、SSR | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 在项目中的使用 |
||||
|
|
||||
|
本项目中 `StorageManager` 主要被 `PreferenceManager` 消费,用于持久化用户偏好设置: |
||||
|
|
||||
|
```typescript |
||||
|
// packages/@core/preferences/src/preferences.ts |
||||
|
class PreferenceManager { |
||||
|
private cache: StorageManager; |
||||
|
|
||||
|
constructor() { |
||||
|
this.cache = new StorageManager(); |
||||
|
this.state = reactive<Preferences>({ ...defaultPreferences }); |
||||
|
} |
||||
|
|
||||
|
initPreferences = async ({ namespace }) => { |
||||
|
// 用应用命名空间重新初始化 |
||||
|
this.cache = new StorageManager({ prefix: namespace }); |
||||
|
|
||||
|
// 从缓存加载偏好设置 |
||||
|
const cached = await this.cache.getItem<Preferences>('preferences'); |
||||
|
// ... |
||||
|
}; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 注意事项 |
||||
|
|
||||
|
1. **所有方法都是异步的** — 即使底层是同步的 localStorage,API 也返回 Promise,确保切换 Driver 时无需改动调用方。 |
||||
|
|
||||
|
2. **TTL 单位是毫秒** — `setItem('key', value, 60000)` 表示 60 秒后过期。 |
||||
|
|
||||
|
3. **IndexedDB 懒初始化** — 不需要手动调用 `init()` 或 `open()`,首次操作时自动打开数据库连接并复用。 |
||||
|
|
||||
|
4. **前缀隔离是逻辑隔离** — `clear()` 只清除当前前缀下的数据,不影响其他前缀或无前缀的数据。 |
||||
|
|
||||
|
5. **错误处理** — LocalStorageDriver 在 JSON 解析失败时自动清除损坏数据; `PreferenceManager.saveToCache` 内部 try-catch 防止未捕获异常。 |
||||
|
|
||||
|
6. **IndexedDB 版本升级** — 如果需要修改 objectStore 结构,需要递增 `dbVersion`。当前实现在 `upgradeneeded` 事件中自动创建 objectStore。 |
||||
@ -1 +1,5 @@ |
|||||
|
export * from './indexeddb-driver'; |
||||
|
export * from './local-storage-driver'; |
||||
|
export * from './memory-storage-driver'; |
||||
export * from './storage-manager'; |
export * from './storage-manager'; |
||||
|
export type * from './types'; |
||||
|
|||||
@ -0,0 +1,137 @@ |
|||||
|
import type { IStorageDriver } from './types'; |
||||
|
|
||||
|
interface IndexedDBDriverOptions { |
||||
|
/** 数据库名称 */ |
||||
|
dbName?: string; |
||||
|
/** 数据库版本 */ |
||||
|
dbVersion?: number; |
||||
|
/** 对象存储名称 */ |
||||
|
storeName?: string; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* IndexedDB 驱动 |
||||
|
* 采用懒初始化模式,首次操作时自动打开数据库 |
||||
|
*/ |
||||
|
class IndexedDBDriver implements IStorageDriver { |
||||
|
private dbName: string; |
||||
|
private dbPromise: null | Promise<IDBDatabase> = null; |
||||
|
private dbVersion: number; |
||||
|
private storeName: string; |
||||
|
|
||||
|
constructor({ |
||||
|
dbName = 'vben-storage', |
||||
|
dbVersion = 1, |
||||
|
storeName = 'kv-store', |
||||
|
}: IndexedDBDriverOptions = {}) { |
||||
|
this.dbName = dbName; |
||||
|
this.dbVersion = dbVersion; |
||||
|
this.storeName = storeName; |
||||
|
} |
||||
|
|
||||
|
async clear(): Promise<void> { |
||||
|
const db = await this.getDB(); |
||||
|
return new Promise((resolve, reject) => { |
||||
|
const tx = db.transaction(this.storeName, 'readwrite'); |
||||
|
const store = tx.objectStore(this.storeName); |
||||
|
store.clear(); |
||||
|
|
||||
|
tx.addEventListener('complete', () => resolve()); |
||||
|
tx.addEventListener('error', () => reject(tx.error)); |
||||
|
tx.addEventListener('abort', () => |
||||
|
reject(tx.error ?? new Error('Transaction aborted')), |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async getItem<T>(key: string): Promise<null | T> { |
||||
|
const db = await this.getDB(); |
||||
|
return new Promise((resolve, reject) => { |
||||
|
const tx = db.transaction(this.storeName, 'readonly'); |
||||
|
const store = tx.objectStore(this.storeName); |
||||
|
const request = store.get(key); |
||||
|
|
||||
|
request.addEventListener('success', () => |
||||
|
resolve(request.result ?? null), |
||||
|
); |
||||
|
request.addEventListener('error', () => reject(request.error)); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async keys(): Promise<string[]> { |
||||
|
const db = await this.getDB(); |
||||
|
return new Promise((resolve, reject) => { |
||||
|
const tx = db.transaction(this.storeName, 'readonly'); |
||||
|
const store = tx.objectStore(this.storeName); |
||||
|
const request = store.getAllKeys(); |
||||
|
|
||||
|
request.addEventListener('success', () => |
||||
|
resolve(request.result.map(String)), |
||||
|
); |
||||
|
request.addEventListener('error', () => reject(request.error)); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async removeItem(key: string): Promise<void> { |
||||
|
const db = await this.getDB(); |
||||
|
return new Promise((resolve, reject) => { |
||||
|
const tx = db.transaction(this.storeName, 'readwrite'); |
||||
|
const store = tx.objectStore(this.storeName); |
||||
|
store.delete(key); |
||||
|
|
||||
|
tx.addEventListener('complete', () => resolve()); |
||||
|
tx.addEventListener('error', () => reject(tx.error)); |
||||
|
tx.addEventListener('abort', () => |
||||
|
reject(tx.error ?? new Error('Transaction aborted')), |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async setItem(key: string, value: unknown): Promise<void> { |
||||
|
const db = await this.getDB(); |
||||
|
return new Promise((resolve, reject) => { |
||||
|
const tx = db.transaction(this.storeName, 'readwrite'); |
||||
|
const store = tx.objectStore(this.storeName); |
||||
|
store.put(value, key); |
||||
|
|
||||
|
tx.addEventListener('complete', () => resolve()); |
||||
|
tx.addEventListener('error', () => reject(tx.error)); |
||||
|
tx.addEventListener('abort', () => |
||||
|
reject(tx.error ?? new Error('Transaction aborted')), |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 懒初始化:首次调用时打开数据库,后续复用同一个 Promise |
||||
|
*/ |
||||
|
private getDB(): Promise<IDBDatabase> { |
||||
|
if (!this.dbPromise) { |
||||
|
this.dbPromise = this.openDB().catch((error) => { |
||||
|
// allow retry on next call
|
||||
|
this.dbPromise = null; |
||||
|
throw error; |
||||
|
}); |
||||
|
} |
||||
|
return this.dbPromise; |
||||
|
} |
||||
|
|
||||
|
private openDB(): Promise<IDBDatabase> { |
||||
|
return new Promise((resolve, reject) => { |
||||
|
const request = indexedDB.open(this.dbName, this.dbVersion); |
||||
|
|
||||
|
request.addEventListener('upgradeneeded', () => { |
||||
|
const db = request.result; |
||||
|
if (!db.objectStoreNames.contains(this.storeName)) { |
||||
|
db.createObjectStore(this.storeName); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
request.addEventListener('success', () => resolve(request.result)); |
||||
|
request.addEventListener('error', () => reject(request.error)); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export { IndexedDBDriver }; |
||||
|
export type { IndexedDBDriverOptions }; |
||||
@ -0,0 +1,71 @@ |
|||||
|
import type { IStorageDriver } from './types'; |
||||
|
|
||||
|
type StorageType = 'localStorage' | 'sessionStorage'; |
||||
|
|
||||
|
interface LocalStorageDriverOptions { |
||||
|
/** 使用 localStorage 还是 sessionStorage */ |
||||
|
storageType?: StorageType; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* LocalStorage / SessionStorage 驱动 |
||||
|
* 用 async 包装同步 API,保持接口统一 |
||||
|
*/ |
||||
|
class LocalStorageDriver implements IStorageDriver { |
||||
|
private storage: Storage; |
||||
|
|
||||
|
constructor({ |
||||
|
storageType = 'localStorage', |
||||
|
}: LocalStorageDriverOptions = {}) { |
||||
|
if (typeof window === 'undefined') { |
||||
|
// eslint-disable-next-line unicorn/prefer-type-error -- not a type check, it's an environment check
|
||||
|
throw new Error( |
||||
|
'LocalStorageDriver is not available in non-browser environments. Use MemoryStorageDriver instead.', |
||||
|
); |
||||
|
} |
||||
|
this.storage = |
||||
|
storageType === 'localStorage' |
||||
|
? window.localStorage |
||||
|
: window.sessionStorage; |
||||
|
} |
||||
|
|
||||
|
async clear(): Promise<void> { |
||||
|
this.storage.clear(); |
||||
|
} |
||||
|
|
||||
|
async getItem<T>(key: string): Promise<null | T> { |
||||
|
const raw = this.storage.getItem(key); |
||||
|
if (raw === null) { |
||||
|
return null; |
||||
|
} |
||||
|
try { |
||||
|
return JSON.parse(raw) as T; |
||||
|
} catch { |
||||
|
// 数据损坏,清除并返回 null
|
||||
|
this.storage.removeItem(key); |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async keys(): Promise<string[]> { |
||||
|
const result: string[] = []; |
||||
|
for (let i = 0; i < this.storage.length; i++) { |
||||
|
const key = this.storage.key(i); |
||||
|
if (key !== null) { |
||||
|
result.push(key); |
||||
|
} |
||||
|
} |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
async removeItem(key: string): Promise<void> { |
||||
|
this.storage.removeItem(key); |
||||
|
} |
||||
|
|
||||
|
async setItem(key: string, value: unknown): Promise<void> { |
||||
|
this.storage.setItem(key, JSON.stringify(value)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export { LocalStorageDriver }; |
||||
|
export type { LocalStorageDriverOptions }; |
||||
@ -0,0 +1,32 @@ |
|||||
|
import type { IStorageDriver } from './types'; |
||||
|
|
||||
|
/** |
||||
|
* 内存存储驱动 |
||||
|
* 适用于测试环境和 SSR 场景,数据不持久化 |
||||
|
*/ |
||||
|
class MemoryStorageDriver implements IStorageDriver { |
||||
|
private store = new Map<string, unknown>(); |
||||
|
|
||||
|
async clear(): Promise<void> { |
||||
|
this.store.clear(); |
||||
|
} |
||||
|
|
||||
|
async getItem<T>(key: string): Promise<null | T> { |
||||
|
const value = this.store.get(key); |
||||
|
return (value as T) ?? null; |
||||
|
} |
||||
|
|
||||
|
async keys(): Promise<string[]> { |
||||
|
return [...this.store.keys()]; |
||||
|
} |
||||
|
|
||||
|
async removeItem(key: string): Promise<void> { |
||||
|
this.store.delete(key); |
||||
|
} |
||||
|
|
||||
|
async setItem(key: string, value: unknown): Promise<void> { |
||||
|
this.store.set(key, value); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export { MemoryStorageDriver }; |
||||
@ -1,17 +1,39 @@ |
|||||
type StorageType = 'localStorage' | 'sessionStorage'; |
/** |
||||
|
* 存储驱动接口(策略模式核心抽象) |
||||
|
* 所有存储实现(localStorage、IndexedDB、Memory 等)都需要实现此接口 |
||||
|
* Driver 层只负责纯粹的 KV 存取,不感知 TTL 和前缀 |
||||
|
*/ |
||||
|
interface IStorageDriver { |
||||
|
/** 清除所有存储项 */ |
||||
|
clear(): Promise<void>; |
||||
|
|
||||
interface StorageValue<T> { |
/** 获取存储项 */ |
||||
data: T; |
getItem<T>(key: string): Promise<null | T>; |
||||
expiry: null | number; |
|
||||
|
/** 获取所有 key */ |
||||
|
keys(): Promise<string[]>; |
||||
|
|
||||
|
/** 移除存储项 */ |
||||
|
removeItem(key: string): Promise<void>; |
||||
|
|
||||
|
/** 设置存储项 */ |
||||
|
setItem(key: string, value: unknown): Promise<void>; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 带 TTL 的存储项包装结构 |
||||
|
* TTL 逻辑由 StorageManager 统一管理,Driver 层不感知 |
||||
|
*/ |
||||
|
interface StorageItem<T> { |
||||
|
expiry?: number; |
||||
|
value: T; |
||||
} |
} |
||||
|
|
||||
interface IStorageCache { |
interface StorageManagerOptions { |
||||
clear(): void; |
/** 存储驱动实例 */ |
||||
getItem<T>(key: string): null | T; |
driver?: IStorageDriver; |
||||
key(index: number): null | string; |
/** 键前缀,用于命名空间隔离 */ |
||||
length(): number; |
prefix?: string; |
||||
removeItem(key: string): void; |
|
||||
setItem<T>(key: string, value: T, expiryInMinutes?: number): void; |
|
||||
} |
} |
||||
|
|
||||
export type { IStorageCache, StorageType, StorageValue }; |
export type { IStorageDriver, StorageItem, StorageManagerOptions }; |
||||
|
|||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue