This commit is contained in:
13121765685 2025-12-28 19:30:09 +08:00
parent 66d4805935
commit 5adfc94746
4 changed files with 623 additions and 20 deletions

View File

@ -0,0 +1,546 @@
<template>
<el-dialog
v-model="dialogVisible"
title="局部修改"
width="90%"
:fullscreen="isFullscreen"
append-to-body
destroy-on-close
@close="handleClose"
>
<div class="canvas-editor-container">
<div class="canvas-toolbar">
<div class="toolbar-section">
<span class="toolbar-label">画笔颜色:</span>
<div class="color-picker">
<div
v-for="color in colors"
:key="color"
:class="['color-option', { active: currentColor === color }]"
:style="{ backgroundColor: color }"
@click="selectColor(color)"
></div>
<el-color-picker
v-model="customColor"
size="small"
@change="selectCustomColor"
class="custom-color-picker"
></el-color-picker>
</div>
</div>
<div class="toolbar-section">
<span class="toolbar-label">画笔大小:</span>
<el-slider
v-model="brushSize"
:min="1"
:max="50"
style="width: 120px"
/>
</div>
<div class="toolbar-section">
<el-button
:type="isEraser ? 'primary' : 'default'"
size="small"
@click="toggleEraser"
>
<el-icon><Delete /></el-icon>
橡皮擦
</el-button>
<el-button
type="warning"
size="small"
@click="clearCanvas"
>
<el-icon><RefreshLeft /></el-icon>
清空
</el-button>
<el-button
type="success"
size="small"
@click="handleSave"
>
<el-icon><Check /></el-icon>
确定
</el-button>
</div>
</div>
<div class="canvas-wrapper" ref="canvasWrapper">
<div v-if="imageLoading" class="loading-overlay">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载图片中...</span>
</div>
<div v-if="imageLoadError" class="error-overlay">
<el-icon><Warning /></el-icon>
<p>图片加载失败</p>
<el-button type="primary" size="small" @click="retryLoadImage">重试</el-button>
<el-button size="small" @click="dialogVisible = false">关闭</el-button>
</div>
<canvas
v-show="!imageLoading && !imageLoadError"
ref="canvas"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="stopDrawing"
@mouseleave="stopDrawing"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
></canvas>
</div>
<div class="edit-textarea-container">
<el-input
v-model="editContent"
type="textarea"
:rows="3"
placeholder="请输入要修改的内容"
class="edit-textarea"
></el-input>
</div>
</div>
</el-dialog>
</template>
<script setup>
import { ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { Delete, RefreshLeft, Check, Loading, Warning } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
imageUrl: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:visible', 'add-prompt-card', 'close'])
const dialogVisible = ref(false)
const isFullscreen = ref(false)
const canvas = ref(null)
const canvasWrapper = ref(null)
const ctx = ref(null)
const isDrawing = ref(false)
const isEraser = ref(false)
const currentColor = ref('#6B46C1')
const customColor = ref('#6B46C1')
const brushSize = ref(5)
const imageLoading = ref(false)
const imageLoadError = ref(false)
const loadRetryCount = ref(0)
const maxRetryCount = 3
const editContent = ref('')
const colors = [
'#6B46C1',
'#EF4444',
'#F59E0B',
'#10B981',
'#3B82F6',
'#8B5CF6',
'#EC4899',
'#1F2937'
]
let lastX = 0
let lastY = 0
let backgroundImage = null
watch(() => props.visible, (val) => {
dialogVisible.value = val
if (val) {
nextTick(() => {
initCanvas()
})
}
})
watch(dialogVisible, (val) => {
emit('update:visible', val)
})
const initCanvas = async () => {
if (!canvas.value || !canvasWrapper.value) return
imageLoading.value = true
imageLoadError.value = false
try {
const img = new Image()
img.crossOrigin = 'anonymous'
await new Promise((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject(new Error('Failed to load image'))
img.src = props.imageUrl
})
const canvasEl = canvas.value
canvasEl.width = img.width
canvasEl.height = img.height
ctx.value = canvasEl.getContext('2d')
backgroundImage = img
drawImage()
imageLoading.value = false
loadRetryCount.value = 0
} catch (error) {
console.error('Failed to load image:', props.imageUrl, error)
imageLoading.value = false
imageLoadError.value = true
ElMessage.error('图片加载失败,请重试')
}
}
const retryLoadImage = () => {
if (loadRetryCount.value < maxRetryCount) {
loadRetryCount.value++
ElMessage.info(`正在重试加载图片 (${loadRetryCount.value}/${maxRetryCount})`)
initCanvas()
} else {
ElMessage.warning('已达到最大重试次数,请稍后再试')
}
}
const drawImage = () => {
if (!ctx.value || !backgroundImage || !canvas.value) return
const canvasEl = canvas.value
const img = backgroundImage
ctx.value.fillStyle = '#FFFFFF'
ctx.value.fillRect(0, 0, canvasEl.width, canvasEl.height)
ctx.value.drawImage(img, 0, 0, img.width, img.height)
}
const selectColor = (color) => {
currentColor.value = color
isEraser.value = false
}
const selectCustomColor = (color) => {
if (color) {
currentColor.value = color
isEraser.value = false
}
}
const toggleEraser = () => {
isEraser.value = !isEraser.value
}
const clearCanvas = () => {
if (!ctx.value || !canvas.value) return
ctx.value.fillStyle = '#FFFFFF'
ctx.value.fillRect(0, 0, canvas.value.width, canvas.value.height)
if (backgroundImage) {
ctx.value.drawImage(backgroundImage, 0, 0, backgroundImage.width, backgroundImage.height)
}
}
const getCanvasCoordinates = (clientX, clientY) => {
const rect = canvas.value.getBoundingClientRect()
const scaleX = canvas.value.width / rect.width
const scaleY = canvas.value.height / rect.height
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY
}
}
const startDrawing = (e) => {
isDrawing.value = true
const coords = getCanvasCoordinates(e.clientX, e.clientY)
lastX = coords.x
lastY = coords.y
}
const draw = (e) => {
if (!isDrawing.value || !ctx.value) return
const coords = getCanvasCoordinates(e.clientX, e.clientY)
ctx.value.beginPath()
ctx.value.moveTo(lastX, lastY)
ctx.value.lineTo(coords.x, coords.y)
ctx.value.strokeStyle = isEraser.value ? '#FFFFFF' : currentColor.value
ctx.value.lineWidth = brushSize.value
ctx.value.lineCap = 'round'
ctx.value.lineJoin = 'round'
ctx.value.stroke()
lastX = coords.x
lastY = coords.y
}
const stopDrawing = () => {
isDrawing.value = false
}
const handleTouchStart = (e) => {
e.preventDefault()
const touch = e.touches[0]
const coords = getCanvasCoordinates(touch.clientX, touch.clientY)
lastX = coords.x
lastY = coords.y
isDrawing.value = true
}
const handleTouchMove = (e) => {
e.preventDefault()
if (!isDrawing.value || !ctx.value) return
const touch = e.touches[0]
const coords = getCanvasCoordinates(touch.clientX, touch.clientY)
ctx.value.beginPath()
ctx.value.moveTo(lastX, lastY)
ctx.value.lineTo(coords.x, coords.y)
ctx.value.strokeStyle = isEraser.value ? '#FFFFFF' : currentColor.value
ctx.value.lineWidth = brushSize.value
ctx.value.lineCap = 'round'
ctx.value.lineJoin = 'round'
ctx.value.stroke()
lastX = coords.x
lastY = coords.y
}
const handleTouchEnd = (e) => {
e.preventDefault()
isDrawing.value = false
}
const handleSave = () => {
const dataUrl = canvas.value.toDataURL('image/png')
emit('add-prompt-card', dataUrl,editContent.value)
dialogVisible.value = false
}
const handleClose = () => {
emit('close')
}
const handleResize = () => {
if (dialogVisible.value) {
nextTick(() => {
initCanvas()
})
}
}
onMounted(() => {
window.addEventListener('resize', handleResize)
if (window.innerWidth < 768) {
isFullscreen.value = true
}
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
})
</script>
<style scoped>
.canvas-editor-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.canvas-toolbar {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
padding: 12px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.toolbar-section {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-label {
font-size: 14px;
font-weight: 500;
color: #1F2937;
}
.color-picker {
display: flex;
gap: 8px;
}
.color-option {
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s ease;
}
.color-option:hover {
transform: scale(1.1);
}
.color-option.active {
border-color: #1F2937;
box-shadow: 0 0 0 2px rgba(107, 70, 193, 0.3);
}
.custom-color-picker {
width: 24px;
height: 24px;
}
.custom-color-picker :deep(.el-color-picker__trigger) {
width: 24px;
height: 24px;
padding: 0;
border: none;
}
.custom-color-picker :deep(.el-color-picker__color) {
border-radius: 50%;
border: 2px solid transparent;
}
.canvas-wrapper {
width: 100%;
height: 500px;
background: #f3f4f6;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e2e8f0;
position: relative;
}
.loading-overlay,
.error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: #f3f4f6;
z-index: 10;
}
.loading-overlay .el-icon {
font-size: 32px;
color: #6B46C1;
}
.loading-overlay span {
font-size: 14px;
color: #6b7280;
}
.error-overlay .el-icon {
font-size: 48px;
color: #EF4444;
}
.error-overlay p {
font-size: 16px;
color: #1F2937;
margin: 0;
}
canvas {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
cursor: crosshair;
display: block;
}
.edit-textarea-container {
width: 100%;
margin-top: 12px;
}
.edit-textarea {
width: 100%;
}
@media (max-width: 768px) {
.canvas-toolbar {
flex-direction: column;
align-items: stretch;
}
.toolbar-section {
flex-wrap: wrap;
justify-content: center;
}
.canvas-wrapper {
height: 400px;
}
.color-picker {
flex-wrap: wrap;
justify-content: center;
}
}
@media (min-width: 768px) and (max-width: 1024px) {
.canvas-wrapper {
height: 450px;
}
}
[data-theme="dark"] .canvas-toolbar {
background: #1f1f1f;
border-color: #303030;
}
[data-theme="dark"] .toolbar-label {
color: #e5e7eb;
}
[data-theme="dark"] .canvas-wrapper {
background: #141414;
border-color: #303030;
}
[data-theme="dark"] .loading-overlay,
[data-theme="dark"] .error-overlay {
background: #141414;
}
[data-theme="dark"] .loading-overlay span,
[data-theme="dark"] .error-overlay p {
color: #e5e7eb;
}
[data-theme="dark"] .color-option.active {
border-color: #e5e7eb;
}
</style>

View File

@ -15,7 +15,7 @@
ref="textInputRef" ref="textInputRef"
v-model="textInputValue" v-model="textInputValue"
class="text-input-area" class="text-input-area"
placeholder="请输入文本内容..." :placeholder="t('modelModal.textInputPlaceholder')"
rows="4" rows="4"
@keydown.enter.ctrl="handleTextInputConfirm" @keydown.enter.ctrl="handleTextInputConfirm"
@keydown.esc="handleTextInputCancel" @keydown.esc="handleTextInputCancel"
@ -74,6 +74,10 @@
<button v-if="!(props.cardData.imgyt)" class="control-button share-btn" title="scene graph" @click="handleTextcjt" @touchend.prevent="handleTextcjt"> <button v-if="!(props.cardData.imgyt)" class="control-button share-btn" title="scene graph" @click="handleTextcjt" @touchend.prevent="handleTextcjt">
<el-icon class="btn-icon"><Grid /></el-icon> <el-icon class="btn-icon"><Grid /></el-icon>
</button> </button>
<button v-if="!(props.cardData.imgyt)" class="control-button share-btn" title="Picture board editor" @click="handlePartialEdit" @touchend.prevent="handlePartialEdit">
<el-icon class="btn-icon"><EditPen /></el-icon>
</button>
</div> </div>
</div> </div>
</div> </div>
@ -91,7 +95,7 @@ import { GiminiServer } from '@deotaland/utils';
// import anTypeImg from '@/assets/sketches/dwww2.png'; // import anTypeImg from '@/assets/sketches/dwww2.png';
// import cz2 from '@/assets/material/cz2.png'; // import cz2 from '@/assets/material/cz2.png';
// Element Plus // Element Plus
import { Cpu, ChatDotRound, CloseBold,Grid,View } from '@element-plus/icons-vue' import { Cpu, ChatDotRound, CloseBold,Grid,View,EditPen } from '@element-plus/icons-vue'
import { ElIcon,ElMessage,ElSkeleton,ElImage } from 'element-plus' import { ElIcon,ElMessage,ElSkeleton,ElImage } from 'element-plus'
const { t } = useI18n(); const { t } = useI18n();
const formData = ref({ const formData = ref({
@ -221,8 +225,10 @@ const props = defineProps({
}); });
// //
const emit = defineEmits(['generate-model-requested', 'create-new-card','create-prompt-card','delete','preview-image','customize-to-home']); const emit = defineEmits(['generate-model-requested', 'create-new-card','create-prompt-card','delete','preview-image','customize-to-home','handlePartialEdit']);
const handlePartialEdit = ()=>{
emit('handlePartialEdit', formData.value.internalImageUrl);
}
// //
const handleGenerateImage = async () => { const handleGenerateImage = async () => {
const iscjt = props?.cardData?.diyPromptText&&props?.cardData?.diyPromptText?.indexOf('[CJT_DEOTA]')!=-1; const iscjt = props?.cardData?.diyPromptText&&props?.cardData?.diyPromptText?.indexOf('[CJT_DEOTA]')!=-1;

View File

@ -124,7 +124,8 @@ export default {
} }
}, },
modelModal: { modelModal: {
customizeToHome: '定制到家' customizeToHome: '定制到家',
textInputPlaceholder: '请输入调整内容,例如:更改角色表情'
}, },
modelCard: { modelCard: {
generateModelButton: '生成模型', generateModelButton: '生成模型',
@ -1446,7 +1447,8 @@ export default {
} }
}, },
modelModal: { modelModal: {
customizeToHome: 'Customize to Home' customizeToHome: 'Customize to Home',
textInputPlaceholder: 'Please enter adjustment content, e.g. change character expression'
}, },
modelCard: { modelCard: {
generateModelButton: 'Generate Model', generateModelButton: 'Generate Model',

View File

@ -55,6 +55,8 @@
class="delete-button" class="delete-button"
@click.stop="handleDeleteCard(index)" @click.stop="handleDeleteCard(index)"
@mousedown.stop @mousedown.stop
@touchstart.stop
@touchend.stop
title="删除卡片" title="删除卡片"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@ -65,6 +67,7 @@
<!-- 根据卡片类型显示不同组件 --> <!-- 根据卡片类型显示不同组件 -->
<IPCard <IPCard
:combinedPromptJson="combinedPromptJson" :combinedPromptJson="combinedPromptJson"
@handlePartialEdit="(imageUrl) => handlePartialEdit(imageUrl, index)"
@customize-to-home="handleCustomizeToHome(index)" @customize-to-home="handleCustomizeToHome(index)"
@preview-image="handlePreviewImage" @preview-image="handlePreviewImage"
@generate-smooth-white-model="(imageUrl)=>handleGenerateSmoothWhiteModel(index,imageUrl)" @generate-smooth-white-model="(imageUrl)=>handleGenerateSmoothWhiteModel(index,imageUrl)"
@ -120,7 +123,11 @@
:initialIndex="currentImageIndex" :initialIndex="currentImageIndex"
@close="showImagePreview = false" @close="showImagePreview = false"
/> />
<CanvasEditor
v-model:visible="canvasEditorVisible"
:image-url="canvasEditorImageUrl"
@add-prompt-card="handleCanvasSave"
/>
<!-- 测试侧边栏动画的按钮 --> <!-- 测试侧边栏动画的按钮 -->
<!-- <button <!-- <button
class="test-animation-btn" class="test-animation-btn"
@ -154,12 +161,13 @@ import HeaderComponent from '../../components/HeaderComponent/HeaderComponent.vu
import GuideModal from '../../components/GuideModal/index.vue'; import GuideModal from '../../components/GuideModal/index.vue';
import ImagePreviewModal from '../../components/ImagePreviewModal/index.vue'; import ImagePreviewModal from '../../components/ImagePreviewModal/index.vue';
import {useRoute,useRouter} from 'vue-router'; import {useRoute,useRouter} from 'vue-router';
import {MeshyServer,GiminiServer} from '@deotaland/utils'; import {MeshyServer,GiminiServer,FileServer} from '@deotaland/utils';
import OrderProcessModal from '../../components/OrderProcessModal/index.vue'; import OrderProcessModal from '../../components/OrderProcessModal/index.vue';
import PurchaseModal from '../../components/PurchaseModal/index.vue'; import PurchaseModal from '../../components/PurchaseModal/index.vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import {Project} from './index'; import {Project} from './index';
import {ModernHome} from '../ModernHome/index.js' import {ModernHome} from '../ModernHome/index.js'
const fileServer = new FileServer();
const modernHome = new ModernHome(); const modernHome = new ModernHome();
const Limits = ref({ const Limits = ref({
generateCount: 0, generateCount: 0,
@ -173,6 +181,8 @@ const selectedModel = ref(null);
const showImportModal = ref(false); const showImportModal = ref(false);
const importUrl = ref('https://xiaozhi.me/console/agents'); const importUrl = ref('https://xiaozhi.me/console/agents');
const showGuideModal = ref(false); const showGuideModal = ref(false);
const canvasEditorVisible = ref(false);//
const canvasEditorImageUrl = ref('');//url
// //
const showImagePreview = ref(false); const showImagePreview = ref(false);
const previewImages = ref([]); const previewImages = ref([]);
@ -191,6 +201,24 @@ const getGenerateCount = async ()=>{
// Limits.value.generateCount = data[0].model_count; // Limits.value.generateCount = data[0].model_count;
// Limits.value.modelCount = data[1].model_count; // Limits.value.modelCount = data[1].model_count;
} }
const handlePartialEdit = (imageUrl, index) => {
canvasEditorImageUrl.value = imageUrl;
canvasEditorVisible.value = true;
}
//
const handleCanvasSave = (editedImageUrl,editContent) => {
fileServer.uploadFile(editedImageUrl).then((url) => {
const newCard = createSmartCard({
diyPromptImg:url,
diyPromptText:editContent,
status:'loading',
type:'image',
// inspirationImage:cardData.inspirationImage||'',
});
// console.log(newCard,'newCardnewCard');
cards.value.push(newCard);
})
}
// //
const cards = ref([ const cards = ref([
@ -429,12 +457,9 @@ const createSmartCard = (cardConfig,index=null) => {
// //
// const highestCard; // const highestCard;
// zIndex // zIndex
let highestCard; let highestCard = cards.value.reduce((prev, current) =>
if(index){ prev.zIndex > current.zIndex ? prev : current, {});
highestCard = cards.value[index]
}else{
highestCard = cards.value[cards.value.length-1]
}
// //
const position = highestCard ? const position = highestCard ?
positionCalculator.calculateRightSidePosition(highestCard, scale.value) : positionCalculator.calculateRightSidePosition(highestCard, scale.value) :
@ -513,10 +538,8 @@ const handleCreatePromptCard = (index,params) => {
type:'image', type:'image',
// inspirationImage:cardData.inspirationImage||'', // inspirationImage:cardData.inspirationImage||'',
},index); },index);
console.log(newCard,'newCardnewCard');
// //
cards.value.push(newCard); cards.value.push(newCard);
console.log(cards.value);
} }
// //
const handleGenerateRequested = async (params) => { const handleGenerateRequested = async (params) => {
@ -1358,10 +1381,12 @@ html.dark .draggable-element:hover {
/* 删除按钮样式 */ /* 删除按钮样式 */
.delete-button { .delete-button {
position: absolute; position: absolute;
top: -10px; top: -12px;
right: -10px; right: -12px;
width: 32px; width: 36px;
height: 32px; height: 36px;
min-width: 36px;
min-height: 36px;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, #6B46C1 0%, #553C9A 50%, #44337A 100%); background: linear-gradient(135deg, #6B46C1 0%, #553C9A 50%, #44337A 100%);
@ -1382,6 +1407,8 @@ html.dark .draggable-element:hover {
transform: scale(0.8); transform: scale(0.8);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
overflow: hidden; overflow: hidden;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
} }
.delete-button::before { .delete-button::before {
@ -1534,5 +1561,27 @@ p {
width: 100%; width: 100%;
border-radius: 0; border-radius: 0;
} }
/* 移动端删除按钮始终可见且更大 */
.delete-button {
opacity: 1 !important;
visibility: visible !important;
transform: scale(1) !important;
width: 40px !important;
height: 40px !important;
min-width: 40px !important;
min-height: 40px !important;
top: -14px !important;
right: -14px !important;
box-shadow:
0 4px 12px rgba(107, 70, 193, 0.6),
0 2px 6px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.3) !important;
}
.delete-button svg {
width: 18px !important;
height: 18px !important;
}
} }
</style> </style>