index.vue 33 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133
  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. // 新建模式(无 templateId):仅在本地添加,后续保存模板时再一并提交
  706. if (!templateId) {
  707. this.goods_text.push({
  708. key: this.addGoodsTextForm.key.trim(),
  709. value: this.addGoodsTextForm.value.trim()
  710. })
  711. this.addGoodsTextForm.visible = false
  712. this.$message.success('商品文字添加成功')
  713. return
  714. }
  715. // 准备新的商品文字数组(包含新添加的)
  716. const newGoodsText = [
  717. ...this.goods_text,
  718. {
  719. key: this.addGoodsTextForm.key.trim(),
  720. value: this.addGoodsTextForm.value.trim()
  721. }
  722. ]
  723. // 调用接口更新商品文字字段
  724. const response = await updateTemplateColumn({
  725. id: templateId,
  726. template_excel_headers: newGoodsText
  727. })
  728. if (response.code === 0) {
  729. // 接口调用成功,更新本地数据
  730. this.goods_text.push({
  731. key: this.addGoodsTextForm.key.trim(),
  732. value: this.addGoodsTextForm.value.trim()
  733. })
  734. this.addGoodsTextForm.visible = false
  735. this.$message.success('商品文字添加成功')
  736. } else {
  737. this.$message.error(response.msg || '更新商品文字失败')
  738. }
  739. } catch (error) {
  740. console.error('更新商品文字失败:', error)
  741. this.$message.error('更新商品文字失败,请稍后重试')
  742. }
  743. }
  744. }
  745. }
  746. </script>
  747. <style lang="scss" >
  748. @import './tpl/header.scss';
  749. </style>
  750. <style lang="scss" scoped>
  751. @import '@/styles/fcanvas.scss';
  752. .picture-editor-wrap {
  753. position: absolute;
  754. left: 0;
  755. top:0;
  756. right: 0;
  757. bottom:0;
  758. background: #e6e6e6;
  759. overflow: auto;
  760. .picture-editor-wrap_canvas {
  761. position: relative;
  762. margin-top: 85px;
  763. box-sizing: border-box;
  764. .picture-editor-canvas {
  765. min-height: calc(100vh - 85px);
  766. display: flex;
  767. align-items: flex-start;
  768. justify-content: center;
  769. }
  770. }
  771. @import '../PictureEditor/mixin/actions/index.scss';
  772. @import '../PictureEditor/mixin/view/index.scss';
  773. @import '../PictureEditor/mixin/layer/index.scss';
  774. @import '../PictureEditor/mixin/color/index.scss';
  775. @import '../PictureEditor/mixin/edit/index.scss';
  776. .picture-editor-empty {
  777. top:0;
  778. height: 100%;
  779. background: #fff;
  780. z-index: 10;
  781. }
  782. .add-action-wrap {
  783. position: fixed;
  784. top:500px;
  785. overflow: auto;
  786. left: 0px;
  787. z-index: 10;
  788. bottom: 0;
  789. width: 280px;
  790. background:#fff;
  791. box-shadow: 0px 2px 4px 0px rgba(170,177,255,0.54);
  792. .icon {
  793. margin-right: 5px;
  794. }
  795. .icon-img {
  796. width: 50px;
  797. height: 50px;
  798. object-fit: contain;
  799. // height: 30px;
  800. border-radius: 5px;
  801. margin-right: 10px;
  802. }
  803. .add-action_title {
  804. border-bottom: 1px solid #eee;
  805. background: #fafafa;
  806. }
  807. .active {
  808. .icon {
  809. border:1px solid $primary2;
  810. }
  811. .text {
  812. color: #000;
  813. }
  814. }
  815. }
  816. }
  817. .canvas-stack {
  818. width: 100%;
  819. display: flex;
  820. flex-direction: column;
  821. gap: 0;
  822. }
  823. .canvas-stack_item {
  824. width: 100%;
  825. border-radius: 0;
  826. box-shadow: none;
  827. padding: 0;
  828. box-sizing: border-box;
  829. position: relative;
  830. border-bottom: 1px solid #f0f0f0;
  831. }
  832. .canvas-stack_body {
  833. background: transparent;
  834. border-radius: 0;
  835. padding: 0;
  836. box-sizing: border-box;
  837. position: relative;
  838. overflow: visible;
  839. transition: background 0.2s, box-shadow 0.2s, border 0.2s;
  840. &.inactive {
  841. ::v-deep {
  842. canvas {
  843. display: none;
  844. }
  845. }
  846. }
  847. }
  848. .canvas-stack_body.active {
  849. background: #f6fbff;
  850. box-shadow: 0 0 10px rgb(0 0 0 / 20%);
  851. }
  852. .canvas-stack_body canvas {
  853. display: block;
  854. margin: 0 auto;
  855. }
  856. .canvas-stack_body .fcanvas {
  857. width: 100% !important;
  858. height: 100% !important;
  859. }
  860. .canvas-stack_body .upper-canvas,
  861. .canvas-stack_body .lower-canvas {
  862. width: 100% !important;
  863. height: 100% !important;
  864. }
  865. .canvas-stack_controls {
  866. position: absolute;
  867. top: 10px;
  868. right: -30px;
  869. display: flex;
  870. flex-direction: column;
  871. align-items: stretch;
  872. gap: 6px;
  873. width: 30px;
  874. }
  875. .canvas-stack_switch {
  876. width: 100%;
  877. height: 60px;
  878. background: rgba(104, 188, 165, 0.1);
  879. border-color: rgba(104, 188, 165, 0.3);
  880. color: #68BCA5;
  881. white-space: break-spaces;
  882. padding: 5px !important;
  883. ::v-deep {
  884. span {
  885. display: block;
  886. width: 100%;
  887. height: 100%;
  888. }
  889. }
  890. }
  891. .canvas-stack_body.active .canvas-stack_switch {
  892. background: $primary1;
  893. border-color: $primary1;
  894. color: #fff;
  895. }
  896. .canvas-stack_sort {
  897. display: flex;
  898. flex-direction: column;
  899. gap: 4px;
  900. }
  901. .canvas-stack_sort .el-button {
  902. width: 100%;
  903. padding: 0;
  904. height: 28px;
  905. margin-left: 0px !important;
  906. }
  907. .canvas-stack_placeholder {
  908. height: 100%;
  909. width: 100%;
  910. display: flex;
  911. align-items: center;
  912. justify-content: center;
  913. flex-direction: column;
  914. color: #888;
  915. font-size: 14px;
  916. text-align: center;
  917. padding: 40px 0;
  918. box-sizing: border-box;
  919. }
  920. .canvas-stack_placeholder img {
  921. max-width: 100%;
  922. display: block;
  923. }
  924. .canvas-stack_empty {
  925. width: 100%;
  926. text-align: center;
  927. padding: 40px 0;
  928. color: #666;
  929. border-bottom: 1px solid #f0f0f0;
  930. }
  931. .goods-mode-samples {
  932. display: flex;
  933. gap: 12px;
  934. margin-top: 8px;
  935. padding-left: 100px; /* 与表单 label 宽度对齐 */
  936. }
  937. .goods-mode-samples .sample-row {
  938. display: flex;
  939. gap: 12px;
  940. justify-content: flex-end; /* 靠右对齐图片 */
  941. }
  942. .goods-mode-samples .sample-item {
  943. display: flex;
  944. flex-direction: column;
  945. align-items: flex-end; /* 让图片和 label 的右对齐 */
  946. width: 140px;
  947. }
  948. .goods-mode-samples .sample-item img,
  949. .goods-mode-samples .sample-item .el-image__inner {
  950. width: 140px;
  951. height: 90px;
  952. object-fit: contain;
  953. border: 1px solid #eee;
  954. border-radius: 6px;
  955. background: #fff;
  956. }
  957. .goods-mode-samples .sample-label {
  958. margin-top: 6px;
  959. font-size: 12px;
  960. color: #666;
  961. text-align: right; /* 右对齐标签文本 */
  962. }
  963. .goods-mode-samples .sample-actions {
  964. align-self: flex-end;
  965. margin-bottom: 6px;
  966. }
  967. /* layer name inline confirm button */
  968. .layer-item__name-wrap {
  969. display: flex;
  970. align-items: center;
  971. gap: 8px;
  972. }
  973. .layer-item__name {
  974. flex: 1;
  975. padding: 4px 8px;
  976. height: 28px;
  977. line-height: 20px;
  978. border: 1px solid #e6e6e6;
  979. border-radius: 4px;
  980. }
  981. .layer-item__confirm {
  982. padding: 2px 8px;
  983. height: 28px;
  984. line-height: 20px;
  985. font-size: 12px;
  986. background: transparent;
  987. border: 1px solid #e6e6e6;
  988. border-radius: 4px;
  989. cursor: pointer;
  990. }
  991. .layer-item__confirm:hover {
  992. background: #f5f7fa;
  993. }
  994. /* 默认隐藏确认按钮,输入框聚焦时显示 */
  995. .layer-item__confirm {
  996. display: none;
  997. }
  998. .layer-item__name-wrap:focus-within .layer-item__confirm {
  999. display: inline-block;
  1000. }
  1001. .goods-mode-samples .sample-row {
  1002. display: flex;
  1003. gap: 12px;
  1004. }
  1005. .goods-mode-samples .sample-item {
  1006. display: flex;
  1007. flex-direction: column;
  1008. align-items: center;
  1009. width: 140px;
  1010. }
  1011. .goods-mode-samples .sample-item img {
  1012. width: 140px;
  1013. height: 90px;
  1014. object-fit: contain;
  1015. border: 1px solid #eee;
  1016. border-radius: 6px;
  1017. background: #fff;
  1018. }
  1019. .goods-mode-samples .sample-label {
  1020. margin-top: 6px;
  1021. font-size: 12px;
  1022. color: #666;
  1023. }
  1024. .fixed-width-tip {
  1025. line-height: 32px;
  1026. color: #666;
  1027. }
  1028. .add-goods-text-item {
  1029. border-top: 1px solid #f0f0f0;
  1030. margin-top: 8px;
  1031. padding-top: 8px;
  1032. &:hover {
  1033. background-color: #f5f7fa;
  1034. }
  1035. .el-icon {
  1036. margin-right: 6px;
  1037. }
  1038. }
  1039. </style>