Dataexport.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. <?php
  2. namespace app\admin\controller\routine;
  3. use PhpOffice\PhpSpreadsheet\Cell\DataType;
  4. use Throwable;
  5. use app\common\controller\Backend;
  6. use ba\Random;
  7. use PhpZip\Exception\ZipException;
  8. use PhpZip\ZipFile;
  9. use think\db\exception\PDOException;
  10. use modules\dataexport\library\ExportLib;
  11. use think\facade\Db;
  12. use PhpOffice\PhpSpreadsheet\Spreadsheet;
  13. use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
  14. use ba\Filesystem;
  15. /**
  16. * 导出任务管理
  17. *
  18. */
  19. class Dataexport extends Backend
  20. {
  21. /**
  22. * Dataexport模型对象
  23. * @var object
  24. * @phpstan-var \app\admin\model\routine\Dataexport
  25. */
  26. protected object $model;
  27. protected string|array $quickSearchField = ['name'];
  28. protected string|array $defaultSortField = 'id,desc';
  29. protected array $withJoinTable = ['admin'];
  30. protected string|array $preExcludeFields = ['createtime'];
  31. protected array $noNeedPermission = ['getFieldList', 'test', 'task', 'taskControl', 'taskZip'];
  32. protected string $saveDir;
  33. protected string $exportZipDir;
  34. public function initialize(): void
  35. {
  36. parent::initialize();
  37. $this->model = new \app\admin\model\routine\Dataexport;
  38. $this->saveDir = runtime_path() . 'export' . DIRECTORY_SEPARATOR;// 临时文件保存位置(每次开始任务时清空)
  39. $this->exportZipDir = root_path() . 'public' . DIRECTORY_SEPARATOR . 'export' . DIRECTORY_SEPARATOR;
  40. }
  41. /**
  42. * 测试导出
  43. * @throws Throwable
  44. */
  45. public function test(): void
  46. {
  47. if (!$this->auth->check('routine/dataexport/start')) {
  48. $this->error(__('You have no permission'), ['routePath' => '/admin'], 302);
  49. }
  50. $id = $this->request->param($this->model->getPk());
  51. $row = $this->model->find($id);
  52. $export = new ExportLib($id);
  53. $exportNumber = $row->export_number ?: $export->getCount();
  54. if (!$exportNumber) {
  55. $this->error('没有数据需要导出~');
  56. }
  57. try {
  58. $subtask = [
  59. [
  60. 'id' => 1,
  61. 'status' => 0,// 状态:0=准备好,1=进行中,2=完成,3=失败
  62. 'min' => 0,
  63. 'max' => 10,
  64. 'sql' => $export->getSql('test')
  65. ]
  66. ];
  67. // 测试sql
  68. Db::query($subtask[0]['sql']);
  69. $row->subtask = $subtask;
  70. $row->save();
  71. } catch (Throwable $e) {
  72. $this->error($e->getMessage());
  73. }
  74. $this->success('', [
  75. 'taskId' => $id
  76. ]);
  77. }
  78. /**
  79. * 开始导出
  80. * @throws Throwable
  81. */
  82. public function start(): void
  83. {
  84. $id = $this->request->param($this->model->getPk());
  85. $row = $this->model->find($id);
  86. $export = new ExportLib($id);
  87. $exportNumber = $row->export_number ?: $export->getCount();
  88. $xlsMaxNumber = ($exportNumber >= $row->xls_max_number) ? $row->xls_max_number : $exportNumber;
  89. if (!$exportNumber) {
  90. $this->error('没有数据需要导出~');
  91. }
  92. try {
  93. $subtask = [];
  94. $subtaskCount = ceil($exportNumber / $xlsMaxNumber);// 子任务数量
  95. for ($i = 0; $i < $subtaskCount; $i++) {
  96. $min = $i * $xlsMaxNumber;
  97. $subtask[$i] = [
  98. 'id' => $i,
  99. 'status' => 0,// 状态:0=准备好,1=进行中,2=完成,3=失败
  100. 'min' => $min,
  101. 'max' => $xlsMaxNumber,
  102. 'sql' => $export->getSql('limit', [$min, $xlsMaxNumber])
  103. ];
  104. }
  105. // 测试sql
  106. Db::query($subtask[0]['sql']);
  107. if (!is_dir($this->exportZipDir)) {
  108. mkdir($this->exportZipDir, 0777, true);
  109. }
  110. if ($subtaskCount > 1) {
  111. // 清理任务临时文件目录
  112. if (!is_dir($this->saveDir . $id)) {
  113. mkdir($this->saveDir . $id . DIRECTORY_SEPARATOR, 0777, true);
  114. } else {
  115. Filesystem::delDir($this->saveDir . $id, false);
  116. }
  117. $row->lastprogress = 5;
  118. } else {
  119. $row->lastprogress = 0;
  120. }
  121. // 删除上次任务的zip包
  122. if ($row->lastfile) {
  123. $fileName = explode(DIRECTORY_SEPARATOR, Filesystem::fsFit($row->lastfile));
  124. if (file_exists($this->exportZipDir . end($fileName))) {
  125. unlink($this->exportZipDir . end($fileName));
  126. }
  127. $row->lastfile = '';
  128. }
  129. $row->subtask = $subtask;
  130. $row->lastexporttime = time();
  131. $row->save();
  132. } catch (Throwable $e) {
  133. $this->error($e->getMessage());
  134. }
  135. $this->success('导出任务初始化成功!', [
  136. 'download' => $subtaskCount == 1,
  137. 'subtask' => $subtask,
  138. 'id' => $id
  139. ]);
  140. }
  141. /**
  142. * 开始子任务
  143. * @throws Throwable
  144. */
  145. public function task(int $id, int $subId, bool $download): void
  146. {
  147. if (!$this->auth->check('routine/dataexport/start')) {
  148. $this->error(__('You have no permission'), ['routePath' => '/admin'], 302);
  149. }
  150. $row = $this->model->find($id);
  151. $export = new ExportLib($id);
  152. if (isset($row->subtask[$subId]) && is_array($row->subtask[$subId])) {
  153. $subTask = $row['subtask'][$subId];
  154. $taskName = $row['name'];
  155. } else {
  156. $this->error('导出子任务未找到,请重新开始!', ['subId' => $subId]);
  157. }
  158. set_time_limit(0);// 脚本执行时间限制
  159. ini_set('memory_limit', $row->memory_limit . 'M');// 脚本内存限制
  160. // 检查任务状态
  161. if (!$download) {
  162. if ($subTask['status'] == 1) {
  163. $this->error('此子任务正在执行中~', ['subId' => $subId]);
  164. } else if ($subTask['status'] == 2) {
  165. if (file_exists($this->saveDir . $id . DIRECTORY_SEPARATOR . $subId . '.xlsx')) {
  166. $this->error('此子任务已经处理过啦~', ['subId' => $subId]);
  167. }
  168. }
  169. }
  170. $spreadsheet = new Spreadsheet();
  171. $worksheet = $spreadsheet->getActiveSheet();
  172. $worksheet->setTitle($taskName);
  173. // 设置表头
  174. $head = $export->getXlsTitle();
  175. $headCount = count($head);
  176. for ($i = 0; $i < $headCount; $i++) {
  177. $worksheet->setCellValue([$i + 1, 1], $head[$i]);
  178. }
  179. // 写入数据
  180. try {
  181. $data = Db::query($subTask['sql']);
  182. } catch (PDOException $e) {
  183. $this->error('任务失败!', ['subId' => $subId, 'msg' => $e->getMessage()]);
  184. }
  185. $y = 2;
  186. $fields = $export->getFields();
  187. foreach ($data as $rowKey => $row) {
  188. $i = 1;
  189. foreach ($fields as $key => $value) {
  190. $field = $value['field'];
  191. if ($value['discern'] == 'text') {
  192. // 文本
  193. $worksheet->setCellValueExplicit([$i, $y], ltrim((string)$row[$field], '='), DataType::TYPE_STRING);
  194. } else if ($value['discern'] == 'int') {
  195. // 数字
  196. $worksheet->setCellValueExplicit([$i, $y], (int)$row[$field], DataType::TYPE_NUMERIC);
  197. } else if ($value['discern'] == 'time') {
  198. // 日期时间
  199. if ($row[$field]) {
  200. if (is_numeric($row[$field])) {
  201. $excelDateValue = date('Y-m-d H:i:s', $row[$field]);
  202. } else {
  203. $excelDateValue = $row[$field];
  204. }
  205. $worksheet->setCellValue([$i, $y], $excelDateValue);
  206. } else {
  207. $worksheet->setCellValue([$i, $y], '-');
  208. }
  209. } else if ($value['discern'] == 'valuation') {
  210. // 赋值
  211. $fieldValue = $export->assignment($row[$field], $value['comment']);
  212. $worksheet->setCellValueExplicit([$i, $y], (string)$fieldValue, DataType::TYPE_STRING);
  213. }
  214. $i++;
  215. }
  216. $y++;
  217. unset($data[$rowKey]); // 能节约一点内存
  218. }
  219. // xls文件处理
  220. if ($download) {
  221. // 直接下载
  222. ob_end_clean();
  223. header("Pragma: public");
  224. header("Expires: 0");
  225. header("Cache-Control:must-revalidate, post-check=0, pre-check=0");
  226. header("Content-Type:application/force-download");
  227. header("Content-Type:application/vnd.ms-execl");
  228. header("Content-Type:application/octet-stream");
  229. header("Content-Type:application/download");
  230. $taskName = $id . '.' . $taskName . '.xlsx';
  231. $encodedFilename = urlencode($taskName);
  232. $ua = $_SERVER["HTTP_USER_AGENT"];
  233. if (str_contains($ua, "MSIE")) {
  234. header('Content-Disposition: attachment; filename="' . $encodedFilename . '"');
  235. } else if (str_contains($ua, "Firefox")) {
  236. header('Content-Disposition: attachment; filename*="utf8\'\'' . $taskName . '"');
  237. } else {
  238. header('Content-Disposition: attachment; filename="' . $taskName . '"');
  239. }
  240. header("Content-Transfer-Encoding:binary");
  241. header('Cache-Control: max-age=0');
  242. $writer = new Xlsx($spreadsheet);
  243. $writer->save('php://output');
  244. $spreadsheet->disconnectWorksheets();
  245. unset($spreadsheet);
  246. } else {
  247. // 保存
  248. $writer = new Xlsx($spreadsheet);
  249. $writer->save($this->saveDir . $id . DIRECTORY_SEPARATOR . $subId . '.xlsx');
  250. $result = false;
  251. Db::startTrans();
  252. try {
  253. // 获取最新状态
  254. $row = $this->model->find($id);
  255. $lastprogress = $row->lastprogress + round(92 / count($row->subtask), 2);
  256. $lastprogress = ($lastprogress > 100) ? 100 : $lastprogress;
  257. if (isset($row->subtask[$subId]) && is_array($row->subtask[$subId])) {
  258. $subTaskTemp = $row->subtask;
  259. $subTaskTemp[$subId]['status'] = 2;
  260. $row->subtask = $subTaskTemp;
  261. $row->lastprogress = $lastprogress;
  262. $row->save();
  263. $result = true;
  264. }
  265. Db::commit();
  266. } catch (Throwable $e) {
  267. Db::rollback();
  268. $this->error('任务失败!', ['subId' => $subId, 'msg' => $e->getMessage()]);
  269. }
  270. $spreadsheet->disconnectWorksheets();
  271. unset($spreadsheet);
  272. if ($result) {
  273. $this->success('', [
  274. 'subId' => $subId,
  275. ]);
  276. } else {
  277. $this->error('', [
  278. 'subId' => $subId,
  279. ]);
  280. }
  281. }
  282. }
  283. /**
  284. * 任务控制器
  285. * @throws Throwable
  286. */
  287. public function taskControl(): void
  288. {
  289. if (!$this->auth->check('routine/dataexport/start')) {
  290. $this->error(__('You have no permission'), ['routePath' => '/admin'], 302);
  291. }
  292. $id = $this->request->param($this->model->getPk());
  293. $row = $this->model->find($id);
  294. if (!$row) {
  295. $this->error('任务找不到啦~');
  296. }
  297. if (!isset($row->subtask) || !is_array($row->subtask)) {
  298. $this->error('子任务找不到啦,请重新开始任务~');
  299. }
  300. $row = $row->toArray();
  301. $currentPage = 0;
  302. $subtaskPage = [];
  303. foreach ($row['subtask'] as $value) {
  304. $value['status'] = 0;// 用户可能会刷新任务控制页面,js将重新确定状态
  305. $subtaskPage[$currentPage][] = $value;
  306. if (count($subtaskPage[$currentPage]) >= $row['concurrent_create_xls']) {
  307. $currentPage++;
  308. }
  309. }
  310. $subtaskCount = is_array($row['subtask']) ? count($row['subtask']) : 0;
  311. $row['subtask_progress'] = round(92 / $subtaskCount, 2);
  312. $row['lastprogress'] = 5;
  313. $this->success('', [
  314. 'task' => $row,
  315. 'subtaskPage' => $subtaskPage,
  316. ]);
  317. }
  318. /**
  319. * 任务ZIP
  320. * @throws Throwable
  321. */
  322. public function taskZip(): void
  323. {
  324. if (!$this->auth->check('routine/dataexport/start')) {
  325. $this->error(__('You have no permission'), ['routePath' => '/admin'], 302);
  326. }
  327. $id = $this->request->param($this->model->getPk());
  328. $row = $this->model->find($id);
  329. if (!$row) {
  330. $this->error(__('Record not found'));
  331. }
  332. if (!isset($row->subtask) || !is_array($row->subtask)) {
  333. $this->error('打包失败,任务找不到啦~');
  334. }
  335. if ($row->lastfile) {
  336. $this->success('', ['file' => $row->lastfile]);
  337. }
  338. foreach ($row->subtask as $subtask) {
  339. if (!file_exists($this->saveDir . $id . DIRECTORY_SEPARATOR . $subtask['id'] . '.xlsx')) {
  340. $this->error('子任务未处理完毕!');
  341. }
  342. }
  343. $taskDir = $this->saveDir . $id . DIRECTORY_SEPARATOR;// 导出任务的临时文件目录
  344. $zipName = $id . '.export_' . Random::build() . '.zip';
  345. $zipUrl = full_url(Filesystem::fsFit(DIRECTORY_SEPARATOR . 'export' . DIRECTORY_SEPARATOR . $zipName));// 绝对地址,以便各处直接点击下载
  346. $zipName = $this->exportZipDir . $zipName;
  347. $zip = new ZipFile();
  348. try {
  349. $dh = opendir($taskDir);
  350. while ($file = readdir($dh)) {
  351. if ($file != "." && $file != "..") {
  352. $fullPath = $taskDir . $file;
  353. if (!is_dir($fullPath)) {
  354. $zip->addFile($fullPath, $file);
  355. }
  356. }
  357. }
  358. closedir($dh);
  359. $zip->saveAsFile($zipName);
  360. Filesystem::delDir($taskDir);
  361. } catch (ZipException $e) {
  362. $this->error('打包失败:' . $e->getMessage());
  363. } finally {
  364. $zip->close();
  365. }
  366. $row->lastfile = $zipUrl;
  367. $row->lastprogress = 100;
  368. $row->save();
  369. $this->success('', ['url' => $zipUrl]);
  370. }
  371. /**
  372. * 添加
  373. * @throws Throwable
  374. */
  375. public function add(): void
  376. {
  377. if ($this->request->isPost()) {
  378. $this->request->filter(['trim']);
  379. $data = $this->request->post();
  380. if (!$data) {
  381. $this->error(__('Parameter %s can not be empty', ['']));
  382. }
  383. $data = $this->excludeFields($data);
  384. $data['admin_id'] = $this->auth->id;
  385. $result = false;
  386. Db::startTrans();
  387. try {
  388. // 模型验证
  389. if ($this->modelValidate) {
  390. $validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
  391. if (class_exists($validate)) {
  392. $validate = new $validate;
  393. if ($this->modelSceneValidate) $validate->scene('add');
  394. $validate->check($data);
  395. }
  396. }
  397. $result = $this->model->save($data);
  398. Db::commit();
  399. } catch (Throwable $e) {
  400. Db::rollback();
  401. $this->error($e->getMessage());
  402. }
  403. if ($result !== false) {
  404. $this->success(__('Added successfully'));
  405. } else {
  406. $this->error(__('No rows were added'));
  407. }
  408. }
  409. $this->success('', [
  410. 'tables' => $this->getTableList(),
  411. ]);
  412. }
  413. /**
  414. * 编辑
  415. * @throws Throwable
  416. */
  417. public function edit(): void
  418. {
  419. $id = $this->request->param($this->model->getPk());
  420. $row = $this->model->find($id);
  421. if (!$row) {
  422. $this->error(__('Record not found'));
  423. }
  424. $dataLimitAdminIds = $this->getDataLimitAdminIds();
  425. if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
  426. $this->error(__('You have no permission'));
  427. }
  428. if ($this->request->isPost()) {
  429. $this->request->filter(['trim']);
  430. $data = $this->request->post();
  431. if (!$data) {
  432. $this->error(__('Parameter %s can not be empty', ['']));
  433. }
  434. $data = $this->excludeFields($data);
  435. $result = false;
  436. Db::startTrans();
  437. try {
  438. // 模型验证
  439. if ($this->modelValidate) {
  440. $validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
  441. if (class_exists($validate)) {
  442. $validate = new $validate;
  443. if ($this->modelSceneValidate) $validate->scene('edit');
  444. $validate->check($data);
  445. }
  446. }
  447. $result = $row->save($data);
  448. Db::commit();
  449. } catch (Throwable $e) {
  450. Db::rollback();
  451. $this->error($e->getMessage());
  452. }
  453. if ($result !== false) {
  454. $this->success(__('Update successful'));
  455. } else {
  456. $this->error(__('No rows updated'));
  457. }
  458. }
  459. $this->success('', [
  460. 'row' => $row,
  461. 'tables' => $this->getTableList(),
  462. ]);
  463. }
  464. /**
  465. * 查看
  466. * @throws Throwable
  467. */
  468. public function index(): void
  469. {
  470. // 设置过滤方法
  471. $this->request->filter(['strip_tags', 'trim']);
  472. // 如果是select则转发到select方法,若select未重写,其实还是继续执行index
  473. if ($this->request->param('select')) {
  474. $this->select();
  475. }
  476. list($where, $alias, $limit, $order) = $this->queryBuilder();
  477. $res = $this->model
  478. ->withJoin($this->withJoinTable, $this->withJoinType)
  479. ->alias($alias)
  480. ->where($where)
  481. ->order($order)
  482. ->visible(['admin' => ['nickname']])
  483. ->paginate($limit);
  484. $this->success('', [
  485. 'list' => $res->items(),
  486. 'total' => $res->total(),
  487. 'remark' => get_route_remark(),
  488. ]);
  489. }
  490. protected function getTableList(): array
  491. {
  492. $tablePrefix = config('database.connections.mysql.prefix');
  493. $outExcludeTable = [
  494. // 功能表
  495. 'area',
  496. 'config',
  497. 'token',
  498. 'captcha',
  499. 'admin_group_access',
  500. ];
  501. $outTables = [];
  502. $tables = get_table_list();
  503. $pattern = '/^' . $tablePrefix . '/i';
  504. foreach ($tables as $table => $tableComment) {
  505. $table = preg_replace($pattern, '', $table);
  506. if (!in_array($table, $outExcludeTable)) {
  507. $outTables[$table] = $tableComment;
  508. }
  509. }
  510. return $outTables;
  511. }
  512. public function getFieldList($table = null): void
  513. {
  514. if (!$table) {
  515. $this->error(__('Parameter error'));
  516. }
  517. // 字段类型识别
  518. $dataTypeInt = [
  519. 'tinyint',
  520. 'int',
  521. 'smallint',
  522. 'mediumint',
  523. 'integer',
  524. 'bigint'
  525. ];
  526. $fieldList = get_table_fields($table);
  527. $fields = [];
  528. foreach ($fieldList as $key => $item) {
  529. $discern = 'text'; // 文本
  530. if (in_array($item['DATA_TYPE'], $dataTypeInt)) {
  531. $discern = 'int'; // 数字
  532. }
  533. if (preg_match("/time$|datetime$/i", $key)) {
  534. $discern = 'time'; // 日期时间
  535. }
  536. if (preg_match("/switch$|toggle$/i", $key)) {
  537. $discern = 'valuation';// 赋值
  538. $commentVal = '0=关闭,1=开启';
  539. }
  540. $comment = str_replace(',', ',', $item['COLUMN_COMMENT']);
  541. $comment = str_replace(['(多选)', '(单选)', '(多选)', '(单选)'], '', $comment);
  542. if (stripos($comment, ':') !== false && stripos($comment, ',') && stripos($comment, '=') !== false) {
  543. list($fieldName, $commentVal) = explode(':', $comment);
  544. $discern = 'valuation';// 赋值
  545. }
  546. $fields[$key] = [
  547. 'name' => $key,
  548. 'discern' => $discern,
  549. 'title' => $fieldName ?? ($item['COLUMN_COMMENT'] ? $item['COLUMN_COMMENT'] : $key),
  550. 'comment' => $commentVal ?? '',
  551. ];
  552. unset($commentVal, $fieldName);
  553. }
  554. $this->success('', [
  555. 'fields' => $fields,
  556. ]);
  557. }
  558. }