diff --git a/devui/image-preview/__tests__/image-preview.spec.ts b/devui/image-preview/__tests__/image-preview.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..519e4e62b15176d7b55b1b19f9ddc51a3443d28c
--- /dev/null
+++ b/devui/image-preview/__tests__/image-preview.spec.ts
@@ -0,0 +1,87 @@
+import { mount } from '@vue/test-utils'
+import { ImagePreviewDirective } from '../index'
+import { ref } from 'vue'
+
+// 指令图片模板
+const imageTemplate = `
+
+
+`
+// 全局属性
+const global = {
+ directives: {
+ dImagePreview: ImagePreviewDirective
+ }
+}
+
+describe('image-preview', () => {
+ it('image-preview click', async () => {
+ const wrapper = mount(
+ {
+ template: `
+
+ ${imageTemplate}
+
+ `
+ },
+ {
+ global
+ }
+ )
+ const img = wrapper.find('#testImg')
+ await img.trigger('click')
+ const ele = document.querySelector('.devui-image-preview-main-image')
+ expect(ele).toBeTruthy()
+ const closeBtn = document.querySelector('.devui-image-preview-close-btn') as any
+ closeBtn.click()
+ })
+
+ it('image-preview disableDefault', async () => {
+ const wrapper = mount(
+ {
+ template: `
+
+ ${imageTemplate}
+
+ `
+ },
+ {
+ global
+ }
+ )
+ const img = wrapper.find('#testImg')
+ await img.trigger('click')
+ const ele = document.querySelector('.devui-image-preview-main-image')
+ expect(ele).toBeFalsy()
+ })
+
+ it('image-preview custom', async () => {
+ const custom: any = ref({})
+ const open = () => custom.value.open()
+ const wrapper = mount(
+ {
+ template: `
+
+ ${imageTemplate}
+
+
+ `,
+ setup() {
+ return {
+ custom,
+ open
+ }
+ }
+ },
+ {
+ global
+ }
+ )
+ const customBtn = wrapper.find('#open')
+ await customBtn.trigger('click')
+ const ele = document.querySelector('.devui-image-preview-main-image')
+ expect(ele).toBeTruthy()
+ const closeBtn = document.querySelector('.devui-image-preview-close-btn') as any
+ closeBtn.click()
+ })
+})
diff --git a/devui/image-preview/index.ts b/devui/image-preview/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8125149636faf834987011197648ba34771f38e0
--- /dev/null
+++ b/devui/image-preview/index.ts
@@ -0,0 +1,14 @@
+import type { App } from 'vue'
+import ImagePreviewDirective from './src/image-preview-directive'
+import ImagePreviewService from './src/image-preview-service'
+
+export { ImagePreviewDirective, ImagePreviewService }
+
+export default {
+ title: 'ImagePreview 图片预览',
+ category: '数据展示',
+ install(app: App): void {
+ app.directive('d-image-preview', ImagePreviewDirective)
+ app.config.globalProperties.$imagePreviewService = ImagePreviewService
+ }
+}
diff --git a/devui/image-preview/src/image-preview-directive.ts b/devui/image-preview/src/image-preview-directive.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5317dff6d37f419071351c395870ed25f4420ade
--- /dev/null
+++ b/devui/image-preview/src/image-preview-directive.ts
@@ -0,0 +1,52 @@
+import { BindingTypes } from './image-preview-types'
+import ImagePreviewService from './image-preview-service'
+
+function mountedPreviewImages(url: string, urlList: Array): void {
+ ImagePreviewService.open({ url, previewUrlList: urlList })
+}
+function unmountedPreviewImages() {
+ ImagePreviewService.close()
+}
+
+function getImgByEl(el: HTMLElement): Array {
+ const urlList = [...el.querySelectorAll('img')].map((item: HTMLImageElement) =>
+ item.getAttribute('src')
+ )
+ return urlList
+}
+
+function handleImgByEl(el: HTMLElement) {
+ el.addEventListener('click', (e: MouseEvent) => {
+ e.stopPropagation()
+ const target = e.target as HTMLElement
+ if (target?.nodeName?.toLowerCase() === 'img') {
+ const urlList = getImgByEl(el)
+ const url = target.getAttribute('src')
+ mountedPreviewImages(url, urlList)
+ }
+ })
+}
+
+export default {
+ mounted(el: HTMLElement, binding: BindingTypes | undefined) {
+ if (!binding.value) {
+ return handleImgByEl(el)
+ }
+ const { custom, disableDefault } = binding.value
+ // console.log('指令参数:', custom, disableDefault, zIndex, backDropZIndex)
+ if (custom instanceof Object) {
+ custom.open = () => {
+ const urlList = getImgByEl(el)
+ mountedPreviewImages(urlList?.[0], urlList)
+ }
+ custom.close = () => unmountedPreviewImages()
+ }
+ if (disableDefault) {
+ return
+ }
+ handleImgByEl(el)
+ },
+ unmounted() {
+ unmountedPreviewImages()
+ }
+}
diff --git a/devui/image-preview/src/image-preview-service.ts b/devui/image-preview/src/image-preview-service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fd26b5c9e471140e3151f2f5b6941f4f44a6a0b3
--- /dev/null
+++ b/devui/image-preview/src/image-preview-service.ts
@@ -0,0 +1,34 @@
+import { createApp } from 'vue'
+import { ImagePreviewProps } from './image-preview-types'
+import imagePreview from './image-preview'
+
+function createComponent(props: ImagePreviewProps) {
+ return createApp(imagePreview, props)
+}
+
+class ImagePreviewService {
+ static $body: HTMLElement | null = null
+ static $div: HTMLDivElement | null = null
+ // 暂时的禁止滚动穿透,后续应该考虑用modal组件来渲染预览组件
+ static $overflow = ''
+
+ static open(props: ImagePreviewProps) {
+ this.$body = document.body
+ this.$div = document.createElement('div')
+ this.$overflow = this.$body.style.overflow
+ this.$body.appendChild(this.$div)
+ createComponent(props).mount(this.$div)
+
+ this.$body.style.setProperty('overflow', 'hidden', 'important')
+ }
+ static close() {
+ this.$body?.style.setProperty('overflow', this.$overflow)
+ this.$overflow = null
+
+ this.$div && this.$body.removeChild(this.$div)
+ this.$body = null
+ this.$div = null
+ }
+}
+
+export default ImagePreviewService
diff --git a/devui/image-preview/src/image-preview-types.ts b/devui/image-preview/src/image-preview-types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1f4053cb665ef1edabc0a59b2d25bb748cc7c945
--- /dev/null
+++ b/devui/image-preview/src/image-preview-types.ts
@@ -0,0 +1,24 @@
+import type { PropType, ExtractPropTypes } from 'vue'
+
+export const imagePreviewProps = {
+ url: {
+ type: String,
+ default: ''
+ },
+ previewUrlList: {
+ type: Array as PropType,
+ default: () => []
+ }
+} as const
+
+export interface BindingTypes {
+ value: {
+ custom?: any
+ disableDefault?: boolean
+ zIndex?: number
+ backDropZIndex?: number
+ }
+ [key: string]: any
+}
+
+export type ImagePreviewProps = ExtractPropTypes
diff --git a/devui/image-preview/src/image-preview.scss b/devui/image-preview/src/image-preview.scss
new file mode 100644
index 0000000000000000000000000000000000000000..28a4f786dd980e419912750b48f772bc6009ef34
--- /dev/null
+++ b/devui/image-preview/src/image-preview.scss
@@ -0,0 +1,115 @@
+@import '../../style/theme/_z-index';
+@import '../../style/theme/_shadow';
+@import '../../style/theme/_color';
+@import '../../style/theme/_corner';
+
+.devui-image-preview {
+ position: fixed;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ z-index: calc(#{$devui-z-index-modal} - 1);
+ background: $devui-shadow;
+ border-radius: $devui-border-radius;
+ box-shadow: $devui-shadow-length-fullscreen-overlay $devui-shadow;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+
+ svg,
+ polygon,
+ g,
+ path {
+ fill: $devui-icon-text;
+ }
+ @mixin fixed-button() {
+ position: fixed;
+ z-index: $devui-z-index-modal;
+ cursor: pointer;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ background: $devui-highlight-overlay;
+ box-shadow: $devui-shadow-length-base $devui-light-shadow;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ &:hover {
+ background: $devui-area;
+ }
+
+ svg {
+ width: 38px;
+ height: 18px;
+ }
+ }
+
+ &-main-image {
+ width: auto;
+ height: auto;
+ max-width: 90%;
+ max-height: 90%;
+ margin-top: -20px;
+ cursor: grab;
+ }
+
+ &-close-btn {
+ @include fixed-button();
+
+ top: 15px;
+ right: 20px;
+ }
+
+ &-arrow-left {
+ @include fixed-button();
+
+ top: 50%;
+ left: 20px;
+ transform: translateY(-50%);
+ }
+
+ &-arrow-right {
+ @include fixed-button();
+
+ top: 50%;
+ right: 20px;
+ transform: translateY(-50%);
+ }
+
+ &-toolbar {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 50px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: $devui-highlight-overlay;
+ box-shadow: $devui-shadow-length-fullscreen-overlay $devui-light-shadow;
+
+ button {
+ display: inline-flex;
+ width: 24px;
+ height: 24px;
+ align-items: center;
+ justify-content: center;
+ color: $devui-text;
+ }
+
+ .devui-image-preview-index {
+ display: inline-flex;
+ width: 100px;
+ justify-content: center;
+ align-items: center;
+ cursor: pointer;
+ }
+
+ & > :not(:first-child) {
+ margin-left: 20px;
+ }
+ }
+}
diff --git a/devui/image-preview/src/image-preview.tsx b/devui/image-preview/src/image-preview.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e389d3391f81bdf153b3e0d07e91e8302d970f1f
--- /dev/null
+++ b/devui/image-preview/src/image-preview.tsx
@@ -0,0 +1,170 @@
+import './image-preview.scss'
+import { defineComponent, ref, computed, onMounted } from 'vue'
+import { imagePreviewProps, ImagePreviewProps } from './image-preview-types'
+import ImagePreviewService from './image-preview-service'
+import Transform from './transform'
+
+export default defineComponent({
+ name: 'DImagePreview',
+ props: imagePreviewProps,
+ emits: [],
+ setup(props: ImagePreviewProps) {
+ let transform: Transform = null
+ const index = ref(0)
+ const url = computed(() => props.previewUrlList[index.value])
+
+ function initTransform() {
+ const imageElement: HTMLImageElement = document.querySelector(
+ '.devui-image-preview-main-image'
+ )
+ transform = new Transform(imageElement)
+ }
+ function initIndex() {
+ index.value = props.previewUrlList.findIndex(curUrl => curUrl === props.url)
+ }
+ function onPrev() {
+ index.value = index.value <= 0 ? props.previewUrlList.length - 1 : index.value - 1
+ }
+ function onNext() {
+ index.value = index.value >= props.previewUrlList.length - 1 ? 0 : index.value + 1
+ }
+ function onClose() {
+ ImagePreviewService.close()
+ }
+ function onZoomIn() {
+ transform.setZoomIn()
+ }
+ function onZoomOut() {
+ transform.setZoomOut()
+ }
+ function onRotate() {
+ transform.setRotate()
+ }
+ function onZoomBest() {
+ transform.setZoomBest()
+ }
+ function onZoomOriginal() {
+ transform.setZoomOriginal()
+ }
+
+ onMounted(() => {
+ initIndex()
+ initTransform()
+ })
+
+ return () => {
+ return (
+
+ {/* 预览图 */}
+

+ {/* 按钮区 */}
+
+
+
+ {/* 底部固定区 */}
+
+
+ )
+ }
+ }
+})
diff --git a/devui/image-preview/src/transform.ts b/devui/image-preview/src/transform.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ff35f90fab0f2b9b8a46424967c5509bf49e3ee5
--- /dev/null
+++ b/devui/image-preview/src/transform.ts
@@ -0,0 +1,130 @@
+interface Options {
+ transformX?: number
+ transformY?: number
+ zoom?: number
+ rotate?: number
+}
+interface HTMLElementPlus extends HTMLElement {
+ onmousewheel?: (...args: any[]) => void
+}
+
+export default class Transform {
+ private el: HTMLElementPlus
+
+ private oTransformX = 0
+ private oTransformY = 0
+ private transformX: number
+ private transformY: number
+ private zoom: number
+ private rotate: number
+
+ private STEP = 0.25
+ private MIN_SCALE = 0.2
+ private MAX_SCALE = 2.5
+ private TRANSFORMX = 0
+ private TRANSFORMY = 0
+ private ZOOM = 1
+ private ROTATE = 0
+
+ constructor(el: HTMLElementPlus, options: Options = {}) {
+ this.el = el
+ this.transformX = options.transformX || this.TRANSFORMX
+ this.transformY = options.transformY || this.TRANSFORMY
+ this.zoom = options.zoom || this.ZOOM
+ this.rotate = options.rotate || this.ROTATE
+
+ this.handleDefaultDraggable()
+ this.onDraggable()
+ this.onMouseWheel()
+ }
+ handleDefaultDraggable() {
+ document.body.ondragstart = () => {
+ window.event.returnValue = false
+ return false
+ }
+ }
+ onDraggable() {
+ this.el.onmousedown = (e: MouseEvent) => {
+ const ox = e.clientX
+ const oy = e.clientY
+ document.onmousemove = (e1: MouseEvent) => {
+ const disX = e1.clientX - ox
+ const disY = e1.clientY - oy
+ this.transformX = this.oTransformX + disX
+ this.transformY = this.oTransformY + disY
+ this.el.style.cursor = 'grabbing'
+ this.setPosition()
+ }
+ }
+ document.onmouseup = () => {
+ document.onmousemove = null
+ this.oTransformX = this.transformX
+ this.oTransformY = this.transformY
+ this.el.style.cursor = 'grab'
+ }
+ }
+ onMouseWheel() {
+ const handleWheel = this.throttle(this.setMouseWheel, 100)
+ this.el.onmousewheel = e => {
+ const value: number = -e.wheelDelta || e.deltaY || e.detail
+ handleWheel(value)
+ }
+ }
+ throttle(fn: (...args: any[]) => void, t: number) {
+ let timer = null
+ return (...args) => {
+ if (!timer) {
+ setTimeout(() => {
+ timer = null
+ fn.apply(this, args)
+ }, t)
+ }
+ }
+ }
+ setMouseWheel(value: number) {
+ if (value < 0) {
+ if (this.zoom >= this.MAX_SCALE) {
+ this.el.style.cursor = 'not-allowed'
+ return
+ }
+ this.el.style.cursor = 'zoom-in'
+ this.setZoomIn(this.STEP)
+ } else {
+ if (this.zoom <= this.MIN_SCALE) {
+ this.el.style.cursor = 'not-allowed'
+ return
+ }
+ this.el.style.cursor = 'zoom-out'
+ this.setZoomOut(this.STEP)
+ }
+ this.setPosition()
+ }
+ setZoomIn(step = this.STEP) {
+ this.zoom = Math.min(this.MAX_SCALE, this.zoom + step)
+ this.setPosition()
+ }
+ setZoomOut(step = this.STEP) {
+ this.zoom = Math.max(this.MIN_SCALE, this.zoom - step)
+ this.setPosition()
+ }
+ setZoomBest() {
+ this.reset()
+ this.setPosition()
+ }
+ setZoomOriginal() {
+ this.reset()
+ this.setPosition()
+ }
+ setRotate() {
+ this.rotate += 0.25
+ this.setPosition()
+ }
+ reset() {
+ this.transformX = this.TRANSFORMX
+ this.transformY = this.TRANSFORMY
+ this.zoom = this.ZOOM
+ }
+ setPosition() {
+ this.el.style.transform = `translate(${this.transformX}px, ${this.transformY}px) scale(${this.zoom}) rotate(${this.rotate}turn)`
+ }
+}
diff --git a/sites/components/image-preview/index.md b/sites/components/image-preview/index.md
new file mode 100644
index 0000000000000000000000000000000000000000..20867c52e01ec4dcb4da29770b171c862ad99727
--- /dev/null
+++ b/sites/components/image-preview/index.md
@@ -0,0 +1,137 @@
+# ImagePreview 图片预览
+
+预览一张或多张图片的组件。
+
+### 何时使用
+
+需要根据用户传入进行图片预览展示或对容器内图片进行预览时。
+
+### 基本用法
+
+使用 v-d-image-preview 指令,对容器内图片进行预览。
+
+
+
![]()
+
+
+```html
+
+
![]()
+
+
+
+```
+
+### 自定义开启预览窗口
+
+传入 custom 参数,指令会自动注入 open 方法,通过 custom.open 开启预览窗口
+
+
+
![]()
+
+自定义
+
+```html
+
+
![]()
+
+自定义
+
+
+```
+
+### 设置 zIndex
+
+通过设置 zIndex 控制弹出效果的层级,设置 backDropZIndex 控制弹出层背景的层级。
+可以看到当设置 zIndex 小于 backDropZIndex 时,imagePreview 会显示在背景下方。
+可以通过 Esc 关闭 imagePreview。
+
+```
+// 待嵌入 modal 组件即可
+```
+
+### API
+
+| 参数 | 类型 | 默认 | 说明 |
+| :------------: | :-------: | :---: | :------------------------------------------------------------ |
+| custom | `Object` | -- | 可选,指令会自动注入 open 方法,通过 custom.open 开启预览窗口 |
+| disableDefault | `Boolean` | false | 可选,关闭默认点击触发图片预览方式 |
+| zIndex | `Number` | 1050 | 可选,可选,设置预览时图片的 z-index 值 |
+| backDropZIndex | `Number` | 1040 | 可选,设置预览时图片背景的 z-index 值 |
+
+
+
+