Material.php 55 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313
  1. <?php
  2. namespace app\api\controller;
  3. use app\common\controller\Api;
  4. use app\service\AIGatewayService;
  5. use think\Db;
  6. use think\Exception;
  7. class Material extends Api
  8. {
  9. protected $noNeedLogin = ['*'];
  10. protected $noNeedRight = ['*'];
  11. public function index()
  12. {
  13. $this->success('Material');
  14. }
  15. /**
  16. * 上传图片保存扩展名:优先客户端文件名,无后缀时用 MIME 推断,避免生成 xxx. 无扩展名
  17. */
  18. protected function resolveUploadedImageExt($file)
  19. {
  20. $name = $file->getInfo('name') ?? '';
  21. $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
  22. if ($ext === 'jpeg') {
  23. $ext = 'jpg';
  24. }
  25. $allowed = ['jpg', 'png', 'gif', 'webp', 'bmp'];
  26. if ($ext && in_array($ext, $allowed, true)) {
  27. return $ext;
  28. }
  29. $mime = method_exists($file, 'getMime') ? strtolower((string) $file->getMime()) : '';
  30. $map = [
  31. 'image/jpeg' => 'jpg',
  32. 'image/png' => 'png',
  33. 'image/gif' => 'gif',
  34. 'image/webp' => 'webp',
  35. 'image/bmp' => 'bmp',
  36. ];
  37. if (isset($map[$mime])) {
  38. return $map[$mime];
  39. }
  40. return 'jpg';
  41. }
  42. /**
  43. * 根据 material_url 删除 public 下对应文件(如 uploads/material/2026-03-25/xxx.png)
  44. */
  45. protected function unlinkMaterialFileByUrl($materialUrl)
  46. {
  47. if ($materialUrl === null || $materialUrl === '') {
  48. return;
  49. }
  50. $rel = str_replace('\\', '/', trim((string) $materialUrl));
  51. $rel = ltrim($rel, '/');
  52. if ($rel === '') {
  53. return;
  54. }
  55. $fullPath = rtrim(str_replace('\\', '/', ROOT_PATH), '/') . '/public/' . $rel;
  56. if (is_file($fullPath)) {
  57. @unlink($fullPath);
  58. }
  59. }
  60. /**
  61. * 图层所属页码(多页模版):0 起,缺省或非数字按 0
  62. */
  63. protected function layerPageIndex(array $layer): int
  64. {
  65. if (!array_key_exists('page_index', $layer)) {
  66. return 0;
  67. }
  68. $v = $layer['page_index'];
  69. if ($v === '' || $v === null) {
  70. return 0;
  71. }
  72. if (is_numeric($v)) {
  73. return (int) $v;
  74. }
  75. return 0;
  76. }
  77. /**
  78. * 解析 product_template.page_image_urls(JSON 数组)
  79. */
  80. protected function decodePageImageUrlsField($stored): array
  81. {
  82. if ($stored === null || $stored === '') {
  83. return [];
  84. }
  85. if (is_array($stored)) {
  86. return $stored;
  87. }
  88. if (!is_string($stored)) {
  89. return [];
  90. }
  91. $decoded = json_decode($stored, true);
  92. return is_array($decoded) ? $decoded : [];
  93. }
  94. /**
  95. * 删除多页预览图(本地 + OSS),用于更新前清理或删模版
  96. */
  97. protected function deleteStoredPageGalleryImages($pageImageUrlsJsonOrArray): void
  98. {
  99. $list = is_array($pageImageUrlsJsonOrArray)
  100. ? $pageImageUrlsJsonOrArray
  101. : $this->decodePageImageUrlsField($pageImageUrlsJsonOrArray);
  102. foreach ($list as $p) {
  103. if ($p === null || $p === '') {
  104. continue;
  105. }
  106. $this->unlinkMaterialFileByUrl((string) $p);
  107. Common::deleteOssObject((string) $p);
  108. }
  109. }
  110. /**
  111. * 将 preview_images[] 中每页 dataURL 落盘并同步 OSS,返回与下标对齐的路径数组(失败页为空串占位)
  112. *
  113. * @param array $previewImages 下标 0=第 1 页…
  114. * @param string $saveDir public/uploads/template/YYYY-mm-dd/
  115. * @param string $dateYmd YYYY-mm-dd
  116. * @return array{paths: string[], oss: bool[]}
  117. */
  118. protected function savePreviewGalleryBase64List(array $previewImages, string $saveDir, string $dateYmd): array
  119. {
  120. if ($previewImages === []) {
  121. return ['paths' => [], 'oss' => []];
  122. }
  123. $keys = array_keys($previewImages);
  124. $maxIdx = $keys === [] ? -1 : max(array_map('intval', $keys));
  125. $n = $maxIdx >= 0 ? ($maxIdx + 1) : 0;
  126. $paths = array_fill(0, $n, '');
  127. $oss = array_fill(0, $n, false);
  128. foreach ($previewImages as $pageIndex => $base64Data) {
  129. $i = (int) $pageIndex;
  130. if ($i < 0 || $i >= $n) {
  131. continue;
  132. }
  133. if (!is_string($base64Data) || trim($base64Data) === '') {
  134. continue;
  135. }
  136. if (!preg_match('/data:image\/(png|jpg|jpeg|webp);base64,(.+)/is', $base64Data, $m)) {
  137. continue;
  138. }
  139. $imageType = strtolower($m[1]);
  140. if ($imageType === 'jpeg') {
  141. $imageType = 'jpg';
  142. }
  143. $b64 = preg_replace('/\s+/', '', $m[2]);
  144. $imageData = base64_decode($b64, true);
  145. if ($imageData === false || strlen($imageData) < 100) {
  146. continue;
  147. }
  148. $fn = 'page_' . $i . '_' . uniqid() . '_' . date('YmdHis') . '.' . $imageType;
  149. $full = $saveDir . $fn;
  150. if (!file_put_contents($full, $imageData)) {
  151. continue;
  152. }
  153. $dbPath = '/uploads/template/' . $dateYmd . '/' . $fn;
  154. $paths[$i] = $dbPath;
  155. $oss[$i] = Common::uploadLocalFileToOss((string) $full, (string) $dbPath);
  156. }
  157. return ['paths' => $paths, 'oss' => $oss];
  158. }
  159. /**
  160. * 从 layers 解析最终 material_id 列表(与写入 relation 时逻辑一致,同一 id 可出现多次)
  161. */
  162. protected function collectMaterialIdsFromLayers($layers, $layerIdToMaterial)
  163. {
  164. $ids = [];
  165. if (empty($layers) || !is_array($layers)) {
  166. return $ids;
  167. }
  168. foreach ($layers as $layer) {
  169. $materialId = $layer['material_id'] ?? null;
  170. if (isset($layer['id']) && isset($layerIdToMaterial[$layer['id']])) {
  171. $materialId = $layerIdToMaterial[$layer['id']]['id'];
  172. }
  173. if ($materialId !== null && $materialId !== '') {
  174. $ids[] = (int) $materialId;
  175. }
  176. }
  177. return $ids;
  178. }
  179. /**
  180. * 按图层引用次数增加 template_material.count
  181. */
  182. protected function incrementMaterialUseCountsFromIds(array $materialIds)
  183. {
  184. if (empty($materialIds)) {
  185. return;
  186. }
  187. $counts = array_count_values($materialIds);
  188. foreach ($counts as $mid => $cnt) {
  189. if ($mid > 0 && $cnt > 0) {
  190. Db::name('template_material')->where('id', $mid)->setInc('count', $cnt);
  191. }
  192. }
  193. }
  194. /**
  195. * 某模版旧关联中各 material_id 出现几次,count 减几次(删除关联前调用)
  196. */
  197. protected function decrementMaterialUseCountsByTemplateId($templateId)
  198. {
  199. $rows = Db::name('template_material_relation')->where('template_id', $templateId)->column('material_id');
  200. if (empty($rows)) {
  201. return;
  202. }
  203. $ids = [];
  204. foreach ($rows as $mid) {
  205. if ($mid !== null && $mid !== '' && (int) $mid > 0) {
  206. $ids[] = (int) $mid;
  207. }
  208. }
  209. if (empty($ids)) {
  210. return;
  211. }
  212. $counts = array_count_values($ids);
  213. foreach ($counts as $mid => $cnt) {
  214. Db::name('template_material')->where('id', $mid)->setDec('count', $cnt);
  215. }
  216. }
  217. /**
  218. * 新增素材图片上传
  219. *
  220. * 方式一(推荐,与当前前端一致):multipart/form-data
  221. * - Category_id:分类 ID
  222. * - img[] 或 img:多张图片文件(字段名 img,多文件用 img[])
  223. * - material_name[] 或 material_name:与图片一一对应的名称数组
  224. * - sys_id:可选
  225. *
  226. * 方式二:JSON / 表单里传 uploaded_materials(base64)
  227. * - uploaded_materials=[{data:'data:image/png;base64,...', material_name:'xxx'}, ...]
  228. */
  229. public function Material_Add()
  230. {
  231. $params = $this->request->param();
  232. $sysId = trim($params['sys_id'] ?? '');
  233. $categoryId = isset($params['Category_id']) ? intval($params['Category_id']) : null;
  234. $dateDir = date('Y-m-d');
  235. $materialSavePath = str_replace('\\', '/', ROOT_PATH) . 'public/uploads/material/' . $dateDir . '/';
  236. if (!is_dir($materialSavePath)) {
  237. mkdir($materialSavePath, 0755, true);
  238. }
  239. $uploaded = [];
  240. // 兼容单图(img)与多图(img[]),ThinkPHP 会返回对象或数组
  241. $files = $this->request->file('img');
  242. if (!empty($files)) {
  243. $fileList = is_array($files) ? $files : [$files];
  244. $names = $params['material_name'] ?? [];
  245. if (!is_array($names)) {
  246. $names = [$names];
  247. }
  248. foreach ($fileList as $i => $file) {
  249. if (!$file || !$file->isValid()) {
  250. continue;
  251. }
  252. // 先落本地(后续可用于备份/排障),再尝试同步 OSS
  253. $saveFileName = uniqid() . '_' . date('YmdHis') . '.' . $this->resolveUploadedImageExt($file);
  254. $info = $file->move($materialSavePath, $saveFileName);
  255. if (!$info) {
  256. continue;
  257. }
  258. $savedName = $info->getFilename();
  259. $fullLocalPath = $materialSavePath . $savedName;
  260. $materialUrl = 'uploads/material/' . $dateDir . '/' . $savedName;
  261. // OSS 失败不阻断主流程(本地已保存)
  262. Common::uploadLocalFileToOss((string)$fullLocalPath, (string)$materialUrl);
  263. $materialRecord = [
  264. 'sys_id' => $sysId,
  265. 'Category_id' => $categoryId,
  266. 'material_name' => trim($names[$i] ?? ''),
  267. 'material_url' => $materialUrl,
  268. 'create_time' => date('Y-m-d H:i:s'),
  269. 'count' => 1
  270. ];
  271. $materialId = Db::name('template_material')->insertGetId($materialRecord);
  272. $uploaded[] = ['id' => $materialId, 'material_url' => $materialUrl];
  273. }
  274. } else {
  275. // 兼容旧版 base64 传参:uploaded_materials=[{data, material_name}, ...]
  276. $materials = $params['uploaded_materials'] ?? [];
  277. if (empty($materials) || !is_array($materials)) {
  278. return json(['code' => 1, 'msg' => '请上传至少一张素材图片(img/img[] 或 uploaded_materials)']);
  279. }
  280. foreach ($materials as $item) {
  281. $base64Data = $item['data'] ?? '';
  282. if (empty($base64Data) || !preg_match('/data:image\/(png|jpg|jpeg|webp);base64,([^\)]+)/i', $base64Data, $m)) {
  283. continue;
  284. }
  285. $imageType = strtolower($m[1]);
  286. $rawBase64 = preg_replace('/\s+/', '', $m[2]);
  287. $imageData = base64_decode($rawBase64);
  288. if ($imageData === false || strlen($imageData) < 100) {
  289. continue;
  290. }
  291. $ext = ($imageType === 'jpeg') ? 'jpg' : $imageType;
  292. $fileName = uniqid() . '_' . date('YmdHis') . '.' . $ext;
  293. $fullPath = $materialSavePath . $fileName;
  294. if (!file_put_contents($fullPath, $imageData)) {
  295. continue;
  296. }
  297. $materialUrl = 'uploads/material/' . $dateDir . '/' . $fileName;
  298. // base64 分支同样尝试同步 OSS
  299. Common::uploadLocalFileToOss((string)$fullPath, (string)$materialUrl);
  300. $materialRecord = [
  301. 'sys_id' => $sysId,
  302. 'Category_id' => $categoryId,
  303. 'material_name' => trim($item['material_name'] ?? ''),
  304. 'material_url' => $materialUrl,
  305. 'create_time' => date('Y-m-d H:i:s'),
  306. 'count' => 1
  307. ];
  308. $materialId = Db::name('template_material')->insertGetId($materialRecord);
  309. $uploaded[] = ['id' => $materialId, 'material_url' => $materialUrl];
  310. }
  311. }
  312. if (empty($uploaded)) {
  313. return json(['code' => 1, 'msg' => '没有有效的图片上传成功']);
  314. }
  315. return json(['code' => 0, 'msg' => '上传成功', 'data' => ['list' => $uploaded, 'count' => count($uploaded)]]);
  316. }
  317. /**
  318. * 素材图片删除:物理删库 + 删除 public 下对应图片文件
  319. */
  320. public function materialDelete()
  321. {
  322. $params = $this->request->param();
  323. if (empty($params['id'])) {
  324. return json(['code' => 1, 'msg' => 'id 不能为空', 'data' => '']);
  325. }
  326. $id = intval($params['id']);
  327. $row = Db::name('template_material')->where('id', $id)->find();
  328. if (!$row) {
  329. return json(['code' => 1, 'msg' => '记录不存在', 'data' => '']);
  330. }
  331. $list = Db::name('template_material_relation')->where('material_id', $id)->select();
  332. if ($list) {
  333. return json([
  334. 'code' => 1,
  335. 'msg' => '当前素材已被模版使用,不可删除',
  336. 'data' => ''
  337. ]);
  338. }
  339. $this->unlinkMaterialFileByUrl($row['material_url'] ?? '');
  340. // 同步删除 OSS 对象(如未配置 OSS 或删除失败,不阻断数据库删除)
  341. Common::deleteOssObject((string)($row['material_url'] ?? ''));
  342. $res = Db::name('template_material')->where('id', $id)->delete();
  343. if (!$res) {
  344. return json(['code' => 1, 'msg' => '删除失败', 'data' => '']);
  345. }
  346. return json(['code' => 0, 'msg' => '删除成功']);
  347. }
  348. /**
  349. * 素材修改
  350. * 参数:id(必填), material_name(可选), Category_id(可选)
  351. */
  352. public function Material_Update()
  353. {
  354. $params = $this->request->param();
  355. if (empty($params['id']) || !is_numeric($params['id'])) {
  356. return json(['code' => 1, 'msg' => 'id 不能为空']);
  357. }
  358. $id = intval($params['id']);
  359. $row = Db::name('template_material')->where('id', $id)->find();
  360. if (!$row) {
  361. return json(['code' => 1, 'msg' => '记录不存在']);
  362. }
  363. $update = ['update_time' => date('Y-m-d H:i:s')];
  364. if (array_key_exists('material_name', $params)) {
  365. $update['material_name'] = trim($params['material_name'] ?? '');
  366. }
  367. if (array_key_exists('Category_id', $params)) {
  368. $update['Category_id'] = $params['Category_id'] === '' || $params['Category_id'] === null ? null : intval($params['Category_id']);
  369. }
  370. if (count($update) <= 1) {
  371. return json(['code' => 1, 'msg' => '无有效修改字段']);
  372. }
  373. $affected = Db::name('template_material')->where('id', $id)->update($update);
  374. if ($affected > 0) {
  375. return json(['code' => 0, 'msg' => '修改成功']);
  376. }
  377. return json(['code' => 1, 'msg' => '修改失败']);
  378. }
  379. /**
  380. * 获取素材库列表接口(分页+搜索)
  381. * 参数:page(页码,从1开始), pageSize(每页条数,默认100,建议上限500)
  382. * 搜索:对 material_name、category_name 模糊匹配
  383. */
  384. public function Material_List(){
  385. $params = $this->request->param();
  386. $page = max(1, intval($params['page'] ?? 1));
  387. $pageSize = min(500, max(1, intval($params['pageSize'] ?? 30)));
  388. $where = [];
  389. if (!empty($params['search'])) {
  390. // 使用更安全的查询方式,material_name 与 category_name 任一匹配即可
  391. $search = trim($params['search']);
  392. $where['a.material_name|b.category_name|a.material_url'] = ['like', '%' . $search . '%'];
  393. }
  394. if (!empty($params['Category_id'])) {
  395. $where['a.Category_id'] = ['like', '%' . $params['Category_id'] . '%'];
  396. }
  397. $query = Db::name('template_material')->alias('a')
  398. ->field('a.id,a.sys_id,a.Category_id,b.category_name,a.material_name,a.material_url,a.count')
  399. ->join('template_material_category b', 'a.Category_id = b.id AND b.mod_rq IS NULL', 'LEFT')
  400. ->where($where)
  401. ->whereNull('a.mod_rq')
  402. ->order('a.id desc');
  403. $total = (clone $query)->count();
  404. $data = $query->page($page, $pageSize)->select();
  405. foreach ($data as &$item) {
  406. if (!empty($item['material_url'])) {
  407. $item['material_url'] = Common::ossFullUrl((string)$item['material_url']);
  408. }
  409. }
  410. unset($item);
  411. return json([
  412. 'code' => 0,
  413. 'msg' => '',
  414. 'data' => $data,
  415. 'total' => $total,
  416. 'count' => count($data)
  417. ]);
  418. }
  419. /**
  420. * 模板关联素材查询
  421. */
  422. public function Template_Material_Relation(){
  423. $params = $this->request->param();
  424. $res = Db::name('template_material_relation')->alias('a')
  425. ->field('a.*,c.chinese_description,c.page_image_urls, b.material_url,c.canvasWidth,c.canvasHeight,c.size')
  426. ->join('template_material b', 'a.material_id = b.id', 'left')
  427. ->join('product_template c', 'a.template_id = c.id', 'left')
  428. ->where('a.template_id',$params['id'])->select();
  429. foreach ($res as &$item) {
  430. if (!empty($item['material_url'])) {
  431. $item['material_url'] = Common::ossFullUrl((string)$item['material_url']);
  432. }
  433. if (array_key_exists('chinese_description', $item)) {
  434. $item['chinese_description'] = Common::decodeChineseDescriptionForApi($item['chinese_description']);
  435. }
  436. if (array_key_exists('page_image_urls', $item) && $item['page_image_urls'] !== null && $item['page_image_urls'] !== '') {
  437. $arr = json_decode((string) $item['page_image_urls'], true);
  438. if (is_array($arr)) {
  439. foreach ($arr as &$pu) {
  440. if ($pu !== null && $pu !== '') {
  441. $pu = Common::ossFullUrl((string) $pu);
  442. }
  443. }
  444. unset($pu);
  445. $item['page_image_urls'] = $arr;
  446. }
  447. }
  448. }
  449. unset($item);
  450. // 处理null值,转换为空字符串
  451. if($res){
  452. foreach($res as &$item){
  453. foreach($item as $key => &$value){
  454. if($value === null){
  455. $value = '';
  456. }
  457. }
  458. }
  459. return json([
  460. 'code' => 0,
  461. 'msg' => '',
  462. 'data' => $res,
  463. 'count' => count($res)
  464. ]);
  465. }else{
  466. return json([
  467. 'code' => 1,
  468. 'msg' => '此模版作品暂无素材图',
  469. 'data' => '',
  470. 'count' => 0
  471. ]);
  472. }
  473. }
  474. /**
  475. * 新增模版(生成模版)
  476. */
  477. public function Template_Material_Add(){
  478. $params = $this->request->param();
  479. // echo "<pre>";
  480. // print_r($params);
  481. // echo "<pre>";die;
  482. // 处理 uploaded_materials:保存素材图片到 uploads/material/ 并写入 template_material 表
  483. $layerIdToMaterial = []; // layer_id => ['id'=>material_id, 'url'=>material_url]
  484. $ossSync = ['configured' => Common::isOssEnabled(), 'materials' => [], 'template_main' => null, 'template_thumb' => null];
  485. if (!empty($params['uploaded_materials'])) {
  486. $materialSavePath = str_replace('\\', '/', ROOT_PATH . 'public/uploads/material/' . date('Y-m-d') . '/');
  487. if (!is_dir($materialSavePath)) {
  488. mkdir($materialSavePath, 0755, true);
  489. }
  490. foreach ($params['uploaded_materials'] as $item) {
  491. $base64Data = $item['data'] ?? '';
  492. if (empty($base64Data) || !preg_match('/data:image\/(png|jpg|jpeg);base64,([A-Za-z0-9+\/=]+)/i', $base64Data, $m)) {
  493. continue;
  494. }
  495. $imageType = strtolower($m[1]);
  496. $imageData = base64_decode($m[2]);
  497. if ($imageData === false || strlen($imageData) < 100) {
  498. continue;
  499. }
  500. $ext = ($imageType === 'jpeg') ? 'jpg' : $imageType;
  501. $fileName = uniqid() . '_' . date('YmdHis') . '.' . $ext;
  502. $fullPath = $materialSavePath . $fileName;
  503. if (!file_put_contents($fullPath, $imageData)) {
  504. continue;
  505. }
  506. $materialUrl = 'uploads/material/' . date('Y-m-d') . '/' . $fileName;
  507. // uploaded_materials 分支:素材图先落本地,再尝试同步到 OSS(失败不影响新增模版)
  508. $ossSync['materials'][] = [
  509. 'objectKey' => $materialUrl,
  510. 'ok' => Common::uploadLocalFileToOss((string)$fullPath, (string)$materialUrl),
  511. ];
  512. $materialRecord = [
  513. 'sys_id' => $params['sys_id'] ?? '',
  514. 'material_url' => $materialUrl,
  515. 'type' => $item['type'] ?? '',
  516. 'Category_id' => $item['Category_id'] ?? '',
  517. 'chinese_description' => $item['chinese_description'] ?? '',
  518. 'material_name' => $item['material_name'] ?? '',
  519. 'create_time' => date('Y-m-d H:i:s'),
  520. 'count' => 1
  521. ];
  522. $materialId = Db::name('template_material')->insertGetId($materialRecord);
  523. if ($materialId && isset($item['layer_id'])) {
  524. $layerIdToMaterial[$item['layer_id']] = ['id' => $materialId, 'url' => $materialUrl];
  525. }
  526. }
  527. }
  528. $save_path = ROOT_PATH . 'public' . '/' . 'uploads' . '/' . 'template' .'/'. date('Y-m-d') . '/';
  529. // 移除ROOT_PATH中可能存在的反斜杠,确保统一使用正斜杠
  530. $save_path = str_replace('\\', '/', $save_path);
  531. // 自动创建文件夹(如果不存在)
  532. if (!is_dir($save_path)) {
  533. mkdir($save_path, 0755, true);
  534. }
  535. // 提取base64图片数据
  536. $previewImage = $params['previewImage'];
  537. // 匹配base64图片数据
  538. preg_match('/data:image\/(png|jpg|jpeg);base64,([^"]+)/', $previewImage, $matches);
  539. if (empty($matches)) {
  540. return '未找到图片数据';
  541. }
  542. $image_type = $matches[1];
  543. $base64_data = $matches[2];
  544. // 解码base64数据
  545. $image_data = base64_decode($base64_data);
  546. if ($image_data === false) {
  547. return '图片解码失败';
  548. }
  549. // 生成唯一文件名(包含正确的扩展名)
  550. $file_name = uniqid() . '_' . date('YmdHis') . '.' . $image_type;
  551. $full_file_path = $save_path . $file_name;
  552. // 保存图片到文件系统
  553. if (!file_put_contents($full_file_path, $image_data)) {
  554. return '图片保存失败';
  555. }
  556. // 生成数据库存储路径(使用正斜杠格式)
  557. $db_img_path = '/uploads/template/'. date('Y-m-d') .'/' . $file_name;
  558. // 预览图(模板原图)同步 OSS,保持本地/云端路径一致(失败见返回 oss_sync 与 runtime/log)
  559. $ossSync['template_main'] = Common::uploadLocalFileToOss((string)$full_file_path, (string)$db_img_path);
  560. // 生成缩略图
  561. $thumbnail_path = $this->generateThumbnail($full_file_path, $save_path, $file_name);
  562. $db_thumbnail_path = '/uploads/template/'.date('Y-m-d') .'/' . $thumbnail_path;
  563. $fullThumbnailPath = $save_path . $thumbnail_path;
  564. // 缩略图文件存在时再同步 OSS,避免空文件名导致无效上传
  565. if (!empty($thumbnail_path) && is_file($fullThumbnailPath)) {
  566. $ossSync['template_thumb'] = Common::uploadLocalFileToOss((string)$fullThumbnailPath, (string)$db_thumbnail_path);
  567. } else {
  568. $ossSync['template_thumb'] = false;
  569. }
  570. $dateYmd = date('Y-m-d');
  571. $newGalleryPathsForRollback = []; // 多页预览路径;插入失败时用于回滚删除
  572. // 多页预览图:preview_images 与页下标一致,落盘 + OSS,JSON 存入 page_image_urls(TEXT,需建表字段)
  573. if (!empty($params['preview_images']) && is_array($params['preview_images'])) {
  574. $gal = $this->savePreviewGalleryBase64List($params['preview_images'], $save_path, $dateYmd);
  575. $newGalleryPathsForRollback = $gal['paths'];
  576. $ossSync['page_images'] = $gal['oss'];
  577. }
  578. //新增到模版表(product_template)
  579. $record['toexamine'] = '审核通过';
  580. $record['sys_id'] = $params['sys_id'];
  581. // 多页提示词:chinese_description 为数组时下标 0=第 1 页…,入库 JSON;缺省存 []
  582. $record['chinese_description'] = Common::encodeChineseDescriptionForDb($params['chinese_description'] ?? []);
  583. $record['template_name'] = isset($params['template_name']) ? (string) $params['template_name'] : '';
  584. $record['canvasWidth'] = $params['canvasWidth'];
  585. $record['canvasHeight'] = $params['canvasHeight'];
  586. $record['size'] = $params['canvasRatio'];
  587. $record['template_image_url'] = $db_img_path;//原图
  588. $record['thumbnail_image'] = $db_thumbnail_path;//缩略图
  589. if (!empty($params['preview_images']) && is_array($params['preview_images'])) {
  590. $record['page_image_urls'] = json_encode($newGalleryPathsForRollback, JSON_UNESCAPED_UNICODE);
  591. }
  592. $record['sys_rq'] = date('Y-m-d');
  593. $record['create_time'] = date('Y-m-d H:i:s');
  594. // 插入模板记录并获取ID
  595. $templateId = Db::name('product_template')->insertGetId($record);
  596. if (!$templateId) {
  597. // 如果数据库插入失败,删除已保存的图片
  598. if (file_exists($full_file_path)) {
  599. unlink($full_file_path);
  600. }
  601. $this->deleteStoredPageGalleryImages($newGalleryPathsForRollback);
  602. return '数据库插入失败';
  603. }
  604. // 处理layers数据,插入到模版-素材表(template_material_relation)
  605. if (!empty($params['layers'])) {
  606. $layers = $params['layers'];
  607. foreach ($layers as $layer) {
  608. $materialId = $layer['material_id'] ?? null;
  609. $materialUrl = isset($layer['url']) ? $layer['url'] : '';
  610. if (isset($layer['id']) && isset($layerIdToMaterial[$layer['id']])) {
  611. $materialId = $layerIdToMaterial[$layer['id']]['id'];
  612. $materialUrl = $layerIdToMaterial[$layer['id']]['url'];
  613. }
  614. $relationData = [
  615. 'template_id' => $templateId,//模版ID
  616. 'sys_id' => $params['sys_id'],//用户名
  617. 'material_id' => $materialId,//素材ID
  618. 'z_index' => $layer['id'],//层级
  619. 'layer_name' => $layer['name'],//图层名称
  620. 'layer_type' => $layer['type'],//类型
  621. 'material_url' => $materialUrl,//素材图片
  622. 'position_x' => $layer['x'],
  623. 'position_y' => $layer['y'],
  624. 'width' => $layer['width'],
  625. 'height' => $layer['height'],
  626. 'rotation' => $layer['rotation'],//素材旋转角度
  627. 'opacity' => $layer['opacity'],//素材透明度
  628. 'visible' => $layer['visible'],//图层是否显示
  629. 'locked' => isset($layer['locked']) && $layer['locked'] ? 1 : 0,//图层是否锁住
  630. //文字部分参数
  631. 'text_content' => isset($layer['text']) ? $layer['text'] : '',//文字内容
  632. 'font_family' => isset($layer['fontFamily']) ? $layer['fontFamily'] : '',//字体(如 Arial)
  633. 'font_size' => isset($layer['fontSize']) ? $layer['fontSize'] : '',//字号大小
  634. 'font_color' => isset($layer['color']) ? $layer['color'] : '',//文字颜色
  635. 'background_border_radius' => isset($layer['background_border_radius']) ? $layer['background_border_radius'] : '',//背景圆角
  636. 'background_color' => isset($layer['backgroundColor']) ? $layer['backgroundColor'] : '',//文字背景颜色
  637. 'text_align' => isset($layer['textAlign']) ? $layer['textAlign'] : '',//对齐方式
  638. 'font_weight' => isset($layer['fontWeight']) ? $layer['fontWeight'] : '',//加粗
  639. 'font_style' => isset($layer['fontStyle']) ? $layer['fontStyle'] : '',//斜体
  640. 'font_underline' => isset($layer['textDecoration']) ? $layer['textDecoration'] : '',//下划线
  641. 'line_height' => isset($layer['lineHeight']) ? $layer['lineHeight'] : '',//行高
  642. 'letter_spacing' => isset($layer['letterSpacing']) ? $layer['letterSpacing'] : '',//字距
  643. //形状与线条
  644. 'shape_type' => $layer['shape_type'] ?? $layer['shapeType'] ?? '',//形状类型 rect/circle/ellipse/line
  645. 'fill_mode' => $layer['fill_mode'] ?? $layer['fillMode'] ?? '',//填充模式 solid/none
  646. 'fill_color' => $layer['fill_color'] ?? $layer['fillColor'] ?? '',//填充色
  647. 'stroke_color' => $layer['stroke_color'] ?? $layer['strokeColor'] ?? '',//描边色
  648. 'page_index' => $this->layerPageIndex($layer),// 多页:0=第 1 页画布
  649. 'stroke_width' => isset($layer['stroke_width']) ? floatval($layer['stroke_width']) : (isset($layer['strokeWidth']) ? floatval($layer['strokeWidth']) : 0),//描边宽度
  650. 'create_time' => date('Y-m-d H:i:s')
  651. ];
  652. // 插入关联记录
  653. Db::name('template_material_relation')->insert($relationData);
  654. }
  655. $this->incrementMaterialUseCountsFromIds($this->collectMaterialIdsFromLayers($layers, $layerIdToMaterial));
  656. }
  657. $pageUrlsOut = [];
  658. foreach ($newGalleryPathsForRollback as $pu) {
  659. $pageUrlsOut[] = ($pu === '' || $pu === null) ? '' : Common::ossFullUrl((string) $pu);
  660. }
  661. return json([
  662. 'code' => 0,
  663. 'msg' => '',
  664. 'data' => '',
  665. 'template_id' => $templateId,
  666. 'template_image_url' => $db_img_path,
  667. 'template_image' => $db_thumbnail_path,
  668. 'page_image_urls' => $pageUrlsOut,
  669. // OSS 是否开启、各文件是否上传成功(任一为 false 时查 runtime/log 中 [OSS uploadLocalFileToOss])
  670. 'oss_sync' => $ossSync,
  671. ]);
  672. }
  673. /**
  674. * 修改模版
  675. */
  676. public function Template_Material_Update(){
  677. $params = $this->request->param();
  678. // echo "<pre>";
  679. // print_r($params);
  680. // echo "<pre>";die;
  681. // 验证模板ID
  682. if (empty($params['template_id'])) {
  683. return json([
  684. 'code' => 1,
  685. 'msg' => '模板ID不能为空',
  686. 'data' => ''
  687. ]);
  688. }
  689. $templateId = $params['template_id'];
  690. // 检查模板是否存在
  691. $template = Db::name('product_template')->where('id', $templateId)->find();
  692. if (!$template) {
  693. return json([
  694. 'code' => 1,
  695. 'msg' => '模板不存在',
  696. 'data' => ''
  697. ]);
  698. }
  699. $oldGalleryPathsToDeleteAfterDbOk = [];
  700. $newGalleryPathsRollbackOnFail = [];
  701. // 处理 uploaded_materials:修改时会有新的素材图上传,保存到 uploads/material/ 并写入 template_material 表(参考新增模版)
  702. $layerIdToMaterial = [];
  703. if (!empty($params['uploaded_materials'])) {
  704. $materialSavePath = str_replace('\\', '/', ROOT_PATH . 'public/uploads/material/' . date('Y-m-d') . '/');
  705. if (!is_dir($materialSavePath)) {
  706. mkdir($materialSavePath, 0755, true);
  707. }
  708. foreach ($params['uploaded_materials'] as $item) {
  709. $base64Data = $item['data'] ?? '';
  710. if (empty($base64Data) || !preg_match('/data:image\/(png|jpg|jpeg);base64,([A-Za-z0-9+\/=]+)/i', $base64Data, $m)) {
  711. continue;
  712. }
  713. $imageType = strtolower($m[1]);
  714. $imageData = base64_decode($m[2]);
  715. if ($imageData === false || strlen($imageData) < 100) {
  716. continue;
  717. }
  718. $ext = ($imageType === 'jpeg') ? 'jpg' : $imageType;
  719. $fileName = uniqid() . '_' . date('YmdHis') . '.' . $ext;
  720. $fullPath = $materialSavePath . $fileName;
  721. if (!file_put_contents($fullPath, $imageData)) {
  722. continue;
  723. }
  724. $materialUrl = 'uploads/material/' . date('Y-m-d') . '/' . $fileName;
  725. // 修改模版时新增素材:先本地保存,再同步 OSS(失败不阻塞更新)
  726. Common::uploadLocalFileToOss((string)$fullPath, (string)$materialUrl);
  727. $materialRecord = [
  728. 'sys_id' => $params['sys_id'] ?? '',
  729. 'material_url' => $materialUrl,
  730. 'type' => $item['type'] ?? '',
  731. 'Category_id' => $item['Category_id'] ?? '',
  732. 'chinese_description' => $item['chinese_description'] ?? '',
  733. 'material_name' => $item['material_name'] ?? '',
  734. 'create_time' => date('Y-m-d H:i:s'),
  735. 'count' => 0
  736. ];
  737. $materialId = Db::name('template_material')->insertGetId($materialRecord);
  738. if ($materialId && isset($item['layer_id'])) {
  739. $layerIdToMaterial[$item['layer_id']] = ['id' => $materialId, 'url' => $materialUrl];
  740. }
  741. }
  742. }
  743. // 处理图片更新
  744. $db_img_path = $template['template_image_url'];
  745. $db_thumbnail_path = $template['thumbnail_image'];
  746. if (!empty($params['previewImage'])) {
  747. $save_path = ROOT_PATH . 'public' . '/' . 'uploads' . '/' . 'template' .'/'. date('Y-m-d') . '/';
  748. // 移除ROOT_PATH中可能存在的反斜杠,确保统一使用正斜杠
  749. $save_path = str_replace('\\', '/', $save_path);
  750. // 自动创建文件夹(如果不存在)
  751. if (!is_dir($save_path)) {
  752. mkdir($save_path, 0755, true);
  753. }
  754. // 提取base64图片数据
  755. $previewImage = $params['previewImage'];
  756. // 匹配base64图片数据
  757. preg_match('/data:image\/(png|jpg|jpeg);base64,([^"]+)/', $previewImage, $matches);
  758. if (empty($matches)) {
  759. return json([
  760. 'code' => 1,
  761. 'msg' => '未找到图片数据',
  762. 'data' => ''
  763. ]);
  764. }
  765. $image_type = $matches[1];
  766. $base64_data = $matches[2];
  767. // 解码base64数据
  768. $image_data = base64_decode($base64_data);
  769. if ($image_data === false) {
  770. return json([
  771. 'code' => 1,
  772. 'msg' => '图片解码失败',
  773. 'data' => ''
  774. ]);
  775. }
  776. // 生成唯一文件名(包含正确的扩展名)
  777. $file_name = uniqid() . '_' . date('YmdHis') . '.' . $image_type;
  778. $full_file_path = $save_path . $file_name;
  779. // 保存图片到文件系统
  780. if (!file_put_contents($full_file_path, $image_data)) {
  781. return json([
  782. 'code' => 1,
  783. 'msg' => '图片保存失败',
  784. 'data' => ''
  785. ]);
  786. }
  787. // 生成数据库存储路径(使用正斜杠格式)
  788. $db_img_path = '/uploads/template/'. date('Y-m-d') .'/' . $file_name;
  789. // 修改模版预览图:同步 OSS,便于前端统一使用云端地址
  790. Common::uploadLocalFileToOss((string)$full_file_path, (string)$db_img_path);
  791. // 生成缩略图
  792. $thumbnail_path = $this->generateThumbnail($full_file_path, $save_path, $file_name);
  793. $db_thumbnail_path = '/uploads/template/'.date('Y-m-d') .'/' . $thumbnail_path;
  794. $fullThumbnailPath = $save_path . $thumbnail_path;
  795. // 修改模版缩略图:存在则同步 OSS
  796. if (!empty($thumbnail_path) && is_file($fullThumbnailPath)) {
  797. Common::uploadLocalFileToOss((string)$fullThumbnailPath, (string)$db_thumbnail_path);
  798. }
  799. // 删除旧图片
  800. if (!empty($template['template_image_url'])) {
  801. $oldImagePath = ROOT_PATH . 'public' . $template['template_image_url'];
  802. if (file_exists($oldImagePath)) {
  803. unlink($oldImagePath);
  804. }
  805. }
  806. if (!empty($template['thumbnail_image'])) {
  807. $oldThumbnailPath = ROOT_PATH . 'public' . $template['thumbnail_image'];
  808. if (file_exists($oldThumbnailPath)) {
  809. unlink($oldThumbnailPath);
  810. }
  811. }
  812. }
  813. // 更新模版表(product_template)
  814. $record['canvasWidth'] = $params['canvasWidth'];
  815. $record['canvasHeight'] = $params['canvasHeight'];
  816. $record['size'] = $params['canvasRatio'];
  817. if (!empty($db_img_path)) {
  818. $record['template_image_url'] = $db_img_path;//原图
  819. $record['thumbnail_image'] = $db_thumbnail_path;//缩略图
  820. }
  821. if (array_key_exists('preview_images', $params) && is_array($params['preview_images'])) {
  822. $oldGalleryPathsToDeleteAfterDbOk = $this->decodePageImageUrlsField($template['page_image_urls'] ?? null);
  823. $saveGal = str_replace('\\', '/', ROOT_PATH . 'public/uploads/template/' . date('Y-m-d') . '/');
  824. if (!is_dir($saveGal)) {
  825. mkdir($saveGal, 0755, true);
  826. }
  827. $gal = $this->savePreviewGalleryBase64List($params['preview_images'], $saveGal, date('Y-m-d'));
  828. $newGalleryPathsRollbackOnFail = $gal['paths'];
  829. $record['page_image_urls'] = json_encode($gal['paths'], JSON_UNESCAPED_UNICODE);
  830. }
  831. $record['sys_rq'] = date('Y-m-d');
  832. if (array_key_exists('chinese_description', $params)) {
  833. $record['chinese_description'] = Common::encodeChineseDescriptionForDb($params['chinese_description']);
  834. }
  835. if (array_key_exists('template_name', $params)) {
  836. $record['template_name'] = (string) $params['template_name'];
  837. }
  838. $record['update_time'] = date('Y-m-d H:i:s');
  839. // 更新模板记录
  840. $res = Db::name('product_template')->where('id', $templateId)->update($record);
  841. if (!$res) {
  842. $this->deleteStoredPageGalleryImages($newGalleryPathsRollbackOnFail);
  843. return json([
  844. 'code' => 1,
  845. 'msg' => '数据库更新失败',
  846. 'data' => ''
  847. ]);
  848. }
  849. if ($oldGalleryPathsToDeleteAfterDbOk !== []) {
  850. foreach ($oldGalleryPathsToDeleteAfterDbOk as $p) {
  851. if ($p === null || $p === '') {
  852. continue;
  853. }
  854. $this->unlinkMaterialFileByUrl((string) $p);
  855. Common::deleteOssObject((string) $p);
  856. }
  857. }
  858. // 处理layers数据,更新模版-素材表(template_material_relation)
  859. if (!empty($params['layers'])) {
  860. $this->decrementMaterialUseCountsByTemplateId($templateId);
  861. // 删除旧的关联记录
  862. Db::name('template_material_relation')->where('template_id', $templateId)->delete();
  863. $layers = $params['layers'];
  864. foreach ($layers as $layer) {
  865. $materialId = $layer['material_id'] ?? null;
  866. $materialUrl = isset($layer['url']) ? $layer['url'] : '';
  867. if (isset($layer['id']) && isset($layerIdToMaterial[$layer['id']])) {
  868. $materialId = $layerIdToMaterial[$layer['id']]['id'];
  869. $materialUrl = $layerIdToMaterial[$layer['id']]['url'];
  870. }
  871. $relationData = [
  872. 'template_id' => $templateId,//模版ID
  873. 'sys_id' => $params['sys_id'] ?? '',//用户名
  874. 'material_id' => $materialId,//素材ID
  875. 'z_index' => $layer['id'],//层级
  876. 'layer_name' => $layer['name'],//图层名称
  877. 'layer_type' => $layer['type'],//类型
  878. 'material_url' => $materialUrl,//素材图片
  879. 'position_x' => $layer['x'],
  880. 'position_y' => $layer['y'],
  881. 'width' => $layer['width'],
  882. 'height' => $layer['height'],
  883. 'rotation' => $layer['rotation'],//素材旋转角度
  884. 'opacity' => $layer['opacity'],//素材透明度
  885. 'visible' => $layer['visible'],//图层是否显示
  886. 'locked' => isset($layer['locked']) && $layer['locked'] ? 1 : 0,//图层是否锁住
  887. //文字部分参数
  888. 'text_content' => isset($layer['text']) ? $layer['text'] : '',//文字内容
  889. 'font_family' => isset($layer['fontFamily']) ? $layer['fontFamily'] : '',//字体(如 Arial)
  890. 'font_size' => isset($layer['fontSize']) ? $layer['fontSize'] : '',//字号大小
  891. 'font_color' => isset($layer['color']) ? $layer['color'] : '',//文字颜色
  892. 'background_color' => isset($layer['backgroundColor']) ? $layer['backgroundColor'] : '',//文字背景颜色
  893. 'background_border_radius' => isset($layer['background_border_radius']) ? $layer['background_border_radius'] : '',//背景圆角
  894. 'text_align' => isset($layer['textAlign']) ? $layer['textAlign'] : '',//对齐方式
  895. 'font_weight' => isset($layer['fontWeight']) ? $layer['fontWeight'] : '',//加粗
  896. 'font_style' => isset($layer['fontStyle']) ? $layer['fontStyle'] : '',//斜体
  897. 'font_underline' => isset($layer['textDecoration']) ? $layer['textDecoration'] : '',//下划线
  898. 'line_height' => isset($layer['lineHeight']) ? $layer['lineHeight'] : '',//行高
  899. 'letter_spacing' => isset($layer['letterSpacing']) ? $layer['letterSpacing'] : '',//字距
  900. //形状与线条
  901. 'shape_type' => $layer['shape_type'] ?? $layer['shapeType'] ?? '',//形状类型 rect/circle/ellipse/line
  902. 'fill_mode' => $layer['fill_mode'] ?? $layer['fillMode'] ?? '',//填充模式 solid/none
  903. 'fill_color' => $layer['fill_color'] ?? $layer['fillColor'] ?? '',//填充色
  904. 'stroke_color' => $layer['stroke_color'] ?? $layer['strokeColor'] ?? '',//描边色
  905. 'page_index' => $this->layerPageIndex($layer),// 多页:0=第 1 页画布
  906. 'stroke_width' => isset($layer['stroke_width']) ? floatval($layer['stroke_width']) : (isset($layer['strokeWidth']) ? floatval($layer['strokeWidth']) : 0),//描边宽度
  907. 'create_time' => date('Y-m-d H:i:s')
  908. ];
  909. // 插入关联记录
  910. Db::name('template_material_relation')->insert($relationData);
  911. }
  912. $this->incrementMaterialUseCountsFromIds($this->collectMaterialIdsFromLayers($layers, $layerIdToMaterial));
  913. }
  914. $pageGalleryOut = array_key_exists('preview_images', $params)
  915. ? $newGalleryPathsRollbackOnFail
  916. : $this->decodePageImageUrlsField($template['page_image_urls'] ?? null);
  917. foreach ($pageGalleryOut as &$gp) {
  918. if ($gp !== null && $gp !== '') {
  919. $gp = Common::ossFullUrl((string) $gp);
  920. }
  921. }
  922. unset($gp);
  923. return json([
  924. 'code' => 0,
  925. 'msg' => '修改成功',
  926. 'data' => '',
  927. 'template_id' => $templateId,
  928. 'template_image_url' => $db_img_path,
  929. 'template_image' => $db_thumbnail_path,
  930. 'page_image_urls' => $pageGalleryOut,
  931. ]);
  932. }
  933. /**
  934. * 生成缩略图(高质量)
  935. * @param string $originalPath 原图路径
  936. * @param string $savePath 保存目录
  937. * @param string $fileName 原文件名
  938. * @return string 缩略图文件名
  939. */
  940. private function generateThumbnail($originalPath, $savePath, $fileName) {
  941. // 获取图片信息
  942. $imageInfo = getimagesize($originalPath);
  943. if (!$imageInfo) {
  944. return '';
  945. }
  946. $width = $imageInfo[0];
  947. $height = $imageInfo[1];
  948. // 计算缩略图尺寸(保持比例,最大宽度400)
  949. $maxWidth = 400;
  950. $maxHeight = 400;
  951. if ($width > $maxWidth || $height > $maxHeight) {
  952. $ratio = min($maxWidth / $width, $maxHeight / $height);
  953. $thumbWidth = round($width * $ratio);
  954. $thumbHeight = round($height * $ratio);
  955. } else {
  956. $thumbWidth = $width;
  957. $thumbHeight = $height;
  958. }
  959. // 创建缩略图画布
  960. $thumbnail = imagecreatetruecolor($thumbWidth, $thumbHeight);
  961. // 根据图片类型创建图像资源
  962. switch ($imageInfo[2]) {
  963. case IMAGETYPE_JPEG:
  964. $source = imagecreatefromjpeg($originalPath);
  965. break;
  966. case IMAGETYPE_PNG:
  967. $source = imagecreatefrompng($originalPath);
  968. // 处理PNG透明
  969. imagealphablending($thumbnail, false);
  970. imagesavealpha($thumbnail, true);
  971. $transparent = imagecolorallocatealpha($thumbnail, 255, 255, 255, 127);
  972. imagefilledrectangle($thumbnail, 0, 0, $thumbWidth, $thumbHeight, $transparent);
  973. break;
  974. case IMAGETYPE_GIF:
  975. $source = imagecreatefromgif($originalPath);
  976. break;
  977. default:
  978. return '';
  979. }
  980. if (!$source) {
  981. return '';
  982. }
  983. // 调整图片大小(使用高质量缩放)
  984. imagecopyresampled($thumbnail, $source, 0, 0, 0, 0, $thumbWidth, $thumbHeight, $width, $height);
  985. // 生成缩略图文件名
  986. $pathInfo = pathinfo($fileName);
  987. $thumbnailName = $pathInfo['filename'] . '_thumb.' . $pathInfo['extension'];
  988. $thumbnailPath = $savePath . $thumbnailName;
  989. // 保存缩略图(使用高质量设置)
  990. switch ($imageInfo[2]) {
  991. case IMAGETYPE_JPEG:
  992. imagejpeg($thumbnail, $thumbnailPath, 95); // 95% 质量,接近原图
  993. break;
  994. case IMAGETYPE_PNG:
  995. imagepng($thumbnail, $thumbnailPath, 3); // 压缩级别 3,保持较高质量
  996. break;
  997. case IMAGETYPE_GIF:
  998. imagegif($thumbnail, $thumbnailPath);
  999. break;
  1000. }
  1001. // 释放资源
  1002. imagedestroy($source);
  1003. imagedestroy($thumbnail);
  1004. return $thumbnailName;
  1005. }
  1006. /**
  1007. * 发布模版(release=1)
  1008. */
  1009. public function Template_Material_Publish(){
  1010. $params = $this->request->param();
  1011. $record['release'] = 1;
  1012. $record['update_time'] = date('Y-m-d H:i:s');
  1013. $res = Db::name('product_template')->where('id', $params['template_id'])->update($record);
  1014. if (!$res) {
  1015. return json([
  1016. 'code' => 1,
  1017. 'msg' => '发布失败',
  1018. 'data' => ''
  1019. ]);
  1020. }
  1021. return json([
  1022. 'code' => 0,
  1023. 'msg' => '发布成功'
  1024. ]);
  1025. }
  1026. /**
  1027. * 取消发布模版(release=0)
  1028. */
  1029. public function Template_Material_Unpublish(){
  1030. $params = $this->request->param();
  1031. $record['release'] = 0;
  1032. $record['update_time'] = date('Y-m-d H:i:s');
  1033. $res = Db::name('product_template')->where('id', $params['template_id'])->update($record);
  1034. if (!$res) {
  1035. return json([
  1036. 'code' => 1,
  1037. 'msg' => '发布失败',
  1038. 'data' => ''
  1039. ]);
  1040. }
  1041. return json([
  1042. 'code' => 0,
  1043. 'msg' => '发布成功'
  1044. ]);
  1045. }
  1046. /**
  1047. * 删除模版
  1048. */
  1049. public function Template_Material_Delete(){
  1050. $params = $this->request->param();
  1051. if (empty($params['template_id']) || !is_numeric($params['template_id'])) {
  1052. return json([
  1053. 'code' => 1,
  1054. 'msg' => 'template_id 参数错误',
  1055. 'data' => ''
  1056. ]);
  1057. }
  1058. $templateId = intval($params['template_id']);
  1059. $template = Db::name('product_template')->where('id', $templateId)->find();
  1060. if (!$template) {
  1061. return json([
  1062. 'code' => 1,
  1063. 'msg' => '模版不存在',
  1064. 'data' => ''
  1065. ]);
  1066. }
  1067. // 删除本地文件
  1068. $this->unlinkMaterialFileByUrl($template['template_image_url'] ?? '');
  1069. $this->unlinkMaterialFileByUrl($template['thumbnail_image'] ?? '');
  1070. $this->deleteStoredPageGalleryImages($template['page_image_urls'] ?? '');
  1071. // 删除 OSS 文件(失败不阻断主流程)
  1072. Common::deleteOssObject((string)($template['template_image_url'] ?? ''));
  1073. Common::deleteOssObject((string)($template['thumbnail_image'] ?? ''));
  1074. // 删除模板记录(物理删除)
  1075. $res = Db::name('product_template')->where('id', $templateId)->delete();
  1076. if (!$res) {
  1077. return json([
  1078. 'code' => 1,
  1079. 'msg' => '模版删除失败',
  1080. 'data' => ''
  1081. ]);
  1082. }
  1083. return json([
  1084. 'code' => 0,
  1085. 'msg' => '模版删除成功'
  1086. ]);
  1087. }
  1088. /**
  1089. * 素材分类查询(一级+二级树形结构,供前端展示)
  1090. */
  1091. public function Material_Category_List()
  1092. {
  1093. $all = Db::name('template_material_category')
  1094. ->field('id,category_name,parent_id,sort')
  1095. ->where('status', 1)
  1096. ->whereNull('mod_rq')
  1097. ->order('sort', 'asc')
  1098. ->select();
  1099. // 分离一级(parent_id=0)和二级
  1100. $level1 = [];
  1101. $level2ByParent = [];
  1102. foreach ($all as $row) {
  1103. if ($row['parent_id'] == 0) {
  1104. $row['children'] = [];
  1105. $level1[] = $row;
  1106. } else {
  1107. $pid = $row['parent_id'];
  1108. if (!isset($level2ByParent[$pid])) {
  1109. $level2ByParent[$pid] = [];
  1110. }
  1111. $level2ByParent[$pid][] = $row;
  1112. }
  1113. }
  1114. // 按 sort 排序一级,并把二级挂到对应一级下
  1115. usort($level1, function ($a, $b) {
  1116. return (int)($a['sort'] ?? 0) - (int)($b['sort'] ?? 0);
  1117. });
  1118. foreach ($level1 as &$item) {
  1119. $item['children'] = $level2ByParent[$item['id']] ?? [];
  1120. usort($item['children'], function ($a, $b) {
  1121. return (int)($a['sort'] ?? 0) - (int)($b['sort'] ?? 0);
  1122. });
  1123. }
  1124. return json(['code' => 0, 'msg' => '', 'data' => $level1]);
  1125. }
  1126. /**
  1127. * 新增素材分类(支持一级、二级)
  1128. * 一级:parent_id 不传或传 0
  1129. * 二级:parent_id 传一级分类的 id
  1130. * 参数:category_name(必填), parent_id(可选,默认0), sort(可选,默认0), status(可选,默认1)
  1131. */
  1132. public function Material_Category_Add()
  1133. {
  1134. $params = $this->request->param();
  1135. if (empty(trim($params['category_name'] ?? ''))) {
  1136. return json(['code' => 1, 'msg' => '分类名称不能为空']);
  1137. }
  1138. $parentId = isset($params['parent_id']) ? intval($params['parent_id']) : 0;
  1139. // 二级分类:校验父分类存在且未软删除
  1140. if ($parentId > 0) {
  1141. $parent = Db::name('template_material_category')
  1142. ->where('id', $parentId)
  1143. ->where('parent_id', 0)
  1144. ->whereNull('mod_rq')
  1145. ->find();
  1146. if (!$parent) {
  1147. return json(['code' => 1, 'msg' => '父分类不存在或已删除']);
  1148. }
  1149. }
  1150. $data = [
  1151. 'category_name' => trim($params['category_name']),
  1152. 'parent_id' => $parentId,
  1153. 'sort' => $params['sort'] ?? 0,
  1154. 'status' => $params['status'] ?? '1',
  1155. 'createtime' => date('Y-m-d H:i:s')
  1156. ];
  1157. $id = Db::name('template_material_category')->insertGetId($data);
  1158. if ($id) {
  1159. return json(['code' => 0, 'msg' => '新增成功', 'data' => ['id' => $id]]);
  1160. }
  1161. return json(['code' => 1, 'msg' => '新增失败']);
  1162. }
  1163. /**
  1164. * 修改素材分类
  1165. * 参数:id(必填), category_name/sort/status/parent_id(可选)
  1166. */
  1167. public function Material_Category_Update()
  1168. {
  1169. $params = $this->request->param();
  1170. if (empty($params['id']) || !is_numeric($params['id'])) {
  1171. return json(['code' => 1, 'msg' => '参数错误']);
  1172. }
  1173. $data = ['updatetime' => date('Y-m-d H:i:s')];
  1174. if (isset($params['category_name']) && trim($params['category_name']) !== '') {
  1175. $data['category_name'] = trim($params['category_name']);
  1176. }
  1177. if (isset($params['parent_id'])) {
  1178. $data['parent_id'] = intval($params['parent_id']);
  1179. }
  1180. if (isset($params['sort'])) {
  1181. $data['sort'] = $params['sort'];
  1182. }
  1183. if (isset($params['status'])) {
  1184. $data['status'] = $params['status'];
  1185. }
  1186. $affected = Db::name('template_material_category')
  1187. ->where('id', intval($params['id']))
  1188. ->whereNull('mod_rq')
  1189. ->update($data);
  1190. if ($affected > 0) {
  1191. return json(['code' => 0, 'msg' => '修改成功']);
  1192. }
  1193. return json(['code' => 1, 'msg' => '记录不存在或已删除']);
  1194. }
  1195. /**
  1196. * 删除素材分类(软删除,设置 mod_rq)
  1197. */
  1198. public function Material_Category_Delete()
  1199. {
  1200. $params = $this->request->param();
  1201. if (empty($params['id']) || !is_numeric($params['id'])) {
  1202. return json(['code' => 1, 'msg' => '参数错误']);
  1203. }
  1204. $affected = Db::name('template_material_category')
  1205. ->where('id', intval($params['id']))
  1206. ->update(['mod_rq' => date('Y-m-d H:i:s')]);
  1207. if ($affected > 0) {
  1208. return json(['code' => 0, 'msg' => '删除成功']);
  1209. }
  1210. return json(['code' => 1, 'msg' => '记录不存在或已删除']);
  1211. }
  1212. }