export-view.js 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059
  1. /**
  2. * 导出动画弹出框
  3. */
  4. class ExportView {
  5. constructor() {
  6. this.overlay = null;
  7. this.modal = null;
  8. this.previewImage = null;
  9. this.previewPlaceholder = null;
  10. this.cancelBtn = null;
  11. this.confirmBtn = null;
  12. this.floatingAIBtn = null;
  13. this.imageData = null;
  14. this.spritesheetCanvas = null;
  15. this.folderName = null;
  16. this.spritesheetLayout = null;
  17. this.replacedImageData = null;
  18. this.geminiOriginalImageData = null;
  19. this.originalSpritesheetData = null;
  20. // 下载确认对话框相关
  21. this.downloadConfirmOverlay = null;
  22. this.downloadConfirmClose = null;
  23. this.downloadOptions = null;
  24. this.init();
  25. }
  26. init() {
  27. this.overlay = document.getElementById('exportOverlay');
  28. this.modal = document.getElementById('exportModal');
  29. this.previewImage = document.getElementById('previewImage');
  30. this.previewPlaceholder = document.getElementById('previewPlaceholder');
  31. this.cancelBtn = document.getElementById('exportCancelBtn');
  32. this.confirmBtn = document.getElementById('exportConfirmBtn');
  33. this.floatingAIBtn = document.getElementById('floatingAIBtn');
  34. // 下载确认对话框元素
  35. this.downloadConfirmOverlay = document.getElementById('downloadConfirmOverlay');
  36. this.downloadConfirmClose = document.getElementById('downloadConfirmClose');
  37. this.downloadOptions = document.querySelectorAll('.download-option');
  38. // 确保对话框初始状态是隐藏的
  39. if (this.downloadConfirmOverlay) {
  40. this.downloadConfirmOverlay.style.display = 'none';
  41. }
  42. // 加载VIP抠图价格
  43. this.loadVIPMattingPrice();
  44. this.bindEvents();
  45. // 初始时禁用确定按钮
  46. if (this.confirmBtn) {
  47. this.confirmBtn.disabled = true;
  48. }
  49. this.reset();
  50. }
  51. bindEvents() {
  52. // 取消按钮(右上角)
  53. this.cancelBtn?.addEventListener('click', () => {
  54. this.close();
  55. });
  56. // 取消按钮(底部操作栏)
  57. this.cancelBtnBottom?.addEventListener('click', () => {
  58. this.close();
  59. });
  60. // 确定按钮(下载)
  61. this.confirmBtn?.addEventListener('click', () => {
  62. this.handleConfirm();
  63. });
  64. // 悬浮AI按钮 - 打开AI生图界面
  65. this.floatingAIBtn?.addEventListener('click', () => {
  66. this.openAIGenerateView();
  67. });
  68. // 点击遮罩层关闭
  69. this.overlay?.addEventListener('click', (e) => {
  70. if (e.target === this.overlay) {
  71. this.close();
  72. }
  73. });
  74. // ESC键关闭 - 直接关闭整个界面
  75. document.addEventListener('keydown', (e) => {
  76. if (e.key === 'Escape') {
  77. this.hideDownloadConfirm();
  78. this.close();
  79. }
  80. });
  81. // 下载确认对话框事件 - 关闭时同时关闭整个界面
  82. this.downloadConfirmClose?.addEventListener('click', () => {
  83. this.hideDownloadConfirm();
  84. this.close();
  85. });
  86. // 点击遮罩层关闭下载确认对话框 - 同时关闭整个界面
  87. this.downloadConfirmOverlay?.addEventListener('click', (e) => {
  88. if (e.target === this.downloadConfirmOverlay) {
  89. this.hideDownloadConfirm();
  90. this.close();
  91. }
  92. });
  93. // 下载选项点击事件
  94. this.downloadOptions?.forEach(option => {
  95. option.addEventListener('click', () => {
  96. const downloadType = option.dataset.option;
  97. this.handleDownloadOption(downloadType);
  98. });
  99. });
  100. // 监听来自父窗口的消息
  101. window.addEventListener('message', (event) => {
  102. if (event.data && event.data.type === 'show-export-preview') {
  103. // console.log('[ExportView] 收到显示预览消息:', event.data);
  104. this.showPreview(event.data.imageUrl || event.data.imageData);
  105. } else if (event.data && event.data.type === 'generate-export-preview') {
  106. // console.log('[ExportView] 收到生成预览消息:', event.data);
  107. this.reset();
  108. this.folderName = event.data.folderName;
  109. this.generatePreview(event.data.folderName);
  110. }
  111. });
  112. }
  113. /**
  114. * 生成预览图
  115. * @param {string} folderName - 文件夹名称
  116. */
  117. async generatePreview(folderName) {
  118. if (!folderName) {
  119. // console.warn('[ExportView] 没有提供文件夹名称');
  120. if (this.previewPlaceholder) {
  121. this.previewPlaceholder.textContent = '没有提供文件夹名称';
  122. }
  123. return;
  124. }
  125. // 重置状态(确保每次打开都是全新状态)
  126. this.reset();
  127. // 保存文件夹名称
  128. this.folderName = folderName;
  129. // 显示加载状态
  130. if (this.previewPlaceholder) {
  131. this.previewPlaceholder.classList.remove('hide');
  132. }
  133. if (this.previewImage) {
  134. this.previewImage.classList.remove('show');
  135. }
  136. try {
  137. // 获取当前登录用户名
  138. const username = this.getCurrentUsername();
  139. if (!username) {
  140. throw new Error('请先登录');
  141. }
  142. // 获取帧列表
  143. const encodedFolderName = encodeURIComponent(folderName);
  144. let apiUrl = `http://localhost:3000/api/frames/${encodedFolderName}`;
  145. if (username) {
  146. apiUrl += `?username=${encodeURIComponent(username)}`;
  147. }
  148. const response = await fetch(apiUrl);
  149. if (!response.ok) {
  150. // 服务端返回错误,解析错误信息并显示
  151. const errorMessage = await this.handleServerError(response, '无法获取帧列表');
  152. throw new Error(errorMessage);
  153. }
  154. const data = await response.json();
  155. const frameNumbers = data.frames || [];
  156. const fileNames = data.fileNames || [];
  157. if (frameNumbers.length === 0) {
  158. throw new Error('该文件夹中没有图片');
  159. }
  160. // 加载所有图片(使用正确的API路径)
  161. const images = [];
  162. for (let i = 0; i < frameNumbers.length; i++) {
  163. const frameNum = frameNumbers[i];
  164. // 使用API路径,从用户目录加载
  165. let imgSrc;
  166. if (fileNames[i]) {
  167. // 使用实际文件名
  168. const imagePath = `${folderName}/${fileNames[i]}`;
  169. imgSrc = `/api/disk/preview?username=${encodeURIComponent(username)}&path=${encodeURIComponent(imagePath)}`;
  170. } else {
  171. // 回退到使用帧号构造文件名
  172. const frameName = frameNum.toString().padStart(2, '0');
  173. const imagePath = `${folderName}/${frameName}.png`;
  174. imgSrc = `/api/disk/preview?username=${encodeURIComponent(username)}&path=${encodeURIComponent(imagePath)}`;
  175. }
  176. const img = await new Promise((resolve, reject) => {
  177. const image = new Image();
  178. image.crossOrigin = 'anonymous';
  179. image.onload = () => resolve(image);
  180. image.onerror = () => reject(new Error(`Failed to load image: ${imgSrc}`));
  181. image.src = imgSrc;
  182. });
  183. images.push({
  184. img: img,
  185. width: img.width,
  186. height: img.height,
  187. frameNum: frameNum
  188. });
  189. }
  190. // 计算布局(简化版,使用简单的网格布局)
  191. const frameWidth = images[0].width;
  192. const frameHeight = images[0].height;
  193. const cols = Math.ceil(Math.sqrt(images.length));
  194. const rows = Math.ceil(images.length / cols);
  195. // 创建 Canvas 并绘制
  196. const canvas = document.createElement('canvas');
  197. canvas.width = frameWidth * cols;
  198. canvas.height = frameHeight * rows;
  199. const ctx = canvas.getContext('2d');
  200. // 保存 canvas 和布局信息用于下载
  201. this.spritesheetCanvas = canvas;
  202. // 填充透明背景
  203. ctx.clearRect(0, 0, canvas.width, canvas.height);
  204. // 保存布局信息(用于生成 JSON)
  205. const layout = [];
  206. // 绘制所有图片
  207. images.forEach((item, index) => {
  208. const col = index % cols;
  209. const row = Math.floor(index / cols);
  210. const x = col * frameWidth;
  211. const y = row * frameHeight;
  212. ctx.drawImage(item.img, x, y);
  213. // 保存布局信息
  214. layout.push({
  215. x: x,
  216. y: y,
  217. width: item.width,
  218. height: item.height,
  219. frameNum: item.frameNum
  220. });
  221. });
  222. // 保存布局信息
  223. this.spritesheetLayout = {
  224. layout: layout,
  225. sheetWidth: canvas.width,
  226. sheetHeight: canvas.height
  227. };
  228. // 转换为 base64
  229. const imageUrl = await new Promise((resolve) => {
  230. canvas.toBlob((blob) => {
  231. const url = URL.createObjectURL(blob);
  232. resolve(url);
  233. }, 'image/png');
  234. });
  235. // 保存原始 spritesheet 的 base64 数据
  236. this.originalSpritesheetData = await new Promise((resolve) => {
  237. canvas.toBlob((blob) => {
  238. const reader = new FileReader();
  239. reader.onload = () => resolve(reader.result);
  240. reader.readAsDataURL(blob);
  241. }, 'image/png');
  242. });
  243. // 显示预览图
  244. this.showPreview(imageUrl);
  245. // 如果已经有参考图,显示替换按钮
  246. if (this.referenceImageData && this.replaceBtn) {
  247. this.replaceBtn.style.display = 'block';
  248. }
  249. // 预览图生成完成后,自动显示下载选项对话框
  250. setTimeout(() => {
  251. this.showDownloadConfirm();
  252. }, 300);
  253. } catch (error) {
  254. // console.error('[ExportView] 生成预览图失败:', error);
  255. if (this.previewPlaceholder) {
  256. // 隐藏加载动画,显示错误信息
  257. const spinner = this.previewPlaceholder.querySelector('.loading-spinner');
  258. const loadingText = this.previewPlaceholder.querySelector('.loading-text');
  259. if (spinner) spinner.style.display = 'none';
  260. if (loadingText) {
  261. loadingText.textContent = '生成预览图失败: ' + error.message;
  262. loadingText.style.color = '#ef4444';
  263. }
  264. this.previewPlaceholder.classList.remove('hide');
  265. }
  266. }
  267. }
  268. /**
  269. * 计算宽高比
  270. * @param {number} width - 宽度
  271. * @param {number} height - 高度
  272. * @returns {string} 宽高比字符串(例如:16:9)
  273. */
  274. calculateAspectRatio(width, height) {
  275. // 计算最大公约数
  276. const gcd = (a, b) => b === 0 ? a : gcd(b, a % b);
  277. const divisor = gcd(width, height);
  278. const ratioWidth = width / divisor;
  279. const ratioHeight = height / divisor;
  280. // 如果比例太大,使用简化版本
  281. if (ratioWidth > 100 || ratioHeight > 100) {
  282. // 使用小数形式
  283. const ratio = width / height;
  284. return ratio.toFixed(2) + ':1';
  285. }
  286. return `${ratioWidth}:${ratioHeight}`;
  287. }
  288. /**
  289. * 显示预览图(Spritesheet)- 简化版,不再显示预览界面
  290. * @param {string} imageUrl - 图片URL或base64数据
  291. */
  292. showPreview(imageUrl) {
  293. if (!imageUrl) {
  294. return;
  295. }
  296. // 保存图片数据
  297. this.imageData = imageUrl;
  298. }
  299. /**
  300. * 生成 JSON 数据
  301. * @param {string} folderName - 文件夹名称
  302. * @param {Array} layout - 布局信息数组
  303. * @param {number} sheetWidth - Spritesheet 宽度
  304. * @param {number} sheetHeight - Spritesheet 高度
  305. * @returns {string} JSON 字符串
  306. */
  307. generateJSON(folderName, layout, sheetWidth, sheetHeight) {
  308. const frames = {};
  309. layout.forEach((item, index) => {
  310. // 使用实际的帧号,确保与原始文件名一致
  311. const frameNum = item.frameNum ? item.frameNum.toString().padStart(2, '0') : (index + 1).toString().padStart(2, '0');
  312. const frameName = `${frameNum}.png`;
  313. const x = item.x;
  314. const y = item.y;
  315. const width = item.width;
  316. const height = item.height;
  317. // 标准的 TexturePacker JSON 格式,Cocos Creator 3.8 完全兼容
  318. frames[frameName] = {
  319. frame: {
  320. x: x,
  321. y: y,
  322. w: width,
  323. h: height
  324. },
  325. rotated: false,
  326. trimmed: false,
  327. spriteSourceSize: { x: 0, y: 0, w: width, h: height },
  328. sourceSize: { w: width, h: height }
  329. };
  330. });
  331. // Cocos Creator 3.8 兼容的 TexturePacker JSON 格式
  332. const json = {
  333. frames: frames,
  334. meta: {
  335. app: "https://www.codeandweb.com/texturepacker",
  336. version: "1.0",
  337. image: `${folderName}.png`,
  338. format: "RGBA8888",
  339. size: { w: sheetWidth, h: sheetHeight },
  340. scale: 1
  341. }
  342. };
  343. return JSON.stringify(json, null, 2);
  344. }
  345. /**
  346. * 将 Blob 转换为 Base64
  347. * @param {Blob} blob - Blob 对象
  348. * @returns {Promise<string>} Base64 字符串
  349. */
  350. blobToBase64(blob) {
  351. return new Promise((resolve, reject) => {
  352. const reader = new FileReader();
  353. reader.onloadend = () => {
  354. try {
  355. let base64 = reader.result;
  356. // 移除 data:image/png;base64, 前缀(如果存在)
  357. if (base64 && base64.includes(',')) {
  358. base64 = base64.split(',')[1];
  359. }
  360. if (!base64) {
  361. reject(new Error('Base64 转换失败:结果为空'));
  362. return;
  363. }
  364. resolve(base64);
  365. } catch (error) {
  366. reject(new Error(`Base64 转换失败: ${error.message}`));
  367. }
  368. };
  369. reader.onerror = () => {
  370. reject(new Error('文件读取失败'));
  371. };
  372. reader.readAsDataURL(blob);
  373. });
  374. }
  375. /**
  376. * 下载文件
  377. * @param {Blob} data - 文件数据
  378. * @param {string} filename - 文件名
  379. * @param {string} mimeType - MIME 类型
  380. */
  381. downloadFile(data, filename, mimeType) {
  382. const blob = new Blob([data], { type: mimeType });
  383. const url = URL.createObjectURL(blob);
  384. const a = document.createElement('a');
  385. a.href = url;
  386. a.download = filename;
  387. document.body.appendChild(a);
  388. a.click();
  389. document.body.removeChild(a);
  390. URL.revokeObjectURL(url);
  391. }
  392. /**
  393. * 显示下载确认对话框
  394. */
  395. showDownloadConfirm() {
  396. if (this.downloadConfirmOverlay) {
  397. console.log('[ExportView] 显示下载确认对话框');
  398. // 确保价格已加载
  399. this.loadVIPMattingPrice();
  400. this.downloadConfirmOverlay.style.display = 'flex';
  401. } else {
  402. console.error('[ExportView] 下载确认对话框元素未找到');
  403. }
  404. }
  405. /**
  406. * 隐藏下载确认对话框
  407. */
  408. hideDownloadConfirm() {
  409. if (this.downloadConfirmOverlay) {
  410. this.downloadConfirmOverlay.style.display = 'none';
  411. }
  412. }
  413. /**
  414. * 打开AI生图界面
  415. */
  416. openAIGenerateView() {
  417. if (!this.originalSpritesheetData) {
  418. this.showAlert('请先生成预览图');
  419. return;
  420. }
  421. // 通知父窗口打开AI生图界面
  422. if (window.parent && window.parent !== window) {
  423. window.parent.postMessage({
  424. type: 'open-ai-generate-view',
  425. folderName: this.folderName,
  426. spritesheetData: this.originalSpritesheetData,
  427. spritesheetLayout: this.spritesheetLayout
  428. }, '*');
  429. }
  430. }
  431. /**
  432. * 处理下载选项选择
  433. * @param {string} downloadType - 下载类型:'original', 'normal', 'vip'
  434. */
  435. async handleDownloadOption(downloadType) {
  436. // 如果是VIP抠图,先检查用户Ani币是否足够
  437. if (downloadType === 'vip') {
  438. const username = this.getCurrentUsername();
  439. if (!username) {
  440. this.showAlert('请先登录');
  441. return;
  442. }
  443. try {
  444. // 获取VIP抠图价格
  445. const pricingResponse = await fetch('/api/product-pricing');
  446. if (!pricingResponse.ok) {
  447. throw new Error('获取价格失败');
  448. }
  449. const pricingResult = await pricingResponse.json();
  450. if (!pricingResult.success || !pricingResult.products) {
  451. throw new Error('获取价格失败');
  452. }
  453. const vipMattingProduct = pricingResult.products.find(p => p.id === 'vip-matting');
  454. const price = vipMattingProduct ? (vipMattingProduct.price || 0) : 0;
  455. // 如果价格为0,直接继续
  456. if (price === 0) {
  457. // 继续执行VIP抠图流程
  458. } else {
  459. // 检查用户点数
  460. const pointsResponse = await fetch(`/api/user/points?username=${encodeURIComponent(username)}`);
  461. if (!pointsResponse.ok) {
  462. throw new Error('获取点数失败');
  463. }
  464. const pointsResult = await pointsResponse.json();
  465. if (!pointsResult.success) {
  466. throw new Error('获取点数失败');
  467. }
  468. const userPoints = pointsResult.points || 0;
  469. if (userPoints < price) {
  470. // 点数不足,弹出充值界面
  471. if (window.parent && window.parent !== window) {
  472. window.parent.postMessage({
  473. type: 'open-recharge-view',
  474. needPoints: price,
  475. currentPoints: userPoints
  476. }, '*');
  477. }
  478. this.hideDownloadConfirm();
  479. return;
  480. }
  481. // 点数充足,弹出确认对话框
  482. let confirmed = false;
  483. if (window.parent && window.parent.GlobalConfirm) {
  484. confirmed = await window.parent.GlobalConfirm.show(
  485. `确定要花费 ${price} Ani币使用VIP抠图吗?`
  486. );
  487. } else {
  488. confirmed = confirm(`确定要花费 ${price} Ani币使用VIP抠图吗?`);
  489. }
  490. if (!confirmed) {
  491. return;
  492. }
  493. // 扣除点数
  494. const deductResponse = await fetch('/api/user/deduct-points', {
  495. method: 'POST',
  496. headers: {
  497. 'Content-Type': 'application/json'
  498. },
  499. body: JSON.stringify({
  500. username: username,
  501. points: price
  502. })
  503. });
  504. if (!deductResponse.ok) {
  505. const deductResult = await deductResponse.json();
  506. throw new Error(deductResult.message || '扣除点数失败');
  507. }
  508. const deductResult = await deductResponse.json();
  509. if (!deductResult.success) {
  510. throw new Error(deductResult.message || '扣除点数失败');
  511. }
  512. // 通知父窗口刷新点数
  513. if (window.parent && window.parent !== window) {
  514. window.parent.postMessage({ type: 'refresh-points' }, '*');
  515. }
  516. }
  517. } catch (error) {
  518. console.error('[ExportView] VIP抠图购买检查失败:', error);
  519. this.showAlert(error.message || '操作失败,请稍后重试');
  520. return;
  521. }
  522. }
  523. this.hideDownloadConfirm();
  524. try {
  525. // 显示加载状态(但不隐藏预览图片)
  526. // 使用全局 Loading 提示,不干扰预览图显示
  527. if (window.parent && window.parent.postMessage) {
  528. window.parent.postMessage({
  529. type: 'global-loading',
  530. action: 'show',
  531. text: downloadType === 'original' ? '正在准备下载...' : '正在处理图片...'
  532. }, '*');
  533. }
  534. let processedImageBase64;
  535. // 确定使用的图片源
  536. if (this.geminiOriginalImageData) {
  537. // 如果有 Gemini 图片,使用 Gemini 图片
  538. const geminiImageBase64 = this.geminiOriginalImageData.replace(/^data:image\/\w+;base64,/, '');
  539. if (downloadType === 'original') {
  540. // 源文件下载:直接使用原始图片
  541. processedImageBase64 = geminiImageBase64;
  542. } else if (downloadType === 'normal') {
  543. // 普通抠图:调用 rembg-matting.py
  544. const response = await fetch('http://localhost:3000/api/matting-normal', {
  545. method: 'POST',
  546. headers: {
  547. 'Content-Type': 'application/json'
  548. },
  549. body: JSON.stringify({
  550. imageBase64: geminiImageBase64
  551. })
  552. });
  553. if (!response.ok) {
  554. const errorMessage = await this.handleServerError(response, '普通抠图失败');
  555. throw new Error(errorMessage);
  556. }
  557. const result = await response.json();
  558. if (!result.success || !result.imageData) {
  559. throw new Error('普通抠图处理失败');
  560. }
  561. processedImageBase64 = result.imageData;
  562. } else if (downloadType === 'vip') {
  563. // VIP抠图:调用 BiRefNet
  564. const response = await fetch('http://localhost:3000/api/matting-vip', {
  565. method: 'POST',
  566. headers: {
  567. 'Content-Type': 'application/json'
  568. },
  569. body: JSON.stringify({
  570. imageBase64: geminiImageBase64
  571. })
  572. });
  573. if (!response.ok) {
  574. const errorMessage = await this.handleServerError(response, 'VIP抠图失败');
  575. throw new Error(errorMessage);
  576. }
  577. const result = await response.json();
  578. if (!result.success || !result.imageData) {
  579. throw new Error('VIP抠图处理失败');
  580. }
  581. processedImageBase64 = result.imageData;
  582. }
  583. } else {
  584. // 如果没有 Gemini 图片,使用原始 spritesheet
  585. const imageBlob = await new Promise((resolve, reject) => {
  586. this.spritesheetCanvas.toBlob((blob) => {
  587. if (blob) {
  588. resolve(blob);
  589. } else {
  590. reject(new Error('Canvas 转换失败'));
  591. }
  592. }, 'image/png');
  593. });
  594. // 将图片转换为 Base64
  595. const originalImageBase64 = await this.blobToBase64(imageBlob);
  596. if (downloadType === 'original') {
  597. // 源文件下载:直接使用原始 spritesheet
  598. processedImageBase64 = originalImageBase64;
  599. } else if (downloadType === 'normal') {
  600. // 普通抠图:对原始 spritesheet 进行抠图
  601. const response = await fetch('http://localhost:3000/api/matting-normal', {
  602. method: 'POST',
  603. headers: {
  604. 'Content-Type': 'application/json'
  605. },
  606. body: JSON.stringify({
  607. imageBase64: originalImageBase64
  608. })
  609. });
  610. if (!response.ok) {
  611. const errorMessage = await this.handleServerError(response, '普通抠图失败');
  612. throw new Error(errorMessage);
  613. }
  614. const result = await response.json();
  615. if (!result.success || !result.imageData) {
  616. throw new Error('普通抠图处理失败');
  617. }
  618. processedImageBase64 = result.imageData;
  619. } else if (downloadType === 'vip') {
  620. // VIP抠图:对原始 spritesheet 进行 VIP 抠图
  621. const response = await fetch('http://localhost:3000/api/matting-vip', {
  622. method: 'POST',
  623. headers: {
  624. 'Content-Type': 'application/json'
  625. },
  626. body: JSON.stringify({
  627. imageBase64: originalImageBase64
  628. })
  629. });
  630. if (!response.ok) {
  631. const errorMessage = await this.handleServerError(response, 'VIP抠图失败');
  632. throw new Error(errorMessage);
  633. }
  634. const result = await response.json();
  635. if (!result.success || !result.imageData) {
  636. throw new Error('VIP抠图处理失败');
  637. }
  638. processedImageBase64 = result.imageData;
  639. }
  640. }
  641. // 生成 JSON 数据
  642. const folderName = this.folderName.split('/').pop() || 'spritesheet';
  643. const jsonData = this.generateJSON(
  644. folderName,
  645. this.spritesheetLayout.layout,
  646. this.spritesheetLayout.sheetWidth,
  647. this.spritesheetLayout.sheetHeight
  648. );
  649. // 发送到服务器打包
  650. const response = await fetch('http://localhost:3000/api/pack', {
  651. method: 'POST',
  652. headers: {
  653. 'Content-Type': 'application/json'
  654. },
  655. body: JSON.stringify({
  656. folderName: folderName,
  657. imageData: processedImageBase64,
  658. jsonData: jsonData
  659. })
  660. });
  661. if (!response.ok) {
  662. const errorMessage = await this.handleServerError(response, '打包失败');
  663. throw new Error(errorMessage);
  664. }
  665. // 获取 ZIP 文件的 Blob
  666. const zipBlob = await response.blob();
  667. // 下载 ZIP 文件
  668. this.downloadFile(zipBlob, `${folderName}.zip`, 'application/zip');
  669. // 隐藏全局 Loading 提示
  670. if (window.parent && window.parent.postMessage) {
  671. window.parent.postMessage({
  672. type: 'global-loading',
  673. action: 'hide'
  674. }, '*');
  675. }
  676. // 下载完成后,不关闭弹出框,不刷新图片
  677. // 显示成功提示
  678. this.showAlert('下载成功!');
  679. } catch (error) {
  680. // console.error('[ExportView] 下载失败:', error);
  681. // 隐藏全局 Loading 提示
  682. if (window.parent && window.parent.postMessage) {
  683. window.parent.postMessage({
  684. type: 'global-loading',
  685. action: 'hide'
  686. }, '*');
  687. }
  688. this.showAlert(`下载失败: ${error.message}`);
  689. }
  690. }
  691. /**
  692. * 处理下载按钮点击
  693. */
  694. async handleConfirm() {
  695. // console.log('[ExportView] 用户点击下载按钮');
  696. if (!this.spritesheetCanvas || !this.spritesheetLayout) {
  697. // console.warn('[ExportView] 没有可下载的 Spritesheet');
  698. return;
  699. }
  700. // 总是显示下载确认对话框,让用户选择下载方式
  701. console.log('[ExportView] 显示下载选项对话框');
  702. this.showDownloadConfirm();
  703. }
  704. /**
  705. * 下载 Spritesheet(原始逻辑)
  706. */
  707. async downloadSpritesheet() {
  708. try {
  709. // 生成 JSON 数据
  710. const folderName = this.folderName.split('/').pop() || 'spritesheet';
  711. const jsonData = this.generateJSON(
  712. folderName,
  713. this.spritesheetLayout.layout,
  714. this.spritesheetLayout.sheetWidth,
  715. this.spritesheetLayout.sheetHeight
  716. );
  717. // 确定使用哪个图片:如果有替换后的图片,使用替换后的;否则使用原始的
  718. let imageBase64;
  719. if (this.replacedImageData) {
  720. // 使用替换后的图片(移除 data:image/png;base64, 前缀)
  721. imageBase64 = this.replacedImageData.replace(/^data:image\/\w+;base64,/, '');
  722. } else {
  723. // 使用原始 spritesheet
  724. const imageBlob = await new Promise((resolve, reject) => {
  725. this.spritesheetCanvas.toBlob((blob) => {
  726. if (blob) {
  727. resolve(blob);
  728. } else {
  729. reject(new Error('Canvas 转换失败'));
  730. }
  731. }, 'image/png');
  732. });
  733. // 将图片转换为 Base64
  734. imageBase64 = await this.blobToBase64(imageBlob);
  735. }
  736. // 验证数据
  737. if (!imageBase64 || typeof imageBase64 !== 'string' || imageBase64.trim().length === 0) {
  738. throw new Error('图片数据无效');
  739. }
  740. if (!jsonData || typeof jsonData !== 'string' || jsonData.trim().length === 0) {
  741. throw new Error('JSON 数据无效');
  742. }
  743. // 发送到服务器打包
  744. const response = await fetch('http://localhost:3000/api/pack', {
  745. method: 'POST',
  746. headers: {
  747. 'Content-Type': 'application/json'
  748. },
  749. body: JSON.stringify({
  750. folderName: folderName,
  751. imageData: imageBase64,
  752. jsonData: jsonData
  753. })
  754. });
  755. if (!response.ok) {
  756. const errorMessage = await this.handleServerError(response, '打包失败');
  757. throw new Error(errorMessage);
  758. }
  759. // 获取 ZIP 文件的 Blob
  760. const zipBlob = await response.blob();
  761. // 下载 ZIP 文件
  762. this.downloadFile(zipBlob, `${folderName}.zip`, 'application/zip');
  763. // 下载完成后,不关闭弹出框,不刷新图片
  764. // 显示成功提示
  765. this.showAlert('下载成功!');
  766. } catch (error) {
  767. // console.error('[ExportView] 下载失败:', error);
  768. this.showAlert(`下载失败: ${error.message}`);
  769. }
  770. }
  771. /**
  772. * 关闭弹出框
  773. */
  774. close() {
  775. // console.log('[ExportView] 关闭导出弹出框');
  776. // 清空所有数据
  777. this.reset();
  778. // 通知父窗口关闭弹出框
  779. if (window.parent && window.parent !== window) {
  780. window.parent.postMessage({
  781. type: 'close-export-view'
  782. }, '*');
  783. }
  784. }
  785. /**
  786. * 获取当前登录用户名
  787. */
  788. getCurrentUsername() {
  789. try {
  790. const loginDataStr = localStorage.getItem('loginData');
  791. if (!loginDataStr) {
  792. return null;
  793. }
  794. const loginData = JSON.parse(loginDataStr);
  795. const now = Date.now();
  796. // 检查是否过期
  797. if (now >= loginData.expireTime) {
  798. localStorage.removeItem('loginData');
  799. return null;
  800. }
  801. return loginData.user ? loginData.user.username : null;
  802. } catch (error) {
  803. console.error('[ExportView] 获取用户名失败:', error);
  804. return null;
  805. }
  806. }
  807. /**
  808. * 重置所有数据和UI状态
  809. */
  810. reset() {
  811. // 清空数据属性
  812. this.imageData = null;
  813. this.spritesheetCanvas = null;
  814. this.folderName = null;
  815. this.spritesheetLayout = null;
  816. this.replacedImageData = null;
  817. this.geminiOriginalImageData = null;
  818. this.originalSpritesheetData = null;
  819. // 重置AI按钮状态
  820. if (this.floatingAIBtn) {
  821. this.floatingAIBtn.disabled = true;
  822. }
  823. // 重置预览图区域
  824. if (this.previewImage) {
  825. this.previewImage.src = '';
  826. this.previewImage.classList.remove('show');
  827. }
  828. if (this.previewPlaceholder) {
  829. const spinner = this.previewPlaceholder.querySelector('.loading-spinner');
  830. const loadingText = this.previewPlaceholder.querySelector('.loading-text');
  831. if (spinner) spinner.style.display = 'block';
  832. if (loadingText) {
  833. loadingText.textContent = '正在生成预览图...';
  834. loadingText.style.color = '#6b7280';
  835. }
  836. this.previewPlaceholder.classList.remove('hide');
  837. }
  838. // 重置按钮状态
  839. if (this.confirmBtn) {
  840. this.confirmBtn.disabled = true;
  841. }
  842. }
  843. /**
  844. * 加载VIP抠图价格
  845. */
  846. async loadVIPMattingPrice() {
  847. try {
  848. const response = await fetch('/api/product-pricing');
  849. if (response.ok) {
  850. const result = await response.json();
  851. if (result.success && result.products) {
  852. const vipMattingProduct = result.products.find(p => p.id === 'vip-matting');
  853. const priceEl = document.getElementById('vipMattingPrice');
  854. if (vipMattingProduct && priceEl) {
  855. const price = vipMattingProduct.price || 0;
  856. if (price > 0) {
  857. priceEl.textContent = `${price} Ani币`;
  858. } else {
  859. priceEl.textContent = '免费';
  860. }
  861. }
  862. }
  863. }
  864. } catch (error) {
  865. console.error('[ExportView] 加载VIP抠图价格失败:', error);
  866. const priceEl = document.getElementById('vipMattingPrice');
  867. if (priceEl) {
  868. priceEl.textContent = '-';
  869. }
  870. }
  871. }
  872. /**
  873. * 显示提示信息(使用全局 Alert 组件,直接调用)
  874. * @param {string} message - 提示信息
  875. * @param {number} duration - 显示时长(毫秒),默认2000
  876. */
  877. showAlert(message, duration = 2000) {
  878. // 直接调用父窗口的 GlobalAlert(不通过 postMessage)
  879. try {
  880. // 优先使用父窗口的 GlobalAlert
  881. if (window.parent && window.parent !== window && window.parent.GlobalAlert) {
  882. window.parent.GlobalAlert.show(message, duration);
  883. return;
  884. }
  885. // 如果不在 iframe 中,直接使用当前窗口的 GlobalAlert
  886. if (window.GlobalAlert) {
  887. window.GlobalAlert.show(message, duration);
  888. return;
  889. }
  890. // 降级处理
  891. console.log('[Alert]', message);
  892. } catch (error) {
  893. console.error('[ExportView] 显示 alert 失败:', error);
  894. alert(message);
  895. }
  896. }
  897. // 统一的错误处理函数:解析服务端错误响应并显示
  898. async handleServerError(response, defaultMessage = '操作失败') {
  899. let errorMessage = defaultMessage;
  900. try {
  901. // 尝试解析 JSON 错误响应
  902. const errorData = await response.json().catch(() => null);
  903. if (errorData) {
  904. // 优先使用服务端返回的 message
  905. if (errorData.message) {
  906. errorMessage = errorData.message;
  907. } else if (errorData.error) {
  908. errorMessage = errorData.error;
  909. } else if (typeof errorData === 'string') {
  910. errorMessage = errorData;
  911. }
  912. }
  913. } catch (e) {
  914. // 如果解析失败,使用默认消息或状态码
  915. if (response.status) {
  916. errorMessage = `${defaultMessage} (状态码: ${response.status})`;
  917. }
  918. }
  919. this.showAlert(errorMessage);
  920. return errorMessage;
  921. }
  922. }
  923. // 初始化
  924. window.ExportView = new ExportView();