index.vue 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090
  1. <script>
  2. import fabric from '../PictureEditor/js/fabric-adapter'
  3. import tpl from './tpl/index'
  4. import viewMixins from '@/views/components/PictureEditor/mixin/view/index'
  5. import actionsMixins from '@/views/components/PictureEditor/mixin/actions/index'
  6. import layerMixins from '@/views/components/PictureEditor/mixin/layer/index'
  7. import colorMixins from '@/views/components/PictureEditor/mixin/color/index'
  8. import editMixins from '@/views/components/PictureEditor/mixin/edit/index'
  9. import { uploadBaseImg, updateTemplateColumn } from '@/apis/other'
  10. import {markRaw} from "vue";
  11. import { ElMessage } from 'element-plus'
  12. import useClientStore from '@/stores/modules/client'
  13. import icpList from '@/utils/ipc'
  14. import goods from './goods.json'
  15. import goods1 from './goods1.json'
  16. import goods2 from './goods2.json'
  17. import goods4 from './goods4.json'
  18. import goods5 from './goods5.json'
  19. import canvas from './canvas.json'
  20. import canvas1 from './canvas1.json'
  21. import { buildRenderPlans, normalizeGoods } from './generateImagesPlan'
  22. import { renderImagesByPlans, generateAllStyleImageBundles } from './generateImagesRender'
  23. const FIXED_CANVAS_WIDTH = 395
  24. export default {
  25. name: 'marketingImageEditor',
  26. mixins: [ viewMixins,actionsMixins,layerMixins,colorMixins,editMixins],
  27. props: {
  28. data: {
  29. type: Array,
  30. default:()=>{
  31. return []
  32. }
  33. },
  34. index:{
  35. type: Number,
  36. default: 0
  37. },
  38. goods_text:{
  39. type: Array,
  40. default: ()=>{
  41. return []
  42. }
  43. },
  44. goods_images:{
  45. type: Array,
  46. default: ()=>{
  47. return []
  48. }
  49. },
  50. },
  51. beforeDestroy() {
  52. this.saveCanvasSnapshot()
  53. this.destroyCanvasInstance()
  54. this.destroyKeyboardNavigation()
  55. },
  56. data() {
  57. return {
  58. disabled:false,
  59. fcanvas: null,
  60. fcanvasId: '',
  61. previewDialogVisible: false,
  62. previewImageUrl: '',
  63. scale: 1,
  64. sceneTplImg:"",//生成的时候记录下来,用户重做
  65. hasLoadedTpl:false,
  66. canvasForm:{
  67. type:'add',
  68. width: FIXED_CANVAS_WIDTH,
  69. height: '1024',
  70. color:"#fff",
  71. bg_color:'#fff',
  72. visible:false,
  73. canvas_type: 'normal', // normal:普通画布 model:模特图 scene:场景图
  74. multi_goods_mode: '', // 多货号模式:''(默认单货号), 'single'(一个货号多角度), 'multiple'(多个货号同角度)
  75. max_goods_count: null, // 多货号模式下,最多可追加多少个货号
  76. },
  77. // 新增商品文字对话框
  78. addGoodsTextForm: {
  79. visible: false,
  80. key: '',
  81. value: ''
  82. }
  83. }
  84. },
  85. template: tpl(),
  86. computed: {
  87. isEmpty(){
  88. return this.data.length === 0
  89. },
  90. this_canvas(){
  91. return this.data[this.index]
  92. }
  93. },
  94. watch: {
  95. data: {
  96. handler(newData, oldData) {
  97. // 当data从空数组变为有数据时(编辑模式加载数据),需要重新初始化
  98. // 避免在组件初始挂载时重复初始化
  99. if (oldData && oldData.length === 0 && newData && newData.length > 0 && this.fcanvas) {
  100. this.saveCanvasSnapshot()
  101. this.destroyCanvasInstance()
  102. this.$nextTick(() => {
  103. this.init()
  104. })
  105. }
  106. },
  107. deep: true
  108. },
  109. index(newValue, oldValue) {
  110. if (this.isEmpty) return
  111. const redirectIndex = this.findEditableCanvas(newValue)
  112. const target = this.data[newValue]
  113. // 模特图/场景图不进入编辑,自动跳到下一个普通画布
  114. if(
  115. target &&
  116. (target.canvas_type === 'model' || target.canvas_type === 'scene')
  117. ){
  118. // 如果没有可切换的普通画布,保持当前索引,避免递归更新
  119. if(redirectIndex !== -1 && redirectIndex !== newValue){
  120. this.$emit('update:index', redirectIndex)
  121. }
  122. return
  123. }
  124. this.saveCanvasSnapshot(oldValue)
  125. this.destroyCanvasInstance()
  126. this.$nextTick(() => {
  127. this.init()
  128. this.scrollToCanvas(newValue)
  129. })
  130. }
  131. },
  132. mounted() {
  133. // 延迟初始化,等待父组件设置数据
  134. this.$nextTick(() => {
  135. if(this.$route?.name === 'editTpl'){
  136. this.loadTemplate()
  137. }
  138. this.init()
  139. })
  140. },
  141. activated(){
  142. },
  143. deactivated() {
  144. },
  145. methods: {
  146. loadTemplate(){
  147. if(this.hasLoadedTpl) return
  148. const tplData = JSON.parse(JSON.stringify(this.data)).map(item => ({
  149. canvas_type: item.canvas_type || 'normal',
  150. canvas_json: (item.canvas_type === 'model' || item.canvas_type === 'scene') ? (item.canvas_type) : (item.canvas_json || ''),
  151. ...item,
  152. }))
  153. this.data.splice(0, this.data.length, ...tplData)
  154. this.$emit('update:index', 0)
  155. this.hasLoadedTpl = true
  156. },
  157. // 加载模板后的初始化处理
  158. setTpl(){
  159. if(!this.fcanvas) return
  160. try {
  161. // 确保所有对象都是可选的,并且控件可见
  162. this.fcanvas.getObjects().forEach(obj => {
  163. // 确保对象可选中和可交互
  164. obj.set({
  165. selectable: true,
  166. evented: true
  167. })
  168. // 根据对象类型设置控件可见性(与原有逻辑保持一致)
  169. if(obj.type === 'image' || obj.type === 'textbox' || obj.type === 'group'){
  170. obj.setControlsVisibility({
  171. 'mtr': false
  172. })
  173. }
  174. // 特殊对象处理(参考原有逻辑)
  175. if(obj.name === 'scene'){
  176. obj.set({
  177. selectable: false,
  178. delable: false
  179. })
  180. }
  181. if(obj.name === 'subject'){
  182. obj.set({
  183. delable: false
  184. })
  185. }
  186. })
  187. // 确保画布尺寸正确
  188. if(this.this_canvas){
  189. this.fcanvas.setWidth(this.this_canvas.width)
  190. this.fcanvas.setHeight(this.this_canvas.height)
  191. }
  192. // 更新图层列表
  193. this.getLayers()
  194. // 恢复选中状态
  195. this.undoAfterSelectLayers()
  196. this.fcanvas.renderAll()
  197. } catch (e) {
  198. console.warn('[marketingEdit] setTpl failed', e)
  199. }
  200. },
  201. // 初始化
  202. async init() {
  203. //不存在数据(新建场景)
  204. if(this.isEmpty ){
  205. this.addCanvas()
  206. return;
  207. }
  208. // 模特图/场景图不初始化 fabric,直接跳过
  209. const canvasType = this.this_canvas?.canvas_type || 'normal'
  210. if (canvasType === 'model' || canvasType === 'scene') {
  211. const redirectIndex = this.findEditableCanvas(this.index)
  212. if(redirectIndex !== -1 && redirectIndex !== this.index){
  213. this.$emit('update:index', redirectIndex)
  214. }
  215. // 模特/场景图不需要 fabric,保持占位
  216. return
  217. }
  218. this.$emit('canvasStyle:update',{
  219. width: this.this_canvas.width,
  220. height: this.this_canvas.height
  221. })
  222. await this.viewInit()
  223. await this.$nextTick()
  224. const canvasId = `marketing-canvas-${this.index}`
  225. const canvasEl = document.getElementById(canvasId)
  226. if (!canvasEl) return
  227. this.destroyCanvasInstance()
  228. this.fcanvas = markRaw(new fabric.Canvas(canvasId, {
  229. backgroundColor: this.this_canvas.bg_color,
  230. containerClass:"fcanvas",
  231. // 元素对象被选中时保持在当前z轴,不会跳到最顶层
  232. preserveObjectStacking:true,
  233. width: this.this_canvas.width,
  234. height: this.this_canvas.height
  235. }))
  236. this.fcanvasId = canvasId
  237. const hydrateCanvas = () => {
  238. this.updateCanvasState()
  239. // this.minimapInit()
  240. this.actionInit()
  241. this.layerInit();
  242. this.$nextTick(() => {
  243. this.$emit('init')
  244. })
  245. }
  246. if(this.this_canvas.canvas_json){
  247. const jsonData = typeof this.this_canvas.canvas_json === 'string'
  248. ? this.this_canvas.canvas_json
  249. : JSON.stringify(this.this_canvas.canvas_json)
  250. this.fcanvas.loadFromJSON(jsonData, () => {
  251. // 如果是加载模板,调用专门的初始化函数
  252. if(this.hasLoadedTpl){
  253. this.setTpl()
  254. }
  255. this.fcanvas.renderAll()
  256. hydrateCanvas()
  257. })
  258. }else{
  259. hydrateCanvas()
  260. }
  261. },
  262. addCanvas(){
  263. this.canvasForm.type = 'add'
  264. this.canvasForm.name = '画布_'+new Date().getTime().toString().substr(8)+Math.round(100)
  265. this.canvasForm.width = FIXED_CANVAS_WIDTH
  266. this.canvasForm.height = 1024
  267. this.canvasForm.bg_color = '#fff'
  268. this.canvasForm.canvas_type = 'normal'
  269. this.canvasForm.multi_goods_mode = ''
  270. this.canvasForm.max_goods_count = null
  271. this.canvasForm.visible = true;
  272. },
  273. handleAdjustCanvas() {
  274. if(!this.this_canvas) return;
  275. this.canvasForm.type = 'edit'
  276. this.canvasForm.name = this.this_canvas.name
  277. this.canvasForm.width = FIXED_CANVAS_WIDTH
  278. this.canvasForm.height = this.this_canvas.height
  279. this.canvasForm.bg_color = this.this_canvas.bg_color
  280. this.canvasForm.canvas_type = this.this_canvas.canvas_type || 'normal'
  281. this.canvasForm.multi_goods_mode = this.this_canvas.multi_goods_mode || ''
  282. this.canvasForm.max_goods_count = this.this_canvas.max_goods_count || null
  283. this.canvasForm.visible = true;
  284. },
  285. handleCanvasTypeChange(type){
  286. if(this.canvasForm.type !== 'add') return
  287. if(type === 'model'){
  288. this.canvasForm.name = '模特图'
  289. }else if(type === 'scene'){
  290. this.canvasForm.name = '场景图'
  291. }else if(!this.canvasForm.name || this.canvasForm.name === '模特图' || this.canvasForm.name === '场景图'){
  292. this.canvasForm.name = '画布_'+new Date().getTime().toString().substr(8)+Math.round(100)
  293. }
  294. },
  295. submitCanvasInfo() {
  296. // 假设 this.canvasForm 包含最新的 width, height 和 color
  297. if(this.canvasForm.type === 'add'){
  298. this.saveCanvasSnapshot()
  299. this.data.push({
  300. tpl_url:"",
  301. image_path:"",
  302. name:this.canvasForm.name,
  303. width:FIXED_CANVAS_WIDTH,
  304. height:this.canvasForm.height,
  305. bg_color:this.canvasForm.bg_color,
  306. canvas_type: this.canvasForm.canvas_type || 'normal',
  307. canvas_json: (this.canvasForm.canvas_type === 'model' || this.canvasForm.canvas_type === 'scene') ? this.canvasForm.canvas_type : '',
  308. preview:'',
  309. multi_goods_mode: this.canvasForm.multi_goods_mode || '',
  310. max_goods_count: this.canvasForm.multi_goods_mode ? (this.canvasForm.max_goods_count || null) : null,
  311. })
  312. const nextIndex = this.data.length - 1
  313. this.$emit('update:index',nextIndex)
  314. if(nextIndex === this.index){
  315. this.$nextTick(() => {
  316. this.init()
  317. // 新增画布后也需要滚动
  318. this.scrollToCanvas(nextIndex)
  319. })
  320. }
  321. /* this.index = this.data.length - 1*/
  322. this.canvasForm.visible = false;
  323. }else{
  324. if(!this.this_canvas){
  325. this.canvasForm.visible = false;
  326. return;
  327. }
  328. const newWidth = FIXED_CANVAS_WIDTH;
  329. const newHeight = Number(this.canvasForm.height) || this.this_canvas.height;
  330. const oldHeight = Number(this.this_canvas.height) || 0;
  331. const newColor = this.canvasForm.bg_color;
  332. // 更新 fcanvas 的宽度和高度
  333. if(this.fcanvas){
  334. // 更新画布尺寸
  335. // 注意:Fabric.js 中对象的坐标是相对于画布左上角的,所以当画布高度变化时
  336. // 对象的 top 值会自动保持相对于顶部的距离不变,实现从底部扩展/收缩的效果
  337. if(newWidth !== this.this_canvas.width || newHeight !== oldHeight){
  338. this.fcanvas.setDimensions({ width: newWidth, height: newHeight });
  339. }
  340. /* if(newHeight !== oldHeight){
  341. this.fcanvas.setHeight(newHeight);
  342. // 高度变小:检查是否有对象超出新高度
  343. if(newHeight < oldHeight){
  344. const objects = this.fcanvas.getObjects();
  345. const outOfBounds = objects.filter(obj => {
  346. const objBottom = obj.top + (obj.height * obj.scaleY || 0);
  347. return objBottom > newHeight;
  348. });
  349. if(outOfBounds.length > 0){
  350. this.$message.warning(`有 ${outOfBounds.length} 个对象超出新画布高度,请手动调整位置`);
  351. }
  352. }
  353. }*/
  354. if(newColor !== this.this_canvas.bg_color)this.fcanvas.setBackgroundColor(newColor);
  355. let objects = this.fcanvas.getObjects();
  356. objects.forEach(function(object) {
  357. // 调整对象的位置
  358. object.left = object.left;
  359. object.top = object.top ;
  360. object.controls = fabric.Object.prototype.controls
  361. });
  362. // 重新渲染以应用更改
  363. this.fcanvas.requestRenderAll();
  364. }
  365. this.data[this.index].name = this.canvasForm.name
  366. this.data[this.index].width = FIXED_CANVAS_WIDTH
  367. this.data[this.index].height = newHeight
  368. this.data[this.index].bg_color = this.canvasForm.bg_color
  369. this.data[this.index].canvas_type = this.canvasForm.canvas_type || 'normal'
  370. this.data[this.index].multi_goods_mode = this.canvasForm.multi_goods_mode || ''
  371. this.data[this.index].max_goods_count = this.canvasForm.multi_goods_mode ? (this.canvasForm.max_goods_count || null) : null
  372. this.canvasForm.visible = false;
  373. }
  374. },
  375. resizeCanvas(width, height) {
  376. // TODO: 实现具体的画布调整逻辑
  377. console.log('调整画布尺寸为:', width, 'x', height);
  378. },
  379. handleSelectCanvas(index){
  380. if(index === this.index) return
  381. this.saveCanvasSnapshot()
  382. this.$emit('update:index', index)
  383. },
  384. handleDeleteCanvas(targetIndex){
  385. if(this.data.length <= 1){
  386. this.$message.warning('至少需要保留一个画布')
  387. return
  388. }
  389. // 如果删除的是当前激活的画布,需要先保存快照并销毁实例
  390. if(targetIndex === this.index){
  391. this.saveCanvasSnapshot()
  392. this.destroyCanvasInstance()
  393. }
  394. // 从数组中移除画布
  395. this.data.splice(targetIndex, 1)
  396. // 计算新的激活索引
  397. let newIndex = this.index
  398. if(targetIndex < this.index){
  399. // 删除的画布在当前画布之前,索引减1
  400. newIndex = this.index - 1
  401. } else if(targetIndex === this.index){
  402. // 删除的是当前画布,切换到前一个(如果存在),否则切换到第一个
  403. newIndex = targetIndex > 0 ? targetIndex - 1 : 0
  404. }
  405. // 确保索引不超出范围
  406. if(newIndex >= this.data.length){
  407. newIndex = this.data.length - 1
  408. }
  409. if(newIndex < 0){
  410. newIndex = 0
  411. }
  412. // 更新激活索引
  413. this.$emit('update:index', newIndex)
  414. // 如果删除的是当前画布,需要重新初始化
  415. if(targetIndex === this.index){
  416. this.$nextTick(() => {
  417. this.init()
  418. })
  419. }
  420. this.$message.success('画布已删除')
  421. },
  422. scrollToCanvas(idx){
  423. // 使用重试机制,确保 DOM 已渲染
  424. const tryScroll = (attempt = 0) => {
  425. if (attempt > 10) return // 最多重试10次
  426. // 增加延迟,确保 DOM 完全渲染
  427. setTimeout(() => {
  428. this.$nextTick(() => {
  429. // 获取滚动容器
  430. const scrollContainer = this.$refs.wrap
  431. if (!scrollContainer) {
  432. if (attempt < 10) {
  433. setTimeout(() => tryScroll(attempt + 1), 100 * (attempt + 1))
  434. }
  435. return
  436. }
  437. const ref = this.$refs[`canvasItem-${idx}`]
  438. const el = Array.isArray(ref) ? ref[0] : ref
  439. const target = el || document.getElementById(`marketing-canvas-${idx}`)?.closest('.canvas-stack_item')
  440. if (target && scrollContainer) {
  441. // 计算目标元素相对于滚动容器的位置
  442. const containerRect = scrollContainer.getBoundingClientRect()
  443. const targetRect = target.getBoundingClientRect()
  444. // 计算需要滚动的距离(目标元素顶部 - 容器顶部 - 导航栏高度100px)
  445. const scrollTop = scrollContainer.scrollTop
  446. const targetPosition = scrollTop + (targetRect.top - containerRect.top) - 100
  447. // 在滚动容器上平滑滚动
  448. scrollContainer.scrollTo({
  449. top: Math.max(0, targetPosition), // 确保不为负数
  450. behavior: 'smooth'
  451. })
  452. } else if (attempt < 10) {
  453. // 如果元素还没准备好,延迟重试
  454. setTimeout(() => tryScroll(attempt + 1), 100 * (attempt + 1))
  455. }
  456. })
  457. }, attempt * 50) // 每次尝试增加延迟
  458. }
  459. // 使用 requestAnimationFrame 确保在下一帧执行
  460. requestAnimationFrame(() => {
  461. tryScroll(0)
  462. })
  463. },
  464. canvasBodyStyle(item){
  465. const canvasType = item?.canvas_type || 'normal'
  466. // 模特图 / 场景图:保持方形显示
  467. if(canvasType === 'model' || canvasType === 'scene'){
  468. return {
  469. width: `${FIXED_CANVAS_WIDTH}px`,
  470. height: `${FIXED_CANVAS_WIDTH}px`,
  471. lineHeight: `${FIXED_CANVAS_WIDTH}px`,
  472. margin: '0 auto'
  473. }
  474. }
  475. // 普通画布
  476. const height = Number(item?.height)
  477. const width = Number(item?.width) || FIXED_CANVAS_WIDTH
  478. const style = {
  479. width: `${width}px`,
  480. margin: '0 auto'
  481. }
  482. if(!height || Number.isNaN(height)){
  483. style.minHeight = '200px'
  484. }else{
  485. style.height = `${height}px`
  486. }
  487. return style
  488. },
  489. handleBack(){
  490. if(this.$router && typeof this.$router.back === 'function'){
  491. this.$router.back()
  492. }else{
  493. this.$emit('back')
  494. }
  495. },
  496. async handleSave(){
  497. // 先确保当前激活画布的快照是最新的
  498. this.saveCanvasSnapshot()
  499. // 上传每个画布的预览图,生成线上地址
  500. console.log('this.data' , this.data)
  501. const payload = await Promise.all(this.data.map(async (item, idx) => {
  502. let previewUrl = item.preview || ''
  503. if(item.preview && typeof item.preview === 'string' && item.preview.indexOf('data:') === 0){
  504. try{
  505. const res = await uploadBaseImg({ image: item.preview })
  506. previewUrl = res?.data?.url || res?.url || previewUrl
  507. }catch (e){
  508. console.warn('[marketingEdit] upload preview failed', e)
  509. }
  510. }
  511. return {
  512. index: idx,
  513. preview: previewUrl,
  514. canvas_json: item.canvas_json || '',
  515. width: item.width,
  516. height: item.height,
  517. bg_color: item.bg_color,
  518. name: item.name || '',
  519. tpl_url: item.tpl_url || '',
  520. image_path: item.image_path || '',
  521. canvas_type: item.canvas_type || 'normal',
  522. multi_goods_mode: item.multi_goods_mode || '',
  523. max_goods_count: item.max_goods_count || null,
  524. }
  525. }))
  526. this.$emit('save', payload)
  527. return payload;
  528. },
  529. async createImg(){
  530. // 1. 组装数据源(后续可以从接口/其他页面传入)
  531. const goodsData = [
  532. JSON.parse(JSON.stringify(goods)),
  533. // JSON.parse(JSON.stringify(goods1)),
  534. // JSON.parse(JSON.stringify(goods2)),
  535. JSON.parse(JSON.stringify(goods4)),
  536. JSON.parse(JSON.stringify(goods5))
  537. ]
  538. const canvasJson = JSON.parse(JSON.stringify(canvas1))
  539. // 2. 针对每个款号,生成所有画布图片 + 组合长图(在内存中生成 dataURL)
  540. const bundles = await generateAllStyleImageBundles(canvasJson, goodsData)
  541. if (!bundles || !bundles.length) {
  542. ElMessage.warning('没有可生成的图片')
  543. return { bundles: [] }
  544. }
  545. // 3. 如果在 Electron 客户端中,直接通过 IPC 把图片写入 EXE 同级的 output 目录
  546. const clientStore = useClientStore()
  547. if (clientStore?.ipc && clientStore.isClient) {
  548. try {
  549. const result = await clientStore.ipc.invoke(icpList.utils.saveGeneratedImages, { bundles })
  550. if (result?.code === 0) {
  551. ElMessage.success(`生成完成,已保存到 output 文件夹(共 ${result.data?.fileCount || 0} 张)`)
  552. } else {
  553. ElMessage.error(result?.msg || '保存生成图片失败')
  554. }
  555. } catch (e) {
  556. console.error('[marketingEdit] saveGeneratedImages ipc error', e)
  557. ElMessage.error('保存生成图片失败')
  558. }
  559. } else {
  560. console.log('[marketingEdit] bundles (非客户端环境,仅返回数据)', bundles)
  561. }
  562. return { bundles }
  563. },
  564. saveCanvasSnapshot(targetIndex){
  565. const snapshotIndex = typeof targetIndex === 'number' ? targetIndex : this.index
  566. if(snapshotIndex === undefined || snapshotIndex === null) return
  567. const canvasData = this.data[snapshotIndex]
  568. if(!canvasData) return
  569. // 模特图 / 场景图:不生成 JSON,只保存类型占位,预览留空
  570. if(canvasData.canvas_type === 'model' || canvasData.canvas_type === 'scene'){
  571. this.data[snapshotIndex] = {
  572. ...canvasData,
  573. canvas_json: canvasData.canvas_type,
  574. preview: ''
  575. }
  576. return
  577. }
  578. if(!this.fcanvas) return
  579. const json = JSON.stringify(this.fcanvas.toJSON(['name','sort','mtr','id','selectable','erasable','data-key','data-type','data-value']))
  580. let preview = canvasData.preview || ''
  581. try{
  582. preview = this.fcanvas.toDataURL({
  583. format: 'png',
  584. multiplier: 1,
  585. enableRetinaScaling: true
  586. })
  587. }catch (err){
  588. console.warn('[marketingEdit] snapshot preview failed', err)
  589. }
  590. const updated = {
  591. ...canvasData,
  592. canvas_json: json,
  593. preview
  594. }
  595. this.data.splice(snapshotIndex, 1, updated)
  596. },
  597. // 打开大图预览(由模板 header 调用)
  598. openPreview(url) {
  599. this.previewImageUrl = url
  600. this.previewDialogVisible = true
  601. },
  602. handleMoveCanvas(idx, direction){
  603. const offset = direction === 'up' ? -1 : 1
  604. const targetIdx = idx + offset
  605. if(targetIdx < 0 || targetIdx >= this.data.length) return
  606. this.saveCanvasSnapshot()
  607. const currentCanvas = this.data[this.index]
  608. const [moved] = this.data.splice(idx, 1)
  609. this.data.splice(targetIdx, 0, moved)
  610. this.$nextTick(() => {
  611. const newIndex = this.data.indexOf(currentCanvas)
  612. if(newIndex !== -1 && newIndex !== this.index){
  613. this.$emit('update:index', newIndex)
  614. }else{
  615. this.reloadCurrentCanvas(false)
  616. }
  617. this.reloadAllNormalCanvases()
  618. })
  619. },
  620. reloadCurrentCanvas(scroll = true){
  621. if(this.isEmpty) return
  622. this.destroyCanvasInstance()
  623. const canvasType = this.this_canvas?.canvas_type || 'normal'
  624. if(canvasType === 'model' || canvasType === 'scene'){
  625. return
  626. }
  627. this.$nextTick(() => {
  628. this.init()
  629. if(scroll){
  630. this.scrollToCanvas(this.index)
  631. }
  632. })
  633. },
  634. reloadAllNormalCanvases(){
  635. // 简单策略:重新加载当前画布即可,避免重复显示;其他画布在切换时再加载
  636. this.reloadCurrentCanvas(false)
  637. },
  638. findEditableCanvas(startIndex = 0){
  639. if(this.isEmpty) return -1
  640. const total = this.data.length
  641. const isEditable = (item) =>
  642. item && item.canvas_type !== 'model' && item.canvas_type !== 'scene'
  643. for(let i = startIndex; i < total; i++){
  644. if(isEditable(this.data[i])) return i
  645. }
  646. for(let i = startIndex - 1; i >= 0; i--){
  647. if(isEditable(this.data[i])) return i
  648. }
  649. return -1
  650. },
  651. destroyCanvasInstance(){
  652. if(!this.fcanvas && !this.fcanvasId) return
  653. const canvasEl = this.fcanvas && this.fcanvas.getElement ? this.fcanvas.getElement() : document.getElementById(this.fcanvasId)
  654. const wrapper = canvasEl && canvasEl.parentNode && canvasEl.parentNode.classList?.contains('fcanvas')
  655. ? canvasEl.parentNode
  656. : null
  657. const parentNode = wrapper ? wrapper.parentNode : (canvasEl ? canvasEl.parentNode : null)
  658. const nextSibling = wrapper ? wrapper.nextSibling : (canvasEl ? canvasEl.nextSibling : null)
  659. try{
  660. this.fcanvas && this.fcanvas.dispose()
  661. }catch(err){
  662. console.warn('[marketingEdit] dispose canvas failed', err)
  663. }finally{
  664. // 安全移除 DOM,避免 NotFoundError
  665. if(wrapper && parentNode && parentNode.contains(wrapper)){
  666. parentNode.removeChild(wrapper)
  667. }else if(canvasEl && canvasEl.parentNode && canvasEl.parentNode.contains(canvasEl)){
  668. canvasEl.parentNode.removeChild(canvasEl)
  669. }
  670. // 仅当当前 id 对应的 canvas 不存在时,才补一个占位 canvas,避免重复
  671. if(parentNode && this.fcanvasId && !document.getElementById(this.fcanvasId)){
  672. const placeholder = document.createElement('canvas')
  673. placeholder.id = this.fcanvasId
  674. placeholder.width = Number(this.this_canvas?.width) || FIXED_CANVAS_WIDTH
  675. placeholder.height = Number(this.this_canvas?.height) || 0
  676. placeholder.style.width = '100%'
  677. placeholder.style.height = '100%'
  678. parentNode.insertBefore(placeholder, nextSibling || null)
  679. }
  680. this.fcanvas = null
  681. this.fcanvasId = ''
  682. }
  683. },
  684. // 显示新增商品文字对话框
  685. showAddGoodsTextDialog() {
  686. this.addGoodsTextForm.key = ''
  687. this.addGoodsTextForm.value = ''
  688. this.addGoodsTextForm.visible = true
  689. },
  690. // 确认新增商品文字
  691. async confirmAddGoodsText() {
  692. if (!this.addGoodsTextForm.key.trim() || !this.addGoodsTextForm.value.trim()) {
  693. this.$message.warning('请填写完整的商品文字信息')
  694. return
  695. }
  696. // 检查是否已存在相同的 key
  697. const existingIndex = this.goods_text.findIndex(item => item.key === this.addGoodsTextForm.key.trim())
  698. if (existingIndex !== -1) {
  699. this.$message.warning('该商品文字键名已存在,请使用不同的键名')
  700. return
  701. }
  702. try {
  703. // 获取模板ID(编辑模式下)
  704. const templateId = this.$route?.params?.id
  705. if (!templateId) {
  706. this.$message.error('无法获取模板ID,请刷新页面后重试')
  707. return
  708. }
  709. // 准备新的商品文字数组(包含新添加的)
  710. const newGoodsText = [
  711. ...this.goods_text,
  712. {
  713. key: this.addGoodsTextForm.key.trim(),
  714. value: this.addGoodsTextForm.value.trim()
  715. }
  716. ]
  717. // 调用接口更新商品文字字段
  718. const response = await updateTemplateColumn({
  719. id: templateId,
  720. template_excel_headers: newGoodsText
  721. })
  722. if (response.code === 0) {
  723. // 接口调用成功,更新本地数据
  724. this.goods_text.push({
  725. key: this.addGoodsTextForm.key.trim(),
  726. value: this.addGoodsTextForm.value.trim()
  727. })
  728. this.addGoodsTextForm.visible = false
  729. this.$message.success('商品文字添加成功')
  730. } else {
  731. this.$message.error(response.msg || '更新商品文字失败')
  732. }
  733. } catch (error) {
  734. console.error('更新商品文字失败:', error)
  735. this.$message.error('更新商品文字失败,请稍后重试')
  736. }
  737. }
  738. }
  739. }
  740. </script>
  741. <style lang="scss" >
  742. @import './tpl/header.scss';
  743. </style>
  744. <style lang="scss" scoped>
  745. @import '@/styles/fcanvas.scss';
  746. .picture-editor-wrap {
  747. position: absolute;
  748. left: 0;
  749. top:0;
  750. right: 0;
  751. bottom:0;
  752. background: #e6e6e6;
  753. overflow: auto;
  754. .picture-editor-wrap_canvas {
  755. position: relative;
  756. margin-top: 85px;
  757. box-sizing: border-box;
  758. .picture-editor-canvas {
  759. min-height: calc(100vh - 85px);
  760. display: flex;
  761. align-items: flex-start;
  762. justify-content: center;
  763. }
  764. }
  765. @import '../PictureEditor/mixin/actions/index.scss';
  766. @import '../PictureEditor/mixin/view/index.scss';
  767. @import '../PictureEditor/mixin/layer/index.scss';
  768. @import '../PictureEditor/mixin/color/index.scss';
  769. @import '../PictureEditor/mixin/edit/index.scss';
  770. .picture-editor-empty {
  771. top:0;
  772. height: 100%;
  773. background: #fff;
  774. z-index: 10;
  775. }
  776. .add-action-wrap {
  777. position: fixed;
  778. top:500px;
  779. overflow: auto;
  780. left: 0px;
  781. z-index: 10;
  782. bottom: 0;
  783. width: 280px;
  784. background:#fff;
  785. box-shadow: 0px 2px 4px 0px rgba(170,177,255,0.54);
  786. .icon {
  787. margin-right: 5px;
  788. }
  789. .icon-img {
  790. width: 50px;
  791. height: 50px;
  792. object-fit: contain;
  793. // height: 30px;
  794. border-radius: 5px;
  795. margin-right: 10px;
  796. }
  797. .add-action_title {
  798. border-bottom: 1px solid #eee;
  799. background: #fafafa;
  800. }
  801. .active {
  802. .icon {
  803. border:1px solid $primary2;
  804. }
  805. .text {
  806. color: #000;
  807. }
  808. }
  809. }
  810. }
  811. .canvas-stack {
  812. width: 100%;
  813. display: flex;
  814. flex-direction: column;
  815. gap: 0;
  816. }
  817. .canvas-stack_item {
  818. width: 100%;
  819. border-radius: 0;
  820. box-shadow: none;
  821. padding: 0;
  822. box-sizing: border-box;
  823. position: relative;
  824. border-bottom: 1px solid #f0f0f0;
  825. }
  826. .canvas-stack_body {
  827. background: transparent;
  828. border-radius: 0;
  829. padding: 0;
  830. box-sizing: border-box;
  831. position: relative;
  832. overflow: visible;
  833. transition: background 0.2s, box-shadow 0.2s, border 0.2s;
  834. &.inactive {
  835. ::v-deep {
  836. canvas {
  837. display: none;
  838. }
  839. }
  840. }
  841. }
  842. .canvas-stack_body.active {
  843. background: #f6fbff;
  844. box-shadow: 0 0 10px rgb(0 0 0 / 20%);
  845. }
  846. .canvas-stack_body canvas {
  847. display: block;
  848. margin: 0 auto;
  849. }
  850. .canvas-stack_body .fcanvas {
  851. width: 100% !important;
  852. height: 100% !important;
  853. }
  854. .canvas-stack_body .upper-canvas,
  855. .canvas-stack_body .lower-canvas {
  856. width: 100% !important;
  857. height: 100% !important;
  858. }
  859. .canvas-stack_controls {
  860. position: absolute;
  861. top: 10px;
  862. right: -30px;
  863. display: flex;
  864. flex-direction: column;
  865. align-items: stretch;
  866. gap: 6px;
  867. width: 30px;
  868. }
  869. .canvas-stack_switch {
  870. width: 100%;
  871. height: 60px;
  872. background: rgba(104, 188, 165, 0.1);
  873. border-color: rgba(104, 188, 165, 0.3);
  874. color: #68BCA5;
  875. white-space: break-spaces;
  876. padding: 5px !important;
  877. ::v-deep {
  878. span {
  879. display: block;
  880. width: 100%;
  881. height: 100%;
  882. }
  883. }
  884. }
  885. .canvas-stack_body.active .canvas-stack_switch {
  886. background: $primary1;
  887. border-color: $primary1;
  888. color: #fff;
  889. }
  890. .canvas-stack_sort {
  891. display: flex;
  892. flex-direction: column;
  893. gap: 4px;
  894. }
  895. .canvas-stack_sort .el-button {
  896. width: 100%;
  897. padding: 0;
  898. height: 28px;
  899. margin-left: 0px !important;
  900. }
  901. .canvas-stack_placeholder {
  902. height: 100%;
  903. width: 100%;
  904. display: flex;
  905. align-items: center;
  906. justify-content: center;
  907. flex-direction: column;
  908. color: #888;
  909. font-size: 14px;
  910. text-align: center;
  911. padding: 40px 0;
  912. box-sizing: border-box;
  913. }
  914. .canvas-stack_placeholder img {
  915. max-width: 100%;
  916. display: block;
  917. }
  918. .canvas-stack_empty {
  919. width: 100%;
  920. text-align: center;
  921. padding: 40px 0;
  922. color: #666;
  923. border-bottom: 1px solid #f0f0f0;
  924. }
  925. .goods-mode-samples {
  926. display: flex;
  927. gap: 12px;
  928. margin-top: 8px;
  929. padding-left: 100px; /* 与表单 label 宽度对齐 */
  930. }
  931. .goods-mode-samples .sample-row {
  932. display: flex;
  933. gap: 12px;
  934. justify-content: flex-end; /* 靠右对齐图片 */
  935. }
  936. .goods-mode-samples .sample-item {
  937. display: flex;
  938. flex-direction: column;
  939. align-items: flex-end; /* 让图片和 label 的右对齐 */
  940. width: 140px;
  941. }
  942. .goods-mode-samples .sample-item img,
  943. .goods-mode-samples .sample-item .el-image__inner {
  944. width: 140px;
  945. height: 90px;
  946. object-fit: contain;
  947. border: 1px solid #eee;
  948. border-radius: 6px;
  949. background: #fff;
  950. }
  951. .goods-mode-samples .sample-label {
  952. margin-top: 6px;
  953. font-size: 12px;
  954. color: #666;
  955. text-align: right; /* 右对齐标签文本 */
  956. }
  957. .goods-mode-samples .sample-actions {
  958. align-self: flex-end;
  959. margin-bottom: 6px;
  960. }
  961. .goods-mode-samples .sample-row {
  962. display: flex;
  963. gap: 12px;
  964. }
  965. .goods-mode-samples .sample-item {
  966. display: flex;
  967. flex-direction: column;
  968. align-items: center;
  969. width: 140px;
  970. }
  971. .goods-mode-samples .sample-item img {
  972. width: 140px;
  973. height: 90px;
  974. object-fit: contain;
  975. border: 1px solid #eee;
  976. border-radius: 6px;
  977. background: #fff;
  978. }
  979. .goods-mode-samples .sample-label {
  980. margin-top: 6px;
  981. font-size: 12px;
  982. color: #666;
  983. }
  984. .fixed-width-tip {
  985. line-height: 32px;
  986. color: #666;
  987. }
  988. .add-goods-text-item {
  989. border-top: 1px solid #f0f0f0;
  990. margin-top: 8px;
  991. padding-top: 8px;
  992. &:hover {
  993. background-color: #f5f7fa;
  994. }
  995. .el-icon {
  996. margin-right: 6px;
  997. }
  998. }
  999. </style>