新增 FaImagePreview 组件,并移除 ImagePreview 组件

This commit is contained in:
Hooray
2025-06-15 23:52:04 +08:00
parent e2c0eb5292
commit 9ce9f36a10
15 changed files with 455 additions and 72 deletions
-63
View File
@@ -1,63 +0,0 @@
<script setup lang="ts">
defineOptions({
name: 'ImagePreview',
})
const props = withDefaults(
defineProps<{
src: string
width?: number | string
height?: number | string
}>(),
{
width: 200,
height: 200,
},
)
const realWidth = computed(() => {
return typeof props.width === 'string' ? props.width : `${props.width}px`
})
const realHeight = computed(() => {
return typeof props.height === 'string' ? props.height : `${props.height}px`
})
</script>
<template>
<ElImage :src="src" fit="cover" :style="`width:${realWidth};height:${realHeight};`" :preview-src-list="[src]" preview-teleported>
<template #error>
<div class="image-slot">
<FaIcon name="i-ph:image-broken-duotone" />
</div>
</template>
</ElImage>
</template>
<style scoped>
.el-image {
background-color: var(--el-fill-color);
border-radius: 5px;
box-shadow: var(--el-box-shadow-light);
transition: background-color 0.3s, var(--el-transition-box-shadow);
:deep(.el-image__inner) {
cursor: pointer;
transition: all 0.3s;
&:hover {
transform: scale(1.2);
}
}
:deep(.image-slot) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 30px;
color: #909399;
}
}
</style>
+8 -8
View File
@@ -110,6 +110,14 @@ const routes: RouteRecordRaw = {
title: '悬浮卡片',
},
},
{
path: 'imagepreview',
name: 'componentExampleBuiltInImagePreview',
component: () => import('@/views/component_built_in_example/imagepreview.vue'),
meta: {
title: '图片预览',
},
},
{
path: 'input',
name: 'componentExampleBuiltInInput',
@@ -271,14 +279,6 @@ const routes: RouteRecordRaw = {
title: '上传',
},
},
{
path: 'imagepreview',
name: 'componentExampleExtendImagePreview',
component: () => import('@/views/component_extend_example/image.preview.vue'),
meta: {
title: '图片预览',
},
},
{
path: 'pcas',
name: 'componentExampleExtendPcas',
+1 -1
View File
@@ -24,6 +24,7 @@ declare module 'vue' {
FaFixedActionBar: typeof import('./../ui/components/FaFixedActionBar/index.vue')['default']
FaHoverCard: typeof import('./../ui/components/FaHoverCard/index.vue')['default']
FaIcon: typeof import('./../ui/components/FaIcon/index.vue')['default']
FaImagePreview: typeof import('./../ui/components/FaImagePreview/index.vue')['default']
FaInput: typeof import('./../ui/components/FaInput/index.vue')['default']
FaKbd: typeof import('./../ui/components/FaKbd/index.vue')['default']
FaMaskScrollContainer: typeof import('./../ui/components/FaMaskScrollContainer/index.vue')['default']
@@ -47,7 +48,6 @@ declare module 'vue' {
FaToast: typeof import('./../ui/components/FaToast/index.vue')['default']
FaTooltip: typeof import('./../ui/components/FaTooltip/index.vue')['default']
FileUpload: typeof import('./../components/FileUpload/index.vue')['default']
ImagePreview: typeof import('./../components/ImagePreview/index.vue')['default']
ImagesUpload: typeof import('./../components/ImagesUpload/index.vue')['default']
ImageUpload: typeof import('./../components/ImageUpload/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { DialogRoot, type DialogRootEmits, type DialogRootProps, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot v-bind="forwarded">
<slot />
</DialogRoot>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { DialogClose, type DialogCloseProps } from 'reka-ui'
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose v-bind="props">
<slot />
</DialogClose>
</template>
@@ -0,0 +1,48 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { X } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
type DialogContentEmits,
type DialogContentProps,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/utils'
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits & { animationEnd: [] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-2000 data-[state=closed]:animate-out data-[state=open]:animate-in bg-black/80 data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0"
/>
<DialogContent
v-bind="forwarded"
:class="
cn(
'fixed z-2000 inset-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
props.class,
)"
@animationend="emits('animationEnd')"
>
<slot />
<DialogClose
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-ring"
>
<X class="h-8 w-8" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription, type DialogDescriptionProps, useForwardProps } from 'reka-ui'
import { cn } from '@/utils'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
v-bind="forwardedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</DialogDescription>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { X } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
type DialogContentEmits,
type DialogContentProps,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/utils'
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid data-[state=closed]:animate-out data-[state=open]:animate-in place-items-center overflow-y-auto bg-black/80 data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="forwarded"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute right-3 top-3 rounded-md p-0.5 transition-colors hover:bg-secondary"
>
<X class="h-4 w-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogTitle, type DialogTitleProps, useForwardProps } from 'reka-ui'
import { cn } from '@/utils'
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
v-bind="forwardedProps"
:class="
cn(
'text-lg font-semibold leading-none tracking-tight',
props.class,
)
"
>
<slot />
</DialogTitle>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { DialogTrigger, type DialogTriggerProps } from 'reka-ui'
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger v-bind="props">
<slot />
</DialogTrigger>
</template>
@@ -0,0 +1,9 @@
export { default as Dialog } from './Dialog.vue'
export { default as DialogClose } from './DialogClose.vue'
export { default as DialogContent } from './DialogContent.vue'
export { default as DialogDescription } from './DialogDescription.vue'
export { default as DialogFooter } from './DialogFooter.vue'
export { default as DialogHeader } from './DialogHeader.vue'
export { default as DialogScrollContent } from './DialogScrollContent.vue'
export { default as DialogTitle } from './DialogTitle.vue'
export { default as DialogTrigger } from './DialogTrigger.vue'
+187
View File
@@ -0,0 +1,187 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
import { Dialog, DialogContent } from './dialog'
defineOptions({
name: 'FaImagePreview',
})
const props = defineProps<{
src: string
class?: HTMLAttributes['class']
}>()
const emits = defineEmits<{
load: []
error: []
}>()
const isLoading = ref(true)
const isError = ref(false)
const isOpen = ref(false)
// 图片操作相关状态
const scale = ref(1)
const rotate = ref(0)
const position = ref({ x: 0, y: 0 })
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
function handleLoad() {
isLoading.value = false
emits('load')
}
function handleError() {
isError.value = true
isLoading.value = false
emits('error')
}
function handleClick() {
if (!isError.value) {
isOpen.value = true
}
}
// 图片操作函数
function handleZoomIn() {
scale.value = Math.min(scale.value + 0.25, 3)
}
function handleZoomOut() {
scale.value = Math.max(scale.value - 0.25, 0.5)
}
function handleOriginalSize() {
scale.value = 1
}
function handleRotateLeft() {
rotate.value = rotate.value - 90
}
function handleRotateRight() {
rotate.value = rotate.value + 90
}
// 处理滚轮缩放
function handleWheel(e: WheelEvent) {
e.preventDefault()
if (e.deltaY < 0) {
handleZoomIn()
}
else {
handleZoomOut()
}
}
// 处理鼠标按下事件
function handleMouseDown(e: MouseEvent) {
isDragging.value = true
dragStart.value = {
x: e.clientX - position.value.x,
y: e.clientY - position.value.y,
}
}
// 处理鼠标移动事件
function handleMouseMove(e: MouseEvent) {
if (!isDragging.value) {
return
}
position.value = {
x: e.clientX - dragStart.value.x,
y: e.clientY - dragStart.value.y,
}
}
// 处理鼠标松开事件
function handleMouseUp() {
isDragging.value = false
}
// 重置图片状态
function resetImageState() {
scale.value = 1
rotate.value = 0
position.value = { x: 0, y: 0 }
}
// 监听鼠标事件
onMounted(() => {
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
})
onUnmounted(() => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
})
function handleAnimationEnd() {
if (!isOpen.value) {
resetImageState()
}
}
</script>
<template>
<div class="group/image-preview relative inline-block overflow-hidden border rounded-lg">
<img
:src="src"
:class="cn('size-50 object-contain cursor-pointer bg-background transition-all duration-300 group-hover/image-preview:scale-110', props.class, {
invisible: isError || isLoading,
})"
@error="handleError"
@load="handleLoad"
@click="!isLoading && !isError && handleClick()"
>
<div v-if="isLoading" class="absolute left-0 top-0 h-full w-full flex-center">
<slot name="loading">
<FaIcon name="i-line-md:loading-twotone-loop" class="size-8 text-secondary-foreground/50" />
</slot>
</div>
<div v-if="isError" class="absolute left-0 top-0 h-full w-full flex-center">
<slot name="error">
<FaIcon name="i-ph:image-broken-duotone" class="size-8 text-secondary-foreground/50" />
</slot>
</div>
<Dialog v-model:open="isOpen">
<DialogContent class="size-full" @animation-end="handleAnimationEnd">
<div class="relative size-full" @wheel="handleWheel">
<img
:src="src"
class="mx-auto max-h-full max-w-full object-contain"
:class="{
'transition-all duration-300': !isDragging,
}"
:style="{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale}) rotate(${rotate}deg)`,
cursor: isDragging ? 'grabbing' : 'grab',
}"
@mousedown.prevent="handleMouseDown"
>
<FaButtonGroup class="absolute bottom-4 left-1/2 scale-125 -translate-x-1/2">
<FaButton variant="outline" size="icon" class="border-none bg-muted/50" @click="handleZoomIn">
<FaIcon name="i-carbon:zoom-in" class="size-5" />
</FaButton>
<FaButton variant="outline" size="icon" class="border-none bg-muted/50" @click="handleZoomOut">
<FaIcon name="i-carbon:zoom-out" class="size-5" />
</FaButton>
<FaButton variant="outline" size="icon" class="border-none bg-muted/50" @click="handleOriginalSize">
<FaIcon name="i-lucide:maximize" class="size-5" />
</FaButton>
<FaButton variant="outline" size="icon" class="border-none bg-muted/50" @click="handleRotateLeft">
<FaIcon name="i-carbon:rotate" class="size-5" />
</FaButton>
<FaButton variant="outline" size="icon" class="border-none bg-muted/50" @click="handleRotateRight">
<FaIcon name="i-carbon:rotate-180" class="size-5" />
</FaButton>
</FaButtonGroup>
</div>
</DialogContent>
</Dialog>
</div>
</template>
@@ -0,0 +1,28 @@
<route lang="yaml">
meta:
enabled: false
</route>
<template>
<div>
<FaPageHeader title="图片预览" description="FaImagePreview" />
<FaPageMain>
<div class="space-x-4">
<FaImagePreview src="https://fantastic-admin.hurui.me/logo.svg" />
<FaImagePreview src="http://www.baidu.com" class="size-25" />
<FaImagePreview src="http://www.baidu.com">
<template #error>
<div class="flex-col-center text-sm text-secondary-foreground/50">
<div>
自定义错误信息
</div>
<div>
图片加载失败
</div>
</div>
</template>
</FaImagePreview>
</div>
</FaPageMain>
</div>
</template>