textbook_management.html 69 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539
  1. {% extends "layout.html" %}
  2. {% block page_title %}教材管理{% endblock %}
  3. {% block content %}
  4. <!-- 目录节点树状结构宏定义 -->
  5. {% macro render_catalog_node(node, level) %}
  6. <div
  7. class="catalog-node relative"
  8. data-node-id="{{ node.id }}"
  9. data-node-type="{{ node.node_type }}"
  10. data-parent="{{ node.parent_id or '' }}"
  11. data-level="{{ level }}"
  12. data-textbook-id="{{ node.textbook_id }}">
  13. <!-- 节点卡片 -->
  14. <div class="flex items-start gap-4 group">
  15. <!-- 展开/折叠按钮区域 -->
  16. <div class="flex items-center gap-2 pt-4 flex-shrink-0">
  17. {% if node.children|length > 0 %}
  18. <button
  19. onclick="toggleCatalogNode('{{ node.id }}'); event.stopPropagation();"
  20. class="w-8 h-8 rounded-full bg-white border-2 border-gray-300 hover:border-blue-500 flex items-center justify-center text-gray-600 hover:text-blue-600 transition-all expand-btn z-10 shadow-sm hover:shadow-md"
  21. data-expanded="{% if level == 0 %}true{% else %}false{% endif %}">
  22. {% if level == 0 %}
  23. <i class="ri-subtract-line text-sm"></i>
  24. {% else %}
  25. <i class="ri-add-line text-sm"></i>
  26. {% endif %}
  27. </button>
  28. {% else %}
  29. <div class="w-8 h-8 flex items-center justify-center">
  30. <div class="w-2 h-2 rounded-full bg-gray-400"></div>
  31. </div>
  32. {% endif %}
  33. </div>
  34. <!-- 节点内容卡片 -->
  35. <div class="flex-1 apple-card p-5 hover:shadow-xl transition-all duration-300 group-hover:border-blue-300 border-2 border-transparent rounded-xl {% if node.children|length > 0 %}cursor-pointer{% endif %}" {% if node.children|length > 0 %}onclick="handleCatalogCardClick(event, '{{ node.id }}')"{% endif %}>
  36. <div class="flex items-start justify-between">
  37. <div class="flex-1 min-w-0">
  38. <div class="flex items-center gap-3 mb-3 flex-wrap">
  39. <span class="px-3 py-1.5 rounded-lg text-xs font-bold font-mono bg-gradient-to-r {% if node.node_type == 'chapter' %}from-blue-500 to-indigo-600{% elif node.node_type == 'section' %}from-green-500 to-emerald-600{% else %}from-orange-500 to-amber-600{% endif %} text-white shadow-md">
  40. {{ node.display_no or node.node_type }}
  41. </span>
  42. <h3 class="text-lg font-bold text-gray-800 group-hover:text-blue-600 transition-colors">
  43. {{ node.title }}
  44. </h3>
  45. </div>
  46. <div class="flex items-center gap-4 text-sm text-gray-500 mt-2 flex-wrap">
  47. <span class="flex items-center gap-1.5 px-2 py-1 bg-gray-50 rounded-md">
  48. <i class="ri-file-list-line text-blue-500"></i>
  49. <span>{{ node.node_type }}</span>
  50. </span>
  51. {% if node.depth %}
  52. <span class="flex items-center gap-1.5 px-2 py-1 bg-gray-50 rounded-md">
  53. <i class="ri-stack-line text-green-500"></i>
  54. <span>层级: {{ node.depth }}</span>
  55. </span>
  56. {% endif %}
  57. {% if node.children|length > 0 %}
  58. <span class="flex items-center gap-1.5 px-2 py-1 bg-blue-50 rounded-md text-blue-600">
  59. <i class="ri-node-tree"></i>
  60. <span class="font-semibold">{{ node.children|length }} 个子节点</span>
  61. </span>
  62. {% endif %}
  63. </div>
  64. </div>
  65. <!-- 操作按钮 -->
  66. <div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity ml-4 flex-shrink-0" onclick="event.stopPropagation()">
  67. {% if node.node_type == 'section' %}
  68. <button
  69. onclick="showAddKpRelationModal('{{ node.id }}', '{{ node.title }}')"
  70. class="px-3 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-purple-500 to-indigo-600 hover:from-purple-600 hover:to-indigo-700 rounded-lg transition-all shadow-sm hover:shadow-md flex items-center gap-1.5"
  71. title="关联知识点">
  72. <i class="ri-link"></i>
  73. <span>关联知识点</span>
  74. </button>
  75. {% endif %}
  76. <button
  77. onclick="showAddChildCatalogModal('{{ node.id }}', '{{ node.title }}', '{{ node.node_type }}')"
  78. class="px-3 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 rounded-lg transition-all shadow-sm hover:shadow-md flex items-center gap-1.5"
  79. title="添加子节点">
  80. <i class="ri-add-line"></i>
  81. <span>添加子节点</span>
  82. </button>
  83. <button
  84. onclick="showEditCatalogModal({{ node.id }})"
  85. class="w-9 h-9 rounded-lg text-blue-600 bg-blue-50 hover:bg-blue-100 flex items-center justify-center transition-colors shadow-sm hover:shadow-md"
  86. title="编辑">
  87. <i class="ri-edit-line"></i>
  88. </button>
  89. <button
  90. onclick="showDeleteCatalogConfirm({{ node.id }}, '{{ node.title }}')"
  91. class="w-9 h-9 rounded-lg text-red-600 bg-red-50 hover:bg-red-100 flex items-center justify-center transition-colors shadow-sm hover:shadow-md"
  92. title="删除">
  93. <i class="ri-delete-bin-line"></i>
  94. </button>
  95. </div>
  96. </div>
  97. </div>
  98. </div>
  99. <!-- 子节点容器 -->
  100. {% if node.children|length > 0 %}
  101. <div class="children-container mt-3 ml-12 {% if level >= 1 %}hidden{% endif %}" id="children-{{ node.id }}" data-level="{{ level }}">
  102. {% for child in node.children %}
  103. {{ render_catalog_node(child, level + 1) }}
  104. {% endfor %}
  105. </div>
  106. {% endif %}
  107. </div>
  108. {% endmacro %}
  109. <div class="flex gap-6">
  110. <!-- 左侧系列菜单 -->
  111. <div class="w-80 flex-shrink-0">
  112. <div class="apple-card p-6 sticky top-4 max-h-[calc(100vh-2rem)] overflow-y-auto">
  113. <div class="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
  114. <h2 class="text-lg font-bold text-gray-800">教材系列</h2>
  115. <button
  116. onclick="showAddSeriesModal()"
  117. class="btn-apple bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-700 hover:to-indigo-700 text-xs py-1.5 px-3 rounded-lg shadow-sm flex items-center gap-1">
  118. <i class="ri-add-circle-line"></i>
  119. <span>添加</span>
  120. </button>
  121. </div>
  122. <!-- 搜索框 -->
  123. <div class="mb-4">
  124. <div class="relative">
  125. <i class="ri-search-line absolute left-2.5 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i>
  126. <input
  127. type="text"
  128. id="seriesSearchInput"
  129. placeholder="搜索系列..."
  130. class="w-full pl-7 pr-3 py-2 text-xs border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
  131. oninput="filterSeriesList()">
  132. </div>
  133. </div>
  134. <!-- 系列列表 -->
  135. <nav class="space-y-1" id="seriesListNav">
  136. {% for series in series_list %}
  137. <div class="series-item mb-2" data-series-id="{{ series.id }}" data-series-name="{{ series.name }}" data-series-active="{{ series.is_active }}">
  138. <div class="flex items-center gap-2 group">
  139. <div class="flex-1 flex items-center gap-2">
  140. <a href="javascript:void(0)"
  141. onclick="switchSeries({{ series.id }})"
  142. class="flex-1 block px-4 py-2.5 rounded-xl hover:bg-gradient-to-r hover:from-slate-50 hover:to-blue-50 transition-all border-l-4 border-transparent hover:border-slate-500 series-link {% if loop.first %}bg-gradient-to-r from-slate-50 to-blue-50 border-slate-500{% endif %}">
  143. <span class="text-sm font-semibold text-gray-800 group-hover:text-slate-600 transition-colors">{{ series.name }}</span>
  144. </a>
  145. <!-- 激活状态开关 -->
  146. <label class="relative inline-flex items-center cursor-pointer flex-shrink-0" onclick="event.stopPropagation(); event.preventDefault(); toggleSeriesActiveDirect({{ series.id }}, this);">
  147. <input type="checkbox" class="sr-only peer" {% if series.is_active == 1 %}checked{% endif %} onclick="event.stopPropagation();">
  148. <div class="w-9 h-5 bg-gray-300 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-500 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
  149. </label>
  150. </div>
  151. <button
  152. onclick="event.stopPropagation(); event.preventDefault(); showEditSeriesModal({{ series.id }});"
  153. class="opacity-0 group-hover:opacity-100 transition-opacity p-1.5 hover:bg-gray-100 rounded text-gray-500 hover:text-blue-600"
  154. title="编辑">
  155. <i class="ri-edit-line text-sm"></i>
  156. </button>
  157. </div>
  158. </div>
  159. {% endfor %}
  160. </nav>
  161. </div>
  162. </div>
  163. <!-- 右侧内容区域 -->
  164. <div class="flex-1 space-y-6">
  165. <!-- 教材列表(当前系列下的教材) -->
  166. <div id="textbookListContainer" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
  167. <!-- 教材卡片将通过JavaScript动态加载 -->
  168. </div>
  169. <!-- 目录树结构(选中教材后显示) -->
  170. <div id="catalogTreeContainer" class="hidden">
  171. <div class="flex items-center justify-between mb-4">
  172. <h2 class="text-xl font-bold text-gray-800" id="currentTextbookTitle"></h2>
  173. <button
  174. onclick="showAddCatalogModal()"
  175. class="btn-apple bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-700 hover:to-indigo-700 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2">
  176. <i class="ri-add-circle-line"></i>
  177. <span>添加目录节点</span>
  178. </button>
  179. </div>
  180. <div class="space-y-3" id="catalogTree">
  181. <!-- 目录树将通过JavaScript动态加载 -->
  182. </div>
  183. </div>
  184. </div>
  185. </div>
  186. <!-- 添加/编辑教材系列模态框 -->
  187. <div id="seriesModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
  188. <div class="bg-white rounded-2xl shadow-2xl max-w-md w-full mx-4">
  189. <div class="p-6 border-b border-gray-200">
  190. <h2 id="seriesModalTitle" class="text-xl font-bold text-gray-800">添加教材系列</h2>
  191. </div>
  192. <form id="seriesForm" class="p-6 space-y-4">
  193. <input type="hidden" id="seriesId" name="id">
  194. <div>
  195. <label class="block text-sm font-semibold text-gray-700 mb-2">系列名称 *</label>
  196. <input type="text" id="seriesName" name="name" required
  197. class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
  198. </div>
  199. <div>
  200. <label class="block text-sm font-semibold text-gray-700 mb-2">标识符</label>
  201. <input type="text" id="seriesSlug" name="slug"
  202. class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
  203. </div>
  204. <div>
  205. <label class="flex items-center justify-between cursor-pointer">
  206. <span class="text-sm font-semibold text-gray-700">激活状态</span>
  207. <label class="relative inline-flex items-center cursor-pointer">
  208. <input type="checkbox" id="seriesIsActive" class="sr-only peer" checked>
  209. <div class="w-11 h-6 bg-gray-300 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-500 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
  210. </label>
  211. </label>
  212. <p class="text-xs text-gray-500 mt-1">激活的系列将在系统中可用</p>
  213. </div>
  214. <div class="flex gap-3 pt-4">
  215. <button type="button" onclick="closeSeriesModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">取消</button>
  216. <button type="submit" class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">保存</button>
  217. </div>
  218. </form>
  219. </div>
  220. </div>
  221. <!-- 添加/编辑教材模态框 -->
  222. <div id="textbookModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
  223. <div class="bg-white rounded-2xl shadow-2xl max-w-md w-full mx-4">
  224. <div class="p-6 border-b border-gray-200">
  225. <h2 id="textbookModalTitle" class="text-xl font-bold text-gray-800">添加教材</h2>
  226. </div>
  227. <form id="textbookForm" class="p-6 space-y-4">
  228. <input type="hidden" id="textbookId" name="id">
  229. <div>
  230. <label class="block text-sm font-semibold text-gray-700 mb-2">教材名称 *</label>
  231. <input type="text" id="textbookTitle" name="official_title" required
  232. class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
  233. </div>
  234. <div class="grid grid-cols-2 gap-4">
  235. <div>
  236. <label class="block text-sm font-semibold text-gray-700 mb-2">学段</label>
  237. <select id="textbookStage" name="stage" class="w-full px-4 py-2 border border-gray-300 rounded-lg">
  238. <option value="">请选择</option>
  239. <option value="primary">小学</option>
  240. <option value="junior">初中</option>
  241. <option value="senior">高中</option>
  242. </select>
  243. </div>
  244. <div>
  245. <label class="block text-sm font-semibold text-gray-700 mb-2">年级</label>
  246. <input type="text" id="textbookGrade" name="grade"
  247. class="w-full px-4 py-2 border border-gray-300 rounded-lg">
  248. </div>
  249. </div>
  250. <div>
  251. <label class="block text-sm font-semibold text-gray-700 mb-2">学期</label>
  252. <select id="textbookSemester" name="semester" class="w-full px-4 py-2 border border-gray-300 rounded-lg">
  253. <option value="">请选择</option>
  254. <option value="1">上学期</option>
  255. <option value="2">下学期</option>
  256. </select>
  257. </div>
  258. <div class="flex gap-3 pt-4">
  259. <button type="button" onclick="closeTextbookModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">取消</button>
  260. <button type="submit" class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">保存</button>
  261. </div>
  262. </form>
  263. </div>
  264. </div>
  265. <!-- 添加/编辑目录节点模态框 -->
  266. <div id="catalogModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
  267. <div class="bg-white rounded-2xl shadow-2xl max-w-md w-full mx-4">
  268. <div class="p-6 border-b border-gray-200">
  269. <h2 id="catalogModalTitle" class="text-xl font-bold text-gray-800">添加目录节点</h2>
  270. </div>
  271. <form id="catalogForm" class="p-6 space-y-4">
  272. <input type="hidden" id="catalogId" name="id">
  273. <input type="hidden" id="catalogTextbookId" name="textbook_id">
  274. <input type="hidden" id="catalogParentId" name="parent_id">
  275. <div>
  276. <label class="block text-sm font-semibold text-gray-700 mb-2">节点类型 *</label>
  277. <select id="catalogNodeType" name="node_type" required class="w-full px-4 py-2 border border-gray-300 rounded-lg">
  278. <option value="chapter">章节</option>
  279. <option value="section">小节</option>
  280. <option value="subsection">子小节</option>
  281. </select>
  282. </div>
  283. <div>
  284. <label class="block text-sm font-semibold text-gray-700 mb-2">标题 *</label>
  285. <input type="text" id="catalogTitle" name="title" required
  286. class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
  287. </div>
  288. <div>
  289. <label class="block text-sm font-semibold text-gray-700 mb-2">编号</label>
  290. <input type="text" id="catalogDisplayNo" name="display_no"
  291. class="w-full px-4 py-2 border border-gray-300 rounded-lg">
  292. </div>
  293. <div class="flex gap-3 pt-4">
  294. <button type="button" onclick="closeCatalogModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">取消</button>
  295. <button type="submit" class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">保存</button>
  296. </div>
  297. </form>
  298. </div>
  299. </div>
  300. <!-- 关联知识点模态框 -->
  301. <div id="kpRelationModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
  302. <div class="bg-white rounded-2xl shadow-2xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
  303. <div class="p-6 border-b border-gray-200">
  304. <h2 id="kpRelationModalTitle" class="text-xl font-bold text-gray-800">关联知识点</h2>
  305. </div>
  306. <div class="p-6">
  307. <div class="mb-4">
  308. <label class="block text-sm font-semibold text-gray-700 mb-2">选择知识点</label>
  309. <select id="kpCodeSelect" class="w-full px-4 py-2 border border-gray-300 rounded-lg">
  310. <option value="">请选择知识点</option>
  311. {% for kp in kp_options %}
  312. <option value="{{ kp.kp_code }}">{{ kp.kp_code }} - {{ kp.name }}</option>
  313. {% endfor %}
  314. </select>
  315. </div>
  316. <button onclick="addKpRelation()" class="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 mb-4">
  317. 添加关联
  318. </button>
  319. <div id="kpRelationList" class="space-y-2">
  320. <!-- 关联列表将通过JavaScript动态加载 -->
  321. </div>
  322. </div>
  323. <div class="p-6 border-t border-gray-200">
  324. <button onclick="closeKpRelationModal()" class="w-full px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">关闭</button>
  325. </div>
  326. </div>
  327. </div>
  328. <script>
  329. // 当前选中的系列ID和教材ID
  330. let currentSeriesId = null;
  331. let currentTextbookId = null;
  332. let currentCatalogChapterId = null;
  333. // 所有系列数据
  334. const allSeries = {{ series_list|tojson|safe }};
  335. // 初始化:加载第一个系列
  336. document.addEventListener('DOMContentLoaded', function() {
  337. const firstSeriesItem = document.querySelector('.series-item');
  338. if (firstSeriesItem) {
  339. const firstSeriesId = parseInt(firstSeriesItem.getAttribute('data-series-id'));
  340. switchSeries(firstSeriesId);
  341. }
  342. });
  343. // 筛选系列列表
  344. function filterSeriesList() {
  345. const searchInput = document.getElementById('seriesSearchInput');
  346. const searchTerm = (searchInput.value || '').toLowerCase().trim();
  347. const seriesItems = document.querySelectorAll('.series-item');
  348. let visibleCount = 0;
  349. seriesItems.forEach(item => {
  350. const seriesName = item.getAttribute('data-series-name').toLowerCase();
  351. const matchesSearch = !searchTerm || seriesName.includes(searchTerm);
  352. if (matchesSearch) {
  353. item.style.display = '';
  354. visibleCount++;
  355. } else {
  356. item.style.display = 'none';
  357. }
  358. });
  359. }
  360. // 切换教材系列
  361. async function switchSeries(seriesId) {
  362. // 确保seriesId是数字类型
  363. seriesId = parseInt(seriesId);
  364. if (!seriesId || isNaN(seriesId)) {
  365. console.error('Invalid seriesId:', seriesId);
  366. return;
  367. }
  368. currentSeriesId = seriesId;
  369. // 更新左侧菜单选中状态
  370. document.querySelectorAll('.series-link').forEach(link => {
  371. link.classList.remove('bg-gradient-to-r', 'from-slate-50', 'to-blue-50', 'border-slate-500');
  372. });
  373. const selectedLink = document.querySelector(`.series-item[data-series-id="${seriesId}"] .series-link`);
  374. if (selectedLink) {
  375. selectedLink.classList.add('bg-gradient-to-r', 'from-slate-50', 'to-blue-50', 'border-slate-500');
  376. }
  377. // 获取容器
  378. const container = document.getElementById('textbookListContainer');
  379. if (!container) {
  380. console.error('textbookListContainer not found');
  381. return;
  382. }
  383. // 显示教材列表容器,隐藏目录树
  384. container.classList.remove('hidden');
  385. document.getElementById('catalogTreeContainer').classList.add('hidden');
  386. // 显示加载状态
  387. container.innerHTML = '<div class="col-span-full text-center py-12"><i class="ri-loader-4-line animate-spin text-3xl text-blue-500"></i><p class="mt-3 text-gray-500">加载中...</p></div>';
  388. // 加载该系列下的教材
  389. try {
  390. const response = await fetch(`/api/textbook/list/${seriesId}`);
  391. if (!response.ok) {
  392. throw new Error(`HTTP error! status: ${response.status}`);
  393. }
  394. const result = await response.json();
  395. if (result.success) {
  396. // 确保data是数组
  397. const textbooks = Array.isArray(result.data) ? result.data : [];
  398. if (textbooks.length === 0) {
  399. container.innerHTML = '<div class="col-span-full text-center py-12"><i class="ri-book-open-line text-4xl text-gray-300 mb-3"></i><p class="text-gray-500">暂无教材</p><p class="text-xs text-gray-400 mt-2">该系列下还没有添加教材</p></div>';
  400. } else {
  401. renderTextbookList(textbooks);
  402. }
  403. } else {
  404. console.error('API error:', result.error);
  405. container.innerHTML = '<div class="col-span-full text-center py-12"><i class="ri-error-warning-line text-4xl text-red-300 mb-3"></i><p class="text-gray-500">加载失败:' + (result.error || '未知错误') + '</p><button onclick="switchSeries(' + seriesId + ')" class="mt-4 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">重试</button></div>';
  406. }
  407. } catch (error) {
  408. console.error('Error loading textbooks:', error);
  409. container.innerHTML = '<div class="col-span-full text-center py-12"><i class="ri-error-warning-line text-4xl text-red-300 mb-3"></i><p class="text-gray-500">加载教材列表失败</p><p class="text-xs text-gray-400 mt-2">' + error.message + '</p><button onclick="switchSeries(' + seriesId + ')" class="mt-4 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">重试</button></div>';
  410. }
  411. }
  412. // 直接在列表中切换系列激活状态
  413. async function toggleSeriesActiveDirect(seriesId, labelElement) {
  414. const checkbox = labelElement.querySelector('input[type="checkbox"]');
  415. if (!checkbox) return;
  416. // 立即切换开关状态(乐观更新)
  417. const newState = !checkbox.checked;
  418. checkbox.checked = newState;
  419. // 禁用开关,防止重复点击
  420. checkbox.disabled = true;
  421. try {
  422. const response = await fetch(`/api/textbook/series/toggle_active/${seriesId}`, {
  423. method: 'POST',
  424. headers: {'Content-Type': 'application/json'}
  425. });
  426. const result = await response.json();
  427. if (result.success) {
  428. // 更新系列项的data-active属性
  429. const seriesItem = document.querySelector(`.series-item[data-series-id="${seriesId}"]`);
  430. if (seriesItem) {
  431. seriesItem.setAttribute('data-series-active', result.is_active);
  432. }
  433. // 更新allSeries数据
  434. const series = allSeries.find(s => s.id === seriesId);
  435. if (series) {
  436. series.is_active = result.is_active;
  437. }
  438. // 确保checkbox状态与服务器一致
  439. checkbox.checked = result.is_active === 1;
  440. // 如果当前选中的系列,刷新教材列表
  441. if (currentSeriesId === seriesId) {
  442. switchSeries(seriesId);
  443. }
  444. } else {
  445. // 恢复开关状态
  446. checkbox.checked = !newState;
  447. if (window.customAlert) {
  448. window.customAlert('操作失败: ' + result.error);
  449. } else {
  450. alert('操作失败: ' + result.error);
  451. }
  452. }
  453. } catch (error) {
  454. console.error('Error:', error);
  455. // 恢复开关状态
  456. checkbox.checked = !newState;
  457. if (window.customAlert) {
  458. window.customAlert('操作失败: ' + error.message);
  459. } else {
  460. alert('操作失败: ' + error.message);
  461. }
  462. } finally {
  463. checkbox.disabled = false;
  464. }
  465. }
  466. // 渲染教材列表
  467. function renderTextbookList(textbooks) {
  468. const container = document.getElementById('textbookListContainer');
  469. container.innerHTML = '';
  470. textbooks.forEach(textbook => {
  471. const card = document.createElement('div');
  472. card.className = 'group relative apple-card overflow-hidden cursor-pointer hover:shadow-2xl transition-all duration-300 hover:-translate-y-1';
  473. card.onclick = () => loadTextbookCatalog(textbook.id, textbook.official_title);
  474. // 生成封面占位图的渐变背景色(根据教材ID生成不同颜色)
  475. const colors = [
  476. 'from-blue-500 to-indigo-600',
  477. 'from-green-500 to-emerald-600',
  478. 'from-purple-500 to-violet-600',
  479. 'from-pink-500 to-rose-600',
  480. 'from-orange-500 to-amber-600',
  481. 'from-cyan-500 to-blue-600'
  482. ];
  483. const colorIndex = (textbook.id || 0) % colors.length;
  484. const gradientColor = colors[colorIndex];
  485. card.innerHTML = `
  486. <!-- 封面占位图 -->
  487. <div class="relative w-full aspect-[3/4] bg-gradient-to-br ${gradientColor} overflow-hidden">
  488. <div class="absolute inset-0 flex items-center justify-center">
  489. <div class="text-center text-white/90">
  490. <i class="ri-book-open-line text-6xl mb-2 opacity-80"></i>
  491. <div class="text-xs font-semibold opacity-70">教材封面</div>
  492. </div>
  493. </div>
  494. <!-- 封面装饰线条 -->
  495. <div class="absolute top-0 left-0 right-0 h-1 bg-white/20"></div>
  496. <div class="absolute bottom-0 left-0 right-0 h-1 bg-black/10"></div>
  497. <!-- 操作按钮(悬停显示) -->
  498. <div class="absolute top-2 right-2 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity" onclick="event.stopPropagation()">
  499. <button onclick="showEditTextbookModal(${textbook.id})" class="w-8 h-8 rounded-full bg-white/90 backdrop-blur-sm text-blue-600 hover:bg-white hover:scale-110 transition-all shadow-lg flex items-center justify-center">
  500. <i class="ri-edit-line text-sm"></i>
  501. </button>
  502. <button onclick="showDeleteTextbookConfirm(${textbook.id}, '${textbook.official_title}')" class="w-8 h-8 rounded-full bg-white/90 backdrop-blur-sm text-red-600 hover:bg-white hover:scale-110 transition-all shadow-lg flex items-center justify-center">
  503. <i class="ri-delete-bin-line text-sm"></i>
  504. </button>
  505. </div>
  506. </div>
  507. <!-- 教材信息 -->
  508. <div class="p-4 bg-white">
  509. <h3 class="text-base font-bold text-gray-800 mb-2 line-clamp-2 leading-tight">${textbook.official_title || '未命名教材'}</h3>
  510. <div class="flex items-center gap-2 text-xs text-gray-500">
  511. ${textbook.grade ? `<span class="px-2 py-0.5 bg-gray-100 rounded">${textbook.grade}年级</span>` : ''}
  512. ${textbook.semester ? `<span class="px-2 py-0.5 bg-gray-100 rounded">${textbook.semester === 1 ? '上学期' : '下学期'}</span>` : ''}
  513. </div>
  514. </div>
  515. `;
  516. container.appendChild(card);
  517. });
  518. // 添加"添加教材"卡片
  519. const addCard = document.createElement('div');
  520. addCard.className = 'apple-card overflow-hidden cursor-pointer hover:shadow-xl transition-all border-2 border-dashed border-gray-300 hover:border-blue-400';
  521. addCard.onclick = () => showAddTextbookModal();
  522. addCard.innerHTML = `
  523. <div class="w-full aspect-[3/4] flex flex-col items-center justify-center text-gray-400 hover:text-blue-500 transition-colors">
  524. <div class="mb-3">
  525. <i class="ri-add-circle-line text-5xl"></i>
  526. </div>
  527. <div class="text-sm font-medium">添加教材</div>
  528. </div>
  529. `;
  530. container.appendChild(addCard);
  531. }
  532. // 加载教材目录树
  533. async function loadTextbookCatalog(textbookId, textbookTitle) {
  534. currentTextbookId = textbookId;
  535. const titleElement = document.getElementById('currentTextbookTitle');
  536. const catalogContainer = document.getElementById('catalogTreeContainer');
  537. const textbookContainer = document.getElementById('textbookListContainer');
  538. if (titleElement) {
  539. titleElement.textContent = textbookTitle;
  540. }
  541. // 显示目录树,隐藏教材列表
  542. if (catalogContainer) {
  543. catalogContainer.classList.remove('hidden');
  544. }
  545. if (textbookContainer) {
  546. textbookContainer.classList.add('hidden');
  547. }
  548. try {
  549. const response = await fetch(`/api/textbook/catalog/tree/${textbookId}`);
  550. const result = await response.json();
  551. if (result.success) {
  552. renderCatalogTree(result.data);
  553. } else {
  554. console.error('Failed to load catalog tree:', result.error);
  555. if (window.customAlert) {
  556. window.customAlert('加载目录树失败: ' + (result.error || '未知错误'));
  557. } else {
  558. alert('加载目录树失败: ' + (result.error || '未知错误'));
  559. }
  560. }
  561. } catch (error) {
  562. console.error('Error loading catalog tree:', error);
  563. if (window.customAlert) {
  564. window.customAlert('加载目录树失败: ' + error.message);
  565. } else {
  566. alert('加载目录树失败: ' + error.message);
  567. }
  568. }
  569. }
  570. // 渲染目录树
  571. function renderCatalogTree(nodes) {
  572. const container = document.getElementById('catalogTree');
  573. container.innerHTML = '';
  574. // 这里需要使用Jinja2宏来渲染,但由于是动态加载,我们需要用JavaScript递归渲染
  575. // 为了简化,我们先用简单的HTML结构
  576. nodes.forEach(node => {
  577. container.appendChild(createCatalogNodeElement(node, 0));
  578. });
  579. }
  580. // 创建目录节点元素(简化版,实际应该使用服务端渲染)
  581. function createCatalogNodeElement(node, level) {
  582. const div = document.createElement('div');
  583. div.className = `catalog-node relative mb-3`;
  584. div.setAttribute('data-node-id', node.id);
  585. div.setAttribute('data-level', level);
  586. const hasChildren = node.children && node.children.length > 0;
  587. const colorClass = node.node_type === 'chapter' ? 'from-blue-500 to-indigo-600' :
  588. node.node_type === 'section' ? 'from-green-500 to-emerald-600' :
  589. 'from-orange-500 to-amber-600';
  590. div.innerHTML = `
  591. <div class="flex items-start gap-4 group">
  592. <div class="flex items-center gap-2 pt-4 flex-shrink-0">
  593. ${hasChildren ? `
  594. <button onclick="toggleCatalogNode('${node.id}'); event.stopPropagation();"
  595. class="w-8 h-8 rounded-full bg-white border-2 border-gray-300 hover:border-blue-500 flex items-center justify-center text-gray-600 hover:text-blue-600 transition-all expand-btn z-10 shadow-sm hover:shadow-md"
  596. data-expanded="${level === 0 ? 'true' : 'false'}">
  597. <i class="ri-${level === 0 ? 'subtract' : 'add'}-line text-sm"></i>
  598. </button>
  599. ` : `
  600. <div class="w-8 h-8 flex items-center justify-center">
  601. <div class="w-2 h-2 rounded-full bg-gray-400"></div>
  602. </div>
  603. `}
  604. </div>
  605. <div class="flex-1 apple-card p-5 hover:shadow-xl transition-all duration-300 group-hover:border-blue-300 border-2 border-transparent rounded-xl ${hasChildren ? 'cursor-pointer' : ''}"
  606. ${hasChildren ? `onclick="handleCatalogCardClick(event, '${node.id}')"` : ''}>
  607. <div class="flex items-start justify-between">
  608. <div class="flex-1 min-w-0">
  609. <div class="flex items-center gap-3 mb-3 flex-wrap">
  610. <span class="px-3 py-1.5 rounded-lg text-xs font-bold font-mono bg-gradient-to-r ${colorClass} text-white shadow-md">
  611. ${node.display_no || node.node_type}
  612. </span>
  613. <h3 class="text-lg font-bold text-gray-800 group-hover:text-blue-600 transition-colors">
  614. ${node.title}
  615. </h3>
  616. </div>
  617. <div class="flex items-center gap-4 text-sm text-gray-500 mt-2 flex-wrap">
  618. <span class="flex items-center gap-1.5 px-2 py-1 bg-gray-50 rounded-md">
  619. <i class="ri-file-list-line text-blue-500"></i>
  620. <span>${node.node_type}</span>
  621. </span>
  622. ${hasChildren ? `
  623. <span class="flex items-center gap-1.5 px-2 py-1 bg-blue-50 rounded-md text-blue-600">
  624. <i class="ri-node-tree"></i>
  625. <span class="font-semibold">${node.children.length} 个子节点</span>
  626. </span>
  627. ` : ''}
  628. </div>
  629. </div>
  630. <div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity ml-4 flex-shrink-0" onclick="event.stopPropagation()">
  631. ${node.node_type === 'section' ? `
  632. <button onclick="showAddKpRelationModal('${node.id}', '${node.title}')"
  633. class="px-3 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-purple-500 to-indigo-600 hover:from-purple-600 hover:to-indigo-700 rounded-lg transition-all shadow-sm hover:shadow-md flex items-center gap-1.5">
  634. <i class="ri-link"></i>
  635. <span>关联知识点</span>
  636. </button>
  637. ` : ''}
  638. <button onclick="showAddChildCatalogModal('${node.id}', '${node.title}', '${node.node_type}')"
  639. class="px-3 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 rounded-lg transition-all shadow-sm hover:shadow-md flex items-center gap-1.5">
  640. <i class="ri-add-line"></i>
  641. <span>添加子节点</span>
  642. </button>
  643. <button onclick="showEditCatalogModal(${node.id})"
  644. class="w-9 h-9 rounded-lg text-blue-600 bg-blue-50 hover:bg-blue-100 flex items-center justify-center transition-colors shadow-sm hover:shadow-md">
  645. <i class="ri-edit-line"></i>
  646. </button>
  647. <button onclick="showDeleteCatalogConfirm(${node.id}, '${node.title}')"
  648. class="w-9 h-9 rounded-lg text-red-600 bg-red-50 hover:bg-red-100 flex items-center justify-center transition-colors shadow-sm hover:shadow-md">
  649. <i class="ri-delete-bin-line"></i>
  650. </button>
  651. </div>
  652. </div>
  653. </div>
  654. </div>
  655. ${hasChildren ? `
  656. <div class="children-container mt-3 ml-12 ${level >= 1 ? 'hidden' : ''}" id="children-${node.id}" data-level="${level}">
  657. ${node.children.map(child => createCatalogNodeElement(child, level + 1).outerHTML).join('')}
  658. </div>
  659. ` : ''}
  660. ${node.node_type === 'section' ? `
  661. <div class="knowledge-points-container mt-3 ml-12" id="kp-${node.id}">
  662. <div class="flex items-center gap-2 mb-2">
  663. <i class="ri-loader-4-line animate-spin text-gray-400 text-sm"></i>
  664. <span class="text-xs text-gray-500">加载知识点...</span>
  665. </div>
  666. </div>
  667. ` : ''}
  668. `;
  669. // 如果是section节点,异步加载关联的知识点
  670. if (node.node_type === 'section') {
  671. setTimeout(() => loadKnowledgePointsForSection(node.id), 100);
  672. }
  673. return div;
  674. }
  675. // 切换目录节点展开/折叠
  676. function toggleCatalogNode(nodeId) {
  677. const childrenContainer = document.getElementById(`children-${nodeId}`);
  678. if (!childrenContainer) return;
  679. const kpNode = childrenContainer.closest('.catalog-node');
  680. if (!kpNode) return;
  681. const expandBtn = kpNode.querySelector('.expand-btn');
  682. if (!expandBtn) return;
  683. const isExpanded = expandBtn.getAttribute('data-expanded') === 'true';
  684. if (isExpanded) {
  685. childrenContainer.classList.add('hidden');
  686. expandBtn.setAttribute('data-expanded', 'false');
  687. const icon = expandBtn.querySelector('i');
  688. if (icon) {
  689. icon.className = 'ri-add-line text-sm';
  690. }
  691. } else {
  692. childrenContainer.classList.remove('hidden');
  693. expandBtn.setAttribute('data-expanded', 'true');
  694. const icon = expandBtn.querySelector('i');
  695. if (icon) {
  696. icon.className = 'ri-subtract-line text-sm';
  697. }
  698. }
  699. }
  700. // 处理卡片点击
  701. function handleCatalogCardClick(event, nodeId) {
  702. if (event.target.closest('button') || event.target.closest('a')) {
  703. return;
  704. }
  705. toggleCatalogNode(nodeId);
  706. }
  707. // 加载section关联的知识点
  708. async function loadKnowledgePointsForSection(sectionId) {
  709. const container = document.getElementById(`kp-${sectionId}`);
  710. if (!container) return;
  711. try {
  712. const response = await fetch(`/api/textbook/relation/list/${sectionId}`);
  713. const result = await response.json();
  714. if (result.success && result.data && result.data.length > 0) {
  715. // 去重:按kp_code去重,保留第一个
  716. const uniqueRelations = [];
  717. const seenKpCodes = new Set();
  718. result.data.forEach(relation => {
  719. if (!seenKpCodes.has(relation.kp_code)) {
  720. seenKpCodes.add(relation.kp_code);
  721. uniqueRelations.push(relation);
  722. }
  723. });
  724. container.innerHTML = `
  725. <div class="mb-2">
  726. <span class="text-xs font-semibold text-purple-600 flex items-center gap-1">
  727. <i class="ri-link"></i>
  728. <span>关联知识点 (${uniqueRelations.length})</span>
  729. </span>
  730. </div>
  731. <div class="flex flex-wrap gap-2">
  732. ${uniqueRelations.map(relation => `
  733. <div class="group relative px-3 py-2 bg-gradient-to-r from-purple-50 to-indigo-50 border border-purple-200 rounded-lg hover:shadow-md transition-all">
  734. <div class="flex items-center gap-2">
  735. <span class="text-xs font-mono font-bold text-purple-600">${relation.kp_code || ''}</span>
  736. <span class="text-sm text-gray-700">${relation.kp_name || '未知知识点'}</span>
  737. <button onclick="removeKpRelationByCode('${relation.kp_code}', ${sectionId})"
  738. class="opacity-0 group-hover:opacity-100 ml-1 w-5 h-5 rounded-full bg-red-100 hover:bg-red-200 text-red-600 flex items-center justify-center transition-all"
  739. title="取消关联">
  740. <i class="ri-close-line text-xs"></i>
  741. </button>
  742. </div>
  743. </div>
  744. `).join('')}
  745. </div>
  746. `;
  747. } else {
  748. container.innerHTML = `
  749. <div class="text-xs text-gray-400 italic">
  750. <i class="ri-information-line"></i>
  751. <span>暂无关联知识点</span>
  752. </div>
  753. `;
  754. }
  755. } catch (error) {
  756. console.error('Error loading knowledge points:', error);
  757. container.innerHTML = `
  758. <div class="text-xs text-red-400">
  759. <i class="ri-error-warning-line"></i>
  760. <span>加载知识点失败</span>
  761. </div>
  762. `;
  763. }
  764. }
  765. // 移除知识点关联(通过relationId)
  766. async function removeKpRelation(relationId, sectionId) {
  767. if (!confirm('确定要取消这个知识点关联吗?')) {
  768. return;
  769. }
  770. try {
  771. const response = await fetch(`/api/textbook/relation/delete/${relationId}`, {
  772. method: 'POST',
  773. headers: {'Content-Type': 'application/json'}
  774. });
  775. const result = await response.json();
  776. if (result.success) {
  777. // 重新加载知识点列表
  778. loadKnowledgePointsForSection(sectionId);
  779. if (window.customAlert) {
  780. window.customAlert('已取消关联');
  781. } else {
  782. alert('已取消关联');
  783. }
  784. } else {
  785. if (window.customAlert) {
  786. window.customAlert('操作失败: ' + result.error);
  787. } else {
  788. alert('操作失败: ' + result.error);
  789. }
  790. }
  791. } catch (error) {
  792. console.error('Error:', error);
  793. if (window.customAlert) {
  794. window.customAlert('操作失败: ' + error.message);
  795. } else {
  796. alert('操作失败: ' + error.message);
  797. }
  798. }
  799. }
  800. // 移除知识点关联(通过kp_code,删除所有重复的关联)
  801. async function removeKpRelationByCode(kpCode, sectionId) {
  802. if (!confirm('确定要取消这个知识点关联吗?')) {
  803. return;
  804. }
  805. try {
  806. // 先获取所有该知识点的关联
  807. const listResponse = await fetch(`/api/textbook/relation/list/${sectionId}`);
  808. const listResult = await listResponse.json();
  809. if (!listResult.success || !listResult.data) {
  810. throw new Error('获取关联列表失败');
  811. }
  812. // 找到所有匹配的关联ID
  813. const relationIds = listResult.data
  814. .filter(rel => rel.kp_code === kpCode)
  815. .map(rel => rel.id);
  816. if (relationIds.length === 0) {
  817. if (window.customAlert) {
  818. window.customAlert('未找到关联');
  819. } else {
  820. alert('未找到关联');
  821. }
  822. return;
  823. }
  824. // 删除所有重复的关联
  825. const deletePromises = relationIds.map(id =>
  826. fetch(`/api/textbook/relation/delete/${id}`, {
  827. method: 'POST',
  828. headers: {'Content-Type': 'application/json'}
  829. })
  830. );
  831. await Promise.all(deletePromises);
  832. // 重新加载知识点列表
  833. loadKnowledgePointsForSection(sectionId);
  834. // 如果模态框打开,也刷新模态框中的列表
  835. if (currentKpRelationSectionId === sectionId) {
  836. await loadKpRelations(sectionId);
  837. }
  838. if (window.customAlert) {
  839. window.customAlert('已取消关联');
  840. } else {
  841. alert('已取消关联');
  842. }
  843. } catch (error) {
  844. console.error('Error:', error);
  845. if (window.customAlert) {
  846. window.customAlert('操作失败: ' + error.message);
  847. } else {
  848. alert('操作失败: ' + error.message);
  849. }
  850. }
  851. }
  852. // ==================== 教材系列 CRUD ====================
  853. function showAddSeriesModal() {
  854. document.getElementById('seriesModalTitle').textContent = '添加教材系列';
  855. document.getElementById('seriesForm').reset();
  856. document.getElementById('seriesId').value = '';
  857. document.getElementById('seriesIsActive').checked = true; // 默认激活
  858. document.getElementById('seriesModal').classList.remove('hidden');
  859. }
  860. async function showEditSeriesModal(seriesId) {
  861. try {
  862. const response = await fetch(`/api/textbook/series/get/${seriesId}`);
  863. const result = await response.json();
  864. if (result.success) {
  865. const series = result.data;
  866. document.getElementById('seriesModalTitle').textContent = '编辑教材系列';
  867. document.getElementById('seriesId').value = series.id;
  868. document.getElementById('seriesName').value = series.name || '';
  869. document.getElementById('seriesSlug').value = series.slug || '';
  870. document.getElementById('seriesIsActive').checked = series.is_active === 1;
  871. document.getElementById('seriesModal').classList.remove('hidden');
  872. }
  873. } catch (error) {
  874. console.error('Error:', error);
  875. if (window.customAlert) {
  876. window.customAlert('获取系列信息失败: ' + error.message);
  877. } else {
  878. alert('获取系列信息失败');
  879. }
  880. }
  881. }
  882. function closeSeriesModal() {
  883. document.getElementById('seriesModal').classList.add('hidden');
  884. }
  885. document.getElementById('seriesForm').addEventListener('submit', async function(e) {
  886. e.preventDefault();
  887. const seriesId = document.getElementById('seriesId').value;
  888. const formData = {
  889. name: document.getElementById('seriesName').value.trim(),
  890. slug: document.getElementById('seriesSlug').value.trim() || null,
  891. is_active: document.getElementById('seriesIsActive').checked
  892. };
  893. try {
  894. let response;
  895. if (seriesId) {
  896. response = await fetch(`/api/textbook/series/update/${seriesId}`, {
  897. method: 'POST',
  898. headers: {'Content-Type': 'application/json'},
  899. body: JSON.stringify(formData)
  900. });
  901. } else {
  902. response = await fetch('/api/textbook/series/create', {
  903. method: 'POST',
  904. headers: {'Content-Type': 'application/json'},
  905. body: JSON.stringify(formData)
  906. });
  907. }
  908. const result = await response.json();
  909. if (result.success) {
  910. alert(result.message || '操作成功');
  911. window.location.reload();
  912. } else {
  913. alert('操作失败: ' + result.error);
  914. }
  915. } catch (error) {
  916. console.error('Error:', error);
  917. alert('操作失败: ' + error.message);
  918. }
  919. });
  920. // ==================== 教材 CRUD ====================
  921. function showAddTextbookModal() {
  922. document.getElementById('textbookModalTitle').textContent = '添加教材';
  923. document.getElementById('textbookForm').reset();
  924. document.getElementById('textbookId').value = '';
  925. document.getElementById('textbookModal').classList.remove('hidden');
  926. }
  927. async function showEditTextbookModal(textbookId) {
  928. try {
  929. const response = await fetch(`/api/textbook/get/${textbookId}`);
  930. const result = await response.json();
  931. if (result.success) {
  932. const textbook = result.data;
  933. document.getElementById('textbookModalTitle').textContent = '编辑教材';
  934. document.getElementById('textbookId').value = textbook.id;
  935. document.getElementById('textbookTitle').value = textbook.official_title || '';
  936. document.getElementById('textbookStage').value = textbook.stage || '';
  937. document.getElementById('textbookGrade').value = textbook.grade || '';
  938. document.getElementById('textbookSemester').value = textbook.semester || '';
  939. document.getElementById('textbookModal').classList.remove('hidden');
  940. }
  941. } catch (error) {
  942. console.error('Error:', error);
  943. alert('获取教材信息失败');
  944. }
  945. }
  946. function closeTextbookModal() {
  947. document.getElementById('textbookModal').classList.add('hidden');
  948. }
  949. document.getElementById('textbookForm').addEventListener('submit', async function(e) {
  950. e.preventDefault();
  951. const textbookId = document.getElementById('textbookId').value;
  952. const formData = {
  953. series_id: currentSeriesId,
  954. official_title: document.getElementById('textbookTitle').value.trim(),
  955. stage: document.getElementById('textbookStage').value || null,
  956. grade: document.getElementById('textbookGrade').value.trim() || null,
  957. semester: document.getElementById('textbookSemester').value ? parseInt(document.getElementById('textbookSemester').value) : null
  958. };
  959. try {
  960. let response;
  961. if (textbookId) {
  962. response = await fetch(`/api/textbook/update/${textbookId}`, {
  963. method: 'POST',
  964. headers: {'Content-Type': 'application/json'},
  965. body: JSON.stringify(formData)
  966. });
  967. } else {
  968. response = await fetch('/api/textbook/create', {
  969. method: 'POST',
  970. headers: {'Content-Type': 'application/json'},
  971. body: JSON.stringify(formData)
  972. });
  973. }
  974. const result = await response.json();
  975. if (result.success) {
  976. if (window.customAlert) {
  977. window.customAlert(result.message || '操作成功', () => {
  978. switchSeries(currentSeriesId);
  979. closeTextbookModal();
  980. });
  981. } else {
  982. alert(result.message || '操作成功');
  983. switchSeries(currentSeriesId);
  984. closeTextbookModal();
  985. }
  986. } else {
  987. if (window.customAlert) {
  988. window.customAlert('操作失败: ' + result.error);
  989. } else {
  990. alert('操作失败: ' + result.error);
  991. }
  992. }
  993. } catch (error) {
  994. console.error('Error:', error);
  995. alert('操作失败: ' + error.message);
  996. }
  997. });
  998. async function showDeleteTextbookConfirm(textbookId, textbookTitle) {
  999. if (confirm(`确定要删除教材 "${textbookTitle}" 吗?`)) {
  1000. try {
  1001. const response = await fetch(`/api/textbook/delete/${textbookId}`, {
  1002. method: 'POST',
  1003. headers: {'Content-Type': 'application/json'}
  1004. });
  1005. const result = await response.json();
  1006. if (result.success) {
  1007. if (window.customAlert) {
  1008. window.customAlert(result.message || '删除成功', () => {
  1009. switchSeries(currentSeriesId);
  1010. });
  1011. } else {
  1012. alert(result.message || '删除成功');
  1013. switchSeries(currentSeriesId);
  1014. }
  1015. } else {
  1016. if (window.customAlert) {
  1017. window.customAlert('删除失败: ' + result.error);
  1018. } else {
  1019. alert('删除失败: ' + result.error);
  1020. }
  1021. }
  1022. } catch (error) {
  1023. console.error('Error:', error);
  1024. alert('删除失败: ' + error.message);
  1025. }
  1026. }
  1027. }
  1028. // ==================== 目录节点 CRUD ====================
  1029. function showAddCatalogModal(parentId = null, parentNodeType = null) {
  1030. document.getElementById('catalogModalTitle').textContent = parentId ? '添加子节点' : '添加目录节点';
  1031. document.getElementById('catalogForm').reset();
  1032. document.getElementById('catalogId').value = '';
  1033. document.getElementById('catalogTextbookId').value = currentTextbookId;
  1034. document.getElementById('catalogParentId').value = parentId || '';
  1035. // 根据父节点类型限制子节点类型
  1036. const nodeTypeSelect = document.getElementById('catalogNodeType');
  1037. const allOptions = nodeTypeSelect.querySelectorAll('option');
  1038. // 先显示所有选项
  1039. allOptions.forEach(opt => opt.style.display = '');
  1040. if (parentId && parentNodeType) {
  1041. // 有父节点,根据父节点类型限制
  1042. if (parentNodeType === 'chapter') {
  1043. // 章节下只能创建 section
  1044. allOptions.forEach(opt => {
  1045. if (opt.value !== 'section') {
  1046. opt.style.display = 'none';
  1047. }
  1048. });
  1049. nodeTypeSelect.value = 'section';
  1050. } else if (parentNodeType === 'section') {
  1051. // section 下只能创建 subsection
  1052. allOptions.forEach(opt => {
  1053. if (opt.value !== 'subsection') {
  1054. opt.style.display = 'none';
  1055. }
  1056. });
  1057. nodeTypeSelect.value = 'subsection';
  1058. } else {
  1059. // subsection 下不能再创建子节点(但这里不应该被调用)
  1060. allOptions.forEach(opt => {
  1061. if (opt.value !== 'subsection') {
  1062. opt.style.display = 'none';
  1063. }
  1064. });
  1065. }
  1066. } else {
  1067. // 没有父节点,只能创建顶级节点(chapter)
  1068. allOptions.forEach(opt => {
  1069. if (opt.value !== 'chapter') {
  1070. opt.style.display = 'none';
  1071. }
  1072. });
  1073. nodeTypeSelect.value = 'chapter';
  1074. }
  1075. document.getElementById('catalogModal').classList.remove('hidden');
  1076. }
  1077. async function showAddChildCatalogModal(parentId, parentTitle, parentNodeType = null) {
  1078. // 如果未传递父节点类型,则通过API获取
  1079. if (!parentNodeType) {
  1080. try {
  1081. const response = await fetch(`/api/textbook/catalog/get/${parentId}`);
  1082. const result = await response.json();
  1083. if (result.success) {
  1084. parentNodeType = result.data.node_type;
  1085. }
  1086. } catch (error) {
  1087. console.error('获取父节点信息失败:', error);
  1088. }
  1089. }
  1090. showAddCatalogModal(parentId, parentNodeType);
  1091. }
  1092. async function showEditCatalogModal(nodeId) {
  1093. try {
  1094. const response = await fetch(`/api/textbook/catalog/get/${nodeId}`);
  1095. const result = await response.json();
  1096. if (result.success) {
  1097. const node = result.data;
  1098. document.getElementById('catalogModalTitle').textContent = '编辑目录节点';
  1099. document.getElementById('catalogId').value = node.id;
  1100. document.getElementById('catalogTextbookId').value = node.textbook_id;
  1101. document.getElementById('catalogParentId').value = node.parent_id || '';
  1102. document.getElementById('catalogTitle').value = node.title || '';
  1103. document.getElementById('catalogDisplayNo').value = node.display_no || '';
  1104. // 根据父节点类型限制节点类型选择
  1105. const nodeTypeSelect = document.getElementById('catalogNodeType');
  1106. const allOptions = nodeTypeSelect.querySelectorAll('option');
  1107. // 先显示所有选项
  1108. allOptions.forEach(opt => opt.style.display = '');
  1109. if (node.parent_id) {
  1110. // 有父节点,需要获取父节点类型
  1111. try {
  1112. const parentResponse = await fetch(`/api/textbook/catalog/get/${node.parent_id}`);
  1113. const parentResult = await parentResponse.json();
  1114. if (parentResult.success) {
  1115. const parentNodeType = parentResult.data.node_type;
  1116. if (parentNodeType === 'chapter') {
  1117. // 章节下只能创建 section
  1118. allOptions.forEach(opt => {
  1119. if (opt.value !== 'section') {
  1120. opt.style.display = 'none';
  1121. }
  1122. });
  1123. } else if (parentNodeType === 'section') {
  1124. // section 下只能创建 subsection
  1125. allOptions.forEach(opt => {
  1126. if (opt.value !== 'subsection') {
  1127. opt.style.display = 'none';
  1128. }
  1129. });
  1130. }
  1131. }
  1132. } catch (error) {
  1133. console.error('获取父节点信息失败:', error);
  1134. }
  1135. } else {
  1136. // 没有父节点,只能创建顶级节点(chapter)
  1137. allOptions.forEach(opt => {
  1138. if (opt.value !== 'chapter') {
  1139. opt.style.display = 'none';
  1140. }
  1141. });
  1142. }
  1143. document.getElementById('catalogNodeType').value = node.node_type || 'chapter';
  1144. document.getElementById('catalogModal').classList.remove('hidden');
  1145. }
  1146. } catch (error) {
  1147. console.error('Error:', error);
  1148. alert('获取节点信息失败');
  1149. }
  1150. }
  1151. function closeCatalogModal() {
  1152. document.getElementById('catalogModal').classList.add('hidden');
  1153. }
  1154. document.getElementById('catalogForm').addEventListener('submit', async function(e) {
  1155. e.preventDefault();
  1156. const catalogId = document.getElementById('catalogId').value;
  1157. const formData = {
  1158. textbook_id: parseInt(document.getElementById('catalogTextbookId').value),
  1159. parent_id: document.getElementById('catalogParentId').value ? parseInt(document.getElementById('catalogParentId').value) : null,
  1160. node_type: document.getElementById('catalogNodeType').value,
  1161. title: document.getElementById('catalogTitle').value.trim(),
  1162. display_no: document.getElementById('catalogDisplayNo').value.trim() || null
  1163. };
  1164. try {
  1165. let response;
  1166. if (catalogId) {
  1167. response = await fetch(`/api/textbook/catalog/update/${catalogId}`, {
  1168. method: 'POST',
  1169. headers: {'Content-Type': 'application/json'},
  1170. body: JSON.stringify(formData)
  1171. });
  1172. } else {
  1173. response = await fetch('/api/textbook/catalog/create', {
  1174. method: 'POST',
  1175. headers: {'Content-Type': 'application/json'},
  1176. body: JSON.stringify(formData)
  1177. });
  1178. }
  1179. const result = await response.json();
  1180. if (result.success) {
  1181. if (window.customAlert) {
  1182. window.customAlert(result.message || '操作成功', () => {
  1183. loadTextbookCatalog(currentTextbookId, document.getElementById('currentTextbookTitle').textContent);
  1184. closeCatalogModal();
  1185. });
  1186. } else {
  1187. alert(result.message || '操作成功');
  1188. loadTextbookCatalog(currentTextbookId, document.getElementById('currentTextbookTitle').textContent);
  1189. closeCatalogModal();
  1190. }
  1191. } else {
  1192. if (window.customAlert) {
  1193. window.customAlert('操作失败: ' + result.error);
  1194. } else {
  1195. alert('操作失败: ' + result.error);
  1196. }
  1197. }
  1198. } catch (error) {
  1199. console.error('Error:', error);
  1200. alert('操作失败: ' + error.message);
  1201. }
  1202. });
  1203. async function showDeleteCatalogConfirm(nodeId, nodeTitle) {
  1204. if (confirm(`确定要删除节点 "${nodeTitle}" 吗?`)) {
  1205. try {
  1206. const response = await fetch(`/api/textbook/catalog/delete/${nodeId}`, {
  1207. method: 'POST',
  1208. headers: {'Content-Type': 'application/json'}
  1209. });
  1210. const result = await response.json();
  1211. if (result.success) {
  1212. if (window.customAlert) {
  1213. window.customAlert(result.message || '删除成功', () => {
  1214. loadTextbookCatalog(currentTextbookId, document.getElementById('currentTextbookTitle').textContent);
  1215. });
  1216. } else {
  1217. alert(result.message || '删除成功');
  1218. loadTextbookCatalog(currentTextbookId, document.getElementById('currentTextbookTitle').textContent);
  1219. }
  1220. } else {
  1221. if (window.customAlert) {
  1222. window.customAlert('删除失败: ' + result.error);
  1223. } else {
  1224. alert('删除失败: ' + result.error);
  1225. }
  1226. }
  1227. } catch (error) {
  1228. console.error('Error:', error);
  1229. alert('删除失败: ' + error.message);
  1230. }
  1231. }
  1232. }
  1233. // ==================== 知识点关联 CRUD ====================
  1234. let currentKpRelationSectionId = null;
  1235. async function showAddKpRelationModal(chapterId, chapterTitle) {
  1236. currentKpRelationSectionId = chapterId;
  1237. currentCatalogChapterId = chapterId;
  1238. document.getElementById('kpRelationModalTitle').textContent = `关联知识点 - ${chapterTitle}`;
  1239. document.getElementById('kpRelationModal').classList.remove('hidden');
  1240. // 加载已有的关联
  1241. await loadKpRelations(chapterId);
  1242. }
  1243. function closeKpRelationModal() {
  1244. document.getElementById('kpRelationModal').classList.add('hidden');
  1245. currentCatalogChapterId = null;
  1246. }
  1247. async function loadKpRelations(chapterId) {
  1248. try {
  1249. const response = await fetch(`/api/textbook/relation/list/${chapterId}`);
  1250. const result = await response.json();
  1251. if (result.success) {
  1252. const container = document.getElementById('kpRelationList');
  1253. container.innerHTML = '';
  1254. if (result.data.length === 0) {
  1255. container.innerHTML = '<p class="text-gray-500 text-center py-4">暂无关联的知识点</p>';
  1256. } else {
  1257. // 去重:按kp_code去重,保留第一个
  1258. const uniqueRelations = [];
  1259. const seenKpCodes = new Set();
  1260. result.data.forEach(relation => {
  1261. if (!seenKpCodes.has(relation.kp_code)) {
  1262. seenKpCodes.add(relation.kp_code);
  1263. uniqueRelations.push(relation);
  1264. }
  1265. });
  1266. uniqueRelations.forEach(relation => {
  1267. const div = document.createElement('div');
  1268. div.className = 'flex items-center justify-between p-3 bg-gray-50 rounded-lg';
  1269. div.innerHTML = `
  1270. <div>
  1271. <span class="font-semibold">${relation.kp_code}</span>
  1272. ${relation.kp_name ? `<span class="text-gray-500 ml-2">${relation.kp_name}</span>` : ''}
  1273. </div>
  1274. <button onclick="deleteKpRelation(${relation.id})" class="text-red-600 hover:text-red-800">
  1275. <i class="ri-delete-bin-line"></i>
  1276. </button>
  1277. `;
  1278. container.appendChild(div);
  1279. });
  1280. }
  1281. }
  1282. } catch (error) {
  1283. console.error('Error:', error);
  1284. }
  1285. }
  1286. async function addKpRelation() {
  1287. const kpCode = document.getElementById('kpCodeSelect').value;
  1288. if (!kpCode) {
  1289. if (window.customAlert) {
  1290. window.customAlert('请选择知识点');
  1291. } else {
  1292. alert('请选择知识点');
  1293. }
  1294. return;
  1295. }
  1296. // 检查是否已存在该关联
  1297. try {
  1298. const checkResponse = await fetch(`/api/textbook/relation/list/${currentCatalogChapterId}`);
  1299. const checkResult = await checkResponse.json();
  1300. if (checkResult.success && checkResult.data) {
  1301. const exists = checkResult.data.some(rel => rel.kp_code === kpCode);
  1302. if (exists) {
  1303. if (window.customAlert) {
  1304. window.customAlert('该知识点已关联,不能重复添加');
  1305. } else {
  1306. alert('该知识点已关联,不能重复添加');
  1307. }
  1308. return;
  1309. }
  1310. }
  1311. } catch (error) {
  1312. console.error('Error checking existing relations:', error);
  1313. }
  1314. try {
  1315. const response = await fetch('/api/textbook/relation/create', {
  1316. method: 'POST',
  1317. headers: {'Content-Type': 'application/json'},
  1318. body: JSON.stringify({
  1319. catalog_chapter_id: currentCatalogChapterId,
  1320. kp_code: kpCode
  1321. })
  1322. });
  1323. const result = await response.json();
  1324. if (result.success) {
  1325. document.getElementById('kpCodeSelect').value = '';
  1326. await loadKpRelations(currentCatalogChapterId);
  1327. // 刷新section下显示的知识点列表
  1328. if (currentKpRelationSectionId) {
  1329. loadKnowledgePointsForSection(currentKpRelationSectionId);
  1330. }
  1331. } else {
  1332. if (window.customAlert) {
  1333. window.customAlert('添加失败: ' + result.error);
  1334. } else {
  1335. alert('添加失败: ' + result.error);
  1336. }
  1337. }
  1338. } catch (error) {
  1339. console.error('Error:', error);
  1340. if (window.customAlert) {
  1341. window.customAlert('添加失败: ' + error.message);
  1342. } else {
  1343. alert('添加失败: ' + error.message);
  1344. }
  1345. }
  1346. }
  1347. async function deleteKpRelation(relationId) {
  1348. if (!confirm('确定要删除这个关联吗?')) {
  1349. return;
  1350. }
  1351. try {
  1352. const response = await fetch(`/api/textbook/relation/delete/${relationId}`, {
  1353. method: 'POST',
  1354. headers: {'Content-Type': 'application/json'}
  1355. });
  1356. const result = await response.json();
  1357. if (result.success) {
  1358. await loadKpRelations(currentCatalogChapterId);
  1359. // 刷新section下显示的知识点列表
  1360. if (currentKpRelationSectionId) {
  1361. loadKnowledgePointsForSection(currentKpRelationSectionId);
  1362. }
  1363. } else {
  1364. alert('删除失败: ' + result.error);
  1365. }
  1366. } catch (error) {
  1367. console.error('Error:', error);
  1368. alert('删除失败: ' + error.message);
  1369. }
  1370. }
  1371. // 点击模态框外部关闭
  1372. document.getElementById('seriesModal').addEventListener('click', function(e) {
  1373. if (e.target === this) closeSeriesModal();
  1374. });
  1375. document.getElementById('textbookModal').addEventListener('click', function(e) {
  1376. if (e.target === this) closeTextbookModal();
  1377. });
  1378. document.getElementById('catalogModal').addEventListener('click', function(e) {
  1379. if (e.target === this) closeCatalogModal();
  1380. });
  1381. document.getElementById('kpRelationModal').addEventListener('click', function(e) {
  1382. if (e.target === this) closeKpRelationModal();
  1383. });
  1384. </script>
  1385. {% endblock %}