Theme.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  1. <?php
  2. // +----------------------------------------------------------------------
  3. // | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
  4. // +----------------------------------------------------------------------
  5. // | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
  6. // +----------------------------------------------------------------------
  7. // | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
  8. // +----------------------------------------------------------------------
  9. // | Author: CRMEB Team <admin@crmeb.com>
  10. // +----------------------------------------------------------------------
  11. namespace app\adminapi\controller\v1\diy;
  12. use app\adminapi\controller\AuthController;
  13. use app\jobs\ThemeExportJob;
  14. use app\services\activity\coupon\StoreCouponIssueServices;
  15. use app\services\article\ArticleServices;
  16. use app\services\diy\ThemeDownloadServices;
  17. use app\services\diy\ThemeServices;
  18. use app\services\product\product\StoreProductServices;
  19. use SplFileInfo;
  20. use think\facade\App;
  21. /**
  22. * 主题管理控制器
  23. * 处理主题的列表、详情、保存、导入导出等功能
  24. * @author wuhaotian
  25. * @email 442384644@qq.com
  26. * @date 2025/12/18
  27. */
  28. class Theme extends AuthController
  29. {
  30. /**
  31. * @var ThemeServices 主题服务类
  32. */
  33. protected $services;
  34. /**
  35. * 构造方法
  36. * 注入 ThemeServices 服务
  37. * @param App $app 应用容器实例
  38. * @param ThemeServices $services 主题服务实例
  39. */
  40. public function __construct(App $app, ThemeServices $services)
  41. {
  42. parent::__construct($app);
  43. $this->services = $services;
  44. }
  45. /**
  46. * 获取主题列表
  47. * 支持根据标题、类型、状态筛选
  48. * @return \think\Response JSON格式的响应
  49. * @throws \think\db\exception\DataNotFoundException
  50. * @throws \think\db\exception\DbException
  51. * @throws \think\db\exception\ModelNotFoundException
  52. * @author wuhaotian
  53. * @email 442384644@qq.com
  54. * @date 2025/12/18
  55. */
  56. public function getThemeList()
  57. {
  58. // 获取请求参数,设置默认值
  59. $where = $this->request->getMore([
  60. ['title', ''],
  61. ['type', ''],
  62. ['page_type', ''],
  63. ['is_del', 0],
  64. ]);
  65. // 调用服务层获取列表数据
  66. $data = $this->services->getThemeList($where);
  67. return app('json')->success($data);
  68. }
  69. /**
  70. * 获取主题详情
  71. * @param int $id 主题ID
  72. * @param string $type 查询类型(可选)
  73. * @return \think\Response JSON格式的响应
  74. * @throws \think\db\exception\DataNotFoundException
  75. * @throws \think\db\exception\DbException
  76. * @throws \think\db\exception\ModelNotFoundException
  77. * @author wuhaotian
  78. * @email 442384644@qq.com
  79. * @date 2025/12/18
  80. */
  81. public function getThemeInfo($id, $type = '')
  82. {
  83. $data = $this->services->getThemeInfo($id, $type);
  84. return app('json')->success($data);
  85. }
  86. /**
  87. * 保存主题基本信息
  88. * @param int $id 主题ID
  89. * @return \think\Response JSON格式的响应
  90. * @author wuhaotian
  91. * @email 442384644@qq.com
  92. * @date 2025/12/18
  93. */
  94. public function saveTheme($id)
  95. {
  96. $data = $this->request->getMore([
  97. ['tid', 0],
  98. ['title', ''],
  99. ['type', ''],
  100. ['value', ''],
  101. ['page_type', 'theme'],
  102. ]);
  103. $id = $this->services->saveTheme($id, $data);
  104. return app('json')->success('保存成功', ['id' => $id]);
  105. }
  106. /**
  107. * 保存主题标题信息
  108. * @param int $id 主题ID
  109. * @return \think\Response JSON格式的响应
  110. * @author wuhaotian
  111. * @email 442384644@qq.com
  112. * @date 2025/12/18
  113. */
  114. public function saveThemeTitle($id)
  115. {
  116. $data = $this->request->getMore([
  117. ['tid', 0],
  118. ['title', ''],
  119. ['info', ''],
  120. ['page_type', 'theme'],
  121. ]);
  122. $id = $this->services->saveThemeTitle($id, $data);
  123. return app('json')->success('保存成功', ['id' => $id]);
  124. }
  125. /**
  126. * 保存主题图片信息
  127. * @param int $id 主题ID
  128. * @return \think\Response JSON格式的响应
  129. * @author wuhaotian
  130. * @email 442384644@qq.com
  131. * @date 2025/12/18
  132. */
  133. public function saveThemeImage($id)
  134. {
  135. $data = $this->request->getMore([
  136. ['image', ''],
  137. ['type', ''],
  138. ]);
  139. $id = $this->services->saveThemeImage($id, $data);
  140. return app('json')->success('保存成功', ['id' => $id]);
  141. }
  142. /**
  143. * 获取自定义组件-文章列表
  144. * 用于DIY页面选择文章组件的数据源
  145. * @return \think\Response JSON格式的响应
  146. * @throws \ReflectionException
  147. * @throws \think\db\exception\DataNotFoundException
  148. * @throws \think\db\exception\DbException
  149. * @throws \think\db\exception\ModelNotFoundException
  150. * @author wuhaotian
  151. * @email 442384644@qq.com
  152. * @date 2026/1/12
  153. */
  154. public function getThemeArticleList()
  155. {
  156. $where = $this->request->getMore([
  157. ['ids', ''],
  158. ['cid', ''],
  159. ['order', 0],
  160. ['sort', 0],
  161. ['limit', 10],
  162. ]);
  163. $data = app()->make(ArticleServices::class)->getThemeArticle($where);
  164. return app('json')->success($data);
  165. }
  166. /**
  167. * 获取自定义组件-优惠券列表
  168. * 用于DIY页面选择优惠券组件的数据源
  169. * @return \think\Response JSON格式的响应
  170. * @throws \think\db\exception\DataNotFoundException
  171. * @throws \think\db\exception\DbException
  172. * @throws \think\db\exception\ModelNotFoundException
  173. * @author wuhaotian
  174. * @email 442384644@qq.com
  175. * @date 2026/1/13
  176. */
  177. public function getThemeCouponList()
  178. {
  179. $where = $this->request->getMore([
  180. ['ids', ''],
  181. ['type', ''],
  182. ['user_type', ''],
  183. ['send_type', ''],
  184. ['is_min_price', 0],
  185. ['min_price', 0],
  186. ['start_time', ''],
  187. ['end_time', ''],
  188. ['order', 0],
  189. ['sort', 0],
  190. ['limit', 10],
  191. ]);
  192. $data = app()->make(StoreCouponIssueServices::class)->getThemeCoupon($where);
  193. return app('json')->success($data);
  194. }
  195. /**
  196. * 获取自定义组件-商品列表
  197. * 用于DIY页面选择商品组件的数据源
  198. * @return \think\Response JSON格式的响应
  199. * @throws \think\db\exception\DataNotFoundException
  200. * @throws \think\db\exception\DbException
  201. * @throws \think\db\exception\ModelNotFoundException
  202. * @author wuhaotian
  203. * @email 442384644@qq.com
  204. * @date 2026/1/13
  205. */
  206. public function getThemeProductList()
  207. {
  208. $where = $this->request->getMore([
  209. ['ids', ''],
  210. ['cate_ids', ''],
  211. ['order', 0],
  212. ['sort', 0],
  213. ['limit', 10],
  214. ]);
  215. $data = app()->make(StoreProductServices::class)->getThemeProduct($where);
  216. return app('json')->success($data);
  217. }
  218. /**
  219. * 导出主题数据
  220. * 1. 在 eb_theme_download 中写入一条待处理记录(不含 download_url)
  221. * 2. 将实际打包任务推入队列异步执行
  222. * 3. 队列完成后回填 download_url
  223. *
  224. * @param int $id 主题ID
  225. * @return \think\Response
  226. * @throws \think\db\exception\DataNotFoundException
  227. * @throws \think\db\exception\DbException
  228. * @throws \think\db\exception\ModelNotFoundException
  229. * @author wuhaotian
  230. * @email 442384644@qq.com
  231. * @date 2026/3/10
  232. */
  233. public function exportTheme($id)
  234. {
  235. // 判断是否使用 Redis 缓存且开启了消息队列
  236. $queueEnabled = sys_config('queue_open', 0) == 1 && \think\facade\Env::get('cache.driver', 'file') == 'redis';
  237. if (!$queueEnabled) {
  238. return app('json')->fail('导出功能需要开启 Redis 缓存并开启消息队列,请先到系统设置中开启对应配置');
  239. }
  240. $id = (int)$id;
  241. // 1. 查询主题基本信息,获取标题
  242. $info = $this->services->getThemeInfo($id);
  243. // 2. 创建主题打包目录
  244. $dir = public_path() . 'theme/download/' . $id . '/';
  245. if (!is_dir($dir)) mkdir($dir, 0755, true);
  246. // 3. 清理打包目录,防止数据污染
  247. $iterator = new \RecursiveIteratorIterator(
  248. new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
  249. \RecursiveIteratorIterator::CHILD_FIRST
  250. );
  251. foreach ($iterator as $fileInfo) {
  252. if ($fileInfo->isDir()) {
  253. @rmdir($fileInfo->getRealPath());
  254. } else {
  255. @unlink($fileInfo->getRealPath());
  256. }
  257. }
  258. // 4. 创建主题图片目录
  259. $imagesDir = $dir . 'images/';
  260. if (!is_dir($imagesDir)) mkdir($imagesDir, 0755, true);
  261. // 5. 向 eb_theme_download 写入待处理记录(download_url 暂不填写)
  262. /** @var ThemeDownloadServices $themeDownloadServices */
  263. $themeDownloadServices = app()->make(ThemeDownloadServices::class);
  264. $recordId = $themeDownloadServices->addDownloadRecord($id, $info['title'], '');
  265. // 6. 将打包任务推入队列
  266. ThemeExportJob::dispatch('export', [$info, $recordId]);
  267. return app('json')->success('正在导出中,请勿操作页面!', ['record_id' => $recordId]);
  268. }
  269. /**
  270. * 查询主题导出记录
  271. * 前端轮询该接口,待 download_url 不为空时说明队列已完成
  272. *
  273. * @param int $record_id 下载记录ID
  274. * @return \think\Response
  275. * @author wuhaotian
  276. * @email 442384644@qq.com
  277. * @date 2026/3/10
  278. */
  279. public function getExportRecord($record_id)
  280. {
  281. /** @var ThemeDownloadServices $themeDownloadServices */
  282. $themeDownloadServices = app()->make(ThemeDownloadServices::class);
  283. $record = $themeDownloadServices->getDownloadInfo((int)$record_id);
  284. return app('json')->success([
  285. 'download_url' => $record['download_url'] ?? '',
  286. ]);
  287. }
  288. /**
  289. * 导入主题
  290. * 上传 Zip 包,解压并还原主题配置
  291. * 1. 解压 Zip 包到 theme/import/
  292. * 2. 读取 config.json
  293. * 3. 提取包内图片移动到 uploads/theme/ 目录
  294. * 4. 递归遍历配置,修正图片路径并替换域名
  295. *
  296. * @return \think\Response
  297. * @author wuhaotian
  298. * @email 442384644@qq.com
  299. * @date 2026/1/15
  300. */
  301. public function importTheme()
  302. {
  303. // 1 获取文件
  304. [$importUrl] = $this->request->postMore([
  305. ['url', ''],
  306. ], true);
  307. $realPath = public_path() . $importUrl;
  308. if (!file_exists($realPath)) return app('json')->fail('文件不存在');
  309. // 2. 解压文件到 theme/import/ 目录
  310. $dir = 'theme/import/';
  311. if (!is_dir($dir)) mkdir($dir, 0755, true);
  312. // 清理导入目录,防止数据污染
  313. $iterator = new \RecursiveIteratorIterator(
  314. new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
  315. \RecursiveIteratorIterator::CHILD_FIRST
  316. );
  317. /** @var SplFileInfo $fileInfo */
  318. foreach ($iterator as $fileInfo) {
  319. if ($fileInfo->isDir()) {
  320. @rmdir($fileInfo->getRealPath());
  321. } else {
  322. @unlink($fileInfo->getRealPath());
  323. }
  324. }
  325. $zip = new \ZipArchive();
  326. $zip->open($realPath);
  327. $zip->extractTo($dir);
  328. $zip->close();
  329. // 3. 读取解压后的 config.json 文件
  330. $configPath = $dir . 'config.json';
  331. if (!file_exists($configPath)) return app('json')->fail('文件不存在');
  332. $config = json_decode(file_get_contents($configPath), true);
  333. if (!is_array($config)) return app('json')->fail('文件内容错误');
  334. // 4. 处理图片资源迁移
  335. // 将压缩包里面的所有图片,移动到 uploads/theme/{时间戳}/ 文件夹下
  336. $timestamp = date('YmdHis');
  337. $themeDir = 'uploads/theme/' . $timestamp . '/';
  338. if (!is_dir($themeDir)) mkdir($themeDir, 0755, true);
  339. $rootPath = realpath($dir);
  340. $imageMap = []; // 记录相对路径到新上传路径的映射
  341. // 递归扫描解压目录中的所有图片
  342. $iterator = new \RecursiveIteratorIterator(
  343. new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
  344. );
  345. foreach ($iterator as $fileInfo) {
  346. if ($fileInfo->isDir()) continue;
  347. $filePath = $fileInfo->getRealPath();
  348. // 跳过配置文件
  349. if (basename($filePath) === 'config.json') continue;
  350. // 只处理指定扩展名的图片
  351. if (!preg_match('/\.(png|jpe?g|gif|webp|svg)$/i', $filePath)) continue;
  352. // 获取文件相对于解压根目录的相对路径
  353. $relative = ltrim(str_replace($rootPath, '', $filePath), DIRECTORY_SEPARATOR);
  354. $basename = basename($filePath);
  355. // 目标路径
  356. $target = $themeDir . $basename;
  357. // 移动/复制文件
  358. if (@copy($filePath, $target)) {
  359. $imageMap[$relative] = $target; // 记录映射:包内相对路径 => 新系统路径
  360. }
  361. }
  362. // 5. 准备域名替换的基础 URL
  363. $siteUrl = rtrim(sys_config('site_url'), '/');
  364. $siteParts = parse_url($siteUrl);
  365. $base = '';
  366. if ($siteParts && isset($siteParts['host'])) {
  367. $scheme = $siteParts['scheme'] ?? 'http';
  368. $base = $scheme . '://' . $siteParts['host'];
  369. if (isset($siteParts['port'])) {
  370. $base .= ':' . $siteParts['port'];
  371. }
  372. }
  373. $rewriteArray = function (&$data) use (&$rewriteArray, $imageMap, $base) {
  374. if (!is_array($data)) return;
  375. foreach ($data as $k => &$v) {
  376. if (is_array($v)) {
  377. $rewriteArray($v);
  378. } elseif (is_string($v)) {
  379. $decoded = json_decode($v, true);
  380. if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
  381. $rewriteArray($decoded);
  382. $v = json_encode($decoded, JSON_UNESCAPED_UNICODE);
  383. continue;
  384. }
  385. // 检查是否在图片映射表中(处理本地导入的图片)
  386. // 如果在映射表中,说明该图片已从压缩包解压并上传到 uploads/theme/ 目录
  387. // 此时需要将其路径替换为带当前站点域名的完整 URL
  388. if (isset($imageMap[$v])) {
  389. if ($base !== '') {
  390. // 拼接域名 + 新路径
  391. $v = rtrim($base, '/') . '/' . ltrim($imageMap[$v], '/');
  392. } else {
  393. // 如果无法获取域名,则仅使用相对路径
  394. $v = $imageMap[$v];
  395. }
  396. continue;
  397. }
  398. // 兼容处理 exportTheme 导出时带有的 theme/download/ 前缀
  399. // 导出时,home_image 等字段被赋值为 theme/download/xxx.png
  400. // 而压缩包内的文件实际上是 xxx.png,导致直接匹配 imageMap 失败
  401. // 因此需要去掉 theme/download/ 前缀再次尝试匹配
  402. if (strpos($v, 'theme/download/') === 0) {
  403. $rel = substr($v, strlen('theme/download/'));
  404. if (isset($imageMap[$rel])) {
  405. if ($base !== '') {
  406. // 同样拼接域名 + 新路径
  407. $v = rtrim($base, '/') . '/' . ltrim($imageMap[$rel], '/');
  408. } else {
  409. $v = $imageMap[$rel];
  410. }
  411. continue;
  412. }
  413. }
  414. // 处理域名替换(将旧域名的链接替换为当前站点域名)
  415. // 防止导入的主题配置中包含旧站点的域名,导致图片无法加载
  416. $parts = parse_url($v);
  417. if (!$parts || !isset($parts['host']) || $base === '') {
  418. continue;
  419. }
  420. $path = $parts['path'] ?? '';
  421. $query = isset($parts['query']) ? '?' . $parts['query'] : '';
  422. $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : '';
  423. $v = rtrim($base, '/') . $path . $query . $fragment;
  424. }
  425. }
  426. unset($v);
  427. };
  428. // 执行替换逻辑
  429. $rewriteArray($config);
  430. // 执行数据写入
  431. $themeId = $this->services->importThemeData($config);
  432. // 返回成功
  433. return app('json')->success('导入成功', ['theme_id' => $themeId]);
  434. }
  435. /**
  436. * @description: 使用主题
  437. * @param int $id 主题ID
  438. * @return array
  439. */
  440. public function useTheme(int $id)
  441. {
  442. $this->services->useTheme($id);
  443. return app('json')->success('使用成功');
  444. }
  445. /**
  446. * @description: 使用主题数据
  447. * @param array $data 主题数据
  448. * @return array
  449. */
  450. public function useThemeData($id)
  451. {
  452. [$theme_id, $type] = $this->request->getMore([
  453. ['theme_id', 0],
  454. ['type', ''],
  455. ], true);
  456. $this->services->useThemeData($id, $theme_id, $type);
  457. return app('json')->success('使用成功');
  458. }
  459. /**
  460. * @description: 获取正在使用的主题
  461. * @return array
  462. */
  463. public function getUsingTheme()
  464. {
  465. $theme = $this->services->getUsingTheme();
  466. return app('json')->success($theme);
  467. }
  468. /**
  469. * @description: 还原主题
  470. * @param int $id 主题ID
  471. * @return array
  472. */
  473. public function restoreTheme(int $id)
  474. {
  475. $this->services->restoreTheme($id);
  476. return app('json')->success('还原成功');
  477. }
  478. /**
  479. * @description: 删除主题
  480. * @param int $id 主题ID
  481. * @return array
  482. */
  483. public function deleteTheme(int $id)
  484. {
  485. $this->services->deleteTheme($id);
  486. return app('json')->success('删除成功');
  487. }
  488. /**
  489. * 获取微页面数据列表
  490. * @return \think\Response JSON格式的响应
  491. * @throws \think\db\exception\DataNotFoundException
  492. * @throws \think\db\exception\DbException
  493. * @throws \think\db\exception\ModelNotFoundException
  494. * @author wuhaotian
  495. * @email 442384644@qq.com
  496. * @date 2026/1/20
  497. */
  498. public function getMicroPageList()
  499. {
  500. $data = $this->services->getMicroPageList();
  501. return app('json')->success($data);
  502. }
  503. }