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 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; |
|||
expiry: null | number; |
|||
/** 获取存储项 */ |
|||
getItem<T>(key: string): Promise<null | T>; |
|||
|
|||
/** 获取所有 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 { |
|||
clear(): void; |
|||
getItem<T>(key: string): null | T; |
|||
key(index: number): null | string; |
|||
length(): number; |
|||
removeItem(key: string): void; |
|||
setItem<T>(key: string, value: T, expiryInMinutes?: number): void; |
|||
interface StorageManagerOptions { |
|||
/** 存储驱动实例 */ |
|||
driver?: IStorageDriver; |
|||
/** 键前缀,用于命名空间隔离 */ |
|||
prefix?: string; |
|||
} |
|||
|
|||
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