Compare commits
No commits in common. "master" and "v0.5" have entirely different histories.
|
|
@ -17,9 +17,6 @@ VITE_API_PAY_TARGET = 'https://sxwz.xueai.art' # http://43.248.133.202
|
|||
VITE_API_WORKFLOW_UPLOAD = 'https://designtools.xueai.art/workflow/file/upload' # https://sxwz.xueai.art/workflow https://designtools.xueai.art/workflow
|
||||
VITE_API_WORKFLOW_WS = 'wss://talkingdraw.xueai.art/testworkflow'
|
||||
|
||||
# 模型资源
|
||||
VITE_API_MODEL_RESOURCE = 'https://resources.xueai.art/AIGC'
|
||||
|
||||
# 是否开启KKFileView
|
||||
FILE_OPEN_PREVIEW = false
|
||||
# KKFileView服务器地址
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"nodeInfoList": {
|
||||
"prompt":{ "nodeId":"2", "fieldName":"prompt", "fieldValue":"" },
|
||||
"resolution":{ "nodeId":"2", "fieldName":"resolution", "fieldValue":"" },
|
||||
"proportion":{ "nodeId":"2", "fieldName":"aspect_ratio", "fieldValue":"" },
|
||||
"duration":{ "nodeId":"2", "fieldName":"duration", "fieldValue": 5},
|
||||
"audio":{ "nodeId":"2", "fieldName":"audio", "fieldValue": false}
|
||||
},
|
||||
"workflowId": "2036349280088231938",
|
||||
"display": {
|
||||
"promptPlaceholder": {"default": "描述你想生成的画面和动作。"},
|
||||
"prompt": {"default": ""},
|
||||
"resolution": {"default": "1k","options":[
|
||||
{ "value": "360", "label": "流畅 360P" },
|
||||
{ "value": "540", "label": "标清 540P" },
|
||||
{ "value": "720", "label": "高清 720P" },
|
||||
{ "value": "1k", "label": "超清 1K" }
|
||||
]},
|
||||
"proportion": {"default": "16:9","options":[
|
||||
{ "value": "21:9", "label": "21:9" },
|
||||
{ "value": "16:9", "label": "16:9" },
|
||||
{ "value": "4:3", "label": "4:3" },
|
||||
{ "value": "1:1", "label": "1:1" },
|
||||
{ "value": "3:4", "label": "3:4" },
|
||||
{ "value": "9:16", "label": "9:16" }
|
||||
]},
|
||||
"duration": {"default": 5,"options":[
|
||||
{ "value": 5, "label": "5秒" },
|
||||
{ "value": 10, "label": "10秒" },
|
||||
{ "value": 15, "label": "15秒" }
|
||||
]},
|
||||
"audio": {"default": false}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@ export {}
|
|||
declare global {
|
||||
const EffectScope: typeof import('vue').EffectScope
|
||||
const ElMessage: typeof import('element-plus/es').ElMessage
|
||||
const ElMessageBox: typeof import('element-plus/es').ElMessageBox
|
||||
const ElNotification: typeof import('element-plus/es').ElNotification
|
||||
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||
const computed: typeof import('vue').computed
|
||||
|
|
|
|||
|
|
@ -24,18 +24,15 @@ declare module 'vue' {
|
|||
IEpStar: typeof import('~icons/ep/star')['default']
|
||||
ImageUploader: typeof import('./src/components/dialogBox/imageUploader/index.vue')['default']
|
||||
Img: typeof import('./src/components/Img/index.vue')['default']
|
||||
Painting: typeof import('./src/components/dialogBox/model/painting.vue')['default']
|
||||
Model: typeof import('./src/components/dialogBox/model/index.vue')['default']
|
||||
Pattern: typeof import('./src/components/dialogBox/pattern/index.vue')['default']
|
||||
Popover: typeof import('./src/components/Popover/index.vue')['default']
|
||||
Proportion: typeof import('./src/components/dialogBox/proportion/index.vue')['default']
|
||||
Quantity: typeof import('./src/components/dialogBox/quantity/index.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Select: typeof import('./src/components/Select/index.vue')['default']
|
||||
Time: typeof import('./src/components/dialogBox/Time/index.vue')['default']
|
||||
Video: typeof import('./src/components/dialogBox/model/video.vue')['default']
|
||||
VideoImageUploader: typeof import('./src/components/dialogBox/videoImageUploader/index.vue')['default']
|
||||
VirtualScroller: typeof import('./src/components/virtual-scroller/VirtualScroller.vue')['default']
|
||||
'VirtualScroller copy': typeof import('./src/components/virtual-scroller/VirtualScroller copy.vue')['default']
|
||||
'VirtualScroller copy 2': typeof import('./src/components/virtual-scroller/VirtualScroller copy 2.vue')['default']
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Cache-Control" content="public, max-age=3600" />
|
||||
<title>AI Painting</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -7,15 +7,5 @@ export function getGenerateHistoryList(query) {
|
|||
|
||||
// 取消或收藏
|
||||
export function cancelOrCollect(query) {
|
||||
return service.post('/collect/toggle', null, { params: query })
|
||||
}
|
||||
|
||||
// 删除生成历史
|
||||
export function deleteGenerateHistory(query) {
|
||||
return service.delete('/taskRecordHistory/delete', { params: query })
|
||||
}
|
||||
|
||||
// 获取免费次数
|
||||
export function getFreeTimes(id) {
|
||||
return service.get('/plantformBalance/userBalances', { params: { id } })
|
||||
return service.post('/collect/toggle', query)
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5 2H15C15.5523 2 16 2.44772 16 3V5.5M16 12.5V15C16 15.5523 15.5523 16 15 16H12.5M5.5 16H3C2.44772 16 2 15.5523 2 15V12.5M2 5.5V3C2 2.44772 2.44772 2 3 2H5.5" stroke="#000F33" stroke-linecap="round"/>
|
||||
<path d="M8.53107 5.26725C8.69215 4.83194 9.30785 4.83194 9.46893 5.26725L10.0313 6.78706C10.2339 7.3345 10.6655 7.76612 11.2129 7.96869L12.7327 8.53107C13.1681 8.69215 13.1681 9.30785 12.7327 9.46893L11.2129 10.0313C10.6655 10.2339 10.2339 10.6655 10.0313 11.2129L9.46893 12.7327C9.30785 13.1681 8.69215 13.1681 8.53107 12.7327L7.96869 11.2129C7.76612 10.6655 7.3345 10.2339 6.78706 10.0313L5.26725 9.46893C4.83194 9.30785 4.83194 8.69215 5.26725 8.53107L6.78706 7.96869C7.3345 7.76612 7.76612 7.3345 7.96869 6.78706L8.53107 5.26725Z" fill="#000F33"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 869 B |
|
Before Width: | Height: | Size: 404 B After Width: | Height: | Size: 404 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5 2H15C15.5523 2 16 2.44772 16 3V5.5M16 12.5V15C16 15.5523 15.5523 16 15 16H12.5M5.5 16H3C2.44772 16 2 15.5523 2 15V12.5M2 5.5V3C2 2.44772 2.44772 2 3 2H5.5" stroke="#000F33" stroke-linecap="round"/>
|
||||
<path d="M8.53107 5.26725C8.69215 4.83194 9.30785 4.83194 9.46893 5.26725L10.0313 6.78706C10.2339 7.3345 10.6655 7.76612 11.2129 7.96869L12.7327 8.53107C13.1681 8.69215 13.1681 9.30785 12.7327 9.46893L11.2129 10.0313C10.6655 10.2339 10.2339 10.6655 10.0313 11.2129L9.46893 12.7327C9.30785 13.1681 8.69215 13.1681 8.53107 12.7327L7.96869 11.2129C7.76612 10.6655 7.3345 10.2339 6.78706 10.0313L5.26725 9.46893C4.83194 9.30785 4.83194 8.69215 5.26725 8.53107L6.78706 7.96869C7.3345 7.76612 7.76612 7.3345 7.96869 6.78706L8.53107 5.26725Z" fill="#000F33"/>
|
||||
<path d="M12.5 2H15C15.5523 2 16 2.44772 16 3V5.5M16 12.5V15C16 15.5523 15.5523 16 15 16H12.5M5.5 16H3C2.44772 16 2 15.5523 2 15V12.5M2 5.5V3C2 2.44772 2.44772 2 3 2H5.5" stroke="#666666" stroke-linecap="round"/>
|
||||
<path d="M10 10.1765C10.7712 10.1765 11.5127 10.456 12.0707 10.957C12.6288 11.458 12.9603 12.142 12.9967 12.867L13 13H5C5 12.2512 5.31607 11.533 5.87868 11.0035C6.44129 10.4739 7.20435 10.1765 8 10.1765H10ZM9 5C9.66304 5 10.2989 5.2479 10.7678 5.68916C11.2366 6.13042 11.5 6.7289 11.5 7.35294C11.5 7.97698 11.2366 8.57546 10.7678 9.01672C10.2989 9.45798 9.66304 9.70588 9 9.70588C8.33696 9.70588 7.70107 9.45798 7.23223 9.01672C6.76339 8.57546 6.5 7.97698 6.5 7.35294C6.5 6.7289 6.76339 6.13042 7.23223 5.68916C7.70107 5.2479 8.33696 5 9 5Z" fill="#666666"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 869 B After Width: | Height: | Size: 875 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5 2H15C15.5523 2 16 2.44772 16 3V5.5M16 12.5V15C16 15.5523 15.5523 16 15 16H12.5M5.5 16H3C2.44772 16 2 15.5523 2 15V12.5M2 5.5V3C2 2.44772 2.44772 2 3 2H5.5" stroke="#666666" stroke-linecap="round"/>
|
||||
<path d="M10 10.1765C10.7712 10.1765 11.5127 10.456 12.0707 10.957C12.6288 11.458 12.9603 12.142 12.9967 12.867L13 13H5C5 12.2512 5.31607 11.533 5.87868 11.0035C6.44129 10.4739 7.20435 10.1765 8 10.1765H10ZM9 5C9.66304 5 10.2989 5.2479 10.7678 5.68916C11.2366 6.13042 11.5 6.7289 11.5 7.35294C11.5 7.97698 11.2366 8.57546 10.7678 9.01672C10.2989 9.45798 9.66304 9.70588 9 9.70588C8.33696 9.70588 7.70107 9.45798 7.23223 9.01672C6.76339 8.57546 6.5 7.97698 6.5 7.35294C6.5 6.7289 6.76339 6.13042 7.23223 5.68916C7.70107 5.2479 8.33696 5 9 5Z" fill="#666666"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 875 B |
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.1494 2.5C13.039 2.5 14.4998 3.96763 14.5 5.88477C14.5 7.06494 13.9819 8.17537 12.9678 9.4209C11.9479 10.6736 10.4752 12.006 8.64648 13.6387C8.63893 13.6454 8.63116 13.652 8.62402 13.6592L8.35547 13.9307C8.15984 14.1281 7.84016 14.1281 7.64453 13.9307L7.37598 13.6592L7.35352 13.6387L6.05078 12.4668C4.81731 11.3444 3.79719 10.3604 3.03223 9.4209C2.01813 8.17537 1.5 7.06494 1.5 5.88477C1.50024 3.9673 2.9594 2.5 4.84863 2.5C5.88613 2.5 6.93824 2.99756 7.61523 3.80469C7.71024 3.91796 7.85021 3.9834 7.99805 3.9834C8.14588 3.9834 8.28586 3.91796 8.38086 3.80469C9.05777 2.99766 10.1098 2.5 11.1494 2.5Z" fill="#FF4D4F"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 736 B |
|
|
@ -29,8 +29,8 @@
|
|||
v-for="(option, index) in group.options"
|
||||
:key="option.value || index"
|
||||
class="dropdown-item"
|
||||
:class="{ selected: option.value === selectedValue, disabled: option.disabled }"
|
||||
@click.stop="!option.disabled && selectOption(option)"
|
||||
:class="{ selected: option.value === selectedValue }"
|
||||
@click.stop="selectOption(option)"
|
||||
>
|
||||
<slot name="option" :option="option" :selected="option.value === selectedValue">
|
||||
<div class="option-content">
|
||||
|
|
@ -46,8 +46,8 @@
|
|||
v-for="(option, index) in options"
|
||||
:key="option.value || index"
|
||||
class="dropdown-item"
|
||||
:class="{ selected: option.value === selectedValue, disabled: option.disabled }"
|
||||
@click.stop="!option.disabled && selectOption(option)"
|
||||
:class="{ selected: option.value === selectedValue }"
|
||||
@click.stop="selectOption(option)"
|
||||
>
|
||||
<slot name="option" :option="option" :selected="option.value === selectedValue">
|
||||
<div class="option-content">
|
||||
|
|
@ -321,17 +321,12 @@ onBeforeUnmount(() => {
|
|||
line-height: normal;
|
||||
}
|
||||
|
||||
.dropdown-item:hover:not(.disabled) {
|
||||
.dropdown-item:hover {
|
||||
color: #333333;
|
||||
background-color: #f5f6f7;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.dropdown-item.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.dropdown-item.selected {
|
||||
color: #333;
|
||||
font-weight: 400;
|
||||
|
|
|
|||
|
|
@ -89,10 +89,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<button class="confirm-btn" :class="{ loading: isSending }" :disabled="isSending" @click="handleSend">
|
||||
<span v-if="isSending">发送中...</span>
|
||||
<span v-else>发送</span>
|
||||
</button>
|
||||
<button class="confirm-btn" @click="handleSend">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -161,8 +158,7 @@
|
|||
|
||||
<script setup>
|
||||
import { generate } from '@/utils/websocket'
|
||||
import { useDisplayStore, useUserStore } from '@/stores'
|
||||
import request from '@/utils/request'
|
||||
import { useDisplayStore } from '@/stores'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
|
|
@ -180,10 +176,6 @@ const props = defineProps({
|
|||
source: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -216,7 +208,6 @@ const selectedReferenceImages = ref([])
|
|||
const currentEditingShapeIndex = ref(-1)
|
||||
const isPanelOpen = ref(false)
|
||||
const currentEditingContent = ref('')
|
||||
const isSending = ref(false)
|
||||
|
||||
const handleInput = (e) => {
|
||||
inputText.value = e.target.innerHTML
|
||||
|
|
@ -245,15 +236,15 @@ const updateCurrentEditingContent = (description) => {
|
|||
const shapeWord = shapeType === 'circle' ? '圈' : '框'
|
||||
|
||||
if (selectedReferenceImages.value.length > 0) {
|
||||
const imageIndex = allReferenceImages.value.indexOf(selectedReferenceImages.value[0]) + 2
|
||||
const imageIndex = allReferenceImages.value.indexOf(selectedReferenceImages.value[0]) + 1
|
||||
const prefix = isFirstShape ? '' : ','
|
||||
currentEditingContent.value = `${prefix}将图1${colorName}${shapeWord}内的【XXX】替换为【图${imageIndex}中的${description}】`
|
||||
currentEditingContent.value = `${prefix}将${colorName}${shapeWord}内的【XXX】替换为【图${imageIndex}中的${description}】`
|
||||
} else if (description) {
|
||||
const prefix = isFirstShape ? '' : ','
|
||||
currentEditingContent.value = `${prefix}将图1${colorName}${shapeWord}内的【XXX】替换为【${description}】`
|
||||
currentEditingContent.value = `${prefix}将${colorName}${shapeWord}内的【XXX】替换为【${description}】`
|
||||
} else {
|
||||
const prefix = isFirstShape ? '' : ','
|
||||
currentEditingContent.value = `${prefix}将图1${colorName}${shapeWord}内的【XXX】替换为【XXX】或【图X中的XXX】`
|
||||
currentEditingContent.value = `${prefix}将${colorName}${shapeWord}内的【XXX】替换为【XXX】或【图X中的XXX】`
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -290,13 +281,7 @@ const initCanvas = () => {
|
|||
const containerHeight = container.clientHeight
|
||||
|
||||
if (currentImage.value) {
|
||||
let imageUrl = currentImage.value
|
||||
|
||||
if (!imageUrl.startsWith('data:')) {
|
||||
imageUrl = imageUrl.replace('https://sxwz.xueai.art', 'https://talkingdraw.xueai.art')
|
||||
}
|
||||
|
||||
if (imageUrl.startsWith('data:')) {
|
||||
if (currentImage.value.startsWith('data:')) {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const imgScale = Math.min(containerWidth / img.width, containerHeight / img.height)
|
||||
|
|
@ -306,9 +291,9 @@ const initCanvas = () => {
|
|||
bgImage.value = img
|
||||
scale.value = imgScale
|
||||
}
|
||||
img.src = imageUrl
|
||||
img.src = currentImage.value
|
||||
} else {
|
||||
fetch(imageUrl)
|
||||
fetch(currentImage.value)
|
||||
.then(res => res.blob())
|
||||
.then(blob => {
|
||||
const img = new Image()
|
||||
|
|
@ -546,93 +531,45 @@ const handleClose = () => {
|
|||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (isSending.value) return
|
||||
if (!inputText.value) {
|
||||
ElMessage.error('请输入提示词')
|
||||
return
|
||||
}
|
||||
|
||||
isSending.value = true
|
||||
const canvas = canvasRef.value
|
||||
const imageData = canvas.toDataURL('image/png')
|
||||
|
||||
try {
|
||||
const canvas = canvasRef.value
|
||||
const imageData = canvas.toDataURL('image/png')
|
||||
|
||||
const imgs = []
|
||||
if (imageData) {
|
||||
imgs.push({ name: 'image_1', url: imageData })
|
||||
}
|
||||
|
||||
allReferenceImages.value.forEach((img, index) => {
|
||||
imgs.push({ name: `image_${index + 2}`, url: img })
|
||||
})
|
||||
|
||||
const uploadImg = async (imgItem) => {
|
||||
if (!imgItem.url.startsWith('data:') && !imgItem.url.startsWith('blob:')) {
|
||||
return imgItem
|
||||
}
|
||||
|
||||
const response = await fetch(imgItem.url)
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], `${imgItem.name}.png`, { type: 'image/png' })
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
const result = await request({
|
||||
url: import.meta.env.VITE_API_WORKFLOW_UPLOAD,
|
||||
method: 'POST',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
if (result.success || result.code === 0) {
|
||||
return { name: imgItem.name, url: result.url }
|
||||
}
|
||||
return imgItem
|
||||
} catch (error) {
|
||||
console.error('上传失败:', error)
|
||||
return imgItem
|
||||
}
|
||||
}
|
||||
|
||||
const uploadedImgs = await Promise.all(imgs.map(uploadImg))
|
||||
|
||||
const generateData = {
|
||||
model: 'banana',
|
||||
modelType: 'edit',
|
||||
prompt: inputText.value,
|
||||
proportion: '比例自动'
|
||||
}
|
||||
const data = {
|
||||
type: props.type,
|
||||
modelType: 'edit',
|
||||
AIGC: 'Painting',
|
||||
platform: 'runninghub',
|
||||
modelName: 'banana',
|
||||
quantity: 1,
|
||||
free: useUserStore().freeTimes,
|
||||
params: [
|
||||
{ name: 'prompt', data: inputText.value + '并且去除掉图1中的框' },
|
||||
{ name: 'index', data: 1 },
|
||||
],
|
||||
imgs: uploadedImgs,
|
||||
result: JSON.stringify(generateData)
|
||||
}
|
||||
|
||||
emit('send', {
|
||||
image: imageData,
|
||||
text: inputText.value,
|
||||
shapes: shapes.value
|
||||
})
|
||||
|
||||
await generate(data, generateData)
|
||||
handleClose()
|
||||
} finally {
|
||||
isSending.value = false
|
||||
const imgs = []
|
||||
if (imageData) {
|
||||
imgs.push({ name: 'image_1', url: imageData })
|
||||
}
|
||||
|
||||
allReferenceImages.value.forEach((img, index) => {
|
||||
imgs.push({ name: `image_${index + 2}`, url: img })
|
||||
})
|
||||
|
||||
const data = {
|
||||
AIGC: 'Painting',
|
||||
platform: 'runninghub',
|
||||
file_type: 'image',
|
||||
modelName: 'flux',
|
||||
params: [
|
||||
{ name: 'prompt', data: inputText.value },
|
||||
{ name: 'quantity', data: 1 },
|
||||
{ name: 'aspect_ratio', data: '16:9' },
|
||||
{ name: 'resolution', data: '1k' }
|
||||
],
|
||||
imgs
|
||||
}
|
||||
|
||||
emit('send', {
|
||||
image: imageData,
|
||||
text: inputText.value,
|
||||
shapes: shapes.value
|
||||
})
|
||||
|
||||
await generate('text', data)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const removeReferenceImage = (index) => {
|
||||
|
|
@ -655,7 +592,7 @@ const handleUploadReference = () => {
|
|||
input.type = 'file'
|
||||
input.accept = 'image/*'
|
||||
input.multiple = true
|
||||
input.onchange = async (e) => {
|
||||
input.onchange = (e) => {
|
||||
const files = Array.from(e.target.files)
|
||||
const remainingSlots = 5 - allReferenceImages.value.length
|
||||
|
||||
|
|
@ -666,17 +603,13 @@ const handleUploadReference = () => {
|
|||
|
||||
const filesToUpload = files.slice(0, remainingSlots)
|
||||
|
||||
const readFileAsDataURL = (file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => resolve(event.target.result)
|
||||
reader.onerror = (error) => reject(error)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
const imageDataUrls = await Promise.all(filesToUpload.map(readFileAsDataURL))
|
||||
allReferenceImages.value.push(...imageDataUrls)
|
||||
filesToUpload.forEach(file => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
allReferenceImages.value.push(event.target.result)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
|
@ -758,7 +691,7 @@ const handleBrushConfirm = () => {
|
|||
.preview-text {
|
||||
color: #333;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: normal;
|
||||
|
|
@ -885,6 +818,11 @@ const handleBrushConfirm = () => {
|
|||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
// border-color: #409eff;
|
||||
// color: #409eff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
|
||||
|
|
@ -909,6 +847,10 @@ const handleBrushConfirm = () => {
|
|||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
// border-color: #409eff;
|
||||
// color: #409eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -925,18 +867,6 @@ const handleBrushConfirm = () => {
|
|||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:disabled {
|
||||
background: #999;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
background: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.brush-panel {
|
||||
|
|
@ -963,7 +893,7 @@ const handleBrushConfirm = () => {
|
|||
.brush-panel-title {
|
||||
color: #333;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: normal;
|
||||
|
|
@ -1029,7 +959,7 @@ const handleBrushConfirm = () => {
|
|||
.brush-panel-text {
|
||||
color: #333;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: normal;
|
||||
|
|
|
|||
|
|
@ -21,17 +21,6 @@ const props = defineProps({
|
|||
modelValue: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ value: 5, label: '5s' },
|
||||
{ value: 6, label: '6s' },
|
||||
{ value: 7, label: '7s' },
|
||||
{ value: 8, label: '8s' },
|
||||
{ value: 9, label: '9s' },
|
||||
{ value: 10, label: '10s' }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -42,7 +31,14 @@ const quantity = computed({
|
|||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const quantityOptions = computed(() => props.options)
|
||||
const quantityOptions = [
|
||||
{ value: 5, label: '5s' },
|
||||
{ value: 6, label: '6s' },
|
||||
{ value: 7, label: '7s' },
|
||||
{ value: 8, label: '8s' },
|
||||
{ value: 9, label: '9s' },
|
||||
{ value: 10, label: '10s' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
|
|
|
|||
|
|
@ -40,9 +40,6 @@ const props = defineProps({
|
|||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
/**
|
||||
* 图片列表,每个元素包含 url 和 uid 属性
|
||||
*/
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
|
|
@ -55,39 +52,13 @@ const uploadurl = import.meta.env.VITE_API_WORKFLOW_UPLOAD
|
|||
const uploadRef = ref(null)
|
||||
const imageList = ref([...props.modelValue])
|
||||
const localPreviewList = ref([...props.modelValue])
|
||||
const isUploading = ref(false)
|
||||
|
||||
watch(() => props.modelValue, async (newVal) => {
|
||||
if (isUploading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
imageList.value = [...newVal]
|
||||
|
||||
const newPreviewList = []
|
||||
for (const img of newVal) {
|
||||
let previewImg = { ...img }
|
||||
if (img.url && !img.url.startsWith('blob:')) {
|
||||
try {
|
||||
const response = await fetch(img.url)
|
||||
const blob = await response.blob()
|
||||
previewImg = {
|
||||
...img,
|
||||
url: URL.createObjectURL(blob),
|
||||
serverUrl: img.url
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create blob URL:', error)
|
||||
previewImg = {
|
||||
...img,
|
||||
serverUrl: img.url
|
||||
}
|
||||
}
|
||||
}
|
||||
newPreviewList.push(previewImg)
|
||||
if (newVal.length === 0) {
|
||||
localPreviewList.value = []
|
||||
}
|
||||
localPreviewList.value = newPreviewList
|
||||
}, { deep: true, immediate: true })
|
||||
}, { deep: true })
|
||||
|
||||
const triggerUpload = () => {
|
||||
uploadRef.value.$el.querySelector('input').click()
|
||||
|
|
@ -110,8 +81,6 @@ const beforeUpload = (rawFile) => {
|
|||
const handleSuccess = (response, uploadFile) => {
|
||||
ElMessage.success('上传成功')
|
||||
|
||||
isUploading.value = true
|
||||
|
||||
const localUrl = URL.createObjectURL(uploadFile.raw)
|
||||
|
||||
const newImage = {
|
||||
|
|
@ -123,14 +92,9 @@ const handleSuccess = (response, uploadFile) => {
|
|||
|
||||
const newPreview = {
|
||||
uid: uploadFile.uid,
|
||||
url: localUrl,
|
||||
serverUrl: response.url
|
||||
url: localUrl
|
||||
}
|
||||
localPreviewList.value.push(newPreview)
|
||||
|
||||
nextTick(() => {
|
||||
isUploading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
|
|
@ -143,7 +107,7 @@ const handleExceed = () => {
|
|||
|
||||
const handleDelete = (index) => {
|
||||
const previewItem = localPreviewList.value[index]
|
||||
if (previewItem && previewItem.url && previewItem.url.startsWith('blob:')) {
|
||||
if (previewItem && previewItem.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewItem.url)
|
||||
}
|
||||
|
||||
|
|
@ -153,10 +117,10 @@ const handleDelete = (index) => {
|
|||
}
|
||||
|
||||
const handleImageClick = (clickedIndex) => {
|
||||
const clickedImage = localPreviewList.value[clickedIndex]
|
||||
const clickedImage = imageList.value[clickedIndex]
|
||||
if (!clickedImage) return
|
||||
|
||||
const otherImages = localPreviewList.value
|
||||
const otherImages = imageList.value
|
||||
.filter((_, index) => index !== clickedIndex)
|
||||
.map((img, index) => ({
|
||||
...img,
|
||||
|
|
|
|||
|
|
@ -1,63 +1,39 @@
|
|||
<template>
|
||||
<Transition name="slide-up">
|
||||
<div class="input-container" :class="{ generate : !props.isGenerate }" @click="handleContainerClick">
|
||||
<div v-if="!props.isGenerate && props.type === 'Painting'" class="title">AI绘画2026</div>
|
||||
<div v-if="!props.isGenerate && props.type === 'Video'" class="title">AI视频2026</div>
|
||||
<div v-if="!props.isGenerate && props.type === 'painting'" class="title">AI绘画2026</div>
|
||||
<div v-if="!props.isGenerate && props.type === 'video'" class="title">AI视频2026</div>
|
||||
|
||||
<div class="sender-top">
|
||||
<div v-if="useDisplay.Sender_variant === 'default'" class="scroll-to-bottom-text" @click.stop="handleScrollToBottom">回到底部<img src="@/assets/dialog/ArrowDown.svg"></div>
|
||||
|
||||
<div v-show="modelType !== 'text'" class="upload-img-container">
|
||||
<div class="reference-diagram">
|
||||
<ImageUploader
|
||||
v-if="props.type === 'Painting'"
|
||||
ref="referenceDiagramRef"
|
||||
v-model="referenceImages"
|
||||
:limit="4"
|
||||
@open-canvas="handleOpenCanvas"
|
||||
/>
|
||||
<VideoImageUploader
|
||||
v-else-if="props.type === 'Video'"
|
||||
ref="referenceDiagramRef"
|
||||
v-model="referenceImages"
|
||||
:model-type="modelType"
|
||||
:images-count="modelDisplayConfig?.display?.images || 1"
|
||||
/>
|
||||
<ImageUploader ref="referenceDiagramRef" v-model="referenceImages" :limit="4" @open-canvas="handleOpenCanvas" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sender :key="useDisplay.Sender_variant" v-model="prompt" :variant="useDisplay.Sender_variant" :placeholder="promptPlaceholder" :submit-btn-disabled="isgerenate.value" :auto-size="autoSizeConfig">
|
||||
<template #prefix>
|
||||
<div v-if="useDisplay.Sender_variant !== 'default' && props.type === 'Painting'" class="prefix-self-wrap">
|
||||
<paintingModel v-model="model" v-model:typeValue="modelType" />
|
||||
<paintingProportion
|
||||
v-model="proportion"
|
||||
v-model:resolution="resolution"
|
||||
:proportion-options="proportionOptions"
|
||||
:resolution-options="resolutionOptions"
|
||||
/>
|
||||
<div v-show="useDisplay.Sender_variant !== 'default' && props.type === 'painting'" class="prefix-self-wrap">
|
||||
<Model v-model="model" v-model:typeValue="modelType" :type="props.type" />
|
||||
<Proportion v-model="proportion" v-model:resolution="resolution" />
|
||||
<Quantity v-model="quantity" />
|
||||
</div>
|
||||
|
||||
<div v-if="useDisplay.Sender_variant !== 'default' && props.type === 'Video'" class="prefix-self-wrap">
|
||||
<div v-show="useDisplay.Sender_variant !== 'default' && props.type === 'video'" class="prefix-self-wrap">
|
||||
<Model v-model="model" v-model:typeValue="modelType" :type="props.type" />
|
||||
<Pattern v-model="videoPattern" />
|
||||
<videoModel v-model="model" v-model:typeValue="modelType" :video-pattern="videoPattern" />
|
||||
|
||||
<videoProportion
|
||||
v-model="proportion"
|
||||
v-model:resolution="resolution"
|
||||
:proportion-options="proportionOptions"
|
||||
:resolution-options="resolutionOptions"
|
||||
/>
|
||||
<Time v-model="duration" :options="durationOptions" />
|
||||
<Proportion v-model="proportion" v-model:resolution="resolution" :type="props.type" />
|
||||
<Time v-model="time" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #action-list>
|
||||
<div style="display: flex; align-items: center; gap: 8px; height: 100%;">
|
||||
<el-button v-if="isgerenate" round color="#626aef">
|
||||
<i-ep-loading style="animation: spin 1s linear infinite;" />
|
||||
<el-button v-if="isgerenate" round color="#626aef" style="animation: spin 1s linear infinite;">
|
||||
<i-ep-loading />
|
||||
</el-button>
|
||||
<div v-else class="gerenate" :class="{ isprompt: prompt }" @click="handleStart">
|
||||
<img v-if="!prompt" src="@/assets/dialog/darkArrow.svg" alt="" />
|
||||
|
|
@ -73,20 +49,16 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import paintingProportion from './proportion/painting.vue'
|
||||
import videoProportion from './proportion/video.vue'
|
||||
import paintingModel from './model/painting.vue'
|
||||
import videoModel from './model/video.vue'
|
||||
import Proportion from './proportion/index.vue'
|
||||
import Quantity from './quantity/index.vue'
|
||||
import Pattern from './pattern/index.vue'
|
||||
import Model from './model/index.vue'
|
||||
import ImageUploader from './imageUploader/index.vue'
|
||||
import VideoImageUploader from './videoImageUploader/index.vue'
|
||||
import Time from './Time/index.vue'
|
||||
import { Sender } from 'vue-element-plus-x'
|
||||
import { useDisplayStore, useUserStore } from '@/stores'
|
||||
import { useDisplayStore } from '@/stores'
|
||||
import { generate } from '@/utils/websocket'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchModelConfig } from '@/utils/modelConfig'
|
||||
|
||||
const props = defineProps({
|
||||
isGenerate: {
|
||||
|
|
@ -99,39 +71,32 @@ const props = defineProps({
|
|||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'Painting'
|
||||
default: 'painting'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['open-canvas'])
|
||||
const router = useRouter()
|
||||
const useDisplay = useDisplayStore()
|
||||
const useUser = useUserStore()
|
||||
|
||||
const isgerenate = ref(false)
|
||||
|
||||
const model = ref() // 模型
|
||||
const model = ref('flux')
|
||||
const modelType = ref('text')
|
||||
|
||||
const modelDisplayConfig = ref(null)
|
||||
const promptPlaceholder = ref('描述你想生成的画面和动作。') // 提示词占位符
|
||||
|
||||
const prompt = ref('') // 提示词
|
||||
const proportion = ref('16:9') // 比例
|
||||
const resolution = ref('1k') // 分辨率
|
||||
// 公用参数
|
||||
const prompt = ref('一个女孩在树下吃苹果')
|
||||
const promptPlaceholder = '描述你想生成的画面和动作。'
|
||||
const proportion = ref('16:9')
|
||||
const referenceImages = ref([])
|
||||
|
||||
// 绘画
|
||||
const quantity = ref(1) // 生成数量
|
||||
const quantity = ref(1)
|
||||
const resolution = ref('1k')
|
||||
|
||||
// 视频
|
||||
const duration = ref(5) // 时间
|
||||
const videoPattern = ref('文生视频') // 视频模式下,默认值为'文生视频'
|
||||
|
||||
const resolutionOptions = ref([])
|
||||
const proportionOptions = ref([])
|
||||
const durationOptions = ref([])
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const time = ref(5)
|
||||
const videoPattern = ref('全能参考')
|
||||
|
||||
const autoSizeConfig = computed(() => {
|
||||
if (useDisplay.Sender_variant !== 'default') {
|
||||
|
|
@ -141,129 +106,39 @@ const autoSizeConfig = computed(() => {
|
|||
}
|
||||
})
|
||||
|
||||
const loadModelConfig = async (modelName, currentModelType) => {
|
||||
try {
|
||||
const config = await fetchModelConfig(props.type, modelName, currentModelType)
|
||||
modelDisplayConfig.value = config
|
||||
|
||||
if (config.display) {
|
||||
const display = config.display
|
||||
|
||||
if (display.promptPlaceholder) {
|
||||
promptPlaceholder.value = display.promptPlaceholder.default || '描述你想生成的画面和动作。'
|
||||
}
|
||||
|
||||
if (display.prompt && !isInitialized.value) {
|
||||
prompt.value = display.prompt.default || ''
|
||||
}
|
||||
|
||||
if (display.resolution) {
|
||||
resolution.value = display.resolution.default || '1k'
|
||||
resolutionOptions.value = display.resolution.options || []
|
||||
}
|
||||
|
||||
if (display.proportion) {
|
||||
proportion.value = display.proportion.default || '16:9'
|
||||
proportionOptions.value = display.proportion.options || []
|
||||
}
|
||||
|
||||
if (display.duration) {
|
||||
duration.value = display.duration.default || 5
|
||||
durationOptions.value = display.duration.options || []
|
||||
}
|
||||
}
|
||||
|
||||
isInitialized.value = true
|
||||
|
||||
return config
|
||||
} catch (error) {
|
||||
console.error('加载模型配置失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const handleStart = async () => {
|
||||
const currentType = props.type
|
||||
let currentModelType = modelType.value
|
||||
|
||||
if(model.value === 'Seedance 2.0') {
|
||||
ElMessage.primary('敬请期待 Seedance 2.0')
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.isGenerate) {
|
||||
router.push({ name: 'home', query: { loading: false, Generate: true, type: currentType } })
|
||||
router.push({ name: 'home', query: { loading: false, Generate: true } })
|
||||
}
|
||||
if (!prompt.value) {
|
||||
// eslint-disable-next-line no-undef
|
||||
ElMessage.error('请输入提示词')
|
||||
return
|
||||
}
|
||||
if (modelType.value === 'image' && !referenceImages.value.length){
|
||||
ElMessage.warning('请上传图片')
|
||||
return
|
||||
}
|
||||
|
||||
isgerenate.value = true
|
||||
console.log('生成开始', isgerenate.value)
|
||||
const imgs = []
|
||||
referenceImages.value.forEach((img, index) => {
|
||||
imgs.push({ name: `image_${index + 1}`, url: img.url })
|
||||
})
|
||||
|
||||
const generateData = {
|
||||
model: model.value,
|
||||
modelType: currentModelType,
|
||||
prompt: prompt.value,
|
||||
proportion: proportion.value,
|
||||
referenceImages: referenceImages.value,
|
||||
quantity: quantity.value,
|
||||
resolution: resolution.value,
|
||||
duration: duration.value,
|
||||
videoPattern: videoPattern.value
|
||||
}
|
||||
|
||||
console.log('imgs', imgs)
|
||||
const data = {
|
||||
type: currentType,
|
||||
modelType: currentModelType,
|
||||
AIGC: currentType,
|
||||
AIGC: 'Painting',
|
||||
platform: 'runninghub',
|
||||
file_type: 'image',
|
||||
modelName: model.value,
|
||||
quantity: quantity.value,
|
||||
free: useUser.freeTimes,
|
||||
params: [
|
||||
{ name: 'prompt', data: prompt.value},
|
||||
{ name: 'quantity', data: quantity.value},
|
||||
{ name: 'proportion', data: proportion.value},
|
||||
{ name: 'aspect_ratio', data: proportion.value},
|
||||
{ name: 'resolution', data: resolution.value},
|
||||
{ name: 'duration', data: duration.value}
|
||||
],
|
||||
imgs,
|
||||
result: JSON.stringify(generateData)
|
||||
imgs
|
||||
}
|
||||
await generate(data, generateData)
|
||||
await generate(modelType.value, data)
|
||||
console.log('生成中', isgerenate.value)
|
||||
}
|
||||
|
||||
const fillParamsFromResult = (resultData) => {
|
||||
if (!resultData) return
|
||||
|
||||
if (resultData.model !== undefined) model.value = resultData.model
|
||||
if (resultData.modelType !== undefined) modelType.value = resultData.modelType
|
||||
if (resultData.prompt !== undefined) prompt.value = resultData.prompt
|
||||
if (resultData.proportion !== undefined) proportion.value = resultData.proportion
|
||||
if (resultData.referenceImages !== undefined) referenceImages.value = resultData.referenceImages
|
||||
if (resultData.quantity !== undefined) quantity.value = resultData.quantity
|
||||
if (resultData.resolution !== undefined) resolution.value = resultData.resolution
|
||||
if (resultData.duration !== undefined) duration.value = resultData.duration
|
||||
if (resultData.videoPattern !== undefined) videoPattern.value = resultData.videoPattern
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
fillParamsFromResult,
|
||||
handleStart
|
||||
})
|
||||
|
||||
const handleContainerClick = () => {
|
||||
if (useDisplay.Sender_variant === 'default') {
|
||||
useDisplay.Sender_variant = 'updown'
|
||||
|
|
@ -276,36 +151,22 @@ const handleScrollToBottom = () => {
|
|||
}
|
||||
|
||||
const handleOpenCanvas = (data) => {
|
||||
useDisplay.openCanvas(data)
|
||||
emit('open-canvas', data)
|
||||
}
|
||||
|
||||
watch(() => useDisplay.isSubGerenate, (newValue) => {
|
||||
console.log('生成状态', newValue)
|
||||
isgerenate.value = newValue
|
||||
// handleScrollToBottom()
|
||||
}, { immediate: true })
|
||||
|
||||
watch([() => model.value, () => modelType.value], async ([newModel, newModelType]) => {
|
||||
console.log('模型或类型改变:', newModel, newModelType)
|
||||
if (newModel && newModelType) {
|
||||
await loadModelConfig(newModel, newModelType)
|
||||
}
|
||||
watch(() => modelType.value, (newValue) => {
|
||||
console.log('modelType.value', newValue)
|
||||
})
|
||||
|
||||
watch(() => props.type, (newType) => {
|
||||
if (newType === 'Video') {
|
||||
model.value = 'LTX2.0'
|
||||
} else {
|
||||
model.value = 'flux'
|
||||
onMounted(() => {
|
||||
if(props.type === 'video'){
|
||||
model.value = 'Vidu Q3-T2V'
|
||||
}
|
||||
const chargeType = newType === 'Painting' ? 1 : 4
|
||||
useUser.fetchFreeTimes(chargeType)
|
||||
}, { immediate: true })
|
||||
|
||||
// 组件挂载时获取免费次数
|
||||
onMounted(async () => {
|
||||
const chargeType = props.type === 'Painting' ? 1 : 4
|
||||
await useUser.fetchFreeTimes(chargeType)
|
||||
console.log('免费次数', useUser.freeTimes)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -333,12 +194,11 @@ onMounted(async () => {
|
|||
position: relative;
|
||||
|
||||
.scroll-to-bottom-text {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
padding: 10px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
// box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
background-color: #F8F9FA;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
|
|
@ -346,7 +206,6 @@ onMounted(async () => {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
|
||||
<script setup>
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import paintingConfig from '@/config/modelConfig/painting.json'
|
||||
import videoConfig from '@/config/modelConfig/video.json'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
|
|
@ -22,52 +24,15 @@ const props = defineProps({
|
|||
typeValue: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'painting'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:typeValue'])
|
||||
|
||||
const paintingConfig = ref({
|
||||
generate: [],
|
||||
edit: [],
|
||||
vision: []
|
||||
})
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const url = `${import.meta.env.VITE_API_MODEL_RESOURCE}/static/public/Platform/AIGC_modelConfig/painting.json`
|
||||
const response = await fetch(url)
|
||||
const data = await response.json()
|
||||
paintingConfig.value = data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch painting config:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fetchConfig()
|
||||
|
||||
watch(() => paintingConfig.value, (newConfig) => {
|
||||
const allModels = [
|
||||
...(newConfig.generate || []),
|
||||
...(newConfig.edit || []),
|
||||
...(newConfig.vision || [])
|
||||
]
|
||||
if (allModels.length > 0) {
|
||||
const enabledModels = allModels.filter(m => !m.disabled)
|
||||
if (enabledModels.length > 0) {
|
||||
const currentModelExists = enabledModels.find(m => m.value === props.modelValue)
|
||||
if (!currentModelExists) {
|
||||
const firstEnabled = enabledModels[0].value
|
||||
emit('update:modelValue', firstEnabled)
|
||||
const newType = getModelType(firstEnabled)
|
||||
emit('update:typeValue', newType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
|
||||
const model = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
|
|
@ -77,11 +42,35 @@ const model = computed({
|
|||
}
|
||||
})
|
||||
|
||||
const generateModels = computed(() => paintingConfig.value.generate || [])
|
||||
const editModels = computed(() => paintingConfig.value.edit || [])
|
||||
const visionModels = computed(() => paintingConfig.value.vision || [])
|
||||
const getModelsByType = (type) => {
|
||||
if (type === 'video') {
|
||||
return videoConfig.video || []
|
||||
}
|
||||
const config = paintingConfig
|
||||
return {
|
||||
generate: config.generate || [],
|
||||
edit: config.edit || [],
|
||||
vision: config.vision || []
|
||||
}
|
||||
}
|
||||
|
||||
const modelData = computed(() => {
|
||||
return getModelsByType(props.type)
|
||||
})
|
||||
|
||||
const isVideo = computed(() => props.type === 'video')
|
||||
|
||||
const generateModels = computed(() => modelData.value.generate || [])
|
||||
const editModels = computed(() => modelData.value.edit || [])
|
||||
const visionModels = computed(() => modelData.value.vision || [])
|
||||
|
||||
const modelGroups = computed(() => {
|
||||
if (isVideo.value) {
|
||||
return [{
|
||||
label: '选择模型',
|
||||
options: modelData.value
|
||||
}]
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: '生成模型',
|
||||
|
|
@ -99,6 +88,9 @@ const modelGroups = computed(() => {
|
|||
})
|
||||
|
||||
const getModelType = (value) => {
|
||||
if (isVideo.value) {
|
||||
return 'text'
|
||||
}
|
||||
if (generateModels.value.find(m => m.value === value)) {
|
||||
return 'text'
|
||||
}
|
||||
|
|
@ -110,25 +102,6 @@ const getModelType = (value) => {
|
|||
}
|
||||
return 'text'
|
||||
}
|
||||
|
||||
const getFirstEnabledModel = () => {
|
||||
const allModels = [...generateModels.value, ...editModels.value, ...visionModels.value]
|
||||
const firstEnabled = allModels.find(m => !m.disabled)
|
||||
return firstEnabled ? firstEnabled.value : ''
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
const allModels = [...generateModels.value, ...editModels.value, ...visionModels.value]
|
||||
const currentModel = allModels.find(m => m.value === newValue)
|
||||
if (currentModel && currentModel.disabled) {
|
||||
const firstEnabled = getFirstEnabledModel()
|
||||
if (firstEnabled) {
|
||||
emit('update:modelValue', firstEnabled)
|
||||
const newType = getModelType(firstEnabled)
|
||||
emit('update:typeValue', newType)
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
<template>
|
||||
<Select
|
||||
v-model="model"
|
||||
:options="modelGroups"
|
||||
class="model-select"
|
||||
position="top"
|
||||
>
|
||||
<template #prefix>
|
||||
<img src="@/assets/dialog/model.svg" alt="" style="width: 16px;">
|
||||
</template>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Select from '@/components/Select/index.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
typeValue: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
videoPattern: {
|
||||
type: String,
|
||||
default: '文生视频'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:typeValue'])
|
||||
|
||||
const videoConfig = ref({})
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const url = `${import.meta.env.VITE_API_MODEL_RESOURCE}/static/public/Platform/AIGC_modelConfig/video.json`
|
||||
const response = await fetch(url)
|
||||
const data = await response.json()
|
||||
videoConfig.value = data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch video config:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fetchConfig()
|
||||
|
||||
watch(() => videoConfig.value, (newConfig) => {
|
||||
const models = newConfig[props.videoPattern] || []
|
||||
if (models.length > 0) {
|
||||
const enabledModels = models.filter(m => !m.disabled)
|
||||
if (enabledModels.length > 0) {
|
||||
const currentModelExists = enabledModels.find(m => m.value === props.modelValue)
|
||||
if (!currentModelExists) {
|
||||
model.value = enabledModels[0].value
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
|
||||
const model = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value)
|
||||
const newType = getModelType(props.videoPattern)
|
||||
emit('update:typeValue', newType)
|
||||
}
|
||||
})
|
||||
|
||||
const modelGroups = computed(() => {
|
||||
const models = videoConfig.value[props.videoPattern] || []
|
||||
return models
|
||||
})
|
||||
|
||||
const getModelType = (value) => {
|
||||
switch (value) {
|
||||
case '文生视频':
|
||||
return 'text'
|
||||
case '首尾帧':
|
||||
return 'image'
|
||||
case '数字人':
|
||||
return 'digitalHuman'
|
||||
default:
|
||||
return 'text'
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.videoPattern, (newPattern) => {
|
||||
const models = videoConfig.value[newPattern] || []
|
||||
if (models.length > 0) {
|
||||
const enabledModels = models.filter(m => !m.disabled)
|
||||
if (enabledModels.length > 0) {
|
||||
const currentModelExists = enabledModels.find(m => m.value === props.modelValue)
|
||||
if (!currentModelExists) {
|
||||
model.value = enabledModels[0].value
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
const models = videoConfig.value[props.videoPattern] || []
|
||||
const currentModel = models.find(m => m.value === newValue)
|
||||
if (currentModel && currentModel.disabled) {
|
||||
const enabledModels = models.filter(m => !m.disabled)
|
||||
if (enabledModels.length > 0) {
|
||||
model.value = enabledModels[0].value
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.model-select {
|
||||
:deep(.select-header) {
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
|
||||
&:hover {
|
||||
background: #e9eaeb;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.select-text) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.dropdown-menu) {
|
||||
max-height: 510px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.dropdown-item) {
|
||||
min-width: 120px;
|
||||
|
||||
&.active {
|
||||
background: rgba(0, 15, 51, 0.10);
|
||||
color: #000F33;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -19,11 +19,10 @@
|
|||
|
||||
<script setup>
|
||||
import Select from '@/components/Select/index.vue'
|
||||
|
||||
import videoPattern1 from '@/assets/dialog/videoPattern1.svg'
|
||||
import videoPattern2 from '@/assets/dialog/videoPattern2.svg'
|
||||
import videoPattern3 from '@/assets/dialog/videoPattern3.svg'
|
||||
import videoPattern4 from '@/assets/dialog/videoPattern4.svg'
|
||||
import videoPattern5 from '@/assets/dialog/videoPattern5.svg'
|
||||
import videoPattern6 from '@/assets/dialog/videoPattern6.svg'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
|
|
@ -40,12 +39,10 @@ const quantity = computed({
|
|||
})
|
||||
|
||||
const quantityOptions = [
|
||||
{ value: '文生视频', label: '文生视频', labelText: '文生视频', icon: videoPattern2 },
|
||||
{ value: '全能参考', label: '全能参考', labelText: '全能参考', icon: videoPattern1 },
|
||||
{ value: '首尾帧', label: '首尾帧', labelText: '首尾帧', icon: videoPattern2 },
|
||||
{ value: '数字人', label: '数字人', labelText: '数字人', icon: videoPattern2 },
|
||||
{ value: '全能参考', label: '全能参考', labelText: '全能参考', icon: videoPattern4 },
|
||||
{ value: '智能多帧', label: '智能多帧', labelText: '智能多帧', icon: videoPattern5 },
|
||||
{ value: '主体参考', label: '主体参考', labelText: '主体参考', icon: videoPattern6 }
|
||||
{ value: '智能多帧', label: '智能多帧', labelText: '智能多帧', icon: videoPattern3 },
|
||||
{ value: '主体参考', label: '主体参考', labelText: '主体参考', icon: videoPattern4 }
|
||||
]
|
||||
|
||||
const selectedIcon = computed(() => {
|
||||
|
|
|
|||
|
|
@ -2,37 +2,37 @@
|
|||
<Popover placement="top" :width="400">
|
||||
<div class="proportion-container">
|
||||
<div class="section">
|
||||
<h3>选择比例</h3>
|
||||
<div class="proportion-options">
|
||||
<div
|
||||
v-for="item in proportionOptions"
|
||||
:key="item.value"
|
||||
class="proportion-item"
|
||||
:class="{ active: proportion === item.value }"
|
||||
:style="getProportionStyle(item.value)"
|
||||
@click="selectProportion(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
<h3>选择比例</h3>
|
||||
<div class="proportion-options" :style="{ marginBottom: props.type === 'video' ? '0px' : '20px' }">
|
||||
<div
|
||||
v-for="item in proportionOptions"
|
||||
:key="item.value"
|
||||
class="proportion-item"
|
||||
:class="{ active: proportion === item.value }"
|
||||
:style="getProportionStyle(item.value)"
|
||||
@click="selectProportion(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="resolutionOptions.length > 0" class="section">
|
||||
<div v-if="type === 'painting'" class="section">
|
||||
<h3>选择分辨率</h3>
|
||||
<div class="resolution-options">
|
||||
<div
|
||||
v-for="item in resolutionOptions"
|
||||
:key="item.value"
|
||||
class="resolution-item"
|
||||
:class="{ active: resolution === item.value }"
|
||||
@click="selectResolution(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<div
|
||||
v-for="item in resolutionOptions"
|
||||
:key="item.value"
|
||||
class="resolution-item"
|
||||
:class="{ active: resolution === item.value }"
|
||||
@click="selectResolution(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div v-if="type === 'painting'" class="section">
|
||||
<h3>尺寸(px)</h3>
|
||||
<div class="size-inputs">
|
||||
<div class="input-group">
|
||||
|
|
@ -60,6 +60,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import Popover from '@/components/Popover/index.vue'
|
||||
import lockIcon from '@/assets/dialog/lock.svg'
|
||||
import lockNoIcon from '@/assets/dialog/lockNo.svg'
|
||||
|
|
@ -73,25 +74,9 @@ const props = defineProps({
|
|||
type: String,
|
||||
default: '2k'
|
||||
},
|
||||
proportionOptions: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ value: '智能', label: '智能' },
|
||||
{ value: '21:9', label: '21:9' },
|
||||
{ value: '16:9', label: '16:9' },
|
||||
{ value: '4:3', label: '4:3' },
|
||||
{ value: '1:1', label: '1:1' },
|
||||
{ value: '3:4', label: '3:4' },
|
||||
{ value: '9:16', label: '9:16' }
|
||||
]
|
||||
},
|
||||
resolutionOptions: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ value: '1k', label: '标清 1K' },
|
||||
{ value: '2k', label: '高清 2K' },
|
||||
{ value: '4k', label: '超清 4K' }
|
||||
]
|
||||
type: {
|
||||
type: String,
|
||||
default: 'painting'
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -107,6 +92,24 @@ const resolution = computed({
|
|||
set: (value) => emit('update:resolution', value)
|
||||
})
|
||||
|
||||
const proportionOptions = [
|
||||
{ value: '智能', label: '智能' },
|
||||
{ value: '21:9', label: '21:9' },
|
||||
{ value: '16:9', label: '16:9' },
|
||||
// { value: '3:2', label: '3:2' },
|
||||
{ value: '4:3', label: '4:3' },
|
||||
{ value: '1:1', label: '1:1' },
|
||||
{ value: '3:4', label: '3:4' },
|
||||
// { value: '2:3', label: '2:3' },
|
||||
{ value: '9:16', label: '9:16' }
|
||||
]
|
||||
|
||||
const resolutionOptions = [
|
||||
{ value: '1k', label: '标清 1K' },
|
||||
{ value: '2k', label: '高清 2K' },
|
||||
{ value: '4k', label: '超清 4K' }
|
||||
]
|
||||
|
||||
const width = ref(2048)
|
||||
const height = ref(2048)
|
||||
const isLocked = ref(true)
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
<template>
|
||||
<Popover placement="top" :width="400">
|
||||
<div class="proportion-container">
|
||||
<div class="section">
|
||||
<h3>选择比例</h3>
|
||||
<div class="proportion-options" :style="{ marginBottom: props.type === 'Video' ? '0px' : '20px' }">
|
||||
<div
|
||||
v-for="item in proportionOptions"
|
||||
:key="item.value"
|
||||
class="proportion-item"
|
||||
:class="{ active: proportion === item.value }"
|
||||
:style="getProportionStyle(item.value)"
|
||||
@click="selectProportion(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>选择分辨率</h3>
|
||||
<div class="resolution-options">
|
||||
<div
|
||||
v-for="item in resolutionOptions"
|
||||
:key="item.value"
|
||||
class="resolution-item"
|
||||
:class="{ active: resolution === item.value }"
|
||||
@click="selectResolution(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #reference>
|
||||
<div class="choice-btn">
|
||||
<img src="@/assets/dialog/proportion.svg" alt="" style="width: 16px;">
|
||||
<span>{{ proportion }}</span>
|
||||
<div style="border: 0.5px solid #000; width: 1px; height:13px;margin: 0 5px;" />
|
||||
<span>{{ resolution }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Popover from '@/components/Popover/index.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '1:1'
|
||||
},
|
||||
resolution: {
|
||||
type: String,
|
||||
default: '2k'
|
||||
},
|
||||
proportionOptions: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ value: '21:9', label: '21:9' },
|
||||
{ value: '16:9', label: '16:9' },
|
||||
{ value: '4:3', label: '4:3' },
|
||||
{ value: '1:1', label: '1:1' },
|
||||
{ value: '3:4', label: '3:4' },
|
||||
{ value: '9:16', label: '9:16' }
|
||||
]
|
||||
},
|
||||
resolutionOptions: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ value: '360', label: '流畅 360P' },
|
||||
{ value: '540', label: '标清 540P' },
|
||||
{ value: '720', label: '高清 720P' },
|
||||
{ value: '1k', label: '超清 1K' }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:resolution'])
|
||||
|
||||
const proportion = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const resolution = computed({
|
||||
get: () => props.resolution,
|
||||
set: (value) => emit('update:resolution', value)
|
||||
})
|
||||
|
||||
const selectProportion = (value) => {
|
||||
proportion.value = value
|
||||
}
|
||||
|
||||
const selectResolution = (value) => {
|
||||
resolution.value = value
|
||||
}
|
||||
|
||||
const getProportionStyle = (value) => {
|
||||
if (value === '智能') {
|
||||
return {
|
||||
'--width': '20px',
|
||||
'--height': '20px'
|
||||
}
|
||||
}
|
||||
const [w, h] = value.split(':').map(Number)
|
||||
const aspectRatio = w / h
|
||||
const baseSize = 20
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
return {
|
||||
'--width': `${baseSize}px`,
|
||||
'--height': `${Math.round(baseSize / aspectRatio)}px`
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
'--width': `${Math.round(baseSize * aspectRatio)}px`,
|
||||
'--height': `${baseSize}px`
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.choice-btn{
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.choice-btn:hover{
|
||||
background: #e9eaeb;
|
||||
}
|
||||
|
||||
.proportion-container{
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.section{
|
||||
margin-bottom: 20px;
|
||||
border-radius: 20px;
|
||||
|
||||
&:last-child{
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h3{
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
.proportion-options{
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
background-color: #F8F9FA;
|
||||
padding: 5px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.proportion-item{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 5px;
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 5px;
|
||||
text-align: bottom;
|
||||
color: #999;
|
||||
|
||||
&::before{
|
||||
content: '';
|
||||
width: var(--width, 20px);
|
||||
height: var(--height, 20px);
|
||||
background: #F5F6F7;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid #999;
|
||||
}
|
||||
|
||||
&:hover{
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
&.active{
|
||||
color: #000F33;
|
||||
background: #ffffff;
|
||||
}
|
||||
&.active::before{
|
||||
border-color: #000F33;
|
||||
}
|
||||
}
|
||||
|
||||
.resolution-options{
|
||||
display: flex;
|
||||
padding: 5px;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
border-radius: 10px;
|
||||
background: #F8F9FA;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.resolution-item{
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
color: #666;
|
||||
|
||||
&:hover{
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
&.active{
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,330 +0,0 @@
|
|||
<template>
|
||||
<div class="video-image-uploader">
|
||||
<div class="image-list">
|
||||
<div
|
||||
v-for="(item, index) in localPreviewList"
|
||||
:key="item.uid"
|
||||
class="image-item"
|
||||
>
|
||||
<img :src="item.url" class="uploaded-image" :alt="getFrameLabel(index)" />
|
||||
<div class="frame-label">{{ getFrameLabel(index) }}</div>
|
||||
<div class="delete-icon" @click.stop="handleDelete(index)">
|
||||
<i-ep-close />
|
||||
</div>
|
||||
</div>
|
||||
<template v-for="i in maxImages" :key="i">
|
||||
<div
|
||||
v-if="localPreviewList.length < maxImages && !localPreviewList[i - 1]"
|
||||
class="upload-trigger"
|
||||
@click="triggerUpload(i - 1)"
|
||||
>
|
||||
<i-ep-plus color="#333333" />
|
||||
<div class="upload-text">{{ getUploadText(i) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<el-upload
|
||||
v-for="i in maxImages"
|
||||
:key="i"
|
||||
:ref="el => setUploadRef(el, i - 1)"
|
||||
v-show="false"
|
||||
:action="uploadurl"
|
||||
:limit="1"
|
||||
:before-upload="beforeUpload"
|
||||
:on-success="(response, uploadFile) => handleSuccess(response, uploadFile, i - 1)"
|
||||
:on-error="handleError"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { genFileId } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
modelType: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
imagesCount: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const uploadurl = import.meta.env.VITE_API_WORKFLOW_UPLOAD
|
||||
const uploadRefs = ref([])
|
||||
const imageList = ref([...props.modelValue])
|
||||
const localPreviewList = ref([...props.modelValue])
|
||||
const isUploading = ref(false)
|
||||
|
||||
const showEndFrame = computed(() => props.modelType === 'image' && props.imagesCount === 2)
|
||||
const maxImages = computed(() => props.modelType === 'image' ? (showEndFrame.value ? 2 : 1) : 1)
|
||||
|
||||
const setUploadRef = (el, index) => {
|
||||
if (el) {
|
||||
uploadRefs.value[index] = el
|
||||
}
|
||||
}
|
||||
|
||||
const getFrameLabel = (index) => {
|
||||
if (props.modelType === 'digitalHuman') {
|
||||
return ''
|
||||
}
|
||||
return index === 0 ? '首帧' : '尾帧'
|
||||
}
|
||||
|
||||
const getUploadText = (i) => {
|
||||
if (props.modelType === 'digitalHuman') {
|
||||
return '参考内容'
|
||||
}
|
||||
return i === 1 ? '首帧' : '尾帧'
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, async (newVal) => {
|
||||
if (isUploading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
imageList.value = [...newVal]
|
||||
|
||||
const newPreviewList = []
|
||||
for (const img of newVal) {
|
||||
let previewImg = { ...img }
|
||||
if (img.url && !img.url.startsWith('blob:')) {
|
||||
try {
|
||||
const response = await fetch(img.url)
|
||||
const blob = await response.blob()
|
||||
previewImg = {
|
||||
...img,
|
||||
url: URL.createObjectURL(blob),
|
||||
serverUrl: img.url
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create blob URL:', error)
|
||||
previewImg = {
|
||||
...img,
|
||||
serverUrl: img.url
|
||||
}
|
||||
}
|
||||
}
|
||||
newPreviewList.push(previewImg)
|
||||
}
|
||||
localPreviewList.value = newPreviewList
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
const triggerUpload = (index) => {
|
||||
if (uploadRefs.value[index]) {
|
||||
uploadRefs.value[index].$el.querySelector('input').click()
|
||||
}
|
||||
}
|
||||
|
||||
const beforeUpload = (rawFile) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/png']
|
||||
|
||||
if (!allowedTypes.includes(rawFile.type)) {
|
||||
ElMessage.error('不支持的文件类型,请上传 JPG、PNG 格式的图片')
|
||||
return false
|
||||
}
|
||||
|
||||
if (rawFile.size / 1024 / 1024 > 10) {
|
||||
ElMessage.error('图片大小不能超过 10MB')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSuccess = (response, uploadFile, index) => {
|
||||
ElMessage.success('上传成功')
|
||||
|
||||
isUploading.value = true
|
||||
|
||||
const localUrl = URL.createObjectURL(uploadFile.raw)
|
||||
|
||||
const newImage = {
|
||||
uid: uploadFile.uid,
|
||||
url: response.url
|
||||
}
|
||||
|
||||
if (imageList.value[index]) {
|
||||
const previewItem = localPreviewList.value[index]
|
||||
if (previewItem && previewItem.url && previewItem.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewItem.url)
|
||||
}
|
||||
imageList.value[index] = newImage
|
||||
localPreviewList.value[index] = {
|
||||
uid: uploadFile.uid,
|
||||
url: localUrl,
|
||||
serverUrl: response.url
|
||||
}
|
||||
} else {
|
||||
imageList.value[index] = newImage
|
||||
localPreviewList.value[index] = {
|
||||
uid: uploadFile.uid,
|
||||
url: localUrl,
|
||||
serverUrl: response.url
|
||||
}
|
||||
}
|
||||
|
||||
emit('update:modelValue', [...imageList.value])
|
||||
|
||||
nextTick(() => {
|
||||
isUploading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
ElMessage.error('上传失败,请重新选择图片')
|
||||
}
|
||||
|
||||
const handleDelete = (index) => {
|
||||
const previewItem = localPreviewList.value[index]
|
||||
if (previewItem && previewItem.url && previewItem.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewItem.url)
|
||||
}
|
||||
|
||||
localPreviewList.value.splice(index, 1)
|
||||
imageList.value.splice(index, 1)
|
||||
emit('update:modelValue', [...imageList.value])
|
||||
}
|
||||
|
||||
const handleSelect = async (url, index = 0) => {
|
||||
const initialFile = await fetch(url)
|
||||
const blob = await initialFile.blob()
|
||||
const file = new File([blob], `selected_image_${Date.now()}.jpg`, { type: blob.type })
|
||||
file.uid = genFileId()
|
||||
|
||||
if (uploadRefs.value[index]) {
|
||||
uploadRefs.value[index].handleStart(file)
|
||||
uploadRefs.value[index].submit()
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
handleSelect
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.video-image-uploader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.image-list {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
z-index: 10;
|
||||
|
||||
.delete-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.uploaded-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.frame-label {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
width: 32px;
|
||||
height: 20px;
|
||||
background: rgba(0, 15, 51, 0.8);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
z-index: 5;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.delete-icon {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 20;
|
||||
|
||||
&:hover {
|
||||
background: #dc2626;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: rgba(0, 15, 51, 0.04);
|
||||
backdrop-filter: blur(5px);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
font-size: 15px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
span {
|
||||
color: #333;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #F0F1F2;
|
||||
border-color: #626aef;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
color: #666;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,486 +0,0 @@
|
|||
<template>
|
||||
<div class="virtual-scroller" :style="containerStyle">
|
||||
<div
|
||||
ref="scrollContainerRef"
|
||||
class="virtual-scroller-container"
|
||||
:style="scrollContainerStyle"
|
||||
@scroll.passive="handleScroll"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
<div class="virtual-scroller-spacer" :style="spacerStyle"></div>
|
||||
<div class="virtual-scroller-placeholder" :style="placeholderStyle">
|
||||
<slot name="bottom-placeholder" />
|
||||
</div>
|
||||
<div
|
||||
v-for="item in visibleItems"
|
||||
:key="item.key"
|
||||
:ref="el => setItemRef(el, item.key)"
|
||||
class="virtual-scroller-item"
|
||||
:style="getItemStyle(item)"
|
||||
:data-index="item.index"
|
||||
:data-key="item.key"
|
||||
>
|
||||
<slot name="default" :item="item.data" :index="item.index" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
},
|
||||
itemKey: {
|
||||
type: [String, Function],
|
||||
default: 'id'
|
||||
},
|
||||
estimatedHeight: {
|
||||
type: Number,
|
||||
default: 100
|
||||
},
|
||||
buffer: {
|
||||
type: Number,
|
||||
default: 5
|
||||
},
|
||||
placeholderHeight: {
|
||||
type: Number,
|
||||
default: 350
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end'])
|
||||
|
||||
const scrollContainerRef = ref(null)
|
||||
const itemRefs = new Map()
|
||||
const itemHeights = ref(new Map())
|
||||
const resizeObserver = ref(null)
|
||||
const isScrolling = ref(false)
|
||||
const scrollTimeout = ref(null)
|
||||
|
||||
const getKey = (item, index) => {
|
||||
if (typeof props.itemKey === 'function') {
|
||||
return props.itemKey(item, index)
|
||||
}
|
||||
if (item && typeof item === 'object') {
|
||||
return item[props.itemKey] ?? index
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
const getItemHeight = (key) => {
|
||||
return itemHeights.value.get(key) ?? props.estimatedHeight
|
||||
}
|
||||
|
||||
const containerStyle = computed(() => ({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}))
|
||||
|
||||
const scrollContainerStyle = computed(() => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
direction: 'rtl',
|
||||
transform: 'rotate(180deg)'
|
||||
}))
|
||||
|
||||
const totalDataHeight = computed(() => {
|
||||
let height = 0
|
||||
for (let i = 0; i < props.data.length; i++) {
|
||||
const key = getKey(props.data[i], i)
|
||||
height += getItemHeight(key)
|
||||
}
|
||||
return height
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => {
|
||||
return props.placeholderHeight + totalDataHeight.value
|
||||
})
|
||||
|
||||
const spacerStyle = computed(() => ({
|
||||
height: `${totalHeight.value}px`,
|
||||
width: '100%',
|
||||
flexShrink: 0
|
||||
}))
|
||||
|
||||
const placeholderStyle = computed(() => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: `${props.placeholderHeight}px`,
|
||||
zIndex: 1,
|
||||
direction: 'ltr'
|
||||
}))
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
const count = props.data.length
|
||||
if (count === 0) {
|
||||
return { start: 0, end: 0, offset: 0 }
|
||||
}
|
||||
|
||||
const el = scrollContainerRef.value
|
||||
if (!el) {
|
||||
return { start: 0, end: Math.min(count - 1, 9), offset: 0 }
|
||||
}
|
||||
|
||||
const scrollTop = el.scrollTop
|
||||
const viewportHeight = el.clientHeight || 600
|
||||
const bufferCount = props.buffer
|
||||
|
||||
// In inverted scroll (180deg rotation):
|
||||
// - scrollTop = 0: visual BOTTOM (shows newer data, lower index)
|
||||
// - scrollTop = max: visual TOP (shows older data, higher index)
|
||||
// - visibleStart/visibleEnd are offsets in the data area (after placeholder)
|
||||
const visibleStart = Math.max(0, scrollTop - props.placeholderHeight)
|
||||
const visibleEnd = visibleStart + viewportHeight
|
||||
|
||||
let startIndex = 0
|
||||
let endIndex = count - 1
|
||||
let startOffset = 0
|
||||
let currentOffset = 0
|
||||
|
||||
// Find startIndex: first item that ends after visibleStart
|
||||
for (let i = 0; i < count; i++) {
|
||||
const key = getKey(props.data[i], i)
|
||||
const height = getItemHeight(key)
|
||||
const itemEnd = currentOffset + height
|
||||
|
||||
if (itemEnd > visibleStart) {
|
||||
startIndex = Math.max(0, i - bufferCount)
|
||||
break
|
||||
}
|
||||
|
||||
currentOffset += height
|
||||
}
|
||||
|
||||
// Calculate startOffset for startIndex
|
||||
startOffset = 0
|
||||
for (let i = 0; i < startIndex; i++) {
|
||||
const key = getKey(props.data[i], i)
|
||||
startOffset += getItemHeight(key)
|
||||
}
|
||||
|
||||
// Find endIndex: last item that starts before visibleEnd
|
||||
currentOffset = startOffset
|
||||
for (let i = startIndex; i < count; i++) {
|
||||
const key = getKey(props.data[i], i)
|
||||
const height = getItemHeight(key)
|
||||
|
||||
// Check if this item is visible (item starts before visibleEnd)
|
||||
if (currentOffset >= visibleEnd) {
|
||||
// This item starts after visibleEnd, so previous item is the last visible
|
||||
endIndex = Math.min(count - 1, Math.max(startIndex, i - 1 + bufferCount))
|
||||
break
|
||||
}
|
||||
|
||||
endIndex = i
|
||||
currentOffset += height
|
||||
}
|
||||
|
||||
return { start: startIndex, end: endIndex, offset: startOffset }
|
||||
})
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
const { start, end, offset } = visibleRange.value
|
||||
const items = []
|
||||
const count = props.data.length
|
||||
|
||||
if (count === 0) return items
|
||||
|
||||
const safeStart = Math.max(0, start)
|
||||
const safeEnd = Math.min(count - 1, end)
|
||||
|
||||
if (safeStart > safeEnd) return items
|
||||
|
||||
let currentOffset = offset + props.placeholderHeight
|
||||
const seenKeys = new Set()
|
||||
|
||||
for (let i = safeStart; i <= safeEnd; i++) {
|
||||
const data = props.data[i]
|
||||
if (!data) continue
|
||||
|
||||
const key = getKey(data, i)
|
||||
|
||||
// Deduplicate by key
|
||||
if (seenKeys.has(key)) continue
|
||||
seenKeys.add(key)
|
||||
|
||||
const height = getItemHeight(key)
|
||||
|
||||
items.push({
|
||||
data,
|
||||
index: i,
|
||||
key,
|
||||
offset: currentOffset,
|
||||
height
|
||||
})
|
||||
|
||||
currentOffset += height
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const getItemStyle = (item) => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
transform: `translateY(${item.offset}px)`,
|
||||
direction: 'ltr',
|
||||
willChange: 'transform'
|
||||
})
|
||||
|
||||
const setItemRef = (el, key) => {
|
||||
if (el) {
|
||||
itemRefs.set(key, el)
|
||||
} else {
|
||||
itemRefs.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const measureItem = (key, element) => {
|
||||
if (!element) return
|
||||
|
||||
const target = element.firstElementChild || element
|
||||
const height = target.getBoundingClientRect().height
|
||||
|
||||
if (height > 0 && height !== itemHeights.value.get(key)) {
|
||||
const newHeights = new Map(itemHeights.value)
|
||||
newHeights.set(key, height)
|
||||
itemHeights.value = newHeights
|
||||
}
|
||||
}
|
||||
|
||||
const setupResizeObserver = () => {
|
||||
if (resizeObserver.value) {
|
||||
resizeObserver.value.disconnect()
|
||||
}
|
||||
|
||||
resizeObserver.value = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const key = entry.target.dataset.key
|
||||
if (key !== undefined) {
|
||||
measureItem(key, entry.target)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const observeItems = () => {
|
||||
if (!resizeObserver.value) return
|
||||
|
||||
resizeObserver.value.disconnect()
|
||||
|
||||
for (const [key, element] of itemRefs) {
|
||||
if (element) {
|
||||
resizeObserver.value.observe(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleWheel = (event) => {
|
||||
if (!scrollContainerRef.value) return
|
||||
|
||||
scrollContainerRef.value.scrollBy({
|
||||
top: -event.deltaY,
|
||||
behavior: 'instant'
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleScroll = (event) => {
|
||||
const target = event.target
|
||||
const scrollHeight = target.scrollHeight
|
||||
const scrollTop = target.scrollTop
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
isScrolling.value = true
|
||||
|
||||
if (scrollTimeout.value) {
|
||||
clearTimeout(scrollTimeout.value)
|
||||
}
|
||||
|
||||
scrollTimeout.value = setTimeout(() => {
|
||||
isScrolling.value = false
|
||||
}, 150)
|
||||
|
||||
// In inverted scroll:
|
||||
// - distanceToTop (visual top) = scrollHeight - scrollTop - clientHeight
|
||||
// - distanceToBottom (visual bottom) = scrollTop
|
||||
// - isAtTop (visual top, older data) = distanceToTop <= threshold
|
||||
// - isAtBottom (visual bottom, newer data) = distanceToBottom <= threshold
|
||||
const distanceToTop = scrollHeight - scrollTop - clientHeight
|
||||
const distanceToBottom = scrollTop
|
||||
const threshold = 5
|
||||
|
||||
const isAtTop = distanceToTop <= threshold
|
||||
const isAtBottom = distanceToBottom <= threshold
|
||||
|
||||
emit('scroll', {
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
distanceToTop,
|
||||
distanceToBottom,
|
||||
isAtTop,
|
||||
isAtBottom
|
||||
})
|
||||
|
||||
// scroll-start: reached visual top (older data, need to load more)
|
||||
if (isAtTop) {
|
||||
emit('scroll-start')
|
||||
}
|
||||
|
||||
// scroll-end: reached visual bottom (newer data)
|
||||
if (isAtBottom) {
|
||||
emit('scroll-end')
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToIndex = (index, behavior = 'auto') => {
|
||||
if (!scrollContainerRef.value || index < 0 || index >= props.data.length) return
|
||||
|
||||
let offset = 0
|
||||
for (let i = 0; i < index; i++) {
|
||||
const key = getKey(props.data[i], i)
|
||||
offset += getItemHeight(key)
|
||||
}
|
||||
|
||||
const targetScrollTop = offset + props.placeholderHeight
|
||||
|
||||
scrollContainerRef.value.scrollTo({
|
||||
top: targetScrollTop,
|
||||
behavior
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToBottom = (behavior = 'smooth') => {
|
||||
if (!scrollContainerRef.value) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!scrollContainerRef.value) return
|
||||
// In inverted scroll, bottom is scrollTop = 0
|
||||
scrollContainerRef.value.scrollTo({ top: 0, behavior })
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToTop = (behavior = 'smooth') => {
|
||||
if (!scrollContainerRef.value) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!scrollContainerRef.value) return
|
||||
// In inverted scroll, top is scrollTop = max
|
||||
scrollContainerRef.value.scrollTo({
|
||||
top: scrollContainerRef.value.scrollHeight,
|
||||
behavior
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const getScrollElement = () => scrollContainerRef.value
|
||||
|
||||
const isAtTop = () => {
|
||||
if (!scrollContainerRef.value) return false
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.value
|
||||
return scrollHeight - scrollTop - clientHeight <= 5
|
||||
}
|
||||
|
||||
const isAtBottom = () => {
|
||||
if (!scrollContainerRef.value) return false
|
||||
return scrollContainerRef.value.scrollTop <= 5
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
itemHeights.value = new Map()
|
||||
itemRefs.clear()
|
||||
}
|
||||
|
||||
watch(() => props.data, (newData, oldData) => {
|
||||
const newLength = newData?.length || 0
|
||||
const oldLength = oldData?.length || 0
|
||||
|
||||
if (newLength < oldLength) {
|
||||
reset()
|
||||
}
|
||||
|
||||
nextTick(observeItems)
|
||||
}, { deep: true })
|
||||
|
||||
watch(visibleItems, () => {
|
||||
nextTick(observeItems)
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
setupResizeObserver()
|
||||
nextTick(observeItems)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver.value) {
|
||||
resizeObserver.value.disconnect()
|
||||
}
|
||||
|
||||
if (scrollTimeout.value) {
|
||||
clearTimeout(scrollTimeout.value)
|
||||
}
|
||||
|
||||
itemRefs.clear()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
scrollToIndex,
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
getScrollElement,
|
||||
isAtTop,
|
||||
isAtBottom,
|
||||
reset,
|
||||
scrollContainerRef
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.virtual-scroller {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&-container {
|
||||
contain: layout style;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-spacer {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-item {
|
||||
contain: layout style;
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
&-placeholder {
|
||||
contain: layout style;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,518 +0,0 @@
|
|||
<template>
|
||||
<div class="virtual-scroller" :style="containerStyle">
|
||||
<div class="virtual-scroller-wrapper" :style="wrapperStyle">
|
||||
<div
|
||||
ref="scrollContainerRef"
|
||||
class="virtual-scroller-container"
|
||||
:style="containerInnerStyle"
|
||||
@scroll.passive="handleScroll"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
<div class="virtual-scroller-spacer" :style="spacerStyle"></div>
|
||||
<div class="virtual-scroller-placeholder" :style="placeholderStyle">
|
||||
<slot name="bottom-placeholder" />
|
||||
</div>
|
||||
<div
|
||||
v-for="item in visibleItems"
|
||||
:key="item.key"
|
||||
:ref="el => setItemRef(el, item.key)"
|
||||
class="virtual-scroller-item"
|
||||
:style="getItemStyle(item)"
|
||||
:data-index="item.index"
|
||||
:data-key="item.key"
|
||||
>
|
||||
<slot name="default" :item="item.data" :index="item.index" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
},
|
||||
itemKey: {
|
||||
type: [String, Function],
|
||||
default: 'id'
|
||||
},
|
||||
estimatedHeight: {
|
||||
type: Number,
|
||||
default: 100
|
||||
},
|
||||
buffer: {
|
||||
type: Number,
|
||||
default: 5
|
||||
},
|
||||
placeholderHeight: {
|
||||
type: Number,
|
||||
default: 350
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end'])
|
||||
|
||||
const scrollContainerRef = ref(null)
|
||||
const itemRefs = new Map()
|
||||
const itemHeights = ref(new Map())
|
||||
const resizeObserver = ref(null)
|
||||
const isScrolling = ref(false)
|
||||
const scrollTimeout = ref(null)
|
||||
|
||||
const getKey = (item, index) => {
|
||||
if (typeof props.itemKey === 'function') {
|
||||
return props.itemKey(item, index)
|
||||
}
|
||||
if (item && typeof item === 'object') {
|
||||
return item[props.itemKey] ?? index
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
const getItemHeight = (key) => {
|
||||
return itemHeights.value.get(key) ?? props.estimatedHeight
|
||||
}
|
||||
|
||||
const containerStyle = computed(() => ({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}))
|
||||
|
||||
const wrapperStyle = computed(() => ({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
transform: 'rotate(180deg)',
|
||||
direction: 'rtl'
|
||||
}))
|
||||
|
||||
const containerInnerStyle = computed(() => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
direction: 'ltr'
|
||||
}))
|
||||
|
||||
const totalDataHeight = computed(() => {
|
||||
let height = 0
|
||||
for (let i = 0; i < props.data.length; i++) {
|
||||
const key = getKey(props.data[i], i)
|
||||
height += getItemHeight(key)
|
||||
}
|
||||
return height
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => {
|
||||
return props.placeholderHeight + totalDataHeight.value
|
||||
})
|
||||
|
||||
const spacerStyle = computed(() => ({
|
||||
height: `${totalHeight.value}px`,
|
||||
width: '100%',
|
||||
flexShrink: 0
|
||||
}))
|
||||
|
||||
const placeholderStyle = computed(() => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: `${props.placeholderHeight}px`,
|
||||
zIndex: 1
|
||||
}))
|
||||
|
||||
const getItemOffsets = () => {
|
||||
const offsets = []
|
||||
let offset = 0
|
||||
for (let i = 0; i < props.data.length; i++) {
|
||||
offsets.push(offset)
|
||||
const key = getKey(props.data[i], i)
|
||||
offset += getItemHeight(key)
|
||||
}
|
||||
return offsets
|
||||
}
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
const count = props.data.length
|
||||
if (count === 0) {
|
||||
return { start: 0, end: 0, offset: 0 }
|
||||
}
|
||||
|
||||
const el = scrollContainerRef.value
|
||||
if (!el) {
|
||||
return { start: 0, end: Math.min(count - 1, 9), offset: 0 }
|
||||
}
|
||||
|
||||
const scrollTop = el.scrollTop
|
||||
const viewportHeight = el.clientHeight || 600
|
||||
const bufferCount = props.buffer
|
||||
|
||||
// In inverted scroll (180deg rotation):
|
||||
// - scrollTop = 0: visual BOTTOM (shows newer data, lower index)
|
||||
// - scrollTop = max: visual TOP (shows older data, higher index)
|
||||
// - Items are positioned from top: placeholderHeight, then data items
|
||||
// - visibleStart/visibleEnd are offsets in the data area (after placeholder)
|
||||
|
||||
// When scrollTop = 0, we're at visual bottom, showing items near the START of data
|
||||
// When scrollTop = max, we're at visual top, showing items near the END of data
|
||||
|
||||
// The visible area in data coordinates:
|
||||
// - scrollTop 0 means we see items at offset 0 (start of data)
|
||||
// - scrollTop increases means we see items at higher offsets (end of data)
|
||||
|
||||
const visibleStart = Math.max(0, scrollTop - props.placeholderHeight)
|
||||
const visibleEnd = visibleStart + viewportHeight
|
||||
|
||||
let startIndex = 0
|
||||
let endIndex = count - 1
|
||||
let startOffset = 0
|
||||
let currentOffset = 0
|
||||
|
||||
// Find startIndex: first item that ends after visibleStart
|
||||
for (let i = 0; i < count; i++) {
|
||||
const key = getKey(props.data[i], i)
|
||||
const height = getItemHeight(key)
|
||||
const itemEnd = currentOffset + height
|
||||
|
||||
if (itemEnd > visibleStart) {
|
||||
startIndex = Math.max(0, i - bufferCount)
|
||||
break
|
||||
}
|
||||
|
||||
currentOffset += height
|
||||
}
|
||||
|
||||
// Calculate startOffset for startIndex
|
||||
startOffset = 0
|
||||
for (let i = 0; i < startIndex; i++) {
|
||||
const key = getKey(props.data[i], i)
|
||||
startOffset += getItemHeight(key)
|
||||
}
|
||||
|
||||
// Find endIndex: last item that starts before visibleEnd
|
||||
currentOffset = startOffset
|
||||
for (let i = startIndex; i < count; i++) {
|
||||
const key = getKey(props.data[i], i)
|
||||
const height = getItemHeight(key)
|
||||
|
||||
// Check if this item is visible (item starts before visibleEnd)
|
||||
if (currentOffset >= visibleEnd) {
|
||||
// This item starts after visibleEnd, so previous item is the last visible
|
||||
endIndex = Math.min(count - 1, Math.max(startIndex, i - 1 + bufferCount))
|
||||
break
|
||||
}
|
||||
|
||||
endIndex = i
|
||||
currentOffset += height
|
||||
}
|
||||
|
||||
return { start: startIndex, end: endIndex, offset: startOffset }
|
||||
})
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
const { start, end, offset } = visibleRange.value
|
||||
const items = []
|
||||
const count = props.data.length
|
||||
|
||||
if (count === 0) return items
|
||||
|
||||
const safeStart = Math.max(0, start)
|
||||
const safeEnd = Math.min(count - 1, end)
|
||||
|
||||
if (safeStart > safeEnd) return items
|
||||
|
||||
let currentOffset = offset + props.placeholderHeight
|
||||
const seenKeys = new Set()
|
||||
|
||||
for (let i = safeStart; i <= safeEnd; i++) {
|
||||
const data = props.data[i]
|
||||
if (!data) continue
|
||||
|
||||
const key = getKey(data, i)
|
||||
|
||||
// Deduplicate by key
|
||||
if (seenKeys.has(key)) continue
|
||||
seenKeys.add(key)
|
||||
|
||||
const height = getItemHeight(key)
|
||||
|
||||
items.push({
|
||||
data,
|
||||
index: i,
|
||||
key,
|
||||
offset: currentOffset,
|
||||
height
|
||||
})
|
||||
|
||||
currentOffset += height
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const getItemStyle = (item) => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
transform: `translateY(${item.offset}px)`,
|
||||
willChange: 'transform'
|
||||
})
|
||||
|
||||
const setItemRef = (el, key) => {
|
||||
if (el) {
|
||||
itemRefs.set(key, el)
|
||||
} else {
|
||||
itemRefs.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const measureItem = (key, element) => {
|
||||
if (!element) return
|
||||
|
||||
const target = element.firstElementChild || element
|
||||
const height = target.getBoundingClientRect().height
|
||||
|
||||
if (height > 0 && height !== itemHeights.value.get(key)) {
|
||||
const newHeights = new Map(itemHeights.value)
|
||||
newHeights.set(key, height)
|
||||
itemHeights.value = newHeights
|
||||
}
|
||||
}
|
||||
|
||||
const setupResizeObserver = () => {
|
||||
if (resizeObserver.value) {
|
||||
resizeObserver.value.disconnect()
|
||||
}
|
||||
|
||||
resizeObserver.value = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const key = entry.target.dataset.key
|
||||
if (key !== undefined) {
|
||||
measureItem(key, entry.target)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const observeItems = () => {
|
||||
if (!resizeObserver.value) return
|
||||
|
||||
resizeObserver.value.disconnect()
|
||||
|
||||
for (const [key, element] of itemRefs) {
|
||||
if (element) {
|
||||
resizeObserver.value.observe(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleWheel = (event) => {
|
||||
if (!scrollContainerRef.value) return
|
||||
|
||||
scrollContainerRef.value.scrollBy({
|
||||
top: -event.deltaY,
|
||||
behavior: 'instant'
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleScroll = (event) => {
|
||||
const target = event.target
|
||||
const scrollHeight = target.scrollHeight
|
||||
const scrollTop = target.scrollTop
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
isScrolling.value = true
|
||||
|
||||
if (scrollTimeout.value) {
|
||||
clearTimeout(scrollTimeout.value)
|
||||
}
|
||||
|
||||
scrollTimeout.value = setTimeout(() => {
|
||||
isScrolling.value = false
|
||||
}, 150)
|
||||
|
||||
// In inverted scroll:
|
||||
// - distanceToTop (visual top) = scrollHeight - scrollTop - clientHeight
|
||||
// - distanceToBottom (visual bottom) = scrollTop
|
||||
// - isAtTop (visual top, older data) = distanceToTop <= threshold
|
||||
// - isAtBottom (visual bottom, newer data) = distanceToBottom <= threshold
|
||||
const distanceToTop = scrollHeight - scrollTop - clientHeight
|
||||
const distanceToBottom = scrollTop
|
||||
const threshold = 5
|
||||
|
||||
const isAtTop = distanceToTop <= threshold
|
||||
const isAtBottom = distanceToBottom <= threshold
|
||||
|
||||
emit('scroll', {
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
distanceToTop,
|
||||
distanceToBottom,
|
||||
isAtTop,
|
||||
isAtBottom
|
||||
})
|
||||
|
||||
// scroll-start: reached visual top (older data, need to load more)
|
||||
if (isAtTop) {
|
||||
emit('scroll-start')
|
||||
}
|
||||
|
||||
// scroll-end: reached visual bottom (newer data)
|
||||
if (isAtBottom) {
|
||||
emit('scroll-end')
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToIndex = (index, behavior = 'auto') => {
|
||||
if (!scrollContainerRef.value || index < 0 || index >= props.data.length) return
|
||||
|
||||
let offset = 0
|
||||
for (let i = 0; i < index; i++) {
|
||||
const key = getKey(props.data[i], i)
|
||||
offset += getItemHeight(key)
|
||||
}
|
||||
|
||||
const targetScrollTop = offset + props.placeholderHeight
|
||||
|
||||
scrollContainerRef.value.scrollTo({
|
||||
top: targetScrollTop,
|
||||
behavior
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToBottom = (behavior = 'smooth') => {
|
||||
if (!scrollContainerRef.value) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!scrollContainerRef.value) return
|
||||
// In inverted scroll, bottom is scrollTop = 0
|
||||
scrollContainerRef.value.scrollTo({ top: 0, behavior })
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToTop = (behavior = 'smooth') => {
|
||||
if (!scrollContainerRef.value) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!scrollContainerRef.value) return
|
||||
// In inverted scroll, top is scrollTop = max
|
||||
scrollContainerRef.value.scrollTo({
|
||||
top: scrollContainerRef.value.scrollHeight,
|
||||
behavior
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const getScrollElement = () => scrollContainerRef.value
|
||||
|
||||
const isAtTop = () => {
|
||||
if (!scrollContainerRef.value) return false
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.value
|
||||
return scrollHeight - scrollTop - clientHeight <= 5
|
||||
}
|
||||
|
||||
const isAtBottom = () => {
|
||||
if (!scrollContainerRef.value) return false
|
||||
return scrollContainerRef.value.scrollTop <= 5
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
itemHeights.value = new Map()
|
||||
itemRefs.clear()
|
||||
}
|
||||
|
||||
watch(() => props.data, (newData, oldData) => {
|
||||
const newLength = newData?.length || 0
|
||||
const oldLength = oldData?.length || 0
|
||||
|
||||
if (newLength < oldLength) {
|
||||
reset()
|
||||
}
|
||||
|
||||
nextTick(observeItems)
|
||||
}, { deep: true })
|
||||
|
||||
watch(visibleItems, () => {
|
||||
nextTick(observeItems)
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
setupResizeObserver()
|
||||
nextTick(observeItems)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver.value) {
|
||||
resizeObserver.value.disconnect()
|
||||
}
|
||||
|
||||
if (scrollTimeout.value) {
|
||||
clearTimeout(scrollTimeout.value)
|
||||
}
|
||||
|
||||
itemRefs.clear()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
scrollToIndex,
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
getScrollElement,
|
||||
isAtTop,
|
||||
isAtBottom,
|
||||
reset,
|
||||
scrollContainerRef
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.virtual-scroller {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&-wrapper {
|
||||
contain: content;
|
||||
}
|
||||
|
||||
&-container {
|
||||
contain: layout style;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-spacer {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-item {
|
||||
contain: layout style;
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
&-placeholder {
|
||||
contain: layout style;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -13,10 +13,8 @@
|
|||
ref="renderContainerRef"
|
||||
class="virtual-scroller-render-container"
|
||||
:style="renderContainerStyle"
|
||||
@scroll.passive="handleScroll"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
<div class="virtual-scroller-spacer" :style="{ height: `${totalHeight}px` }"></div>
|
||||
<div class="virtual-scroller-spacer" :style="{ height: `${totalSize}px` }"></div>
|
||||
<div
|
||||
class="virtual-scroller-bottom-placeholder"
|
||||
:style="bottomPlaceholderStyle"
|
||||
|
|
@ -24,17 +22,18 @@
|
|||
<slot name="bottom-placeholder" />
|
||||
</div>
|
||||
<div
|
||||
v-for="renderItem in visibleItems"
|
||||
:key="getItemKey(renderItem.item, renderItem.index)"
|
||||
:ref="el => setItemRef(el, renderItem.index)"
|
||||
v-for="renderItem in pool"
|
||||
:key="renderItem.nr.key"
|
||||
:ref="el => setItemRef(el, renderItem.nr.key)"
|
||||
class="virtual-scroller-item"
|
||||
:style="getItemStyle(renderItem)"
|
||||
:data-index="renderItem.index"
|
||||
:data-index="renderItem.nr.index"
|
||||
:data-key="renderItem.nr.key"
|
||||
>
|
||||
<slot
|
||||
name="default"
|
||||
:item="renderItem.item"
|
||||
:index="renderItem.index"
|
||||
:index="renderItem.nr.index"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -43,38 +42,29 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowReactive, watch, markRaw } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: false,
|
||||
required: true,
|
||||
default: () => []
|
||||
},
|
||||
itemKey: {
|
||||
type: [String, Function],
|
||||
default: 'id'
|
||||
},
|
||||
keyField: {
|
||||
type: String,
|
||||
default: 'id'
|
||||
},
|
||||
estimatedHeight: {
|
||||
type: Number,
|
||||
default: 100
|
||||
default: null
|
||||
},
|
||||
minItemSize: {
|
||||
type: [Number, String],
|
||||
default: null
|
||||
},
|
||||
buffer: {
|
||||
type: Number,
|
||||
default: 3
|
||||
},
|
||||
bufferSize: {
|
||||
type: Number,
|
||||
default: 3
|
||||
default: 200
|
||||
},
|
||||
height: {
|
||||
type: [String, Number],
|
||||
|
|
@ -89,45 +79,36 @@ const props = defineProps({
|
|||
default: 'default',
|
||||
validator: (value) => ['default', 'top'].includes(value)
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'reverse',
|
||||
validator: (value) => ['normal', 'reverse'].includes(value)
|
||||
},
|
||||
bottomPlaceholderHeight: {
|
||||
type: Number,
|
||||
default: 350
|
||||
}
|
||||
})
|
||||
|
||||
const computedData = computed(() => {
|
||||
return props.data.length > 0 ? props.data : props.items
|
||||
})
|
||||
|
||||
const computedItemKey = computed(() => {
|
||||
if (typeof props.itemKey === 'function') return props.itemKey
|
||||
if (props.itemKey !== 'id') return props.itemKey
|
||||
return props.keyField
|
||||
})
|
||||
|
||||
const computedBuffer = computed(() => {
|
||||
return props.buffer !== 3 ? props.buffer : props.bufferSize
|
||||
})
|
||||
|
||||
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end', 'visible-change'])
|
||||
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end', 'resize', 'update'])
|
||||
|
||||
const containerRef = ref(null)
|
||||
const wrapperRef = ref(null)
|
||||
const renderContainerRef = ref(null)
|
||||
const itemRefs = new Map()
|
||||
const itemHeights = ref(new Map())
|
||||
const resizeObserver = ref(null)
|
||||
const scrollTop = ref(0)
|
||||
const isScrolling = ref(false)
|
||||
const scrollTimeout = ref(null)
|
||||
const isInitialized = ref(false)
|
||||
const pendingScrollToBottom = ref(false)
|
||||
const previousDataLength = ref(0)
|
||||
|
||||
let uid = 0
|
||||
const pool = ref([])
|
||||
const viewMap = new Map()
|
||||
const unusedViews = new Map()
|
||||
const itemSizeMap = ref(new Map())
|
||||
const totalSize = ref(0)
|
||||
|
||||
let $_startIndex = 0
|
||||
let $_endIndex = 0
|
||||
let $_scrollDirty = false
|
||||
let $_lastUpdateScrollPosition = 0
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
return {
|
||||
|
|
@ -137,108 +118,48 @@ const containerStyle = computed(() => {
|
|||
}
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => {
|
||||
let height = 0
|
||||
const len = computedData.value.length
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
height += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
const getItemKey = (item, index) => {
|
||||
if (typeof props.itemKey === 'function') {
|
||||
return props.itemKey(item, index)
|
||||
}
|
||||
|
||||
height += props.bottomPlaceholderHeight
|
||||
|
||||
return height
|
||||
})
|
||||
|
||||
const getItemPosition = (index) => {
|
||||
let offset = 0
|
||||
|
||||
for (let i = 0; i < index; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
offset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
if (typeof props.itemKey === 'string' && item && typeof item === 'object') {
|
||||
return item[props.itemKey] ?? index
|
||||
}
|
||||
|
||||
const height = itemHeights.value.get(index) ?? props.estimatedHeight
|
||||
|
||||
return { offset, height }
|
||||
return index
|
||||
}
|
||||
|
||||
const containerHeight = computed(() => {
|
||||
if (!renderContainerRef.value) return 0
|
||||
return renderContainerRef.value.clientHeight
|
||||
const minSize = computed(() => {
|
||||
if (props.minItemSize) {
|
||||
return typeof props.minItemSize === 'string' ? parseInt(props.minItemSize, 10) : props.minItemSize
|
||||
}
|
||||
return props.estimatedHeight || 50
|
||||
})
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
if (!renderContainerRef.value || computedData.value.length === 0) {
|
||||
return { start: 0, end: 0, offset: 0 }
|
||||
const sizes = computed(() => {
|
||||
const sizesMap = {
|
||||
'-1': { accumulator: 0 }
|
||||
}
|
||||
const items = props.data
|
||||
let accumulator = 0
|
||||
let computedMinSize = 10000
|
||||
|
||||
const viewportHeight = containerHeight.value
|
||||
const currentScrollTop = scrollTop.value
|
||||
const bufferCount = computedBuffer.value
|
||||
|
||||
let startIndex = 0
|
||||
let endIndex = computedData.value.length - 1
|
||||
let startOffset = 0
|
||||
|
||||
let offset = 0
|
||||
for (let i = 0; i < computedData.value.length; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
for (let i = 0, l = items.length; i < l; i++) {
|
||||
const key = getItemKey(items[i], i)
|
||||
let size = itemSizeMap.value.get(key)
|
||||
|
||||
if (offset + height > currentScrollTop) {
|
||||
startIndex = Math.max(0, i - bufferCount)
|
||||
break
|
||||
if (size === undefined) {
|
||||
size = minSize.value
|
||||
}
|
||||
|
||||
offset += height
|
||||
}
|
||||
|
||||
startOffset = 0
|
||||
for (let i = 0; i < startIndex; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
startOffset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
}
|
||||
|
||||
offset = startOffset
|
||||
for (let i = startIndex; i < computedData.value.length; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
|
||||
if (offset >= currentScrollTop + viewportHeight) {
|
||||
endIndex = Math.min(computedData.value.length - 1, i + bufferCount)
|
||||
break
|
||||
if (size < computedMinSize) {
|
||||
computedMinSize = size
|
||||
}
|
||||
|
||||
offset += height
|
||||
endIndex = i
|
||||
accumulator += size
|
||||
sizesMap[i] = { accumulator, size }
|
||||
}
|
||||
|
||||
return { start: startIndex, end: endIndex, offset: startOffset }
|
||||
})
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
const { start, end, offset } = visibleRange.value
|
||||
const items = []
|
||||
|
||||
let currentOffset = offset
|
||||
|
||||
for (let i = start; i <= end && i < computedData.value.length; i++) {
|
||||
const cachedHeight = itemHeights.value.get(i)
|
||||
const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
|
||||
|
||||
items.push({
|
||||
item: computedData.value[i],
|
||||
index: i,
|
||||
offset: currentOffset + props.bottomPlaceholderHeight,
|
||||
height
|
||||
})
|
||||
|
||||
currentOffset += height
|
||||
}
|
||||
|
||||
return items
|
||||
return sizesMap
|
||||
})
|
||||
|
||||
const wrapperStyle = computed(() => ({
|
||||
|
|
@ -277,17 +198,6 @@ const bottomPlaceholderStyle = computed(() => ({
|
|||
zIndex: 1
|
||||
}))
|
||||
|
||||
const getItemKey = (item, index) => {
|
||||
const keyField = computedItemKey.value
|
||||
if (typeof keyField === 'function') {
|
||||
return keyField(item, index)
|
||||
}
|
||||
if (typeof keyField === 'string' && item && typeof item === 'object') {
|
||||
return item[keyField] ?? index
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
const getItemStyle = (renderItem) => {
|
||||
return {
|
||||
position: 'absolute',
|
||||
|
|
@ -295,20 +205,196 @@ const getItemStyle = (renderItem) => {
|
|||
right: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${renderItem.offset}px)`,
|
||||
transform: `translateY(${renderItem.position}px)`,
|
||||
willChange: 'transform'
|
||||
}
|
||||
}
|
||||
|
||||
const setItemRef = (el, index) => {
|
||||
const setItemRef = (el, key) => {
|
||||
if (el) {
|
||||
itemRefs.set(index, el)
|
||||
itemRefs.set(key, el)
|
||||
} else {
|
||||
itemRefs.delete(index)
|
||||
itemRefs.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const measureItem = (index, element) => {
|
||||
const addView = (index, item, key) => {
|
||||
const nr = markRaw({
|
||||
id: uid++,
|
||||
index,
|
||||
used: true,
|
||||
key
|
||||
})
|
||||
const view = shallowReactive({
|
||||
item,
|
||||
position: 0,
|
||||
nr
|
||||
})
|
||||
pool.value.push(view)
|
||||
return view
|
||||
}
|
||||
|
||||
const unuseView = (view) => {
|
||||
view.nr.used = false
|
||||
view.position = -9999
|
||||
}
|
||||
|
||||
const getScroll = () => {
|
||||
const el = renderContainerRef.value
|
||||
if (!el) return { start: 0, end: 0 }
|
||||
|
||||
return {
|
||||
start: el.scrollTop,
|
||||
end: el.scrollTop + el.clientHeight
|
||||
}
|
||||
}
|
||||
|
||||
const updateVisibleItems = (checkItem = false, checkPositionDiff = false) => {
|
||||
const items = props.data
|
||||
const count = items.length
|
||||
const sizesMap = sizes.value
|
||||
const keyField = typeof props.itemKey === 'string' ? props.itemKey : 'id'
|
||||
|
||||
if (!count) {
|
||||
pool.value = []
|
||||
viewMap.clear()
|
||||
totalSize.value = props.bottomPlaceholderHeight
|
||||
return { continuous: true }
|
||||
}
|
||||
|
||||
const el = renderContainerRef.value
|
||||
if (!el) return { continuous: true }
|
||||
|
||||
const scrollHeight = el.scrollHeight
|
||||
const scrollTop = el.scrollTop
|
||||
const clientHeight = el.clientHeight
|
||||
|
||||
const visualScrollStart = scrollHeight - scrollTop - clientHeight
|
||||
const visualScrollEnd = scrollHeight - scrollTop
|
||||
|
||||
if (checkPositionDiff) {
|
||||
let positionDiff = visualScrollStart - $_lastUpdateScrollPosition
|
||||
if (positionDiff < 0) positionDiff = -positionDiff
|
||||
if (positionDiff < minSize.value) {
|
||||
return { continuous: true }
|
||||
}
|
||||
}
|
||||
|
||||
$_lastUpdateScrollPosition = visualScrollStart
|
||||
|
||||
const buffer = props.buffer
|
||||
let scrollStart = visualScrollStart - buffer
|
||||
let scrollEnd = visualScrollEnd + buffer
|
||||
|
||||
scrollStart -= props.bottomPlaceholderHeight
|
||||
scrollEnd += props.bottomPlaceholderHeight
|
||||
|
||||
scrollStart = Math.max(0, scrollStart)
|
||||
|
||||
let startIndex = 0
|
||||
let endIndex = count - 1
|
||||
let newTotalSize = 0
|
||||
|
||||
let a = 0
|
||||
let b = count - 1
|
||||
let i = ~~(count / 2)
|
||||
let oldI
|
||||
|
||||
do {
|
||||
oldI = i
|
||||
const h = sizesMap[i]?.accumulator || 0
|
||||
if (h < scrollStart) {
|
||||
a = i
|
||||
} else if (i < count - 1 && (sizesMap[i + 1]?.accumulator || 0) > scrollStart) {
|
||||
b = i
|
||||
}
|
||||
i = ~~((a + b) / 2)
|
||||
} while (i !== oldI)
|
||||
|
||||
i < 0 && (i = 0)
|
||||
startIndex = i
|
||||
|
||||
newTotalSize = sizesMap[count - 1]?.accumulator || count * minSize.value
|
||||
|
||||
for (endIndex = i; endIndex < count && (sizesMap[endIndex]?.accumulator || 0) < scrollEnd; endIndex++);
|
||||
|
||||
if (endIndex === -1) {
|
||||
endIndex = count - 1
|
||||
} else {
|
||||
endIndex++
|
||||
endIndex > count && (endIndex = count)
|
||||
}
|
||||
|
||||
totalSize.value = newTotalSize + props.bottomPlaceholderHeight
|
||||
|
||||
const continuous = startIndex <= $_endIndex && endIndex >= $_startIndex
|
||||
|
||||
if (continuous) {
|
||||
for (let j = 0, l = pool.value.length; j < l; j++) {
|
||||
const view = pool.value[j]
|
||||
if (view.nr.used) {
|
||||
if (checkItem) {
|
||||
const newKey = getItemKey(view.item, view.nr.index)
|
||||
let newIndex = -1
|
||||
for (let k = 0; k < count; k++) {
|
||||
if (getItemKey(items[k], k) === newKey) {
|
||||
newIndex = k
|
||||
break
|
||||
}
|
||||
}
|
||||
view.nr.index = newIndex
|
||||
}
|
||||
|
||||
if (
|
||||
view.nr.index == null ||
|
||||
view.nr.index < startIndex ||
|
||||
view.nr.index >= endIndex
|
||||
) {
|
||||
unuseView(view)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let j = startIndex; j < endIndex; j++) {
|
||||
const item = items[j]
|
||||
if (!item) continue
|
||||
|
||||
const key = getItemKey(item, j)
|
||||
let view = viewMap.get(key)
|
||||
|
||||
if (!view) {
|
||||
view = addView(j, item, key)
|
||||
viewMap.set(key, view)
|
||||
} else {
|
||||
if (!view.nr.used) {
|
||||
view.nr.used = true
|
||||
}
|
||||
}
|
||||
|
||||
view.item = item
|
||||
view.nr.index = j
|
||||
view.nr.key = key
|
||||
|
||||
const prevSize = j > 0 ? sizesMap[j - 1] : { accumulator: 0 }
|
||||
view.position = (prevSize?.accumulator || 0) + props.bottomPlaceholderHeight
|
||||
}
|
||||
|
||||
pool.value = pool.value.filter(v => v.nr.used)
|
||||
|
||||
$_startIndex = startIndex
|
||||
$_endIndex = endIndex
|
||||
|
||||
emit('update', startIndex, endIndex)
|
||||
|
||||
nextTick(() => {
|
||||
observeVisibleItems()
|
||||
})
|
||||
|
||||
return { continuous }
|
||||
}
|
||||
|
||||
const measureItem = (key, element) => {
|
||||
if (!element) return
|
||||
|
||||
const firstChild = element.firstElementChild
|
||||
|
|
@ -317,11 +403,11 @@ const measureItem = (index, element) => {
|
|||
const height = targetElement.getBoundingClientRect().height
|
||||
|
||||
if (height > 0) {
|
||||
const cachedHeight = itemHeights.value.get(index)
|
||||
if (cachedHeight !== height) {
|
||||
const newHeights = new Map(itemHeights.value)
|
||||
newHeights.set(index, height)
|
||||
itemHeights.value = newHeights
|
||||
const currentSize = itemSizeMap.value.get(key)
|
||||
if (currentSize !== height) {
|
||||
const newSizes = new Map(itemSizeMap.value)
|
||||
newSizes.set(key, height)
|
||||
itemSizeMap.value = newSizes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -332,12 +418,22 @@ const setupResizeObserver = () => {
|
|||
}
|
||||
|
||||
resizeObserver.value = new ResizeObserver((entries) => {
|
||||
let hasChanges = false
|
||||
for (const entry of entries) {
|
||||
const index = parseInt(entry.target.dataset.index, 10)
|
||||
if (!isNaN(index)) {
|
||||
measureItem(index, entry.target)
|
||||
const key = entry.target.dataset.key
|
||||
if (key !== undefined) {
|
||||
const oldSize = itemSizeMap.value.get(key)
|
||||
measureItem(key, entry.target)
|
||||
const newSize = itemSizeMap.value.get(key)
|
||||
if (oldSize !== newSize) {
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
updateVisibleItems(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -356,6 +452,15 @@ const handleWheel = (event) => {
|
|||
}
|
||||
|
||||
const handleScroll = (event) => {
|
||||
if (!$_scrollDirty) {
|
||||
$_scrollDirty = true
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
$_scrollDirty = false
|
||||
updateVisibleItems(false, true)
|
||||
})
|
||||
}
|
||||
|
||||
const target = event.target
|
||||
scrollTop.value = target.scrollTop
|
||||
isScrolling.value = true
|
||||
|
|
@ -401,12 +506,13 @@ const handleScroll = (event) => {
|
|||
}
|
||||
|
||||
const scrollToIndex = (index, behavior = 'auto') => {
|
||||
if (!renderContainerRef.value || index < 0 || index >= computedData.value.length) return
|
||||
if (!renderContainerRef.value || index < 0 || index >= props.data.length) return
|
||||
|
||||
const position = getItemPosition(index)
|
||||
const sizesMap = sizes.value
|
||||
const offset = index > 0 ? (sizesMap[index - 1]?.accumulator || 0) : 0
|
||||
|
||||
renderContainerRef.value.scrollTo({
|
||||
top: position.offset,
|
||||
top: offset,
|
||||
behavior
|
||||
})
|
||||
}
|
||||
|
|
@ -444,13 +550,21 @@ const scrollToTop = (behavior = 'smooth') => {
|
|||
const getScrollElement = () => renderContainerRef.value
|
||||
|
||||
const getVisibleIndices = () => {
|
||||
const { start, end } = visibleRange.value
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
const indices = []
|
||||
for (let i = $_startIndex; i < $_endIndex; i++) {
|
||||
indices.push(i)
|
||||
}
|
||||
return indices
|
||||
}
|
||||
|
||||
const resetMeasurements = () => {
|
||||
itemHeights.value = new Map()
|
||||
itemSizeMap.value = new Map()
|
||||
itemRefs.clear()
|
||||
pool.value = []
|
||||
viewMap.clear()
|
||||
scrollTop.value = 0
|
||||
$_startIndex = 0
|
||||
$_endIndex = 0
|
||||
}
|
||||
|
||||
const isAtPageBottom = () => {
|
||||
|
|
@ -470,52 +584,33 @@ const observeVisibleItems = () => {
|
|||
|
||||
resizeObserver.value.disconnect()
|
||||
|
||||
for (const [index, element] of itemRefs) {
|
||||
for (const [key, element] of itemRefs) {
|
||||
if (element) {
|
||||
resizeObserver.value.observe(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => computedData.value, (newData, oldData) => {
|
||||
const oldLength = oldData?.length || 0
|
||||
const newLength = newData.length
|
||||
watch(() => props.data, () => {
|
||||
itemSizeMap.value = new Map()
|
||||
pool.value = []
|
||||
viewMap.clear()
|
||||
itemRefs.clear()
|
||||
$_startIndex = 0
|
||||
$_endIndex = 0
|
||||
|
||||
if (newLength !== oldLength) {
|
||||
const newHeights = new Map()
|
||||
|
||||
const minLen = Math.min(oldLength, newLength)
|
||||
for (let i = 0; i < minLen; i++) {
|
||||
if (itemHeights.value.has(i)) {
|
||||
newHeights.set(i, itemHeights.value.get(i))
|
||||
}
|
||||
}
|
||||
|
||||
itemHeights.value = newHeights
|
||||
|
||||
nextTick(() => {
|
||||
observeVisibleItems()
|
||||
})
|
||||
}
|
||||
|
||||
previousDataLength.value = newLength
|
||||
}, { deep: false })
|
||||
|
||||
watch(visibleItems, (newItems) => {
|
||||
nextTick(() => {
|
||||
observeVisibleItems()
|
||||
updateVisibleItems(true)
|
||||
})
|
||||
if (newItems.length > 0) {
|
||||
const firstItem = newItems[0]
|
||||
const lastItem = newItems[newItems.length - 1]
|
||||
emit('visible-change', firstItem.index, lastItem.index)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
watch(sizes, () => {
|
||||
updateVisibleItems(false)
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
setupResizeObserver()
|
||||
isInitialized.value = true
|
||||
previousDataLength.value = computedData.value.length
|
||||
|
||||
nextTick(() => {
|
||||
if (pendingScrollToBottom.value) {
|
||||
|
|
@ -523,7 +618,7 @@ onMounted(() => {
|
|||
scrollToBottom()
|
||||
}
|
||||
|
||||
observeVisibleItems()
|
||||
updateVisibleItems(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -537,11 +632,11 @@ onBeforeUnmount(() => {
|
|||
}
|
||||
|
||||
itemRefs.clear()
|
||||
viewMap.clear()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
scrollToIndex,
|
||||
scrollToItem: scrollToIndex,
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
getScrollElement,
|
||||
|
|
@ -556,29 +651,17 @@ defineExpose({
|
|||
<style lang="less" scoped>
|
||||
.virtual-scroller {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.virtual-scroller-wrapper {
|
||||
contain: content;
|
||||
|
||||
}
|
||||
|
||||
.virtual-scroller-spacer {
|
||||
|
|
@ -592,12 +675,6 @@ defineExpose({
|
|||
|
||||
.virtual-scroller-render-container {
|
||||
contain: layout style;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-scroller-item {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,6 @@
|
|||
{ "value": "Jimeng_4.0", "label": "Jimeng.4.0" }
|
||||
],
|
||||
"vision": [
|
||||
{ "value": "Qwen3.5plus", "label": "Qwen3.5plus", "disabled": true }
|
||||
{ "value": "Qwen3.5plus", "label": "Qwen3.5plus" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,8 @@
|
|||
{
|
||||
"文生视频": [
|
||||
{ "value": "LTX2.0", "label": "LTX2.0 T2V" },
|
||||
{ "value": "viduQ3-T2V", "label": "viduQ3 T2V" }
|
||||
],
|
||||
"首尾帧": [
|
||||
{ "value": "Hailuo-02-fast", "label": "海螺 fast" },
|
||||
{ "value": "LTX2.0-I2V", "label": "LTX2.0 I2V" },
|
||||
{ "value": "LTX2.3-T2V", "label": "LTX2.3 T2V", "disabled": true },
|
||||
{ "value": "ViduQ3-turbo", "label": "ViduQ3-turbo" }
|
||||
],
|
||||
"数字人": [
|
||||
{ "value": "FlashHead", "label": "FlashHead" }
|
||||
],
|
||||
"全能参考": [
|
||||
{ "value": "Seedance 2.0", "label": "Seedance 2.0", "disabled": true }
|
||||
],
|
||||
"智能多帧": [
|
||||
{ "value": "Seedance 2.0", "label": "Seedance 2.0", "disabled": true }
|
||||
],
|
||||
"主体参考": [
|
||||
{ "value": "Seedance 2.0", "label": "Seedance 2.0", "disabled": true }
|
||||
"video": [
|
||||
{ "value": "FlashHead", "label": "FlashHead" },
|
||||
{ "value": "LTX2.3-T2V", "label": "LTX 2.3-T2V" },
|
||||
{ "value": "Vidu Q3-I2V", "label": "Vidu Q3-I2V" },
|
||||
{ "value": "Vidu Q3-T2V", "label": "Vidu Q3-T2V" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import { fetchModelConfig } from '@/utils/modelConfig'
|
||||
|
||||
export async function Playload(data) {
|
||||
export async function Playload(data,type) {
|
||||
// data = getWidthHeight(data)
|
||||
try {
|
||||
const json = await fetchModelConfig(data.type, data.modelName, data.modelType)
|
||||
const response = await fetch(`https://resources.xueai.art/AIGC/static/public/Platform/Painting/workflows/${type}/${data.modelName}.json`)
|
||||
const json = await response.json()
|
||||
|
||||
const nodeInfoList = []
|
||||
const proportionParam = data.params.find(param => param.name === 'proportion') || {data: 0}
|
||||
const resolutionParam = data.params.find(param => param.name === 'resolution') || {data: 0}
|
||||
|
||||
if (Array.isArray(data.imgs) && data.imgs.length > 0 && (data.modelType === 'image' || data.modelType === 'edit')) {
|
||||
if (Array.isArray(data.imgs) && data.imgs.length > 0 && type === 'image') {
|
||||
for (const key of data.imgs) {
|
||||
if (json.nodeInfoList[key.name]) {
|
||||
console.log(key)
|
||||
|
|
@ -29,30 +27,9 @@ export async function Playload(data) {
|
|||
}
|
||||
}
|
||||
|
||||
if ((json.nodeInfoList.width || json.nodeInfoList.height) && (proportionParam.data && resolutionParam.data)) {
|
||||
const { width, height } = getWidthHeight({
|
||||
proportion: proportionParam.data,
|
||||
resolution: resolutionParam.data
|
||||
})
|
||||
json.nodeInfoList.width.fieldValue = width
|
||||
json.nodeInfoList.height.fieldValue = height
|
||||
nodeInfoList.push(json.nodeInfoList.width, json.nodeInfoList.height)
|
||||
}
|
||||
|
||||
if (Array.isArray(json.seed)) {
|
||||
const min = Math.pow(10, 0)
|
||||
const max = Math.pow(10, 9) - 1
|
||||
const randomNum = Math.floor(Math.random() * (max - min + 1)) + min
|
||||
json.seed.map((seedItem) => {
|
||||
seedItem.fieldValue = randomNum
|
||||
nodeInfoList.push(seedItem)
|
||||
})
|
||||
}
|
||||
|
||||
if (Array.isArray(json.must)) {
|
||||
nodeInfoList.push(...json.must)
|
||||
}
|
||||
|
||||
return {
|
||||
workflowId: json.workflowId,
|
||||
nodeInfoList
|
||||
|
|
@ -69,3 +46,22 @@ export function result(result) {
|
|||
}
|
||||
return { type: false, message: result.data.exception_message }
|
||||
}
|
||||
function getWidthHeight(data) {
|
||||
// 去除分辨率字符串中的'p'并转换为数字
|
||||
// const resolution = 720
|
||||
const resolution = Number.parseInt(data.resolution.replace('p', '')) || Number.parseInt(data.resolution)
|
||||
// 解析宽高比
|
||||
const aspectRatioParts = data.aspect_ratio.split(':') || data.aspect_ratio.split(':')
|
||||
const widthRatio = Number.parseInt(aspectRatioParts[0])
|
||||
const heightRatio = Number.parseInt(aspectRatioParts[1])
|
||||
if (widthRatio > heightRatio) {
|
||||
data.height = resolution
|
||||
data.width = Math.round(resolution * widthRatio / heightRatio)
|
||||
} else {
|
||||
data.width = resolution
|
||||
data.height = Math.round(resolution * heightRatio / widthRatio)
|
||||
}
|
||||
console.log(data.width, data.height)
|
||||
|
||||
return data
|
||||
}
|
||||
|
|
@ -29,47 +29,49 @@ const router = createRouter({
|
|||
routes
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, from) => {
|
||||
if(to.query.token){
|
||||
setToken(to.query.token)
|
||||
} else {
|
||||
// 检查是否有 token
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
// 没有 token,重定向到登录页
|
||||
return '/login'
|
||||
}
|
||||
}
|
||||
// router.beforeEach(async (to, from) => {
|
||||
// if(to.query.token){
|
||||
// setToken(to.query.token)
|
||||
// } else {
|
||||
// // 检查是否有 token
|
||||
// const token = getToken()
|
||||
// if (!token) {
|
||||
// // 没有 token,重定向到登录页
|
||||
// return '/login'
|
||||
// }
|
||||
// }
|
||||
|
||||
// 白名单路径(不需要验证 token 的路径)
|
||||
const whiteList = ['/login']
|
||||
// 获取用户 store 实例
|
||||
const userStore = useUserStore()
|
||||
// 如果访问的是白名单路径,直接放行
|
||||
if (whiteList.includes(to.path)) {
|
||||
return true
|
||||
}
|
||||
// // 白名单路径(不需要验证 token 的路径)
|
||||
// const whiteList = ['/login']
|
||||
// // 获取用户 store 实例
|
||||
// const userStore = useUserStore()
|
||||
// // 如果访问的是白名单路径,直接放行
|
||||
// if (whiteList.includes(to.path)) {
|
||||
// return true
|
||||
// }
|
||||
|
||||
// 检查 token 是否有效
|
||||
try {
|
||||
const isTokenValid = await userStore.checkTokenValid()
|
||||
console.log(isTokenValid)
|
||||
if (isTokenValid) {
|
||||
// token 有效,允许访问
|
||||
if (!userStore.userInfo.id) {
|
||||
// 如果用户信息不存在,则从服务器获取
|
||||
await userStore.getInfo()
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
// token 无效,重定向到登录页
|
||||
return '/login'
|
||||
}
|
||||
} catch (error) {
|
||||
// 验证过程中出错,重定向到登录页
|
||||
console.error('验证 token 时出错:', error)
|
||||
return '/login'
|
||||
}
|
||||
})
|
||||
// // 检查 token 是否有效
|
||||
// try {
|
||||
// const isTokenValid = await userStore.checkTokenValid()
|
||||
// console.log(isTokenValid)
|
||||
// if (isTokenValid) {
|
||||
// // token 有效,允许访问
|
||||
// if (!userStore.userInfo.id) {
|
||||
// // 如果用户信息不存在,则从服务器获取
|
||||
// await userStore.getInfo()
|
||||
// }
|
||||
// return true
|
||||
// } else {
|
||||
// // token 无效,重定向到登录页
|
||||
// return '/login'
|
||||
// }
|
||||
// } catch (error) {
|
||||
// // 验证过程中出错,重定向到登录页
|
||||
// console.error('验证 token 时出错:', error)
|
||||
// return '/login'
|
||||
// }
|
||||
// })
|
||||
|
||||
router.beforeEach(async (to, from) => {return true})
|
||||
|
||||
export default router
|
||||
|
|
|
|||
|
|
@ -6,22 +6,17 @@ const DisplayStoreSetup = () => {
|
|||
const currentPage = ref(0)
|
||||
const hasMoreData = ref(true)
|
||||
const isLoading = ref(false)
|
||||
const currentResultData = ref(null)
|
||||
const dialogBoxRef = ref(null)
|
||||
|
||||
const canvasVisible = ref(false)
|
||||
const canvasImage = ref('')
|
||||
const canvasReferenceImages = ref([])
|
||||
const canvasSource = ref('')
|
||||
|
||||
const addGeneratingItem = (item) => {
|
||||
const newItem = {
|
||||
id: item.taskId || crypto.randomUUID(),
|
||||
status: 'generate',
|
||||
text: item.text || '生成中...',
|
||||
name: item.name || '生成中...',
|
||||
type: item.type || 'image',
|
||||
time: item.time || new Date().toLocaleString(),
|
||||
files: [],
|
||||
generateData: item.generateData || {}
|
||||
...item
|
||||
}
|
||||
tempList.value.unshift(newItem)
|
||||
return newItem
|
||||
|
|
@ -54,13 +49,6 @@ const DisplayStoreSetup = () => {
|
|||
hasMoreData.value = true
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const deleteHistoryItem = (id) => {
|
||||
const index = tempList.value.findIndex(item => item.id === id)
|
||||
if (index !== -1) {
|
||||
tempList.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
const refValue = scrollerRef.value
|
||||
|
|
@ -92,40 +80,6 @@ const DisplayStoreSetup = () => {
|
|||
console.error('滚动出错:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const setResultData = (data) => {
|
||||
currentResultData.value = data
|
||||
}
|
||||
|
||||
const setDialogBoxRef = (ref) => {
|
||||
dialogBoxRef.value = ref
|
||||
}
|
||||
|
||||
const triggerGenerateWithResult = async () => {
|
||||
if (dialogBoxRef.value && currentResultData.value) {
|
||||
await dialogBoxRef.value.fillParamsFromResult(currentResultData.value)
|
||||
await dialogBoxRef.value.handleStart()
|
||||
}
|
||||
}
|
||||
|
||||
const fillParamsForEdit = () => {
|
||||
if (dialogBoxRef.value && currentResultData.value) {
|
||||
dialogBoxRef.value.fillParamsFromResult(currentResultData.value)
|
||||
}
|
||||
}
|
||||
|
||||
const openCanvas = (data) => {
|
||||
if (typeof data === 'string') {
|
||||
canvasImage.value = data
|
||||
canvasReferenceImages.value = []
|
||||
canvasSource.value = ''
|
||||
} else {
|
||||
canvasImage.value = data.mainImage?.url || data.mainImage || ''
|
||||
canvasReferenceImages.value = data.referenceImages || []
|
||||
canvasSource.value = data.source || ''
|
||||
}
|
||||
canvasVisible.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
Sender_variant,
|
||||
|
|
@ -135,25 +89,13 @@ const DisplayStoreSetup = () => {
|
|||
currentPage,
|
||||
hasMoreData,
|
||||
isLoading,
|
||||
currentResultData,
|
||||
dialogBoxRef,
|
||||
canvasVisible,
|
||||
canvasImage,
|
||||
canvasReferenceImages,
|
||||
canvasSource,
|
||||
addGeneratingItem,
|
||||
updateItemToSuccess,
|
||||
initHistoryList,
|
||||
prependHistoryList,
|
||||
appendHistoryList,
|
||||
resetPagination,
|
||||
scrollToBottom,
|
||||
deleteHistoryItem,
|
||||
setResultData,
|
||||
setDialogBoxRef,
|
||||
triggerGenerateWithResult,
|
||||
fillParamsForEdit,
|
||||
openCanvas
|
||||
scrollToBottom
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
getUserInfo as getUserInfoApi,
|
||||
logout as logoutApi
|
||||
} from '@/apis/auth'
|
||||
import { getFreeTimes } from '@/apis/display'
|
||||
import { clearToken, getToken, setToken } from '@/utils/auth'
|
||||
|
||||
const storeSetup = () => {
|
||||
|
|
@ -34,7 +33,6 @@ const storeSetup = () => {
|
|||
|
||||
const dept = ref({}) // 当前用户所在部门集合
|
||||
const isLogin = ref(false)
|
||||
const freeTimes = ref(0) // 免费次数
|
||||
|
||||
// 重置token
|
||||
const resetToken = () => {
|
||||
|
|
@ -72,18 +70,6 @@ const storeSetup = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 获取免费次数
|
||||
const fetchFreeTimes = async (chargeType = 1) => {
|
||||
if (userInfo.id) {
|
||||
const res = await getFreeTimes(userInfo.id)
|
||||
const balanceList = res.data || []
|
||||
const target = balanceList.find((item) => item.chargeType === chargeType)
|
||||
freeTimes.value = target?.balance || 0
|
||||
return freeTimes.value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 登录
|
||||
const accountLogin = async (req) => {
|
||||
const res = await accountLoginApi(req)
|
||||
|
|
@ -134,14 +120,12 @@ const storeSetup = () => {
|
|||
dept,
|
||||
username,
|
||||
isLogin,
|
||||
freeTimes,
|
||||
accountLogin,
|
||||
logout,
|
||||
logoutCallBack,
|
||||
getInfo,
|
||||
resetToken,
|
||||
checkTokenValid,
|
||||
fetchFreeTimes
|
||||
checkTokenValid
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,19 @@
|
|||
import outPlatform from '@/config/index'
|
||||
|
||||
// 处理音频生成任务的数据并返回
|
||||
export async function createTask(data, taskId, token) {
|
||||
console.log(data)
|
||||
const payload = await outPlatform[data.platform].Playload(data)
|
||||
export async function createTask(data, type, taskId, token) {
|
||||
console.log(data, type)
|
||||
const payload = await outPlatform[data.platform].Playload(data, type)
|
||||
|
||||
return {
|
||||
AIGC: data.AIGC,
|
||||
platform: data.platform,
|
||||
taskType: data.modelType === 'text' ? 1 : 2,
|
||||
prompt: data.prompt,
|
||||
taskType: type === 'text' ? 1 : 2,
|
||||
modelName: data.modelName,
|
||||
payload,
|
||||
taskId,
|
||||
token,
|
||||
quantity: data.quantity,
|
||||
free: data.free,
|
||||
result: data.result
|
||||
token
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -25,5 +23,5 @@ export async function getTask(result) {
|
|||
const urls = result.data.map(item => item.fileUrl)
|
||||
return { type: true, urls: urls }
|
||||
}
|
||||
return { type: false, message: result.data.exception_message || '生成失败' }
|
||||
return { type: false, message: result.data?.exception_message || '生成失败' }
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
const STORAGE_PREFIX = 'model_config_'
|
||||
|
||||
function getTodayDateString() {
|
||||
const today = new Date()
|
||||
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getStorageKey(modelName, modelType) {
|
||||
return `${STORAGE_PREFIX}${modelType}_${modelName}`
|
||||
}
|
||||
|
||||
function getConfigFromStorage(modelName, modelType) {
|
||||
try {
|
||||
const key = getStorageKey(modelName, modelType)
|
||||
const stored = localStorage.getItem(key)
|
||||
|
||||
if (!stored) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = JSON.parse(stored)
|
||||
const todayStr = getTodayDateString()
|
||||
|
||||
if (data.storageDate !== todayStr) {
|
||||
localStorage.removeItem(key)
|
||||
return null
|
||||
}
|
||||
|
||||
return data.config
|
||||
} catch (error) {
|
||||
console.error('从localStorage读取配置失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveConfigToStorage(modelName, modelType, config) {
|
||||
try {
|
||||
const key = getStorageKey(modelName, modelType)
|
||||
const data = {
|
||||
config,
|
||||
storageDate: getTodayDateString(),
|
||||
timestamp: Date.now()
|
||||
}
|
||||
localStorage.setItem(key, JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.error('保存配置到localStorage失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchModelConfig(type, modelName, modelType) {
|
||||
const cachedConfig = getConfigFromStorage(modelName, modelType)
|
||||
|
||||
if (cachedConfig) {
|
||||
console.log(`从缓存加载模型配置: ${modelName}`)
|
||||
return cachedConfig
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${import.meta.env.VITE_API_MODEL_RESOURCE}/static/public/Platform/${type}/workflows/${modelType}/${modelName}.json`
|
||||
console.log(`从远程获取模型配置: ${url}`)
|
||||
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const config = await response.json()
|
||||
|
||||
saveConfigToStorage(modelName, modelType, config)
|
||||
|
||||
return config
|
||||
} catch (error) {
|
||||
console.error('获取模型配置失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function clearModelConfigCache(modelName, modelType) {
|
||||
const key = getStorageKey(modelName, modelType)
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
|
||||
export function clearAllModelConfigCache() {
|
||||
const keysToRemove = []
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key && key.startsWith(STORAGE_PREFIX)) {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key))
|
||||
}
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
import { ElNotification } from 'element-plus'
|
||||
import { h, ref } from 'vue'
|
||||
import { useDisplayStore, useUserStore } from '@/stores'
|
||||
import { useDisplayStore } from '@/stores'
|
||||
import { getToken } from '@/utils/auth'
|
||||
import { createTask, getTask } from '@/utils/createTask'
|
||||
import { userError } from '@/utils/tokenError'
|
||||
|
||||
export function getChargeType(chargeType) {
|
||||
switch (chargeType) {
|
||||
case 'Painting':
|
||||
case 'painting':
|
||||
return 1
|
||||
case 'Video':
|
||||
return 4
|
||||
case 'video':
|
||||
return 2
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
|
|
@ -37,18 +37,17 @@ export function websocketError(code, msg) {
|
|||
}
|
||||
|
||||
ElNotification({
|
||||
title: '生成失败',
|
||||
title: '生成通知',
|
||||
|
||||
message: h('i', { style: 'color: teal' }, message),
|
||||
type: 'error',
|
||||
duration: 6000 // 增加持续时间以适应更多信息
|
||||
type: 'error'
|
||||
})
|
||||
}
|
||||
|
||||
export function websocketSuccess() {
|
||||
// 合并两个通知为一个
|
||||
ElNotification({
|
||||
title: '生成成功',
|
||||
title: '生成通知',
|
||||
message: h('div', [
|
||||
h('div', { style: 'font-weight: bold; color: teal;' }, '生成成功!'),
|
||||
h('br'),
|
||||
|
|
@ -59,7 +58,7 @@ export function websocketSuccess() {
|
|||
})
|
||||
}
|
||||
|
||||
export async function generate(data, generateData) {
|
||||
export async function generate(type, data) {
|
||||
const progress_text = ref('')
|
||||
const message = ref('')
|
||||
const useDisplay = useDisplayStore()
|
||||
|
|
@ -69,7 +68,7 @@ export async function generate(data, generateData) {
|
|||
|
||||
useDisplay.isSubGerenate = true
|
||||
|
||||
const result = await createTask(data, taskId, token)
|
||||
const result = await createTask(data, type, taskId, token)
|
||||
console.log(result)
|
||||
// const token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOjY0NDEwODAyMjk1OTgzNzIzMCwicm5TdHIiOiJiWkVwS2JLWFJyZmRIaFFHWXZKTkdzOGdGM0JSRmxQOCJ9.5eQ2GtVdrDntQDe2tnF8vl_DhTfd2uW-KNqzvl1imc0'
|
||||
const wsURL = `${import.meta.env.VITE_API_WORKFLOW_WS}/?token=Bearer ${token}`
|
||||
|
|
@ -103,8 +102,9 @@ export async function generate(data, generateData) {
|
|||
|
||||
useDisplay.addGeneratingItem({
|
||||
taskId: taskId,
|
||||
type: data.type,
|
||||
generateData: generateData
|
||||
text: data.prompt || '生成中...',
|
||||
name: data.prompt || '生成中...',
|
||||
type: type
|
||||
})
|
||||
setTimeout(() => {
|
||||
useDisplay.scrollToBottom()
|
||||
|
|
@ -136,6 +136,7 @@ export async function generate(data, generateData) {
|
|||
socket.onclose = async (event) => {
|
||||
console.log('WebSocket已关闭:', event)
|
||||
useDisplay.isSubGerenate = false
|
||||
// 清理心跳定时器
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval)
|
||||
}
|
||||
|
|
@ -146,19 +147,21 @@ export async function generate(data, generateData) {
|
|||
} else if (event.code === 1000 && event.reason === 'success') {
|
||||
console.log('收到服务器消息:', res)
|
||||
const result = await getTask(res)
|
||||
if(useUserStore().freeTimes) await useUserStore().fetchFreeTimes()
|
||||
if (result.type) {
|
||||
|
||||
if (currentTaskId) {
|
||||
useDisplay.updateItemToSuccess(currentTaskId, result.urls)
|
||||
}
|
||||
|
||||
websocketSuccess()
|
||||
} else {
|
||||
websocketError(4403, result.message)
|
||||
websocketError(4403, res.message)
|
||||
}
|
||||
} else {
|
||||
websocketError(event.code, event.reason)
|
||||
}
|
||||
// clearInterval(progressInterval.value)
|
||||
// 清理心跳定时器
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
<template>
|
||||
<div style="width: 100%;display: flex;justify-content: center;align-items: center;transform: rotate(180deg);">
|
||||
<div class="primary-box" :class="{ 'none-primary-box': props.item.status === 'none' }">
|
||||
|
||||
<div class="prompt-container" ref="promptContainerRef">
|
||||
<div class="prompt-wrapper" ref="promptWrapperRef">
|
||||
<div class="prompt" ref="promptRef" :class="{ 'expanded': isHovering }">
|
||||
<span class="prompt-text" @mouseenter="isHovering = true" @mouseleave="isHovering = false">{{ props.item.generateData.prompt || '生成图片' }}</span>
|
||||
<div class="generate-data internal" v-show="!isHovering && !showExternalGenerateData">
|
||||
<div class="detailed-data first-detailed-data">{{ props.item.generateData.model }}</div>
|
||||
<div class="detailed-data">{{ props.item.generateData.proportion }}</div>
|
||||
</div>
|
||||
<!-- 标题 -->
|
||||
<div class="title">
|
||||
<div class="style">
|
||||
<div class="name" ref="nameRef">{{ props.item.text }}</div>
|
||||
<div class="generate-data" ref="generateDataRef">
|
||||
<div class="detailed-data first-detailed-data">{{ props.item.model }}</div>
|
||||
<!-- <div class="detailed-data">多少秒</div> -->
|
||||
<div class="detailed-data">{{ props.item.proportion }}</div>
|
||||
<div class="detailed-data">{{ props.item.resolution }}</div>
|
||||
<div class="detailed-data">{{ props.item.quantity }}张</div>
|
||||
<!-- <div class="detailed-data">多少k</div> -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="generate-data external" v-show="!isHovering && showExternalGenerateData">
|
||||
<div class="detailed-data first-detailed-data">{{ props.item.generateData.model }}</div>
|
||||
<div class="detailed-data">{{ props.item.generateData.proportion }}</div>
|
||||
<div v-if="props.item.parentIndex" class="style">
|
||||
<span class="time">源自 {{ props.item.parentTime }}</span>
|
||||
<div class="dividing-line"></div>
|
||||
<span class="time">源自 {{ props.item.parentTime }}</span>
|
||||
<div v-if="props.item.parentIndex" class="dividing-line"></div>
|
||||
<span class="time"> 第 {{ props.item.parentIndex }} 张图片</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -44,16 +49,16 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已完成 图片 -->
|
||||
<div v-if="props.item.status === 'success' && props.item.type === 'Painting'" class="box success-box">
|
||||
<div v-for="(file, index) in props.item.files" :key="index" class="one-box" :class="{ 'collected': isCollected(file) }" @mouseenter="hoverIndex = index" @mouseleave="hoverIndex = -1">
|
||||
<!-- 已完成 -->
|
||||
<div v-if="props.item.status === 'success'" class="box success-box">
|
||||
<div v-for="(file, index) in props.item.files" :key="index" class="one-box">
|
||||
<!-- <img :src="file" alt="index" class="img" /> -->
|
||||
<Img :src="file" alt="index" class="img" />
|
||||
|
||||
<div class="left-top">
|
||||
<div v-show="hoverIndex === index" class="left-top-btn download-btn" @click="downloadImage(file, 'image')"><img src="@/assets/display/download.svg" /></div>
|
||||
<span v-if="hoverIndex === index" class="line" />
|
||||
<div class="left-top-btn collect-btn" @click="addCollection(file)"><img :src="isCollected(file) ? collectionActiveIcon : collectionIcon" /></div>
|
||||
<div class="left-top-btn" @click="downloadImage(file, 'image')"><img src="@/assets/display/download.svg" /></div>
|
||||
<span class="line" />
|
||||
<div class="left-top-btn" @click="addCollection(file)"><img src="@/assets/display/collection.svg" /></div>
|
||||
</div>
|
||||
|
||||
<el-tooltip
|
||||
|
|
@ -69,22 +74,8 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已完成 视频 -->
|
||||
<div v-if="props.item.status === 'success' && props.item.type === 'Video'" class="box success-box">
|
||||
<div class="one-box" :class="{ 'collected': isCollected(props.item.files[0]) }" @mouseenter="hoverIndex = 0" @mouseleave="hoverIndex = -1">
|
||||
<!-- <img :src="file" alt="index" class="img" /> -->
|
||||
<video :src="props.item.files[0]" class="video" controls playsinline />
|
||||
|
||||
<div class="left-top">
|
||||
<div v-show="hoverIndex === 0" class="left-top-btn download-btn" @click="downloadImage(props.item.files[0], 'video')"><img src="@/assets/display/download.svg" /></div>
|
||||
<span v-if="hoverIndex === 0" class="line" />
|
||||
<div class="left-top-btn collect-btn" @click="addCollection(props.item.files[0])"><img :src="isCollected(props.item.files[0]) ? collectionActiveIcon : collectionIcon" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.item.status === 'success'" class="bottom-btn-group" style="margin-top: 8px;">
|
||||
<div v-for="(item, index) in bottomBtnGroup" :key="index" class="bottom-btn" @click="item.click()">
|
||||
<div v-if="props.item.status === 'success'" class="bottom-btn-group">
|
||||
<div v-for="(item, index) in bottomBtnGroup" :key="index" class="bottom-btn" @click="item.click(file, index)">
|
||||
<img :src="item.icon" />
|
||||
<span>{{ item.name }}</span>
|
||||
</div>
|
||||
|
|
@ -95,15 +86,13 @@
|
|||
|
||||
<script setup>
|
||||
import brush from '@/assets/display/brush.svg'
|
||||
import collectionIcon from '@/assets/display/collection.svg'
|
||||
import collectionActiveIcon from '@/assets/display/collection-active.svg'
|
||||
import { useDisplayStore, useParamStore, useUserStore } from '@/stores'
|
||||
import { downloadImage } from '@/utils/downloadImage.js'
|
||||
import reEditIcon from '@/assets/display/reEdit.svg'
|
||||
import againGenerateIcon from '@/assets/display/againGenerate.svg'
|
||||
import deleteImageIcon from '@/assets/display/deleteImage.svg'
|
||||
import Img from '@/components/Img/index.vue'
|
||||
import { cancelOrCollect, deleteGenerateHistory } from '@/apis/display'
|
||||
import { cancelOrCollect } from '@/apis/display'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
|
|
@ -111,84 +100,13 @@ const props = defineProps({
|
|||
default: () => ({})
|
||||
}
|
||||
})
|
||||
const emit = defineEmits(['open-canvas', 'delete-success'])
|
||||
|
||||
const emit = defineEmits(['open-canvas'])
|
||||
|
||||
const useDisplay = useDisplayStore()
|
||||
const useParams = useParamStore()
|
||||
const useUser = useUserStore()
|
||||
|
||||
const localCollectStatus = ref({ ...props.item.collectStatus })
|
||||
const hoverIndex = ref(-1)
|
||||
const isHovering = ref(false)
|
||||
const showExternalGenerateData = ref(false)
|
||||
const promptContainerRef = ref(null)
|
||||
const promptWrapperRef = ref(null)
|
||||
const promptRef = ref(null)
|
||||
|
||||
const checkTextOverflow = () => {
|
||||
nextTick(() => {
|
||||
if (promptRef.value && promptWrapperRef.value && promptContainerRef.value) {
|
||||
const lineHeight = 22.5
|
||||
const padding = 8
|
||||
const twoLineHeight = lineHeight * 2 + padding
|
||||
|
||||
promptRef.value.style.maxHeight = 'none'
|
||||
promptRef.value.style.overflow = 'hidden'
|
||||
|
||||
const actualHeight = promptRef.value.scrollHeight
|
||||
const lineCount = Math.ceil((actualHeight - padding) / lineHeight)
|
||||
|
||||
if (!isHovering.value) {
|
||||
promptRef.value.style.maxHeight = `${twoLineHeight}px`
|
||||
promptRef.value.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
showExternalGenerateData.value = lineCount >= 3
|
||||
|
||||
if (!isHovering.value) {
|
||||
if(lineCount >= 3){
|
||||
promptContainerRef.value.style.height = `${twoLineHeight}px`
|
||||
} else {
|
||||
promptContainerRef.value.style.height = `${actualHeight}px`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(isHovering, (newVal) => {
|
||||
if (promptRef.value) {
|
||||
const lineHeight = 22.5
|
||||
const padding = 8
|
||||
const twoLineHeight = lineHeight * 2 + padding
|
||||
|
||||
if (newVal) {
|
||||
promptRef.value.style.maxHeight = 'none'
|
||||
promptRef.value.style.overflow = 'visible'
|
||||
} else {
|
||||
promptRef.value.style.maxHeight = `${twoLineHeight}px`
|
||||
promptRef.value.style.overflow = 'hidden'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.item.generateData.prompt, () => {
|
||||
checkTextOverflow()
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
checkTextOverflow()
|
||||
window.addEventListener('resize', checkTextOverflow)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkTextOverflow)
|
||||
})
|
||||
|
||||
const isCollected = (url) => {
|
||||
return localCollectStatus.value[url] === true
|
||||
}
|
||||
|
||||
const generateStatusText = computed(() => {
|
||||
if (props.item.status === 'generate') {
|
||||
return '正在生成中...'
|
||||
|
|
@ -204,53 +122,19 @@ const AIbrush = (file, index) => {
|
|||
})
|
||||
}
|
||||
|
||||
const reEdit = () => {
|
||||
if(props.item.generateData?.modelType === 'edit'){
|
||||
ElMessage.error('画笔生成的任务不能重新编辑')
|
||||
return
|
||||
}
|
||||
useDisplay.setResultData(props.item.generateData)
|
||||
useDisplay.fillParamsForEdit()
|
||||
const reEdit = (url, number) => {
|
||||
console.log(number)
|
||||
}
|
||||
|
||||
const againGenerate = () => {
|
||||
if(props.item.generateData?.modelType === 'edit'){
|
||||
ElMessage.error('画笔生成的任务不能再次生成')
|
||||
return
|
||||
}
|
||||
useDisplay.setResultData(props.item.generateData)
|
||||
useDisplay.triggerGenerateWithResult()
|
||||
const againGenerate = (url, number) => {
|
||||
console.log(number)
|
||||
}
|
||||
|
||||
const deleteImage = () => {
|
||||
ElMessageBox.confirm(
|
||||
'确定要删除该批次图片吗?此操作不可恢复!',
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
const res = await deleteGenerateHistory({
|
||||
id: props.item.id
|
||||
})
|
||||
if (res.success) {
|
||||
ElMessage.success('删除成功')
|
||||
emit('delete-success', props.item.id)
|
||||
} else {
|
||||
ElMessage.error(res.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除操作失败:', error)
|
||||
ElMessage.error('删除操作失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
const deleteImage = (url, number) => {
|
||||
console.log(number)
|
||||
}
|
||||
|
||||
const bottomBtnGroup = computed(() => [
|
||||
const bottomBtnGroup = [
|
||||
{
|
||||
name: '重新编辑',
|
||||
icon: reEditIcon,
|
||||
|
|
@ -266,18 +150,17 @@ const bottomBtnGroup = computed(() => [
|
|||
icon: deleteImageIcon,
|
||||
click: deleteImage
|
||||
}
|
||||
])
|
||||
]
|
||||
|
||||
const addCollection = async (url) => {
|
||||
try {
|
||||
const res = await cancelOrCollect({
|
||||
taskId: props.item.id,
|
||||
userId: useUser.userInfo.id,
|
||||
url: url,
|
||||
url: url
|
||||
})
|
||||
if (res.success) {
|
||||
ElMessage.success(res.message || '操作成功')
|
||||
localCollectStatus.value[url] = !localCollectStatus.value[url]
|
||||
} else {
|
||||
ElMessage.error(res.message || '操作失败')
|
||||
}
|
||||
|
|
@ -295,105 +178,84 @@ const addCollection = async (url) => {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 12px;
|
||||
padding-bottom: 40px;
|
||||
gap: 20px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.none-primary-box{
|
||||
height: 350px;
|
||||
}
|
||||
|
||||
.prompt-container{
|
||||
.title{
|
||||
// height: 25px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.prompt-wrapper{
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.prompt{
|
||||
color: #333;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 22.5px;
|
||||
word-break: break-all;
|
||||
padding: 8px;
|
||||
background-color: #fff;
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
top: 0;
|
||||
width: 102%;
|
||||
z-index: 5;
|
||||
width: auto;
|
||||
|
||||
&.expanded{
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-text{
|
||||
display: inline;
|
||||
color: #333;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.generate-data{
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #fff;
|
||||
height: 22.5px;
|
||||
text-align: center;
|
||||
padding: 0 12px;
|
||||
gap: 20px;
|
||||
|
||||
color: #999;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
|
||||
&.internal{
|
||||
.style{
|
||||
width: 100%;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.name{
|
||||
color: #333;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 15px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
display: inline;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.generate-data{
|
||||
display: inline-flex;
|
||||
margin-left: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&.external{
|
||||
position: absolute;
|
||||
right: -16px;
|
||||
bottom: 0;
|
||||
z-index: 6;
|
||||
// padding-right: 20px;
|
||||
|
||||
.detailed-data{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
padding: 0 10px;
|
||||
border-left: 1px solid #999;
|
||||
white-space: nowrap;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.detailed-data{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
padding: 0 10px;
|
||||
border-left: 1px solid #999;
|
||||
white-space: nowrap;
|
||||
height: 12px;
|
||||
}
|
||||
.first-detailed-data{
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.first-detailed-data{
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
.time{
|
||||
color: #333;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.dividing-line{
|
||||
height: 100%;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.delete-btn{
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.delete-btn:hover{
|
||||
color: #ff4949;
|
||||
}
|
||||
}
|
||||
|
||||
.box{
|
||||
|
|
@ -484,11 +346,6 @@ const addCollection = async (url) => {
|
|||
display:flex
|
||||
}
|
||||
}
|
||||
.one-box.collected{
|
||||
.left-top{
|
||||
display:flex
|
||||
}
|
||||
}
|
||||
.success-box{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
|
|
@ -501,13 +358,6 @@ const addCollection = async (url) => {
|
|||
object-fit: contain; /* 确保图片完整显示,不被裁剪 */
|
||||
border-radius: 8px; /* 可选:给图片添加圆角 */
|
||||
}
|
||||
|
||||
.video{
|
||||
width: 380px;
|
||||
height: auto; /* 保持宽高比 */
|
||||
object-fit: contain; /* 确保图片完整显示,不被裁剪 */
|
||||
border-radius: 8px; /* 可选:给图片添加圆角 */
|
||||
}
|
||||
}
|
||||
|
||||
.left-top,.bottom-brush{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,391 @@
|
|||
<template>
|
||||
<div id="display" class="content-area">
|
||||
<RefreshOverlay :visible="refreshing" />
|
||||
|
||||
<div class="back">
|
||||
<img src="@/assets/display/back.svg" alt="">
|
||||
<span class="title-text">退出</span>
|
||||
</div>
|
||||
|
||||
<div v-if="props.if" class="btn-container">
|
||||
<div class="btn">
|
||||
<!-- <span class="btn-text">全部</span> -->
|
||||
<img src="@/assets/display/search.svg" alt="">
|
||||
</div>
|
||||
<span class="line"></span>
|
||||
<div class="btn">
|
||||
<Select v-model="selectedTime" :options="timeOptions" width="auto">
|
||||
<template #prefix>
|
||||
<i-ep-Calendar />
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<div class="header">
|
||||
<el-date-picker
|
||||
v-model="value1"
|
||||
type="daterange"
|
||||
start-placeholder="Start date"
|
||||
end-placeholder="End date"
|
||||
:size="size"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
<span class="line"></span>
|
||||
<div class="btn">
|
||||
<Select v-model="selectedFavorite" :options="favoriteOptions" width="auto" >
|
||||
<template #prefix>
|
||||
<i-ep-Star />
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DynamicScroller
|
||||
ref="scrollerRef"
|
||||
v-if="props.if"
|
||||
:items="list"
|
||||
:min-item-size="800"
|
||||
class="scroller"
|
||||
:buffer="50"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<template #default="{ item, index, active }">
|
||||
<DynamicScrollerItem
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:active="active"
|
||||
:index="index"
|
||||
data-index="index"
|
||||
>
|
||||
<Set :key="`${item.id}`" :item="item" />
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useDisplayStore, useParamStore, useUserStore } from '@/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Set from './components/set.vue'
|
||||
import RefreshOverlay from './components/RefreshOverlay.vue'
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import { getGenerateHistoryList } from '@/apis/display'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
if: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const useDisplay = useDisplayStore()
|
||||
const useParams = useParamStore()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const refreshing = ref(false)
|
||||
const scrollerRef = ref(null)
|
||||
const isLoadingMore = ref(false)
|
||||
const activeTab = ref('all')
|
||||
const isInitializing = ref(true)
|
||||
let total = 0
|
||||
|
||||
const timeOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '最近一周', value: 'week' },
|
||||
{ label: '最近一个月', value: 'month' },
|
||||
{ label: '最近三个月', value: 'quarter' }
|
||||
]
|
||||
|
||||
const favoriteOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '已收藏', value: 'favorite' }
|
||||
]
|
||||
|
||||
const selectedTime = ref('all')
|
||||
const selectedFavorite = ref('all')
|
||||
const { tempList } = storeToRefs(useDisplay)
|
||||
|
||||
// const tempList = ref([
|
||||
// { id: 0, type: 'image', status: 'none', name: '局部重绘', time: '2025-12-01 18:26', files: [] },
|
||||
// { id: 1, type: 'image', status: 'success', name: '局部重绘', time: '2025-12-01 18:26', parentName: '2', parentTime: '2025-12-01 12:26', files: ['https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png'] },
|
||||
// { id: 2, type: 'image', status: 'success', name: '局部重绘', time: '2025-12-01 18:26', parentName: '2', parentTime: '2025-12-01 12:26', files: ['https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png'] },
|
||||
// { id: 3, type: 'image', status: 'success', name: '局部重绘', time: '2025-12-01 18:26', parentName: '2', parentTime: '2025-12-01 12:26', files: ['https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png'] },
|
||||
// { id: 4, type: 'image', status: 'success', name: '局部重绘', time: '2025-12-01 18:26', parentName: '2', parentTime: '2025-12-01 12:26', files: ['https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png', 'https://sxwz.xueai.art/static/png/image4-1-1-BZWQeEAk.png'] }
|
||||
|
||||
|
||||
// ])
|
||||
const activeFilter = ref('all')
|
||||
const list = computed(() => {
|
||||
const data = tempList.value || []
|
||||
if (activeFilter.value === 'all') {
|
||||
return data
|
||||
}
|
||||
return data.filter((item) => item.type === activeFilter.value)
|
||||
})
|
||||
|
||||
const page = ref(1)
|
||||
// 筛选列表里的不同生成类型: 图片,视频
|
||||
const toggleDisplay = (newValue, oldValue) => {
|
||||
activeFilter.value = newValue
|
||||
}
|
||||
|
||||
// 转换数据
|
||||
const conversion = (newlist) => {
|
||||
const temp = newlist.data.records.map((item) => {
|
||||
return {
|
||||
id: item.taskId,
|
||||
collection: item.collection,
|
||||
status: 'success',
|
||||
prompt: item.prompt,
|
||||
params: item.params,
|
||||
time: item.createTime,
|
||||
files: [item.fileUrl]
|
||||
}
|
||||
})
|
||||
return temp
|
||||
}
|
||||
|
||||
// 获取历史列表
|
||||
const fetchHistory= async (isScrollTopLoad = false) => {
|
||||
try {
|
||||
if (isScrollTopLoad) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await getGenerateHistoryList({ userId: userStore.userInfo.id, chargeType: 1 })
|
||||
total = result.data ? result.data.length : 0
|
||||
if (total === 0) {
|
||||
useDisplay.Sender_variant = 'updown'
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
|
||||
const wrappedData = {
|
||||
data: {
|
||||
records: result.data
|
||||
}
|
||||
}
|
||||
const convertedList = conversion(wrappedData)
|
||||
|
||||
const adaptedList = convertedList.map((item, index) => {
|
||||
const originalItem = result.data[index]
|
||||
return {
|
||||
...item,
|
||||
text: originalItem?.title || item.prompt || '生成图片',
|
||||
name: originalItem?.title || item.prompt || '生成图片',
|
||||
type: 'image',
|
||||
title: originalItem?.title || '生成图片'
|
||||
}
|
||||
})
|
||||
|
||||
if (!isScrollTopLoad && adaptedList.length > 0) {
|
||||
useDisplay.initHistoryList(adaptedList)
|
||||
|
||||
await nextTick()
|
||||
|
||||
const scrollToBottomDirect = (force = false) => {
|
||||
if (scrollerRef.value) {
|
||||
const el = scrollerRef.value.$el
|
||||
if (el) {
|
||||
const viewport = el.querySelector('.vue-recycle-scroller__viewport')
|
||||
if (viewport) {
|
||||
console.log('直接滚动 - scrollHeight:', viewport.scrollHeight, 'force:', force)
|
||||
if (force) {
|
||||
viewport.scrollTop = viewport.scrollHeight + 1000
|
||||
} else {
|
||||
viewport.scrollTop = viewport.scrollHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof scrollerRef.value.scrollToItem === 'function') {
|
||||
console.log('直接 scrollToItem')
|
||||
scrollerRef.value.scrollToItem(list.value.length - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
setTimeout(() => {
|
||||
scrollToBottomDirect(i >= 15)
|
||||
}, 60 * i)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToBottomDirect(true)
|
||||
setTimeout(() => {
|
||||
refreshing.value = false
|
||||
isInitializing.value = false
|
||||
useDisplay.scrollToBottom()
|
||||
}, 600)
|
||||
}, 1500)
|
||||
} else {
|
||||
useDisplay.initHistoryList(adaptedList)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取历史失败:', error)
|
||||
ElMessage({
|
||||
message: '获取历史失败',
|
||||
type: 'warning'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 获取下一页数据
|
||||
const getList = async () => {
|
||||
if (isLoadingMore.value) return
|
||||
isLoadingMore.value = true
|
||||
try {
|
||||
await fetchHistory(true)
|
||||
} finally {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理滚动事件
|
||||
const handleScroll = (event) => {
|
||||
if (isInitializing.value) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.target
|
||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight
|
||||
|
||||
// 临时禁用滚动到顶部获取历史记录
|
||||
// if (scrollTop <= 50 && !isLoadingMore.value) {
|
||||
// getList()
|
||||
// }
|
||||
|
||||
if (distanceToBottom <= 50) {
|
||||
useDisplay.Sender_variant = 'updown'
|
||||
} else if (distanceToBottom >= 350) {
|
||||
useDisplay.Sender_variant = 'default'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('display 组件已挂载')
|
||||
if (!props.loading) return
|
||||
refreshing.value = true
|
||||
|
||||
nextTick(() => {
|
||||
console.log('设置 scrollerRef 到 store')
|
||||
useDisplay.scrollerRef = scrollerRef.value
|
||||
fetchHistory()
|
||||
})
|
||||
|
||||
page.value++
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.content-area {
|
||||
width: 100%;
|
||||
min-width: 750px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.back{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 10px;
|
||||
position: absolute;
|
||||
left: 30px;
|
||||
top: 22px;
|
||||
z-index: 3;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
// background-color: #FAFBFC;
|
||||
}
|
||||
|
||||
.back:hover{
|
||||
background-color: #e4e7ed;
|
||||
}
|
||||
|
||||
.btn-container{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: auto;
|
||||
padding: 4px;
|
||||
right: 30px;
|
||||
top: 22px;
|
||||
z-index: 3;
|
||||
|
||||
border-radius: 10px;
|
||||
background-color: #FAFBFC;
|
||||
position: absolute;
|
||||
|
||||
.btn{
|
||||
display: flex;
|
||||
padding: 5px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.btn-text{
|
||||
color: #000;
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.line{
|
||||
width: 1px;
|
||||
height: 8px;
|
||||
background-color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.scroller {
|
||||
height: 100%;
|
||||
padding: 30px 0px 350px 0px;
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch; /* iOS Safari */
|
||||
scroll-behavior: smooth; /* 平滑滚动 */
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent; /* 轨道透明 */
|
||||
}
|
||||
|
||||
:deep(.option-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:deep(.option-item:hover) {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
:deep(.option-item.selected) {
|
||||
color: #000F33;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.option-text) {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:deep(.option-check) {
|
||||
margin-left: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div id="display" class="content-area">
|
||||
<RefreshOverlay :visible="refreshing" />
|
||||
<Canvas v-model:visible="canvasVisible" :image="canvasImage" :reference-images="canvasReferenceImages" :source="canvasSource" :type="props.type" @send="handleCanvasSend" />
|
||||
<Canvas v-model:visible="canvasVisible" :image="canvasImage" :reference-images="canvasReferenceImages" :source="canvasSource" @send="handleCanvasSend" />
|
||||
|
||||
<div class="back" @click="handleExit">
|
||||
<img src="@/assets/display/back.svg" alt="">
|
||||
|
|
@ -45,21 +45,16 @@
|
|||
<VirtualScroller
|
||||
ref="scrollerRef"
|
||||
v-if="props.if"
|
||||
:items="list"
|
||||
key-field="id"
|
||||
:data="list"
|
||||
:item-key="'id'"
|
||||
:estimated-height="300"
|
||||
:buffer-size="3"
|
||||
direction="reverse"
|
||||
:bottom-placeholder-height="350"
|
||||
:render-mode="'top'"
|
||||
:buffer="2"
|
||||
class="scroller"
|
||||
@scroll="handleScroll"
|
||||
@visible-change="handleVisibleChange"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<Set :key="item.id" :item="item" @open-canvas="openCanvas" @delete-success="handleDeleteSuccess" />
|
||||
</template>
|
||||
<template #bottom-placeholder>
|
||||
<div style="height: 350px;"></div>
|
||||
<Set :key="item.id" :item="item" @open-canvas="openCanvas" />
|
||||
</template>
|
||||
</VirtualScroller>
|
||||
</div>
|
||||
|
|
@ -88,7 +83,7 @@ const props = defineProps({
|
|||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'Painting'
|
||||
default: 'painting'
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -101,10 +96,13 @@ const scrollerRef = ref(null)
|
|||
const isLoadingMoreLocked = ref(false)
|
||||
const activeTab = ref('all')
|
||||
const isInitializing = ref(true)
|
||||
const { canvasVisible, canvasImage, canvasReferenceImages, canvasSource } = storeToRefs(useDisplay)
|
||||
const canvasVisible = ref(false)
|
||||
const canvasImage = ref('')
|
||||
const canvasReferenceImages = ref([])
|
||||
const canvasSource = ref('')
|
||||
|
||||
const chargeType = computed(() => getChargeType(props.type))
|
||||
// console.log(chargeType.value)
|
||||
console.log(chargeType.value)
|
||||
|
||||
const timeOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
|
|
@ -136,25 +134,35 @@ const toggleDisplay = (newValue, oldValue) => {
|
|||
}
|
||||
|
||||
const conversion = (newlist) => {
|
||||
const temp = newlist.map((item) => {
|
||||
const temp = newlist.list.map((item) => {
|
||||
const files = item.fileUrl ? item.fileUrl.split(',').filter(url => url.trim()) : []
|
||||
const generateData = JSON.parse(item.result || '{}')
|
||||
return {
|
||||
id: item.id,
|
||||
taskId: item.taskId,
|
||||
type: props.type,
|
||||
id: item.taskId,
|
||||
collection: item.collection,
|
||||
status: 'success',
|
||||
generateData: generateData,
|
||||
prompt: item.prompt,
|
||||
params: item.params,
|
||||
time: item.createTime,
|
||||
files: files,
|
||||
collectStatus: item.collectStatus || {}
|
||||
files: files
|
||||
}
|
||||
})
|
||||
console.log(temp)
|
||||
return temp
|
||||
}
|
||||
|
||||
const adaptDataList = (dataList) => {
|
||||
const convertedList = conversion({ list: dataList })
|
||||
return convertedList.map((item, index) => {
|
||||
const originalItem = dataList[index]
|
||||
return {
|
||||
...item,
|
||||
text: originalItem?.title || item.prompt || '生成图片',
|
||||
name: originalItem?.title || item.prompt || '生成图片',
|
||||
type: 'image',
|
||||
title: originalItem?.title || '生成图片'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fetchHistory = async (isLoadMore = false) => {
|
||||
if (isLoading.value || (!isLoadMore && !hasMoreData.value)) return
|
||||
|
||||
|
|
@ -179,12 +187,12 @@ const fetchHistory = async (isLoadMore = false) => {
|
|||
refreshing.value = false
|
||||
isInitializing.value = false
|
||||
useDisplay.Sender_variant = 'updown'
|
||||
router.push({ name: 'generate', query: { type: props.type } })
|
||||
router.push({ name: 'generate' })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const adaptedList = conversion(dataList)
|
||||
const adaptedList = adaptDataList(dataList)
|
||||
|
||||
if (isLoadMore) {
|
||||
useDisplay.appendHistoryList(adaptedList)
|
||||
|
|
@ -228,12 +236,11 @@ const fetchHistory = async (isLoadMore = false) => {
|
|||
}
|
||||
}
|
||||
|
||||
const handleScroll = (scrollInfo) => {
|
||||
const handleScroll = (event) => {
|
||||
if (isInitializing.value) return
|
||||
|
||||
if (!scrollInfo) return
|
||||
const { isAtPageTop, isAtPageBottom, distanceToPageTop, distanceToPageBottom } = scrollInfo
|
||||
|
||||
const { distanceToPageTop, distanceToPageBottom, isAtPageTop, isAtPageBottom } = event
|
||||
|
||||
if (isAtPageTop && !isLoading.value && !isLoadingMoreLocked.value && hasMoreData.value) {
|
||||
isLoadingMoreLocked.value = true
|
||||
fetchHistory(true)
|
||||
|
|
@ -249,15 +256,17 @@ const handleScroll = (scrollInfo) => {
|
|||
}
|
||||
}
|
||||
|
||||
const handleVisibleChange = (startIndex, endIndex) => {
|
||||
}
|
||||
|
||||
const handleScrollStart = () => {}
|
||||
|
||||
const handleScrollEnd = () => {}
|
||||
|
||||
const openCanvas = (data) => {
|
||||
useDisplay.openCanvas(data)
|
||||
if (typeof data === 'string') {
|
||||
canvasImage.value = data
|
||||
canvasReferenceImages.value = []
|
||||
canvasSource.value = ''
|
||||
} else {
|
||||
canvasImage.value = data.mainImage?.url || data.mainImage || ''
|
||||
canvasReferenceImages.value = data.referenceImages || []
|
||||
canvasSource.value = data.source || ''
|
||||
}
|
||||
canvasVisible.value = true
|
||||
}
|
||||
|
||||
const handleCanvasSend = (data) => {
|
||||
|
|
@ -275,10 +284,6 @@ const handleExit = () => {
|
|||
}
|
||||
}
|
||||
|
||||
const handleDeleteSuccess = (id) => {
|
||||
useDisplay.deleteHistoryItem(id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.loading) return
|
||||
refreshing.value = true
|
||||
|
|
|
|||
|
|
@ -1,35 +1,43 @@
|
|||
<script setup>
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import display from './display/index.vue'
|
||||
import { useDisplayStore } from '@/stores'
|
||||
import Canvas from '@/components/canvas/index.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const useDisplay = useDisplayStore()
|
||||
const dialogBoxRef = ref(null)
|
||||
|
||||
const shouldShowDisplay = computed(() => route.path === '/home')
|
||||
const loading = computed(() => route.query.loading ? false : (route.path === '/home'))
|
||||
const Generate = computed(() => route.query.Generate || false)
|
||||
const type = computed(() => route.query.type || 'Painting')
|
||||
const type = computed(() => route.query.type || 'painting')
|
||||
console.log(type.value)
|
||||
|
||||
watch(dialogBoxRef, (newRef) => {
|
||||
if (newRef) {
|
||||
useDisplay.setDialogBoxRef(newRef)
|
||||
}
|
||||
}, { immediate: true })
|
||||
const canvasVisible = ref(false)
|
||||
const canvasImage = ref('')
|
||||
const canvasReferenceImages = ref([])
|
||||
const canvasSource = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
if (dialogBoxRef.value) {
|
||||
useDisplay.setDialogBoxRef(dialogBoxRef.value)
|
||||
}
|
||||
})
|
||||
const handleOpenCanvas = (data) => {
|
||||
canvasImage.value = data.mainImage?.url || data.mainImage || ''
|
||||
canvasReferenceImages.value = data.referenceImages || []
|
||||
canvasSource.value = data.source || 'uploader'
|
||||
canvasVisible.value = true
|
||||
}
|
||||
|
||||
const handleCanvasSend = (data) => {
|
||||
console.log('Canvas send:', data)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<dialogBox ref="dialogBoxRef" :is-generate="shouldShowDisplay" :type="type" :generate="Generate" :loading="loading" />
|
||||
<Canvas
|
||||
v-model:visible="canvasVisible"
|
||||
:image="canvasImage"
|
||||
:reference-images="canvasReferenceImages"
|
||||
:source="canvasSource"
|
||||
@send="handleCanvasSend"
|
||||
/>
|
||||
<dialogBox :is-generate="shouldShowDisplay" :type="type" :generate="Generate" :loading="loading" @open-canvas="handleOpenCanvas" />
|
||||
|
||||
<display :if="shouldShowDisplay" :type="type" :loading="loading" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"nodeInfoList": {
|
||||
"value_1":{ "nodeId":"20", "fieldName":"value", "fieldValue":"512" },
|
||||
"value_2":{ "nodeId":"21", "fieldName":"value", "fieldValue":"512" },
|
||||
"model_type":{ "nodeId":"18", "fieldName":"model_type", "fieldValue":"pro" },
|
||||
"audio":{ "nodeId":"16", "fieldName":"audio", "fieldValue":"dce37fba29e596ddcd927c4660b4fb47bd3ecdbef4ad242fed72bd860e032b1f.flac" },
|
||||
"image":{ "nodeId":"17", "fieldName":"image", "fieldValue":"d3ee810ce387739e7a99cb3ba87a104e34b0a955149b8525a600867edbab1138.png" }
|
||||
},
|
||||
"workflowId": "2036266399357739009"
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"nodeInfoList": {
|
||||
"text":{ "nodeId":"40", "fieldName":"text", "fieldValue":"深夜,一个美丽的中年中国女人在一边弹吉他一边歌唱,环绕镜头,半身特写,逆光,月光,海风吹拂。场景是海边。" },
|
||||
"audio":{ "nodeId":"39", "fieldName":"audio", "fieldValue":"62e5c0b15854bcac34e9aa0bf9f449767bda9f66047a60abeddbcd47c712ee8d.mp3" },
|
||||
"start_index":{ "nodeId":"58", "fieldName":"start_index", "fieldValue":0 },
|
||||
"duration":{ "nodeId":"58", "fieldName":"duration", "fieldValue":25 },
|
||||
"value_1":{ "nodeId":"55", "fieldName":"value", "fieldValue":1280 },
|
||||
"value_2":{ "nodeId":"56", "fieldName":"value", "fieldValue":720 }
|
||||
},
|
||||
"workflowId": "2036343285949665282"
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"nodeInfoList": {
|
||||
"image":{ "nodeId":"2", "fieldName":"image", "fieldValue":"67bbf03a4ce453557b8c9acf85bd83d3519d3374ef35c54da1084d03f9ac111f.png" },
|
||||
"prompt":{ "nodeId":"3", "fieldName":"prompt", "fieldValue":"一个小女孩在树下吃苹果" },
|
||||
"resolution":{ "nodeId":"3", "fieldName":"resolution", "fieldValue":"540p" },
|
||||
"duration":{ "nodeId":"3", "fieldName":"duration", "fieldValue":5 },
|
||||
"audio":{ "nodeId":"3", "fieldName":"audio", "fieldValue":true }
|
||||
},
|
||||
"workflowId": "2036354451904139265"
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"nodeInfoList": {
|
||||
"style":{ "nodeId":"2", "fieldName":"style", "fieldValue":"general" },
|
||||
"prompt":{ "nodeId":"2", "fieldName":"prompt", "fieldValue":"一个小女孩在树下吃苹果" },
|
||||
"resolution":{ "nodeId":"2", "fieldName":"resolution", "fieldValue":"540p" },
|
||||
"aspect_ratio":{ "nodeId":"2", "fieldName":"aspect_ratio", "fieldValue":"4:3" },
|
||||
"duration":{ "nodeId":"2", "fieldName":"duration", "fieldValue":5 },
|
||||
"audio":{ "nodeId":"2", "fieldName":"audio", "fieldValue":true }
|
||||
},
|
||||
"workflowId": "2036349280088231938"
|
||||
}
|
||||