Merge pull request #60 from JeremyYu-cn/feat-upgrade-vue3

feat: Convert imageCutout component to vue3
This commit is contained in:
Jeremy Yu 2024-03-03 21:41:32 +00:00 committed by GitHub
commit 563cec2b99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 290 additions and 230 deletions

View File

@ -2,23 +2,32 @@
* @Author: ShawnPhang
* @Date: 2021-08-27 14:42:15
* @Description: AI相关接口
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-10-13 00:07:19
* @LastEditors: ShawnPhang <https://m.palxp.cn>, Jeremy Yu <https://github.com/JeremyYu-cn>
* @Date: 2024-03-03 19:00:00
*/
import fetch from '@/utils/axios'
export type TCommonUploadCb = (up: number, dp: number) => void
type TUploadProgressCbData = {
loaded: number
total: number
}
export type TUploadErrorResult = {type: "application/json"}
// 上传接口
export const upload = (file: File, cb: Function) => {
export const upload = (file: File, cb: TCommonUploadCb) => {
const formData = new FormData()
formData.append('file', file)
const extra = {
responseType: 'blob',
onUploadProgress: (progress: any) => {
onUploadProgress: (progress: TUploadProgressCbData) => {
cb(Math.floor((progress.loaded / progress.total) * 100), 0)
},
onDownloadProgress: (progress: any) => {
onDownloadProgress: (progress: TUploadProgressCbData) => {
cb(100, Math.floor((progress.loaded / progress.total) * 100))
},
}
return fetch('https://res.palxp.cn/ai/upload', formData, 'post', {}, extra)
return fetch<MediaSource | TUploadErrorResult>('https://res.palxp.cn/ai/upload', formData, 'post', {}, extra)
}

View File

@ -1,222 +0,0 @@
<!--
* @Author: ShawnPhang
* @Date: 2023-07-11 23:50:22
* @Description: 抠图组件
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2023-10-09 00:42:48
-->
<template>
<el-dialog v-model="show" title="AI 智能抠图" align-center width="650" @close="handleClose">
<uploader v-if="!rawImage" :hold="true" :drag="true" :multiple="true" class="uploader" @load="selectFile">
<div class="uploader__box">
<upload-filled style="width: 64px; height: 64px" />
<div class="el-upload__text">在此拖入或选择<em>上传图片</em></div>
</div>
<div class="el-upload__tip">服务器带宽过低为了更好的体验请上传 2M 内的图片</div>
</uploader>
<el-progress v-if="!cutImage && progressText" :percentage="progress">
<el-button text>
{{ progressText }} <span v-show="progress">{{ progress }}%</span>
</el-button>
</el-progress>
<div class="content">
<div v-show="rawImage" v-loading="!cutImage" :style="{ width: offsetWidth ? offsetWidth + 'px' : '100%' }" class="scan-effect transparent-bg">
<img ref="raw" :style="{ 'clip-path': 'inset(0 0 0 ' + percent + '%)' }" :src="rawImage" alt="" />
<img v-show="cutImage" :src="cutImage" alt="结果图像" @mousemove="mousemove" />
<div v-show="cutImage" :style="{ left: percent + '%' }" class="scan-line"></div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button v-show="rawImage && toolModel" @click="clear">清空重选</el-button>
<el-button v-show="cutImage" type="primary" plain @click="edit">进入编辑模式</el-button>
<el-button v-show="cutImage && toolModel" type="primary" plain @click="download"> 下载 </el-button>
<el-button v-show="cutImage && !toolModel" v-loading="loading" type="primary" plain @click="cutDone"> {{ loading ? '上传中..' : '完成抠图' }} </el-button>
</span>
</template>
<ImageExtraction ref="matting" />
</el-dialog>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, nextTick } from 'vue'
import { useStore } from 'vuex'
import { ElProgress } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import uploader from '@/components/common/Uploader/index.vue'
import _dl from '@/common/methods/download'
import api from '@/api'
import Qiniu from '@/common/methods/QiNiu'
import _config from '@/config'
import { getImage } from '@/common/methods/getImgDetail'
import ImageExtraction from './ImageExtraction.vue'
export default defineComponent({
components: { uploader, UploadFilled, ElProgress, ImageExtraction },
emits: ['done'],
setup(props, { emit }) {
const store = useStore()
const state: any = reactive({
show: false,
rawImage: '',
cutImage: '',
raw: null,
offsetWidth: 0,
percent: 0,
progress: 0,
progressText: '',
toolModel: true,
loading: false,
matting: null,
})
let fileName: string = 'unknow'
let isRuning: boolean = false
const selectFile = async (file: File) => {
if (file.size > 1024 * 1024 * 2) {
alert('上传图片超出限制')
return false
}
//
state.raw.addEventListener('load', () => {
state.offsetWidth = state.raw.offsetWidth
})
state.rawImage = URL.createObjectURL(file)
fileName = file.name
//
const result: any = await api.ai.upload(file, (up: number, dp: number) => {
if (dp) {
state.progressText = dp === 100 ? '' : '导入中..'
state.progress = dp
} else {
state.progressText = up < 100 ? '上传中..' : '正在处理,请稍候..'
state.progress = up < 100 ? up : 0
}
})
if (result.type !== 'application/json') {
const resultImage = URL.createObjectURL(result)
state.rawImage && (state.cutImage = resultImage)
requestAnimationFrame(run)
} else alert('服务器繁忙,请稍等下重新尝试~')
}
const open = (file: File) => {
state.loading = false
state.show = true
store.commit('setShowMoveable', false)
nextTick(() => {
if (file) {
selectFile(file)
state.toolModel = false
}
})
}
const handleClose = () => {
store.commit('setShowMoveable', true)
}
const mousemove = (e: MouseEvent) => {
!isRuning && (state.percent = (e.offsetX / (e.target as any).width) * 100)
}
const download = () => {
_dl.downloadBase64File(state.cutImage, fileName)
}
const clear = () => {
URL.revokeObjectURL(state.rawImage)
state.rawImage = ''
// URL.revokeObjectURL(state.cutImage)
state.cutImage = ''
state.percent = 0
state.offsetWidth = 0
}
const run = () => {
state.percent += 1
isRuning = true
state.percent < 100 ? requestAnimationFrame(run) : (isRuning = false)
}
const cutDone = async () => {
state.loading = true
const response = await fetch(state.cutImage)
const buffer = await response.arrayBuffer()
const file = new File([buffer], `cut_image_${Math.random()}.png`)
// upload
const qnOptions = { bucket: 'xp-design', prePath: 'user' }
const result = await Qiniu.upload(file, qnOptions)
const { width, height } = await getImage(file)
const url = _config.IMG_URL + result.key
await api.material.addMyPhoto({ width, height, url })
emit('done', url)
state.show = false
handleClose()
}
const edit = () => {
state.matting.open(state.rawImage, state.cutImage, (base64: any) => {
state.cutImage = base64
state.percent = 0
requestAnimationFrame(run)
})
}
return {
clear,
download,
mousemove,
selectFile,
open,
handleClose,
...toRefs(state),
cutDone,
edit,
}
},
})
</script>
<style lang="less" scoped>
.uploader {
&__box {
color: #333333;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
}
.content {
position: relative;
display: flex;
justify-content: center;
}
.scan-effect {
position: relative;
height: 50vh;
overflow: hidden;
img {
// width: 100%;
height: 100%;
object-fit: contain;
position: absolute;
}
}
.scan-line {
position: absolute;
top: 0;
width: 1.5px;
height: 100%;
background: rgba(255, 255, 255, 0.7);
// background-image: linear-gradient(to top, transparent, rgba(255, 255, 255, 0.7), transparent);
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
}
.progress {
width: 100%;
}
</style>

View File

@ -0,0 +1,202 @@
<!--
* @Author: ShawnPhang
* @Date: 2024-03-03 19:00:00
* @Description: 裁剪组件
* @LastEditors: ShawnPhang <site: book.palxp.com>, Jeremy Yu <https://github.com/JeremyYu-cn>
* @Date: 2024-03-03 19:00:00
-->
<template>
<el-dialog v-model="state.show" title="AI 智能抠图" align-center width="650" @close="handleClose">
<uploader v-if="!state.rawImage" :hold="true" :drag="true" :multiple="true" class="uploader" @load="handleUploaderLoad">
<div class="uploader__box">
<upload-filled style="width: 64px; height: 64px" />
<div class="el-upload__text">在此拖入或选择<em>上传图片</em></div>
</div>
<div class="el-upload__tip">服务器带宽过低为了更好的体验请上传 2M 内的图片</div>
</uploader>
<el-progress v-if="!state.cutImage && state.progressText" :percentage="state.progress">
<el-button text>
{{ state.progressText }} <span v-show="state.progress">{{ state.progress }}%</span>
</el-button>
</el-progress>
<div class="content">
<div v-show="state.rawImage" v-loading="!state.cutImage" :style="{ width: state.offsetWidth ? state.offsetWidth + 'px' : '100%' }" class="scan-effect transparent-bg">
<img ref="raw" :style="{ 'clip-path': 'inset(0 0 0 ' + state.percent + '%)' }" :src="state.rawImage" alt="" />
<img v-show="state.cutImage" :src="state.cutImage" alt="结果图像" @mousemove="mousemove" />
<div v-show="state.cutImage" :style="{ left: state.percent + '%' }" class="scan-line"></div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button v-show="state.rawImage && state.toolModel" @click="clear">清空重选</el-button>
<el-button v-show="state.cutImage" type="primary" plain @click="edit">进入编辑模式</el-button>
<el-button v-show="state.cutImage && state.toolModel" type="primary" plain @click="download"> 下载 </el-button>
<el-button v-show="state.cutImage && !state.toolModel" v-loading="state.loading" type="primary" plain @click="cutDone"> {{ state.loading ? '上传中..' : '完成抠图' }} </el-button>
</span>
</template>
<ImageExtraction ref="matting" />
</el-dialog>
</template>
<script lang="ts" setup>
import { reactive, nextTick, defineEmits, ref } from 'vue'
import { useStore } from 'vuex'
import { ElProgress } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import uploader from '@/components/common/Uploader/index.vue'
import _dl from '@/common/methods/download'
import ImageExtraction from '../ImageExtraction.vue'
import { selectImageFile, uploadCutPhotoToCloud } from './method'
export type TImageCutoutState = {
show: boolean;
rawImage: string;
cutImage: string;
offsetWidth: number;
percent: number;
progress: number;
progressText: string;
toolModel: boolean;
loading: boolean;
}
const store = useStore()
const state = reactive<TImageCutoutState>({
show: false,
rawImage: '',
cutImage: '',
offsetWidth: 0,
percent: 0,
progress: 0,
progressText: '',
toolModel: true,
loading: false,
})
let fileName: string = 'unknow'
let isRuning: boolean = false
const emits = defineEmits<{
(event: "done", data: string): void
}>()
const raw = ref(null)
const matting = ref<typeof ImageExtraction | null>(null)
const open = (file: File) => {
state.loading = false
state.show = true
store.commit('setShowMoveable', false)
nextTick(() => {
if (file) {
handleUploaderLoad(file)
}
})
}
defineExpose({
open
})
const handleUploaderLoad = (file: File) => {
console.log(file)
selectImageFile(state as TImageCutoutState, raw, file, (result, name) => {
fileName = name
const resultImage = URL.createObjectURL(result)
state.rawImage && (state.cutImage = resultImage)
requestAnimationFrame(run)
})
state.toolModel = false
}
const handleClose = () => {
store.commit('setShowMoveable', true)
}
const mousemove = (e: MouseEvent) => {
!isRuning && (state.percent = (e.offsetX / (e.target as any).width) * 100)
}
const download = () => {
_dl.downloadBase64File(state.cutImage, fileName)
}
const clear = () => {
URL.revokeObjectURL(state.rawImage)
state.rawImage = ''
// URL.revokeObjectURL(state.cutImage)
state.cutImage = ''
state.percent = 0
state.offsetWidth = 0
}
const run = () => {
state.percent += 1
isRuning = true
state.percent < 100 ? requestAnimationFrame(run) : (isRuning = false)
}
const cutDone = async () => {
state.loading = true
const url = await uploadCutPhotoToCloud(state.cutImage)
emits('done', url)
state.show = false
handleClose()
}
const edit = () => {
if (!matting.value) return
matting.value.open(state.rawImage, state.cutImage, (base64: string) => {
state.cutImage = base64
state.percent = 0
requestAnimationFrame(run)
})
}
</script>
<style lang="less" scoped>
.uploader {
&__box {
color: #333333;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
}
.content {
position: relative;
display: flex;
justify-content: center;
}
.scan-effect {
position: relative;
height: 50vh;
overflow: hidden;
img {
// width: 100%;
height: 100%;
object-fit: contain;
position: absolute;
}
}
.scan-line {
position: absolute;
top: 0;
width: 1.5px;
height: 100%;
background: rgba(255, 255, 255, 0.7);
// background-image: linear-gradient(to top, transparent, rgba(255, 255, 255, 0.7), transparent);
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
}
.progress {
width: 100%;
}
</style>

View File

@ -0,0 +1,68 @@
/*
* @Author: Jeremy Yu
* @Date: 2024-03-03 19:00:00
* @Description:
* @LastEditors: Jeremy Yu <https://github.com/JeremyYu-cn>
* @Date: 2024-03-03 19:00:00
*/
import Qiniu from '@/common/methods/QiNiu'
import { TCommonUploadCb, TUploadErrorResult } from "@/api/ai"
import { TImageCutoutState } from "./index.vue"
import api from "@/api"
import { getImage } from '@/common/methods/getImgDetail'
import _config from '@/config'
import { Ref } from 'vue'
/** 选择图片 */
export const selectImageFile = async (
state: TImageCutoutState,
raw: Ref<HTMLElement | null>,
file: File,
successCb?: (result: MediaSource, fileName: string) => void,
uploadCb?: TCommonUploadCb,
) => {
if (file.size > 1024 * 1024 * 2) {
alert('上传图片超出限制')
return false
}
if (!raw.value) return
// 显示选择的图片
raw.value.addEventListener('load', () => {
state.offsetWidth = (raw.value as HTMLElement).offsetWidth
})
state.rawImage = URL.createObjectURL(file)
// 返回抠图结果
const result = await api.ai.upload(file, (up: number, dp: number) => {
uploadCb && uploadCb(up, dp)
if (dp) {
state.progressText = dp === 100 ? '' : '导入中..'
state.progress = dp
} else {
state.progressText = up < 100 ? '上传中..' : '正在处理,请稍候..'
state.progress = up < 100 ? up : 0
}
})
if (typeof result == 'object' && (result as TUploadErrorResult).type !== 'application/json') {
successCb && successCb(result as MediaSource, file.name)
} else alert('服务器繁忙,请稍等下重新尝试~')
}
export async function uploadCutPhotoToCloud(cutImage: string) {
try {
const response = await fetch(cutImage)
const buffer = await response.arrayBuffer()
const file = new File([buffer], `cut_image_${Math.random()}.png`)
// upload
const qnOptions = { bucket: 'xp-design', prePath: 'user' }
const result = await Qiniu.upload(file, qnOptions)
const { width, height } = await getImage(file)
const url = _config.IMG_URL + result.key
await api.material.addMyPhoto({ width, height, url })
return url
} catch(e) {
console.error(`upload cut file error: msg: ${e}`)
return ''
}
}

View File

@ -5,5 +5,5 @@
* @LastEditors: ShawnPhang <site: book.palxp.com>
* @LastEditTime: 2023-07-12 00:05:48
*/
import index from './ImageCutout.vue'
import index from './ImageCutout/index.vue'
export default index

View File

@ -31,7 +31,10 @@ interface HTMLElementEventMap {
}
interface IQiniuSubscribeCb {
(result: { total: { percent: number }}): void
(result: {
total: { percent: number }
key: string
}): void
}
interface Window {