mirror of
https://github.com/palxiao/poster-design.git
synced 2025-07-15 16:02:19 +08:00
Merge pull request #60 from JeremyYu-cn/feat-upgrade-vue3
feat: Convert imageCutout component to vue3
This commit is contained in:
commit
563cec2b99
@ -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)
|
||||
}
|
||||
|
@ -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>
|
202
src/components/business/image-cutout/ImageCutout/index.vue
Normal file
202
src/components/business/image-cutout/ImageCutout/index.vue
Normal 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>
|
||||
|
||||
|
68
src/components/business/image-cutout/ImageCutout/method.ts
Normal file
68
src/components/business/image-cutout/ImageCutout/method.ts
Normal 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 ''
|
||||
}
|
||||
}
|
@ -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
|
||||
|
5
src/types/global.d.ts
vendored
5
src/types/global.d.ts
vendored
@ -31,7 +31,10 @@ interface HTMLElementEventMap {
|
||||
}
|
||||
|
||||
interface IQiniuSubscribeCb {
|
||||
(result: { total: { percent: number }}): void
|
||||
(result: {
|
||||
total: { percent: number }
|
||||
key: string
|
||||
}): void
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
Loading…
x
Reference in New Issue
Block a user