index.vue 28 KB

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