TextbookExcelImporter.php 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734
  1. <?php
  2. namespace App\Services\Import;
  3. use PhpOffice\PhpSpreadsheet\IOFactory;
  4. use PhpOffice\PhpSpreadsheet\Spreadsheet;
  5. use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
  6. use Illuminate\Support\Facades\Storage;
  7. use Illuminate\Support\Facades\Log;
  8. use App\Services\TextbookApiService;
  9. class TextbookExcelImporter
  10. {
  11. protected $apiService;
  12. public function __construct(TextbookApiService $apiService)
  13. {
  14. $this->apiService = $apiService;
  15. }
  16. /**
  17. * 生成教材系列Excel模板
  18. */
  19. public function generateTextbookSeriesTemplate(): string
  20. {
  21. $spreadsheet = new Spreadsheet();
  22. $sheet = $spreadsheet->getActiveSheet();
  23. // 设置列标题
  24. $headers = [
  25. 'A1' => '系列名称',
  26. 'B1' => '别名',
  27. 'C1' => '出版社',
  28. 'D1' => '适用地区',
  29. 'E1' => '适用学段',
  30. 'F1' => '是否启用',
  31. 'G1' => '排序',
  32. 'H1' => '扩展信息(JSON)',
  33. ];
  34. foreach ($headers as $cell => $header) {
  35. $sheet->setCellValue($cell, $header);
  36. }
  37. // 设置表头样式
  38. $headerStyle = [
  39. 'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
  40. 'fill' => ['fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID, 'color' => ['rgb' => '0EA5E9']],
  41. 'alignment' => ['horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER],
  42. ];
  43. $sheet->getStyle('A1:H1')->applyFromArray($headerStyle);
  44. // 设置列宽
  45. $sheet->getColumnDimension('A')->setWidth(20);
  46. $sheet->getColumnDimension('B')->setWidth(15);
  47. $sheet->getColumnDimension('C')->setWidth(25);
  48. $sheet->getColumnDimension('D')->setWidth(20);
  49. $sheet->getColumnDimension('E')->setWidth(20);
  50. $sheet->getColumnDimension('F')->setWidth(12);
  51. $sheet->getColumnDimension('G')->setWidth(10);
  52. $sheet->getColumnDimension('H')->setWidth(30);
  53. // 添加示例数据
  54. $examples = [
  55. ['人教版', 'pep', '人民教育出版社', '全国', '["primary","junior","senior"]', '是', '1', '{"website":"http://www.pep.com.cn"}'],
  56. ['北师大版', 'bsd', '北京师范大学出版社', '全国', '["primary","junior","senior"]', '是', '2', '{}'],
  57. ['苏教版', 'js', '江苏教育出版社', '江苏省', '["primary","junior"]', '是', '3', '{}'],
  58. ];
  59. for ($i = 0; $i < count($examples); $i++) {
  60. $row = $i + 2;
  61. for ($j = 0; $j < count($examples[$i]); $j++) {
  62. $column = chr(65 + $j); // A, B, C...
  63. $sheet->setCellValue($column . $row, $examples[$i][$j]);
  64. }
  65. }
  66. // 添加说明信息
  67. $sheet->setCellValue('A6', '填写说明:');
  68. $sheet->setCellValue('A7', '1. 系列名称: 必填,如"人教版"、"北师大版"');
  69. $sheet->setCellValue('A8', '2. 别名: 必填,唯一标识,如"pep"、"bsd"');
  70. $sheet->setCellValue('A9', '3. 出版社: 必填,如"人民教育出版社"');
  71. $sheet->setCellValue('A10', '4. 适用地区: 可选,如"全国"、"江苏省"');
  72. $sheet->setCellValue('A11', '5. 适用学段: 必填,JSON格式["primary","junior","senior"]');
  73. $sheet->setCellValue('A12', '6. 是否启用: 填"是"或"否"');
  74. $sheet->setCellValue('A13', '7. 排序: 数字,越小越靠前');
  75. $sheet->setCellValue('A14', '8. 扩展信息: JSON格式,可选');
  76. // 保存文件
  77. $fileName = 'textbook_series_template_' . date('Ymd_His') . '.xlsx';
  78. $filePath = storage_path('app/templates/' . $fileName);
  79. // 确保目录存在
  80. if (!is_dir(dirname($filePath))) {
  81. mkdir(dirname($filePath), 0755, true);
  82. }
  83. $writer = new Xlsx($spreadsheet);
  84. $writer->save($filePath);
  85. return $filePath;
  86. }
  87. /**
  88. * 生成教材Excel模板
  89. */
  90. public function generateTextbookTemplate(): string
  91. {
  92. $spreadsheet = new Spreadsheet();
  93. $sheet = $spreadsheet->getActiveSheet();
  94. // 设置列标题
  95. $headers = [
  96. 'A1' => '系列ID',
  97. 'B1' => '学段',
  98. 'C1' => '年级',
  99. 'D1' => '学期',
  100. 'E1' => '命名体系',
  101. 'F1' => '版本',
  102. 'G1' => '模块类型',
  103. 'H1' => '册次',
  104. 'I1' => '旧体系编码',
  105. 'J1' => '课标年代',
  106. 'K1' => '修订年份',
  107. 'L1' => '审定年份',
  108. 'M1' => '版次标识',
  109. 'N1' => 'ISBN',
  110. 'O1' => '封面路径',
  111. 'P1' => '官方书名',
  112. 'Q1' => '展示名称',
  113. 'R1' => '别名(JSON)',
  114. 'S1' => '状态',
  115. 'T1' => '扩展信息(JSON)',
  116. ];
  117. foreach ($headers as $cell => $header) {
  118. $sheet->setCellValue($cell, $header);
  119. }
  120. // 设置表头样式
  121. $headerStyle = [
  122. 'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
  123. 'fill' => ['fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID, 'color' => ['rgb' => '0EA5E9']],
  124. 'alignment' => ['horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER],
  125. ];
  126. $sheet->getStyle('A1:T1')->applyFromArray($headerStyle);
  127. // 设置列宽
  128. $columnWidths = [10, 12, 10, 10, 12, 10, 15, 10, 15, 12, 12, 12, 15, 15, 20, 30, 30, 20, 12, 30];
  129. foreach ($columnWidths as $i => $width) {
  130. $sheet->getColumnDimensionByColumn($i + 1)->setWidth($width);
  131. }
  132. // 添加示例数据
  133. $examples = [
  134. ['1', '小学', '1', '上册', '', '', '', '', '', '2011', '', '', '', '', '', '人教版小学数学一年级上册', '', '[]', '草稿', '{}'],
  135. ['1', '小学', '1', '下册', '', '', '', '', '', '2011', '', '', '', '', '', '人教版小学数学一年级下册', '', '[]', '草稿', '{}'],
  136. ['1', '初中', '7', '上册', '', '', '', '', '', '2022', '', '', '', '', '', '人教版初中数学七年级上册', '', '[]', '草稿', '{}'],
  137. ['1', '高中', '10', '', '新体系', 'A版', '必修', '1', '', '2017', '2020', '', '', '', '', '人教A版高中数学必修第一册', '', '[]', '草稿', '{}'],
  138. ];
  139. for ($i = 0; $i < count($examples); $i++) {
  140. $row = $i + 2;
  141. for ($j = 0; $j < count($examples[$i]); $j++) {
  142. $column = chr(65 + $j); // A, B, C...
  143. $sheet->setCellValue($column . $row, $examples[$i][$j]);
  144. }
  145. }
  146. // 添加说明信息
  147. $sheet->setCellValue('A7', '填写说明:');
  148. $sheet->setCellValue('A8', '1. 系列ID: 必填,对应教材系列的ID(可在教材系列列表查看)');
  149. $sheet->setCellValue('A9', '2. 学段: 必填,小学/初中/高中');
  150. $sheet->setCellValue('A10', '3. 年级: 数字,小学1-6,初中7-9,高中10-12');
  151. $sheet->setCellValue('A11', '4. 学期: 上册/下册(高中可留空)');
  152. $sheet->setCellValue('A12', '5. 命名体系: 新体系/旧体系,高中适用');
  153. $sheet->setCellValue('A13', '6. 版本: A版/B版,高中新体系适用');
  154. $sheet->setCellValue('A14', '7. 模块类型: 必修/选择性必修/选修,高中适用');
  155. $sheet->setCellValue('A15', '8. 册次: 数字,高中新体系适用');
  156. $sheet->setCellValue('A16', '9. 旧体系编码: 高中旧体系适用,如"必修1"');
  157. $sheet->setCellValue('A17', '10. 课标年代: 义务教育2011/2022,高中2017');
  158. $sheet->setCellValue('A18', '11. 修订年份: 高中为2020');
  159. $sheet->setCellValue('A19', '12. 审定年份: 如2024');
  160. $sheet->setCellValue('A20', '13. 版次标识: 如"2024秋版"');
  161. $sheet->setCellValue('A21', '14. ISBN: 可选');
  162. $sheet->setCellValue('A22', '15. 封面路径: 可选');
  163. $sheet->setCellValue('A23', '16. 官方书名: 可选,系统自动生成');
  164. $sheet->setCellValue('A24', '17. 展示名称: 可选,站内显示');
  165. $sheet->setCellValue('A25', '18. 别名: JSON格式,可选');
  166. $sheet->setCellValue('A26', '19. 状态: 草稿/已发布/已归档');
  167. $sheet->setCellValue('A27', '20. 扩展信息: JSON格式,可选');
  168. // 保存文件
  169. $fileName = 'textbook_template_' . date('Ymd_His') . '.xlsx';
  170. $filePath = storage_path('app/templates/' . $fileName);
  171. if (!is_dir(dirname($filePath))) {
  172. mkdir(dirname($filePath), 0755, true);
  173. }
  174. $writer = new Xlsx($spreadsheet);
  175. $writer->save($filePath);
  176. return $filePath;
  177. }
  178. /**
  179. * 导入教材系列Excel文件
  180. */
  181. public function importTextbookSeries(string $filePath): array
  182. {
  183. try {
  184. $spreadsheet = IOFactory::load($filePath);
  185. $sheet = $spreadsheet->getActiveSheet();
  186. $data = $sheet->toArray();
  187. // 跳过标题行
  188. $rows = array_slice($data, 1);
  189. $successCount = 0;
  190. $errorCount = 0;
  191. $errors = [];
  192. foreach ($rows as $index => $row) {
  193. try {
  194. // 验证必填字段
  195. if (empty($row[0]) || empty($row[1]) || empty($row[2]) || empty($row[4])) {
  196. throw new \Exception('必填字段不能为空');
  197. }
  198. // 解析适用学段
  199. $stages = json_decode($row[4], true);
  200. if (!is_array($stages)) {
  201. throw new \Exception('适用学段必须是JSON数组格式');
  202. }
  203. // 解析是否启用
  204. $isActive = $row[5] === '是' || $row[5] === 'true' || $row[5] === '1';
  205. // 解析扩展信息
  206. $meta = !empty($row[7]) ? json_decode($row[7], true) : [];
  207. if (!is_array($meta)) {
  208. $meta = [];
  209. }
  210. $textbookSeriesData = [
  211. 'name' => $row[0],
  212. 'slug' => $row[1],
  213. 'publisher' => $row[2],
  214. 'region' => $row[3] ?: null,
  215. 'stages' => json_encode($stages),
  216. 'is_active' => $isActive,
  217. 'sort_order' => (int)($row[6] ?: 0),
  218. 'meta' => json_encode($meta),
  219. ];
  220. // 通过API创建教材系列
  221. $result = $this->apiService->createTextbookSeries($textbookSeriesData);
  222. if ($result && isset($result['data'])) {
  223. $successCount++;
  224. } else {
  225. throw new \Exception('API创建失败');
  226. }
  227. } catch (\Exception $e) {
  228. $errorCount++;
  229. $errors[] = "第" . ($index + 2) . "行: " . $e->getMessage();
  230. }
  231. }
  232. return [
  233. 'success' => true,
  234. 'success_count' => $successCount,
  235. 'error_count' => $errorCount,
  236. 'errors' => $errors,
  237. ];
  238. } catch (\Exception $e) {
  239. Log::error('Excel导入失败', ['error' => $e->getMessage()]);
  240. return [
  241. 'success' => false,
  242. 'message' => '文件解析失败: ' . $e->getMessage(),
  243. ];
  244. }
  245. }
  246. /**
  247. * 导入教材Excel文件
  248. * 支持覆盖更新:相同系列、学段、年级、学期、官方书名则更新
  249. */
  250. public function importTextbook(string $filePath): array
  251. {
  252. // 中文到英文的映射
  253. $stageMap = [
  254. '小学' => 'primary',
  255. '初中' => 'junior',
  256. '高中' => 'senior',
  257. // 兼容英文输入
  258. 'primary' => 'primary',
  259. 'junior' => 'junior',
  260. 'senior' => 'senior',
  261. ];
  262. $semesterMap = [
  263. '上册' => 1,
  264. '下册' => 2,
  265. '1' => 1,
  266. '2' => 2,
  267. ];
  268. $namingSchemeMap = [
  269. '新体系' => 'new',
  270. '旧体系' => 'old',
  271. 'new' => 'new',
  272. 'old' => 'old',
  273. ];
  274. $trackMap = [
  275. 'A版' => 'A',
  276. 'B版' => 'B',
  277. 'A' => 'A',
  278. 'B' => 'B',
  279. ];
  280. $statusMap = [
  281. '草稿' => 'draft',
  282. '已发布' => 'published',
  283. '已归档' => 'archived',
  284. 'draft' => 'draft',
  285. 'published' => 'published',
  286. 'archived' => 'archived',
  287. ];
  288. try {
  289. $spreadsheet = IOFactory::load($filePath);
  290. $sheet = $spreadsheet->getActiveSheet();
  291. $data = $sheet->toArray();
  292. // 跳过标题行
  293. $rows = array_slice($data, 1);
  294. $successCount = 0;
  295. $errorCount = 0;
  296. $errors = [];
  297. $updateCount = 0;
  298. foreach ($rows as $index => $row) {
  299. try {
  300. // 跳过空行
  301. if (empty($row[0]) && empty($row[1])) {
  302. continue;
  303. }
  304. // 验证必填字段
  305. if (empty($row[0]) || empty($row[1])) {
  306. throw new \Exception('系列ID和学段不能为空');
  307. }
  308. // 转换学段
  309. $stageInput = trim($row[1]);
  310. $stage = $stageMap[$stageInput] ?? null;
  311. if (!$stage) {
  312. throw new \Exception("无效的学段: {$stageInput},请填写:小学/初中/高中");
  313. }
  314. // 转换学期
  315. $semester = null;
  316. if (!empty($row[3])) {
  317. $semesterInput = trim($row[3]);
  318. $semester = $semesterMap[$semesterInput] ?? null;
  319. if ($semester === null) {
  320. throw new \Exception("无效的学期: {$semesterInput},请填写:上册/下册");
  321. }
  322. }
  323. // 转换命名体系
  324. $namingScheme = null;
  325. if (!empty($row[4])) {
  326. $namingInput = trim($row[4]);
  327. $namingScheme = $namingSchemeMap[$namingInput] ?? null;
  328. }
  329. // 转换版本
  330. $track = null;
  331. if (!empty($row[5])) {
  332. $trackInput = trim($row[5]);
  333. $track = $trackMap[$trackInput] ?? $trackInput;
  334. }
  335. // 转换状态 - 默认为已发布
  336. $statusInput = trim($row[18] ?? '');
  337. $status = !empty($statusInput) ? ($statusMap[$statusInput] ?? 'published') : 'published';
  338. // 解析别名
  339. $aliases = !empty($row[17]) ? json_decode($row[17], true) : [];
  340. if (!is_array($aliases)) {
  341. $aliases = [];
  342. }
  343. // 解析扩展信息
  344. $meta = !empty($row[19]) ? json_decode($row[19], true) : [];
  345. if (!is_array($meta)) {
  346. $meta = [];
  347. }
  348. $seriesId = (int)$row[0];
  349. $textbookData = [
  350. 'series_id' => $seriesId,
  351. 'stage' => $stage,
  352. 'grade' => $row[2] ?: null,
  353. 'semester' => $semester,
  354. 'naming_scheme' => $namingScheme,
  355. 'track' => $track,
  356. 'module_type' => $row[6] ?: null,
  357. 'volume_no' => $row[7] ? (int)$row[7] : null,
  358. 'legacy_code' => $row[8] ?: null,
  359. 'curriculum_standard_year' => $row[9] ?: null,
  360. 'curriculum_revision_year' => $row[10] ?: null,
  361. 'approval_year' => $row[11] ?: null,
  362. 'edition_label' => $row[12] ?: null,
  363. 'isbn' => $row[13] ?: null,
  364. 'cover_path' => $row[14] ?: null,
  365. 'official_title' => $row[15] ?: null,
  366. 'aliases' => json_encode($aliases),
  367. 'status' => $status,
  368. 'meta' => json_encode($meta),
  369. ];
  370. // 通过API创建或更新教材(使用upsert模式)
  371. $result = $this->apiService->createOrUpdateTextbook($textbookData);
  372. if ($result && isset($result['data'])) {
  373. $successCount++;
  374. if (isset($result['updated']) && $result['updated']) {
  375. $updateCount++;
  376. }
  377. } else {
  378. throw new \Exception('API创建失败');
  379. }
  380. } catch (\Exception $e) {
  381. $errorCount++;
  382. $errors[] = "第" . ($index + 2) . "行: " . $e->getMessage();
  383. }
  384. }
  385. return [
  386. 'success' => true,
  387. 'success_count' => $successCount,
  388. 'update_count' => $updateCount,
  389. 'error_count' => $errorCount,
  390. 'errors' => $errors,
  391. ];
  392. } catch (\Exception $e) {
  393. Log::error('Excel导入失败', ['error' => $e->getMessage()]);
  394. return [
  395. 'success' => false,
  396. 'message' => '文件解析失败: ' . $e->getMessage(),
  397. ];
  398. }
  399. }
  400. /**
  401. * 生成教材目录Excel模板
  402. */
  403. public function generateTextbookCatalogTemplate(): string
  404. {
  405. $spreadsheet = new Spreadsheet();
  406. $sheet = $spreadsheet->getActiveSheet();
  407. // 设置列标题
  408. $headers = [
  409. 'A1' => '教材ID',
  410. 'B1' => '目录标题',
  411. 'C1' => '显示编号',
  412. 'D1' => '节点类型',
  413. 'E1' => '层级深度',
  414. 'F1' => '排序',
  415. 'G1' => '父级ID',
  416. 'H1' => '路径键',
  417. 'I1' => '起始页码',
  418. 'J1' => '结束页码',
  419. 'K1' => '是否必修',
  420. 'L1' => '是否选修',
  421. 'M1' => '标签(JSON)',
  422. 'N1' => '扩展信息(JSON)',
  423. ];
  424. foreach ($headers as $cell => $header) {
  425. $sheet->setCellValue($cell, $header);
  426. }
  427. // 设置表头样式
  428. $headerStyle = [
  429. 'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
  430. 'fill' => ['fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID, 'color' => ['rgb' => '0EA5E9']],
  431. 'alignment' => ['horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER],
  432. ];
  433. $sheet->getStyle('A1:N1')->applyFromArray($headerStyle);
  434. // 设置列宽
  435. $columnWidths = [12, 30, 15, 15, 12, 10, 12, 25, 12, 12, 12, 12, 20, 30];
  436. foreach ($columnWidths as $i => $width) {
  437. $sheet->getColumnDimensionByColumn($i + 1)->setWidth($width);
  438. }
  439. // 添加示例数据(使用中文节点类型)
  440. $examples = [
  441. ['1', '第一章 有理数', '1', '章', '1', '1', '', 'chapter-1', '1', '20', '是', '否', '[]', '{}'],
  442. ['1', '1.1 正数和负数', '1.1', '节', '2', '1', '', 'chapter-1-section-1', '2', '5', '是', '否', '[]', '{}'],
  443. ['1', '1.2 有理数', '1.2', '节', '2', '2', '', 'chapter-1-section-2', '6', '10', '是', '否', '[]', '{}'],
  444. ['1', '1.2.1 有理数的概念', '1.2.1', '小节', '3', '1', '', 'chapter-1-section-2-sub-1', '6', '7', '是', '否', '[]', '{}'],
  445. ['1', '第二章 整式的加减', '2', '章', '1', '2', '', 'chapter-2', '21', '40', '是', '否', '[]', '{}'],
  446. ];
  447. for ($i = 0; $i < count($examples); $i++) {
  448. $row = $i + 2;
  449. for ($j = 0; $j < count($examples[$i]); $j++) {
  450. $column = chr(65 + $j); // A, B, C...
  451. $sheet->setCellValue($column . $row, $examples[$i][$j]);
  452. }
  453. }
  454. // 添加说明信息
  455. $sheet->setCellValue('A8', '填写说明:');
  456. $sheet->setCellValue('A9', '1. 教材ID: 必填,对应教材的ID(可在教材列表查看)');
  457. $sheet->setCellValue('A10', '2. 目录标题: 必填,如"第一章 有理数"');
  458. $sheet->setCellValue('A11', '3. 显示编号: 可选,如"1"、"1.1"、"1.2.1"');
  459. $sheet->setCellValue('A12', '4. 节点类型: 章/节/小节/条目/项目学习');
  460. $sheet->setCellValue('A13', '5. 层级深度: 数字,1=章,2=节,3=小节');
  461. $sheet->setCellValue('A14', '6. 排序: 数字,同级内排序');
  462. $sheet->setCellValue('A15', '7. 父级ID: 可选,上级节点的ID(首次导入可留空,系统自动处理)');
  463. $sheet->setCellValue('A16', '8. 路径键: 可选,层级路径标识');
  464. $sheet->setCellValue('A17', '9. 起始页码: 数字,可选');
  465. $sheet->setCellValue('A18', '10. 结束页码: 数字,可选');
  466. $sheet->setCellValue('A19', '11. 是否必修: 是/否');
  467. $sheet->setCellValue('A20', '12. 是否选修: 是/否');
  468. $sheet->setCellValue('A21', '13. 标签: JSON格式,可选');
  469. $sheet->setCellValue('A22', '14. 扩展信息: JSON格式,可选');
  470. // 保存文件
  471. $fileName = 'textbook_catalog_template_' . date('Ymd_His') . '.xlsx';
  472. $filePath = storage_path('app/templates/' . $fileName);
  473. if (!is_dir(dirname($filePath))) {
  474. mkdir(dirname($filePath), 0755, true);
  475. }
  476. $writer = new Xlsx($spreadsheet);
  477. $writer->save($filePath);
  478. return $filePath;
  479. }
  480. /**
  481. * 导入教材目录Excel文件
  482. */
  483. public function importTextbookCatalog(string $filePath, int $textbookId, ?int $seriesId = null): array
  484. {
  485. // 中文到英文的映射
  486. $nodeTypeMap = [
  487. '章' => 'chapter',
  488. '节' => 'section',
  489. '小节' => 'subsection',
  490. '条目' => 'item',
  491. '项目学习' => 'project',
  492. // 兼容英文输入
  493. 'chapter' => 'chapter',
  494. 'section' => 'section',
  495. 'subsection' => 'subsection',
  496. 'item' => 'item',
  497. 'project' => 'project',
  498. ];
  499. try {
  500. // 加载Excel文件并解析
  501. $spreadsheet = IOFactory::load($filePath);
  502. $sheet = $spreadsheet->getActiveSheet();
  503. $data = $sheet->toArray();
  504. // 准备数据发送到API
  505. $catalogData = [];
  506. $successCount = 0;
  507. $errorCount = 0;
  508. $errors = [];
  509. $header = $data[0] ?? [];
  510. $indexMap = [];
  511. foreach ($header as $index => $label) {
  512. $label = trim((string) $label);
  513. if ($label === '') {
  514. continue;
  515. }
  516. if (str_contains($label, '系列ID')) {
  517. $indexMap['series_id'] = $index;
  518. }
  519. if (str_contains($label, '教材ID')) {
  520. $indexMap['textbook_id'] = $index;
  521. } elseif (str_contains($label, '目录标题')) {
  522. $indexMap['title'] = $index;
  523. } elseif (str_contains($label, '显示编号')) {
  524. $indexMap['display_no'] = $index;
  525. } elseif (str_contains($label, '节点类型')) {
  526. $indexMap['node_type'] = $index;
  527. } elseif (str_contains($label, '层级')) {
  528. $indexMap['depth'] = $index;
  529. } elseif (str_contains($label, '排序')) {
  530. $indexMap['sort_order'] = $index;
  531. } elseif (str_contains($label, '父级')) {
  532. $indexMap['parent_id'] = $index;
  533. } elseif (str_contains($label, '路径')) {
  534. $indexMap['path_key'] = $index;
  535. } elseif (str_contains($label, '起始页')) {
  536. $indexMap['page_start'] = $index;
  537. } elseif (str_contains($label, '结束页')) {
  538. $indexMap['page_end'] = $index;
  539. } elseif (str_contains($label, '必修')) {
  540. $indexMap['is_required'] = $index;
  541. } elseif (str_contains($label, '选修')) {
  542. $indexMap['is_elective'] = $index;
  543. } elseif (str_contains($label, '标签')) {
  544. $indexMap['tags'] = $index;
  545. } elseif (str_contains($label, '扩展') || str_contains($label, 'meta')) {
  546. $indexMap['meta'] = $index;
  547. }
  548. }
  549. $rows = array_slice($data, 1);
  550. foreach ($rows as $index => $row) {
  551. try {
  552. $seriesIdCell = $row[$indexMap['series_id'] ?? -1] ?? null;
  553. $textbookIdCell = $row[$indexMap['textbook_id'] ?? 0] ?? null;
  554. $titleCell = $row[$indexMap['title'] ?? 1] ?? null;
  555. // 跳过空行
  556. if (empty($textbookIdCell) && empty($titleCell)) {
  557. continue;
  558. }
  559. // 验证必填字段
  560. if (empty($textbookIdCell) || empty($titleCell)) {
  561. throw new \Exception('教材ID和目录标题不能为空');
  562. }
  563. // 验证教材ID是否匹配
  564. if ((int) $textbookIdCell !== $textbookId) {
  565. throw new \Exception('教材ID必须与选择的教材ID一致');
  566. }
  567. if ($seriesId !== null && (int) $seriesIdCell !== $seriesId) {
  568. throw new \Exception('系列ID必须与选择的教材系列一致');
  569. }
  570. // 转换节点类型
  571. $nodeTypeInput = trim((string) ($row[$indexMap['node_type'] ?? 3] ?? '章'));
  572. $nodeType = $nodeTypeMap[$nodeTypeInput] ?? 'chapter';
  573. // 解析是否必修/选修
  574. $isRequired = ($row[$indexMap['is_required'] ?? 10] ?? null) === '是'
  575. || ($row[$indexMap['is_required'] ?? 10] ?? null) === 'true'
  576. || ($row[$indexMap['is_required'] ?? 10] ?? null) === '1';
  577. $isElective = ($row[$indexMap['is_elective'] ?? 11] ?? null) === '是'
  578. || ($row[$indexMap['is_elective'] ?? 11] ?? null) === 'true'
  579. || ($row[$indexMap['is_elective'] ?? 11] ?? null) === '1';
  580. // 解析标签和扩展信息
  581. $tagsCell = $row[$indexMap['tags'] ?? 12] ?? null;
  582. $tags = !empty($tagsCell) ? json_decode($tagsCell, true) : [];
  583. if (!is_array($tags)) {
  584. $tags = [];
  585. }
  586. $metaCell = $row[$indexMap['meta'] ?? 13] ?? null;
  587. $meta = !empty($metaCell) ? json_decode($metaCell, true) : [];
  588. if (!is_array($meta)) {
  589. $meta = [];
  590. }
  591. // 构建目录数据
  592. $nodeData = [
  593. 'textbook_id' => (int) $textbookIdCell,
  594. 'title' => $titleCell,
  595. 'display_no' => $row[$indexMap['display_no'] ?? 2] ?? null,
  596. 'node_type' => $nodeType,
  597. 'depth' => $row[$indexMap['depth'] ?? 4] ? (int) $row[$indexMap['depth'] ?? 4] : 1,
  598. 'sort_order' => $row[$indexMap['sort_order'] ?? 5] ? (int) $row[$indexMap['sort_order'] ?? 5] : 0,
  599. 'parent_id' => $row[$indexMap['parent_id'] ?? 6] ?: null,
  600. 'path_key' => $row[$indexMap['path_key'] ?? 7] ?: null,
  601. 'page_start' => $row[$indexMap['page_start'] ?? 8] ? (int) $row[$indexMap['page_start'] ?? 8] : null,
  602. 'page_end' => $row[$indexMap['page_end'] ?? 9] ? (int) $row[$indexMap['page_end'] ?? 9] : null,
  603. 'is_required' => $isRequired,
  604. 'is_elective' => $isElective,
  605. 'tags' => $tags,
  606. 'meta' => $meta,
  607. ];
  608. $catalogData[] = $nodeData;
  609. $successCount++;
  610. } catch (\Exception $e) {
  611. $errorCount++;
  612. $errors[] = "第" . ($index + 2) . "行: " . $e->getMessage();
  613. }
  614. }
  615. // 如果没有有效数据,返回错误
  616. if (empty($catalogData)) {
  617. return [
  618. 'success' => false,
  619. 'message' => 'Excel文件中没有有效的目录数据',
  620. ];
  621. }
  622. // 调用API导入教材目录,直接传递JSON数据
  623. $apiService = app(TextbookApiService::class);
  624. $result = $apiService->importTextbookCatalog($textbookId, $catalogData, 'overwrite', $seriesId);
  625. return [
  626. 'success' => $result['success'] ?? false,
  627. 'success_count' => $result['success_count'] ?? $successCount,
  628. 'error_count' => $result['error_count'] ?? $errorCount,
  629. 'errors' => array_merge($errors, $result['errors'] ?? []),
  630. 'message' => $result['message'] ?? null,
  631. ];
  632. } catch (\Exception $e) {
  633. Log::error('Excel导入失败', ['error' => $e->getMessage()]);
  634. return [
  635. 'success' => false,
  636. 'message' => '文件解析失败: ' . $e->getMessage(),
  637. ];
  638. }
  639. }
  640. }