add_question.html 74 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930
  1. {% extends "layout.html" %}
  2. {% block page_title %}录入新题目{% endblock %}
  3. {% block content %}
  4. <!-- LaTeX 实时预览气泡 -->
  5. <div id="latex-preview-bubble" class="fixed right-8 top-24 w-[420px] max-h-[75vh] bg-white rounded-2xl shadow-2xl border border-gray-200 z-50 overflow-hidden flex flex-col" style="box-shadow: 0 20px 60px rgba(0,0,0,0.15);">
  6. <div class="bg-gradient-to-r from-blue-500 to-indigo-600 text-white px-4 py-3 flex items-center justify-between">
  7. <span class="text-sm font-bold">题目预览</span>
  8. <button type="button" onclick="hidePreviewBubble()" class="text-white hover:text-gray-200 text-xl leading-none w-6 h-6 flex items-center justify-center rounded-full hover:bg-white/20 transition-colors">×</button>
  9. </div>
  10. <div id="latex-preview-content" class="flex-1 p-6 overflow-y-auto bg-gray-50 text-base leading-relaxed" style="min-height: 200px;">
  11. <p class="text-sm text-gray-400 text-center">题目预览</p>
  12. </div>
  13. </div>
  14. <form id="add-form" class="grid grid-cols-1 gap-4">
  15. <div class="apple-card p-6 space-y-4">
  16. <div class="border-b border-gray-100 pb-3 mb-3">
  17. <div class="flex items-center gap-3 flex-wrap">
  18. <h2 class="text-xl font-bold">录入新题目</h2>
  19. </div>
  20. <!-- 层级信息标签 -->
  21. {% if chapter_label or section_label or subsection_label %}
  22. <div class="flex items-center gap-2 flex-wrap">
  23. {% if chapter_label %}
  24. <div class="flex items-center gap-1.5">
  25. <span class="bg-gradient-to-r from-slate-600 to-slate-700 text-white px-2 py-0.5 rounded text-xs font-bold">章</span>
  26. <span class="text-xs text-gray-700">{{ chapter_label }}</span>
  27. </div>
  28. {% endif %}
  29. {% if section_label %}
  30. <span class="text-gray-300 text-xs">›</span>
  31. <div class="flex items-center gap-1.5">
  32. <span class="bg-gradient-to-r from-blue-500 to-blue-600 text-white px-2 py-0.5 rounded text-xs font-bold">节</span>
  33. <span class="text-xs text-gray-600">{{ section_label }}</span>
  34. </div>
  35. {% endif %}
  36. {% if subsection_label %}
  37. <span class="text-gray-300 text-xs">›</span>
  38. <div class="flex items-center gap-1.5">
  39. <span class="bg-gray-200 text-gray-700 px-2 py-0.5 rounded text-xs font-bold">小节</span>
  40. <span class="text-xs text-gray-600">{{ subsection_label }}</span>
  41. </div>
  42. {% endif %}
  43. </div>
  44. {% endif %}
  45. </div>
  46. <p class="text-gray-400 text-xs mt-1">填写题目信息,保存后自动生成题号并创建新题目</p>
  47. </div>
  48. <!-- JSON输入框(永久显示) -->
  49. <div id="json-input-section" class="mb-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
  50. <label class="block text-sm font-bold text-gray-700 mb-2">JSON输入(实时双向同步)</label>
  51. <textarea id="json-input"
  52. class="w-full h-48 p-3 border border-gray-300 rounded-lg font-mono text-xs focus:border-blue-500 focus:ring-2 focus:ring-blue-500/10 outline-none"
  53. placeholder='{"number": "", "stem": "", "options": {"A": "", "B": "", "C": "", "D": ""}, "answer": "", "question_type": "", "solution": "", "difficulty": ""}'
  54. oninput="handleJsonInputChange()"></textarea>
  55. <p class="text-xs text-gray-500 mt-2">修改JSON自动同步到表单,修改表单字段自动同步到JSON</p>
  56. </div>
  57. <!-- 题型和难度选择 -->
  58. <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
  59. <div class="space-y-1">
  60. <label class="text-xs font-bold text-gray-400 uppercase">题型</label>
  61. <select name="question_type" id="question-type-select" class="w-full p-2 rounded-lg bg-gray-50 border border-gray-100 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/10 outline-none transition-all text-sm">
  62. <option value="choice" selected>选择题</option>
  63. <option value="fill">填空题</option>
  64. <option value="answer">解答题</option>
  65. </select>
  66. </div>
  67. <!-- 难度选择 -->
  68. <div id="difficulty-section" class="space-y-1">
  69. <div class="flex items-center gap-2">
  70. <label class="text-xs font-bold text-gray-400 uppercase">难度</label>
  71. <button type="button" id="evaluate-difficulty-btn" onclick="evaluateDifficulty()" class="btn-apple bg-gradient-to-r from-purple-500 to-indigo-600 text-white hover:from-purple-600 hover:to-indigo-700 text-xs py-1 px-2 shadow-md whitespace-nowrap">
  72. <svg class="w-3 h-3 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  73. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
  74. </svg>
  75. 难度评价
  76. </button>
  77. </div>
  78. <select name="difficulty" id="difficulty-select" class="w-full p-2 rounded-lg bg-gray-50 border border-gray-100 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/10 outline-none transition-all text-sm">
  79. <option value="">请选择难度</option>
  80. <option value="0.2">筑基</option>
  81. <option value="0.4">提分</option>
  82. <option value="0.7">培优</option>
  83. </select>
  84. </div>
  85. </div>
  86. <!-- 题干编辑 -->
  87. <div class="space-y-2">
  88. <div class="flex items-center justify-between">
  89. <label class="text-xs font-bold text-gray-400 uppercase">题干 <span class="text-red-500">*</span> (支持 LaTeX 和 HTML/SVG)</label>
  90. <button
  91. type="button"
  92. id="upload-image-btn"
  93. class="btn-apple bg-blue-600 text-white hover:bg-blue-700 text-xs py-1.5 px-3 h-fit"
  94. onclick="triggerStemImageUpload()"
  95. >
  96. 上传图片
  97. </button>
  98. </div>
  99. <textarea
  100. id="stem-textarea"
  101. name="stem"
  102. required
  103. class="w-full h-32 p-3 rounded-xl bg-gray-50 border border-gray-100 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/10 outline-none transition-all font-mono text-sm"
  104. placeholder="请输入题干内容或拖拽图片..."
  105. ondrop="handleStemDrop(event)"
  106. ondragover="handleStemDragOver(event)"
  107. ondragleave="handleStemDragLeave(event)"
  108. ></textarea>
  109. <div id="upload-status" class="mt-1 text-xs hidden"></div>
  110. </div>
  111. <!-- 选项编辑 -->
  112. <div id="options-section" class="space-y-2">
  113. <label class="text-xs font-bold text-gray-400 uppercase">选项</label>
  114. <!-- 选项输入区域 - 2列布局 -->
  115. <div class="grid grid-cols-2 gap-3" id="options-container">
  116. <!-- 选项A -->
  117. <div class="option-item" data-option="A">
  118. <label class="text-xs font-medium text-gray-600 mb-1 block">选项 A</label>
  119. <div class="flex gap-1.5">
  120. <textarea
  121. name="option_A"
  122. id="option-A-input"
  123. class="flex-1 h-12 p-2 rounded-lg bg-gray-50 border border-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500/10 outline-none transition-all text-xs"
  124. placeholder="输入选项A的内容或拖拽图片..."
  125. oninput="updateOptionsPreview()"
  126. ondrop="handleOptionDrop(event, 'A')"
  127. ondragover="handleOptionDragOver(event)"
  128. ondragleave="handleOptionDragLeave(event)"
  129. ></textarea>
  130. <button
  131. type="button"
  132. class="option-upload-btn btn-apple bg-blue-600 text-white hover:bg-blue-700 text-xs py-1.5 px-2 h-fit self-start"
  133. onclick="uploadOptionImage('A')"
  134. >
  135. 上传图片
  136. </button>
  137. </div>
  138. </div>
  139. <!-- 选项B -->
  140. <div class="option-item" data-option="B">
  141. <label class="text-xs font-medium text-gray-600 mb-1 block">选项 B</label>
  142. <div class="flex gap-1.5">
  143. <textarea
  144. name="option_B"
  145. id="option-B-input"
  146. class="flex-1 h-12 p-2 rounded-lg bg-gray-50 border border-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500/10 outline-none transition-all text-xs"
  147. placeholder="输入选项B的内容或拖拽图片..."
  148. oninput="updateOptionsPreview()"
  149. ondrop="handleOptionDrop(event, 'B')"
  150. ondragover="handleOptionDragOver(event)"
  151. ondragleave="handleOptionDragLeave(event)"
  152. ></textarea>
  153. <button
  154. type="button"
  155. class="option-upload-btn btn-apple bg-blue-600 text-white hover:bg-blue-700 text-xs py-1.5 px-2 h-fit self-start"
  156. onclick="uploadOptionImage('B')"
  157. >
  158. 上传图片
  159. </button>
  160. </div>
  161. </div>
  162. <!-- 选项C -->
  163. <div class="option-item" data-option="C">
  164. <label class="text-xs font-medium text-gray-600 mb-1 block">选项 C</label>
  165. <div class="flex gap-1.5">
  166. <textarea
  167. name="option_C"
  168. id="option-C-input"
  169. class="flex-1 h-12 p-2 rounded-lg bg-gray-50 border border-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500/10 outline-none transition-all text-xs"
  170. placeholder="输入选项C的内容或拖拽图片..."
  171. oninput="updateOptionsPreview()"
  172. ondrop="handleOptionDrop(event, 'C')"
  173. ondragover="handleOptionDragOver(event)"
  174. ondragleave="handleOptionDragLeave(event)"
  175. ></textarea>
  176. <button
  177. type="button"
  178. class="option-upload-btn btn-apple bg-blue-600 text-white hover:bg-blue-700 text-xs py-1.5 px-2 h-fit self-start"
  179. onclick="uploadOptionImage('C')"
  180. >
  181. 上传图片
  182. </button>
  183. </div>
  184. </div>
  185. <!-- 选项D -->
  186. <div class="option-item" data-option="D">
  187. <label class="text-xs font-medium text-gray-600 mb-1 block">选项 D</label>
  188. <div class="flex gap-1.5">
  189. <textarea
  190. name="option_D"
  191. id="option-D-input"
  192. class="flex-1 h-12 p-2 rounded-lg bg-gray-50 border border-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500/10 outline-none transition-all text-xs"
  193. placeholder="输入选项D的内容或拖拽图片..."
  194. oninput="updateOptionsPreview()"
  195. ondrop="handleOptionDrop(event, 'D')"
  196. ondragover="handleOptionDragOver(event)"
  197. ondragleave="handleOptionDragLeave(event)"
  198. ></textarea>
  199. <button
  200. type="button"
  201. class="option-upload-btn btn-apple bg-blue-600 text-white hover:bg-blue-700 text-xs py-1.5 px-2 h-fit self-start"
  202. onclick="uploadOptionImage('D')"
  203. >
  204. 上传图片
  205. </button>
  206. </div>
  207. </div>
  208. </div>
  209. <!-- 选项预览(可编辑) -->
  210. <div class="mt-2 p-2 rounded-lg bg-gray-50 border border-gray-200">
  211. <label class="text-xs font-bold text-gray-600 mb-1 block">选项预览 (JSON格式,可直接编辑)</label>
  212. <textarea
  213. id="options-preview"
  214. class="w-full text-xs text-gray-700 font-mono bg-white p-2 rounded border border-gray-100 min-h-[80px] focus:border-blue-500 focus:ring-2 focus:ring-blue-500/10 outline-none transition-all resize-y"
  215. placeholder='{"A": "选项A内容", "B": "选项B内容", ...}'
  216. oninput="syncOptionsFromPreview()"
  217. >{}</textarea>
  218. <input type="hidden" name="options" id="options-json-input">
  219. </div>
  220. </div>
  221. <!-- 答案 -->
  222. <div class="space-y-1">
  223. <label class="text-xs font-bold text-gray-400 uppercase">正确答案</label>
  224. <input type="text" name="answer" id="answer-input" class="w-full p-2 rounded-lg bg-gray-50 border border-gray-100 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/10 outline-none transition-all text-sm" placeholder="例如: A">
  225. </div>
  226. <!-- 解析 -->
  227. <div class="space-y-2">
  228. <label class="text-xs font-bold text-gray-400 uppercase">解析</label>
  229. <textarea
  230. id="solution-textarea"
  231. name="solution"
  232. class="w-full h-32 p-3 rounded-xl bg-gray-50 border border-gray-100 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/10 outline-none transition-all font-mono text-sm"
  233. placeholder="请输入解析内容或拖拽图片..."
  234. ondrop="handleSolutionDrop(event)"
  235. ondragover="handleSolutionDragOver(event)"
  236. ondragleave="handleSolutionDragLeave(event)"
  237. ></textarea>
  238. </div>
  239. <!-- 隐藏字段:用于提交层级信息 -->
  240. {% if chapter_label %}
  241. <input type="hidden" name="chapter" id="chapter-input" value="{{ chapter_label }}">
  242. {% endif %}
  243. {% if section_label %}
  244. <input type="hidden" name="section" id="section-input" value="{{ section_label }}">
  245. {% endif %}
  246. {% if subsection_label %}
  247. <input type="hidden" name="subsection" id="subsection-input" value="{{ subsection_label }}">
  248. {% endif %}
  249. <!-- 保存按钮 -->
  250. <div class="pt-4 flex justify-end space-x-2">
  251. <button type="button" onclick="window.history.back()" class="btn-apple bg-gray-100 text-gray-700 hover:bg-gray-200 text-sm py-2 px-4">取消</button>
  252. <button type="submit" class="btn-apple bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-200 text-sm py-2 px-4">保存并创建 <span class="text-xs opacity-75 ml-1">(Alt+R)</span></button>
  253. </div>
  254. </div>
  255. </form>
  256. <script>
  257. // 工具函数:保留两位小数
  258. function round(value, decimals) {
  259. return Number(Math.round(value + 'e' + decimals) + 'e-' + decimals);
  260. }
  261. // LaTeX 实时预览功能
  262. let previewUpdateTimer = null;
  263. let currentPreviewElement = null;
  264. function showPreviewBubble(element, label) {
  265. const bubble = document.getElementById('latex-preview-bubble');
  266. const content = document.getElementById('latex-preview-content');
  267. if (!bubble || !content) return;
  268. // 更新标题
  269. const title = bubble.querySelector('.bg-gradient-to-r span');
  270. if (title) {
  271. title.textContent = label || '实时预览';
  272. }
  273. currentPreviewElement = element;
  274. bubble.classList.remove('hidden');
  275. updatePreviewContent(element.value || '');
  276. }
  277. function hidePreviewBubble() {
  278. const bubble = document.getElementById('latex-preview-bubble');
  279. if (bubble) {
  280. bubble.classList.add('hidden');
  281. }
  282. currentPreviewElement = null;
  283. }
  284. function updatePreviewContent(text) {
  285. const content = document.getElementById('latex-preview-content');
  286. if (!content) return;
  287. if (!text || !text.trim()) {
  288. content.innerHTML = '<p class="text-sm text-gray-400 text-center">聚焦输入框查看预览</p>';
  289. return;
  290. }
  291. // 将文本内容转换为 HTML,保留换行和基本格式
  292. let html = text
  293. .replace(/&/g, '&amp;')
  294. .replace(/</g, '&lt;')
  295. .replace(/>/g, '&gt;')
  296. .replace(/\n/g, '<br>');
  297. // 处理图片标签
  298. html = html.replace(/&lt;image\s+src="([^"]+)"\s*\/?&gt;/gi, '<img src="$1" class="max-w-[400px] max-h-[300px] w-auto h-auto my-2 rounded-lg mx-auto block" alt="题目图片" style="object-fit: contain;">');
  299. content.innerHTML = html;
  300. // 等待 KaTeX 加载完成后渲染数学公式
  301. if (window.renderMathInElement) {
  302. try {
  303. window.renderMathInElement(content, {
  304. delimiters: [
  305. {left: "$$", right: "$$", display: true},
  306. {left: "$", right: "$", display: false},
  307. {left: "\\(", right: "\\)", display: false},
  308. {left: "\\[", right: "\\]", display: true}
  309. ],
  310. throwOnError: false
  311. });
  312. } catch (e) {
  313. console.warn('LaTeX 渲染失败:', e);
  314. }
  315. } else {
  316. // 如果 KaTeX 还没加载,等待一下再试
  317. setTimeout(() => {
  318. if (window.renderMathInElement) {
  319. try {
  320. window.renderMathInElement(content, {
  321. delimiters: [
  322. {left: "$$", right: "$$", display: true},
  323. {left: "$", right: "$", display: false},
  324. {left: "\\(", right: "\\)", display: false},
  325. {left: "\\[", right: "\\]", display: true}
  326. ],
  327. throwOnError: false
  328. });
  329. } catch (e) {
  330. console.warn('LaTeX 渲染失败:', e);
  331. }
  332. }
  333. }, 100);
  334. }
  335. }
  336. function setupPreviewForElement(elementId, label) {
  337. const element = document.getElementById(elementId);
  338. if (!element) return;
  339. element.addEventListener('focus', function() {
  340. showPreviewBubble(this, label);
  341. updatePreviewContent(this.value || '');
  342. });
  343. element.addEventListener('input', function() {
  344. if (currentPreviewElement === this) {
  345. // 防抖处理,避免频繁更新
  346. clearTimeout(previewUpdateTimer);
  347. previewUpdateTimer = setTimeout(() => {
  348. updatePreviewContent(this.value || '');
  349. }, 300);
  350. }
  351. });
  352. element.addEventListener('blur', function() {
  353. // 不自动隐藏,保持预览气泡显示,直到用户点击其他预览或关闭按钮
  354. // 移除自动隐藏逻辑,让预览气泡一直显示
  355. });
  356. }
  357. // 更新选项预览气泡内容(显示4个选项的渲染)
  358. function updateOptionsPreviewBubble() {
  359. const previewTextarea = document.getElementById('options-preview');
  360. const bubble = document.getElementById('latex-preview-bubble');
  361. const content = document.getElementById('latex-preview-content');
  362. if (!previewTextarea || !bubble || !content) return;
  363. const jsonStr = previewTextarea.value.trim();
  364. if (!jsonStr || jsonStr === '{}') {
  365. content.innerHTML = '<p class="text-sm text-gray-400 text-center">暂无选项内容</p>';
  366. return;
  367. }
  368. try {
  369. const optionsObj = JSON.parse(jsonStr);
  370. const optionKeys = ['A', 'B', 'C', 'D'];
  371. let html = '<div class="space-y-4">';
  372. optionKeys.forEach(key => {
  373. const optionText = optionsObj[key] || '';
  374. html += `<div class="border-b border-gray-200 pb-3 last:border-0 last:pb-0">`;
  375. html += `<div class="text-xs font-bold text-gray-500 mb-2">选项 ${key}</div>`;
  376. if (!optionText || !optionText.trim()) {
  377. html += `<p class="text-xs text-gray-400">暂无内容</p>`;
  378. } else {
  379. // 将文本内容转换为 HTML,保留换行和基本格式
  380. let optionHtml = optionText
  381. .replace(/&/g, '&amp;')
  382. .replace(/</g, '&lt;')
  383. .replace(/>/g, '&gt;')
  384. .replace(/\n/g, '<br>');
  385. // 处理图片标签
  386. optionHtml = optionHtml.replace(/&lt;image\s+src="([^"]+)"\s*\/?&gt;/gi, '<img src="$1" class="max-w-[400px] max-h-[300px] w-auto h-auto my-2 rounded-lg mx-auto block" alt="题目图片" style="object-fit: contain;">');
  387. html += `<div class="text-sm leading-relaxed">${optionHtml}</div>`;
  388. }
  389. html += `</div>`;
  390. });
  391. html += '</div>';
  392. content.innerHTML = html;
  393. // 等待 KaTeX 加载完成后渲染数学公式
  394. if (window.renderMathInElement) {
  395. try {
  396. window.renderMathInElement(content, {
  397. delimiters: [
  398. {left: "$$", right: "$$", display: true},
  399. {left: "$", right: "$", display: false},
  400. {left: "\\(", right: "\\)", display: false},
  401. {left: "\\[", right: "\\]", display: true}
  402. ],
  403. throwOnError: false
  404. });
  405. } catch (e) {
  406. console.warn('LaTeX 渲染失败:', e);
  407. }
  408. } else {
  409. // 如果 KaTeX 还没加载,等待一下再试
  410. setTimeout(() => {
  411. if (window.renderMathInElement) {
  412. try {
  413. window.renderMathInElement(content, {
  414. delimiters: [
  415. {left: "$$", right: "$$", display: true},
  416. {left: "$", right: "$", display: false},
  417. {left: "\\(", right: "\\)", display: false},
  418. {left: "\\[", right: "\\]", display: true}
  419. ],
  420. throwOnError: false
  421. });
  422. } catch (e) {
  423. console.warn('LaTeX 渲染失败:', e);
  424. }
  425. }
  426. }, 100);
  427. }
  428. } catch (error) {
  429. content.innerHTML = '<p class="text-sm text-red-400 text-center">JSON 格式错误,请检查输入</p>';
  430. }
  431. }
  432. // 设置选项预览输入框的预览功能
  433. function setupOptionsPreviewTextarea() {
  434. const optionsPreviewTextarea = document.getElementById('options-preview');
  435. if (!optionsPreviewTextarea) return;
  436. optionsPreviewTextarea.addEventListener('focus', function() {
  437. showPreviewBubble(this, '选项预览');
  438. updateOptionsPreviewBubble();
  439. });
  440. optionsPreviewTextarea.addEventListener('input', function() {
  441. if (currentPreviewElement === this) {
  442. // 防抖处理,避免频繁更新
  443. clearTimeout(previewUpdateTimer);
  444. previewUpdateTimer = setTimeout(() => {
  445. updateOptionsPreviewBubble();
  446. }, 300);
  447. }
  448. });
  449. optionsPreviewTextarea.addEventListener('blur', function() {
  450. // 不自动隐藏,保持预览气泡显示
  451. });
  452. }
  453. // 根据题型显示/隐藏选项相关内容
  454. function toggleOptionsVisibility(questionType) {
  455. const optionsSection = document.getElementById('options-section');
  456. const difficultySection = document.getElementById('difficulty-section');
  457. if (optionsSection) {
  458. if (questionType === 'choice') {
  459. optionsSection.style.display = 'block';
  460. } else {
  461. optionsSection.style.display = 'none';
  462. }
  463. }
  464. // 难度选择框:所有题型都显示
  465. if (difficultySection) {
  466. difficultySection.style.display = 'block';
  467. }
  468. }
  469. // 难度评价函数
  470. async function evaluateDifficulty() {
  471. const btn = document.getElementById('evaluate-difficulty-btn');
  472. const difficultySelect = document.getElementById('difficulty-select');
  473. if (!btn || !difficultySelect) return;
  474. // 收集题目信息
  475. const stemTextarea = document.getElementById('stem-textarea');
  476. const answerInput = document.getElementById('answer-input');
  477. const solutionTextarea = document.getElementById('solution-textarea');
  478. const optionsPreview = document.getElementById('options-preview');
  479. const questionTypeSelect = document.getElementById('question-type-select');
  480. const stem = stemTextarea ? stemTextarea.value.trim() : '';
  481. if (!stem) {
  482. if (window.customAlert) {
  483. window.customAlert('请先填写题干内容');
  484. } else {
  485. alert('请先填写题干内容');
  486. }
  487. return;
  488. }
  489. // 构建请求数据
  490. const requestData = {
  491. stem: stem,
  492. answer: answerInput ? answerInput.value.trim() : '',
  493. solution: solutionTextarea ? solutionTextarea.value.trim() : '',
  494. question_type: questionTypeSelect ? questionTypeSelect.value : ''
  495. };
  496. // 处理选项
  497. if (optionsPreview && optionsPreview.value.trim() && optionsPreview.value.trim() !== '{}') {
  498. try {
  499. requestData.options = JSON.parse(optionsPreview.value.trim());
  500. } catch (e) {
  501. console.warn('选项JSON解析失败:', e);
  502. }
  503. }
  504. // 显示加载状态
  505. const originalText = btn.innerHTML;
  506. btn.disabled = true;
  507. btn.innerHTML = '<svg class="w-3 h-3 inline-block mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>评价中...';
  508. try {
  509. const response = await fetch('/api/score', {
  510. method: 'POST',
  511. headers: {
  512. 'Content-Type': 'application/json'
  513. },
  514. body: JSON.stringify(requestData)
  515. });
  516. if (!response.ok) {
  517. throw new Error(`请求失败: ${response.status} ${response.statusText}`);
  518. }
  519. const result = await response.json();
  520. // 调试:打印完整返回结果
  521. console.log('难度评价接口返回:', result);
  522. // 处理返回的 difficulty_level(优先使用 data.difficulty_level,兼容旧格式)
  523. let difficultyLevel = result.data?.difficulty_level || result.difficulty_level || result.difficulty || result.level;
  524. // 映射难度等级到枚举值
  525. // 可能的返回值:字符串 "筑基"、"提分"、"培优" 或数字 0.2, 0.4, 0.7
  526. let difficultyValue = '';
  527. if (difficultyLevel !== undefined && difficultyLevel !== null) {
  528. const levelStr = String(difficultyLevel).trim();
  529. // 字符串匹配
  530. if (levelStr === '筑基' || levelStr === '0.2' || levelStr === '0.20') {
  531. difficultyValue = '0.2';
  532. } else if (levelStr === '提分' || levelStr === '0.4' || levelStr === '0.40') {
  533. difficultyValue = '0.4';
  534. } else if (levelStr === '培优' || levelStr === '0.7' || levelStr === '0.70') {
  535. difficultyValue = '0.7';
  536. } else {
  537. // 尝试转换为数字
  538. const levelNum = parseFloat(levelStr);
  539. if (!isNaN(levelNum)) {
  540. if (Math.abs(levelNum - 0.2) < 0.1) {
  541. difficultyValue = '0.2';
  542. } else if (Math.abs(levelNum - 0.4) < 0.1) {
  543. difficultyValue = '0.4';
  544. } else if (Math.abs(levelNum - 0.7) < 0.1) {
  545. difficultyValue = '0.7';
  546. }
  547. }
  548. }
  549. }
  550. if (difficultyValue) {
  551. difficultySelect.value = difficultyValue;
  552. // 触发change事件以更新预览
  553. difficultySelect.dispatchEvent(new Event('change', { bubbles: true }));
  554. // 不显示弹窗,直接完成
  555. } else {
  556. // 如果无法识别,打印完整返回结果以便调试
  557. console.error('无法识别难度等级,完整返回结果:', result);
  558. if (window.customAlert) {
  559. window.customAlert('无法识别返回的难度等级。返回数据:' + JSON.stringify(result));
  560. } else {
  561. alert('无法识别返回的难度等级。返回数据:' + JSON.stringify(result));
  562. }
  563. }
  564. } catch (error) {
  565. console.error('难度评价失败:', error);
  566. if (window.customAlert) {
  567. window.customAlert('难度评价失败: ' + error.message);
  568. } else {
  569. alert('难度评价失败: ' + error.message);
  570. }
  571. } finally {
  572. // 恢复按钮
  573. btn.disabled = false;
  574. btn.innerHTML = originalText;
  575. }
  576. }
  577. // 页面加载时初始化选项预览和题型默认值
  578. document.addEventListener('DOMContentLoaded', function() {
  579. updateOptionsPreview();
  580. // 读取保存的题型选择,如果没有则默认为"choice"(选择题)
  581. const savedQuestionType = localStorage.getItem('default_question_type') || 'choice';
  582. const questionTypeSelect = document.getElementById('question-type-select');
  583. if (questionTypeSelect) {
  584. questionTypeSelect.value = savedQuestionType;
  585. // 监听题型变化,保存用户选择并控制选项显示
  586. questionTypeSelect.addEventListener('change', function() {
  587. const selectedType = this.value;
  588. if (selectedType) {
  589. localStorage.setItem('default_question_type', selectedType);
  590. }
  591. toggleOptionsVisibility(selectedType);
  592. });
  593. // 初始化时根据题型显示/隐藏选项
  594. toggleOptionsVisibility(questionTypeSelect.value);
  595. }
  596. // Alt+R 快捷键:保存并创建
  597. document.addEventListener('keydown', function(e) {
  598. if (e.altKey && (e.key === 'r' || e.key === 'R')) {
  599. e.preventDefault();
  600. const submitBtn = document.querySelector('button[type="submit"]');
  601. if (submitBtn) {
  602. submitBtn.click();
  603. }
  604. }
  605. });
  606. // 设置 LaTeX 实时预览
  607. setupPreviewForElement('stem-textarea', '题干预览');
  608. setupPreviewForElement('solution-textarea', '解析预览');
  609. setupPreviewForElement('option-A-input', '选项 A 预览');
  610. setupPreviewForElement('option-B-input', '选项 B 预览');
  611. setupPreviewForElement('option-C-input', '选项 C 预览');
  612. setupPreviewForElement('option-D-input', '选项 D 预览');
  613. setupPreviewForElement('option-E-input', '选项 E 预览');
  614. setupPreviewForElement('option-F-input', '选项 F 预览');
  615. setupPreviewForElement('answer-input', '正确答案预览');
  616. // 设置选项预览输入框的预览功能(在右侧气泡显示4个选项)
  617. setupOptionsPreviewTextarea();
  618. // 为所有输入框添加粘贴图片功能
  619. setupPasteImageForAllInputs();
  620. // 初始化JSON输入框
  621. initJsonInput();
  622. // 设置表单字段变化时同步到JSON
  623. setupFormToJsonSync();
  624. // 默认显示完整预览
  625. updateFullPreview();
  626. // 保持预览气泡一直显示,不自动关闭
  627. // 移除鼠标离开时的自动关闭逻辑
  628. });
  629. // JSON输入框相关功能
  630. function toggleJsonInput() {
  631. const jsonSection = document.getElementById('json-input-section');
  632. const toggleText = document.getElementById('json-toggle-text');
  633. if (jsonSection.classList.contains('hidden')) {
  634. jsonSection.classList.remove('hidden');
  635. toggleText.textContent = '隐藏JSON输入';
  636. syncToJson(); // 显示时同步当前表单数据到JSON
  637. } else {
  638. jsonSection.classList.add('hidden');
  639. toggleText.textContent = '显示JSON输入';
  640. }
  641. }
  642. function initJsonInput() {
  643. const jsonInput = document.getElementById('json-input');
  644. const defaultJson = {
  645. number: "",
  646. stem: "",
  647. options: {
  648. A: "",
  649. B: "",
  650. C: "",
  651. D: ""
  652. },
  653. answer: "",
  654. question_type: "",
  655. solution: "",
  656. difficulty: ""
  657. };
  658. jsonInput.value = JSON.stringify(defaultJson, null, 2);
  659. }
  660. function syncFromJson() {
  661. const jsonInput = document.getElementById('json-input');
  662. const jsonText = jsonInput.value.trim();
  663. if (!jsonText) {
  664. return;
  665. }
  666. try {
  667. isSyncingFromJson = true; // 标记正在从JSON同步,防止循环
  668. const data = JSON.parse(jsonText);
  669. // 填充题干
  670. const stemTextarea = document.getElementById('stem-textarea');
  671. if (stemTextarea && data.stem !== undefined) {
  672. stemTextarea.value = data.stem || '';
  673. // 触发input事件以更新预览
  674. stemTextarea.dispatchEvent(new Event('input', { bubbles: true }));
  675. }
  676. // 填充题型
  677. const questionTypeSelect = document.getElementById('question-type-select');
  678. if (questionTypeSelect && data.question_type !== undefined) {
  679. const mappedType = mapQuestionTypeForJson(data.question_type);
  680. questionTypeSelect.value = mappedType || 'choice';
  681. // 触发题型变化事件
  682. questionTypeSelect.dispatchEvent(new Event('change'));
  683. }
  684. // 填充难度
  685. const difficultySelect = document.getElementById('difficulty-select');
  686. if (difficultySelect && data.difficulty !== undefined) {
  687. difficultySelect.value = data.difficulty || '';
  688. }
  689. // 填充选项
  690. if (data.options && typeof data.options === 'object') {
  691. ['A', 'B', 'C', 'D', 'E', 'F'].forEach(key => {
  692. const optionInput = document.getElementById(`option-${key}-input`);
  693. if (optionInput && data.options[key] !== undefined) {
  694. optionInput.value = data.options[key] || '';
  695. optionInput.dispatchEvent(new Event('input', { bubbles: true }));
  696. }
  697. });
  698. // 更新选项预览
  699. updateOptionsPreview();
  700. }
  701. // 填充答案
  702. const answerInput = document.getElementById('answer-input');
  703. if (answerInput && data.answer !== undefined) {
  704. answerInput.value = data.answer || '';
  705. answerInput.dispatchEvent(new Event('input', { bubbles: true }));
  706. }
  707. // 填充解析
  708. const solutionTextarea = document.getElementById('solution-textarea');
  709. if (solutionTextarea && data.solution !== undefined) {
  710. solutionTextarea.value = data.solution || '';
  711. solutionTextarea.dispatchEvent(new Event('input', { bubbles: true }));
  712. }
  713. // 更新预览区域
  714. updateFullPreview();
  715. } catch (error) {
  716. // JSON格式错误时不显示错误,只标记输入框
  717. console.warn('JSON格式错误:', error);
  718. } finally {
  719. isSyncingFromJson = false;
  720. }
  721. }
  722. function syncToJson() {
  723. const jsonInput = document.getElementById('json-input');
  724. // 收集表单数据
  725. const stemTextarea = document.getElementById('stem-textarea');
  726. const questionTypeSelect = document.getElementById('question-type-select');
  727. const difficultySelect = document.getElementById('difficulty-select');
  728. const answerInput = document.getElementById('answer-input');
  729. const solutionTextarea = document.getElementById('solution-textarea');
  730. const optionsObj = {};
  731. ['A', 'B', 'C', 'D', 'E', 'F'].forEach(key => {
  732. const optionInput = document.getElementById(`option-${key}-input`);
  733. if (optionInput && optionInput.value.trim()) {
  734. optionsObj[key] = optionInput.value.trim();
  735. }
  736. });
  737. // 映射题型(英文转中文)
  738. const questionTypeMap = {
  739. 'choice': '选择题',
  740. 'fill': '填空题',
  741. 'answer': '解答题'
  742. };
  743. const jsonData = {
  744. number: "",
  745. stem: stemTextarea ? stemTextarea.value : "",
  746. options: Object.keys(optionsObj).length > 0 ? optionsObj : { A: "", B: "", C: "", D: "" },
  747. answer: answerInput ? answerInput.value : "",
  748. question_type: questionTypeSelect && questionTypeSelect.value ? (questionTypeMap[questionTypeSelect.value] || '') : '',
  749. solution: solutionTextarea ? solutionTextarea.value : "",
  750. difficulty: difficultySelect ? difficultySelect.value : ""
  751. };
  752. jsonInput.value = JSON.stringify(jsonData, null, 2);
  753. }
  754. let jsonSyncTimer = null;
  755. let isSyncingFromJson = false; // 防止循环同步
  756. function handleJsonInputChange() {
  757. const jsonInput = document.getElementById('json-input');
  758. const jsonText = jsonInput.value.trim();
  759. // 实时验证JSON格式
  760. if (jsonText) {
  761. try {
  762. JSON.parse(jsonText);
  763. jsonInput.classList.remove('border-red-500');
  764. jsonInput.classList.add('border-gray-300');
  765. // 防抖处理,避免频繁同步
  766. clearTimeout(jsonSyncTimer);
  767. jsonSyncTimer = setTimeout(() => {
  768. if (!isSyncingFromJson) {
  769. syncFromJson();
  770. updateFullPreview();
  771. }
  772. }, 500); // 500ms延迟
  773. } catch (e) {
  774. jsonInput.classList.remove('border-gray-300');
  775. jsonInput.classList.add('border-red-500');
  776. }
  777. }
  778. }
  779. function mapQuestionTypeForJson(type) {
  780. const typeMap = {
  781. '选择题': 'choice',
  782. '填空题': 'fill',
  783. '解答题': 'answer',
  784. 'choice': 'choice',
  785. 'fill': 'fill',
  786. 'answer': 'answer'
  787. };
  788. return typeMap[type] || 'choice';
  789. }
  790. let formSyncTimer = null;
  791. function setupFormToJsonSync() {
  792. // 监听所有表单字段的变化,自动同步到JSON(防抖处理)
  793. const fieldsToWatch = [
  794. { id: 'stem-textarea', event: 'input' },
  795. { id: 'answer-input', event: 'input' },
  796. { id: 'solution-textarea', event: 'input' },
  797. { id: 'question-type-select', event: 'change' },
  798. { id: 'difficulty-select', event: 'change' }
  799. ];
  800. fieldsToWatch.forEach(field => {
  801. const element = document.getElementById(field.id);
  802. if (element) {
  803. element.addEventListener(field.event, function() {
  804. if (!isSyncingFromJson) {
  805. clearTimeout(formSyncTimer);
  806. formSyncTimer = setTimeout(() => {
  807. syncToJson();
  808. updateFullPreview();
  809. }, 300);
  810. }
  811. });
  812. }
  813. });
  814. // 监听选项输入框变化
  815. ['A', 'B', 'C', 'D', 'E', 'F'].forEach(key => {
  816. const optionInput = document.getElementById(`option-${key}-input`);
  817. if (optionInput) {
  818. optionInput.addEventListener('input', function() {
  819. if (!isSyncingFromJson) {
  820. clearTimeout(formSyncTimer);
  821. formSyncTimer = setTimeout(() => {
  822. syncToJson();
  823. updateFullPreview();
  824. }, 300);
  825. }
  826. });
  827. }
  828. });
  829. // 监听选项预览变化
  830. const optionsPreview = document.getElementById('options-preview');
  831. if (optionsPreview) {
  832. optionsPreview.addEventListener('input', function() {
  833. if (!isSyncingFromJson) {
  834. clearTimeout(formSyncTimer);
  835. formSyncTimer = setTimeout(() => {
  836. syncToJson();
  837. updateFullPreview();
  838. }, 300);
  839. }
  840. });
  841. }
  842. }
  843. // 更新完整预览(题干、选项、解析)
  844. function updateFullPreview() {
  845. const previewContent = document.getElementById('latex-preview-content');
  846. if (!previewContent) return;
  847. const bubble = document.getElementById('latex-preview-bubble');
  848. if (!bubble) return;
  849. // 收集所有数据
  850. const stemTextarea = document.getElementById('stem-textarea');
  851. const answerInput = document.getElementById('answer-input');
  852. const solutionTextarea = document.getElementById('solution-textarea');
  853. const optionsPreview = document.getElementById('options-preview');
  854. const questionTypeSelect = document.getElementById('question-type-select');
  855. let html = '<div class="space-y-6">';
  856. // 题干预览
  857. html += '<div class="border-b border-gray-200 pb-4">';
  858. html += '<div class="text-xs font-bold text-gray-500 mb-2">题干</div>';
  859. const stem = stemTextarea ? stemTextarea.value : '';
  860. if (stem) {
  861. let stemHtml = stem
  862. .replace(/&/g, '&amp;')
  863. .replace(/</g, '&lt;')
  864. .replace(/>/g, '&gt;')
  865. .replace(/\n/g, '<br>');
  866. stemHtml = stemHtml.replace(/&lt;image\s+src="([^"]+)"\s*\/?&gt;/gi, '<img src="$1" class="max-w-[400px] max-h-[300px] w-auto h-auto my-2 rounded-lg mx-auto block" alt="题目图片" style="object-fit: contain;">');
  867. html += `<div class="text-sm leading-relaxed">${stemHtml}</div>`;
  868. } else {
  869. html += '<p class="text-xs text-gray-400">暂无内容</p>';
  870. }
  871. html += '</div>';
  872. // 选项预览(仅选择题)
  873. if (questionTypeSelect && questionTypeSelect.value === 'choice') {
  874. html += '<div class="border-b border-gray-200 pb-4">';
  875. html += '<div class="text-xs font-bold text-gray-500 mb-2">选项</div>';
  876. if (optionsPreview && optionsPreview.value.trim() && optionsPreview.value.trim() !== '{}') {
  877. try {
  878. const optionsObj = JSON.parse(optionsPreview.value.trim());
  879. ['A', 'B', 'C', 'D'].forEach(key => {
  880. const optionText = optionsObj[key] || '';
  881. html += `<div class="mb-3">`;
  882. html += `<div class="text-xs font-bold text-gray-500 mb-1">选项 ${key}</div>`;
  883. if (optionText) {
  884. let optionHtml = optionText
  885. .replace(/&/g, '&amp;')
  886. .replace(/</g, '&lt;')
  887. .replace(/>/g, '&gt;')
  888. .replace(/\n/g, '<br>');
  889. optionHtml = optionHtml.replace(/&lt;image\s+src="([^"]+)"\s*\/?&gt;/gi, '<img src="$1" class="max-w-[400px] max-h-[300px] w-auto h-auto my-2 rounded-lg mx-auto block" alt="题目图片" style="object-fit: contain;">');
  890. html += `<div class="text-sm leading-relaxed">${optionHtml}</div>`;
  891. } else {
  892. html += '<p class="text-xs text-gray-400">暂无内容</p>';
  893. }
  894. html += `</div>`;
  895. });
  896. } catch (e) {
  897. html += '<p class="text-xs text-red-400">选项JSON格式错误</p>';
  898. }
  899. } else {
  900. html += '<p class="text-xs text-gray-400">暂无选项</p>';
  901. }
  902. html += '</div>';
  903. }
  904. // 答案预览
  905. html += '<div class="border-b border-gray-200 pb-4">';
  906. html += '<div class="text-xs font-bold text-gray-500 mb-2">正确答案</div>';
  907. const answer = answerInput ? answerInput.value : '';
  908. if (answer) {
  909. html += `<div class="text-sm font-bold text-blue-600">${answer}</div>`;
  910. } else {
  911. html += '<p class="text-xs text-gray-400">暂无答案</p>';
  912. }
  913. html += '</div>';
  914. // 解析预览
  915. html += '<div>';
  916. html += '<div class="text-xs font-bold text-gray-500 mb-2">解析</div>';
  917. const solution = solutionTextarea ? solutionTextarea.value : '';
  918. if (solution) {
  919. let solutionHtml = solution
  920. .replace(/&/g, '&amp;')
  921. .replace(/</g, '&lt;')
  922. .replace(/>/g, '&gt;')
  923. .replace(/\n/g, '<br>');
  924. solutionHtml = solutionHtml.replace(/&lt;image\s+src="([^"]+)"\s*\/?&gt;/gi, '<img src="$1" class="max-w-[400px] max-h-[300px] w-auto h-auto my-2 rounded-lg mx-auto block" alt="题目图片" style="object-fit: contain;">');
  925. html += `<div class="text-sm leading-relaxed">${solutionHtml}</div>`;
  926. } else {
  927. html += '<p class="text-xs text-gray-400">暂无解析</p>';
  928. }
  929. html += '</div>';
  930. html += '</div>';
  931. previewContent.innerHTML = html;
  932. // 渲染LaTeX
  933. if (window.renderMathInElement) {
  934. try {
  935. window.renderMathInElement(previewContent, {
  936. delimiters: [
  937. {left: "$$", right: "$$", display: true},
  938. {left: "$", right: "$", display: false},
  939. {left: "\\(", right: "\\)", display: false},
  940. {left: "\\[", right: "\\]", display: true}
  941. ],
  942. throwOnError: false
  943. });
  944. } catch (e) {
  945. console.warn('LaTeX 渲染失败:', e);
  946. }
  947. }
  948. // 显示预览气泡
  949. bubble.classList.remove('hidden');
  950. }
  951. document.getElementById('add-form').addEventListener('submit', async (e) => {
  952. e.preventDefault();
  953. const formData = new FormData(e.target);
  954. // 构建提交数据(question_code 由后端自动生成)
  955. const data = {};
  956. const fields = ['stem', 'answer', 'solution', 'question_type',
  957. 'chapter', 'section', 'subsection', 'difficulty'];
  958. fields.forEach(field => {
  959. const value = formData.get(field);
  960. if (value && value.trim()) {
  961. // 处理difficulty字段:转换为浮点数,保留两位小数
  962. if (field === 'difficulty') {
  963. const difficultyValue = parseFloat(value);
  964. if (!isNaN(difficultyValue)) {
  965. data[field] = round(difficultyValue, 2);
  966. }
  967. } else {
  968. data[field] = value.trim();
  969. }
  970. }
  971. });
  972. // 如果难度为空,不提交difficulty字段
  973. if (!data.difficulty || data.difficulty === '') {
  974. delete data.difficulty;
  975. }
  976. // 添加 kp_code(从URL参数获取)
  977. const urlParams = new URLSearchParams(window.location.search);
  978. const kpCode = urlParams.get('kp_code');
  979. if (kpCode) {
  980. data.kp_code = kpCode;
  981. }
  982. // 添加 create_by(从localStorage获取用户姓名)
  983. const userName = localStorage.getItem('user_name');
  984. if (userName && userName.trim()) {
  985. data.create_by = userName.trim();
  986. }
  987. // 处理选项:优先使用预览区域的JSON,如果预览区域为空或无效,则从输入框收集
  988. const previewTextarea = document.getElementById('options-preview');
  989. const jsonInput = document.getElementById('options-json-input');
  990. let optionsJson = '';
  991. if (previewTextarea && previewTextarea.value.trim() && previewTextarea.value.trim() !== '{}') {
  992. try {
  993. // 验证预览区域的JSON是否有效
  994. const parsed = JSON.parse(previewTextarea.value.trim());
  995. optionsJson = JSON.stringify(parsed);
  996. } catch (e) {
  997. // JSON无效,从输入框收集
  998. const optionsObj = {};
  999. const optionKeys = ['A', 'B', 'C', 'D', 'E', 'F'];
  1000. optionKeys.forEach(key => {
  1001. const input = document.getElementById(`option-${key}-input`);
  1002. if (input && input.value && input.value.trim()) {
  1003. optionsObj[key] = input.value.trim();
  1004. }
  1005. });
  1006. if (Object.keys(optionsObj).length > 0) {
  1007. optionsJson = JSON.stringify(optionsObj);
  1008. }
  1009. }
  1010. } else {
  1011. // 预览区域为空,从输入框收集
  1012. const optionsObj = {};
  1013. const optionKeys = ['A', 'B', 'C', 'D', 'E', 'F'];
  1014. optionKeys.forEach(key => {
  1015. const input = document.getElementById(`option-${key}-input`);
  1016. if (input && input.value && input.value.trim()) {
  1017. optionsObj[key] = input.value.trim();
  1018. }
  1019. });
  1020. if (Object.keys(optionsObj).length > 0) {
  1021. optionsJson = JSON.stringify(optionsObj);
  1022. }
  1023. }
  1024. if (optionsJson) {
  1025. data.options = optionsJson;
  1026. }
  1027. // 验证必填项(仅题干)
  1028. if (!data.stem || !data.stem.trim()) {
  1029. const message = '请填写题干';
  1030. if (window.customAlert) {
  1031. window.customAlert(message);
  1032. } else {
  1033. alert(message);
  1034. }
  1035. return;
  1036. }
  1037. const res = await fetch('/create_question', {
  1038. method: 'POST',
  1039. headers: {'Content-Type': 'application/json'},
  1040. body: JSON.stringify(data)
  1041. });
  1042. const result = await res.json();
  1043. if(result.success) {
  1044. // 直接跳转到题目详情页,不显示弹窗
  1045. window.location.href = '/detail/' + result.question_code;
  1046. } else {
  1047. if (window.customAlert) {
  1048. window.customAlert('创建失败: ' + result.error);
  1049. } else {
  1050. alert('创建失败: ' + result.error);
  1051. }
  1052. }
  1053. });
  1054. // 更新选项预览(从输入框同步到预览区域)
  1055. function updateOptionsPreview() {
  1056. const optionsObj = {};
  1057. const optionKeys = ['A', 'B', 'C', 'D', 'E', 'F'];
  1058. optionKeys.forEach(key => {
  1059. const input = document.getElementById(`option-${key}-input`);
  1060. if (input && input.value && input.value.trim()) {
  1061. optionsObj[key] = input.value.trim();
  1062. }
  1063. });
  1064. const preview = document.getElementById('options-preview');
  1065. const jsonInput = document.getElementById('options-json-input');
  1066. if (Object.keys(optionsObj).length > 0) {
  1067. const jsonStr = JSON.stringify(optionsObj, null, 2);
  1068. preview.value = jsonStr;
  1069. jsonInput.value = JSON.stringify(optionsObj);
  1070. } else {
  1071. preview.value = '{}';
  1072. jsonInput.value = '';
  1073. }
  1074. }
  1075. // 从预览区域同步到选项输入框(当用户直接编辑预览时)
  1076. function syncOptionsFromPreview() {
  1077. const preview = document.getElementById('options-preview');
  1078. const jsonInput = document.getElementById('options-json-input');
  1079. try {
  1080. const jsonStr = preview.value.trim();
  1081. if (!jsonStr || jsonStr === '{}') {
  1082. // 清空所有选项输入框
  1083. ['A', 'B', 'C', 'D', 'E', 'F'].forEach(key => {
  1084. const input = document.getElementById(`option-${key}-input`);
  1085. if (input) {
  1086. input.value = '';
  1087. }
  1088. });
  1089. jsonInput.value = '';
  1090. // 如果预览气泡正在显示,更新它
  1091. if (currentPreviewElement === preview) {
  1092. updateOptionsPreviewBubble();
  1093. }
  1094. return;
  1095. }
  1096. const optionsObj = JSON.parse(jsonStr);
  1097. jsonInput.value = JSON.stringify(optionsObj);
  1098. // 同步到各个选项输入框
  1099. ['A', 'B', 'C', 'D', 'E', 'F'].forEach(key => {
  1100. const input = document.getElementById(`option-${key}-input`);
  1101. if (input) {
  1102. input.value = optionsObj[key] || '';
  1103. }
  1104. });
  1105. // 如果预览气泡正在显示,更新它
  1106. if (currentPreviewElement === preview) {
  1107. updateOptionsPreviewBubble();
  1108. }
  1109. } catch (error) {
  1110. // JSON 解析失败,不更新输入框,保持预览区域的内容
  1111. console.warn('选项预览 JSON 格式错误:', error);
  1112. // 如果预览气泡正在显示,显示错误信息
  1113. if (currentPreviewElement === preview) {
  1114. const content = document.getElementById('latex-preview-content');
  1115. if (content) {
  1116. content.innerHTML = '<p class="text-sm text-red-400 text-center">JSON 格式错误,请检查输入</p>';
  1117. }
  1118. }
  1119. }
  1120. }
  1121. // 选项输入框拖拽处理
  1122. function handleOptionDragOver(e) {
  1123. e.preventDefault();
  1124. e.stopPropagation();
  1125. e.currentTarget.classList.add('border-blue-500', 'bg-blue-50');
  1126. }
  1127. function handleOptionDragLeave(e) {
  1128. e.preventDefault();
  1129. e.stopPropagation();
  1130. e.currentTarget.classList.remove('border-blue-500', 'bg-blue-50');
  1131. }
  1132. async function handleOptionDrop(e, optionKey) {
  1133. e.preventDefault();
  1134. e.stopPropagation();
  1135. const textarea = e.currentTarget;
  1136. textarea.classList.remove('border-blue-500', 'bg-blue-50');
  1137. const files = e.dataTransfer.files;
  1138. if (files && files.length > 0) {
  1139. const file = files[0];
  1140. if (file.type.startsWith('image/')) {
  1141. // 直接上传图片
  1142. await uploadOptionImageFile(file, optionKey);
  1143. } else {
  1144. if (window.customAlert) {
  1145. window.customAlert('请拖拽图片文件!');
  1146. } else {
  1147. alert('请拖拽图片文件!');
  1148. }
  1149. }
  1150. }
  1151. }
  1152. // 上传选项图片(通过文件选择)
  1153. async function uploadOptionImage(optionKey) {
  1154. // 创建隐藏的文件输入框
  1155. const fileInput = document.createElement('input');
  1156. fileInput.type = 'file';
  1157. fileInput.accept = 'image/*';
  1158. fileInput.style.display = 'none';
  1159. fileInput.onchange = async (e) => {
  1160. const file = e.target.files[0];
  1161. if (!file) return;
  1162. if (!file.type.startsWith('image/')) {
  1163. if (window.customAlert) {
  1164. window.customAlert('请选择图片文件!');
  1165. } else {
  1166. alert('请选择图片文件!');
  1167. }
  1168. return;
  1169. }
  1170. await uploadOptionImageFile(file, optionKey);
  1171. };
  1172. document.body.appendChild(fileInput);
  1173. fileInput.click();
  1174. }
  1175. // 通用的选项图片上传函数
  1176. async function uploadOptionImageFile(file, optionKey) {
  1177. const optionInput = document.getElementById(`option-${optionKey}-input`);
  1178. const uploadBtn = document.querySelector(`[onclick="uploadOptionImage('${optionKey}')"]`);
  1179. // 显示上传中状态
  1180. let originalText = '';
  1181. if (uploadBtn) {
  1182. originalText = uploadBtn.textContent;
  1183. uploadBtn.disabled = true;
  1184. uploadBtn.textContent = '上传中...';
  1185. }
  1186. // 在输入框中显示上传中提示
  1187. const originalPlaceholder = optionInput.placeholder;
  1188. optionInput.placeholder = '正在上传图片...';
  1189. optionInput.style.opacity = '0.6';
  1190. try {
  1191. const formData = new FormData();
  1192. formData.append('file', file);
  1193. const response = await fetch('https://crmapi.dcjxb.yunzhixue.cn/file/upload', {
  1194. method: 'POST',
  1195. body: formData
  1196. });
  1197. if (!response.ok) {
  1198. throw new Error(`上传失败: ${response.status} ${response.statusText}`);
  1199. }
  1200. const result = await response.json();
  1201. // 检查返回结果,提取URL
  1202. let imageUrl = null;
  1203. if (typeof result === 'string') {
  1204. imageUrl = result;
  1205. } else if (result.url) {
  1206. imageUrl = result.url;
  1207. } else if (result.data && result.data.url) {
  1208. imageUrl = result.data.url;
  1209. } else if (result.data && typeof result.data === 'string') {
  1210. imageUrl = result.data;
  1211. } else {
  1212. const resultStr = JSON.stringify(result);
  1213. const urlMatch = resultStr.match(/https?:\/\/[^\s"']+/);
  1214. if (urlMatch) {
  1215. imageUrl = urlMatch[0];
  1216. }
  1217. }
  1218. if (!imageUrl) {
  1219. throw new Error('接口返回格式异常,未找到图片URL。返回结果:' + JSON.stringify(result));
  1220. }
  1221. // 构建 image 标签
  1222. const imageTag = `<image src="${imageUrl}"/>`;
  1223. // 获取当前光标位置
  1224. const cursorPos = optionInput.selectionStart;
  1225. const textBefore = optionInput.value.substring(0, cursorPos);
  1226. const textAfter = optionInput.value.substring(cursorPos);
  1227. // 插入 image 标签
  1228. optionInput.value = textBefore + imageTag + textAfter;
  1229. // 设置光标位置到插入内容之后
  1230. const newCursorPos = cursorPos + imageTag.length;
  1231. optionInput.setSelectionRange(newCursorPos, newCursorPos);
  1232. optionInput.focus();
  1233. // 更新选项预览JSON
  1234. updateOptionsPreview();
  1235. // 如果选项预览输入框正在显示预览气泡,更新预览气泡
  1236. const optionsPreviewTextarea = document.getElementById('options-preview');
  1237. if (currentPreviewElement === optionsPreviewTextarea) {
  1238. updateOptionsPreviewBubble();
  1239. }
  1240. // 图片已插入,无需弹窗提示
  1241. } catch (error) {
  1242. console.error('上传失败:', error);
  1243. if (window.customAlert) {
  1244. window.customAlert('图片上传失败: ' + error.message);
  1245. } else {
  1246. alert('图片上传失败: ' + error.message);
  1247. }
  1248. } finally {
  1249. if (uploadBtn) {
  1250. uploadBtn.disabled = false;
  1251. uploadBtn.textContent = originalText;
  1252. }
  1253. optionInput.placeholder = originalPlaceholder;
  1254. optionInput.style.opacity = '1';
  1255. }
  1256. }
  1257. // 触发题干图片上传
  1258. function triggerStemImageUpload() {
  1259. const fileInput = document.createElement('input');
  1260. fileInput.type = 'file';
  1261. fileInput.accept = 'image/*';
  1262. fileInput.style.display = 'none';
  1263. fileInput.onchange = async (e) => {
  1264. const file = e.target.files[0];
  1265. if (file) {
  1266. await uploadStemImageFile(file);
  1267. }
  1268. };
  1269. document.body.appendChild(fileInput);
  1270. fileInput.click();
  1271. document.body.removeChild(fileInput);
  1272. }
  1273. // 题干拖拽处理函数
  1274. function handleStemDragOver(e) {
  1275. e.preventDefault();
  1276. e.stopPropagation();
  1277. e.currentTarget.classList.add('border-blue-500', 'bg-blue-50');
  1278. }
  1279. function handleStemDragLeave(e) {
  1280. e.preventDefault();
  1281. e.stopPropagation();
  1282. e.currentTarget.classList.remove('border-blue-500', 'bg-blue-50');
  1283. }
  1284. async function handleStemDrop(e) {
  1285. e.preventDefault();
  1286. e.stopPropagation();
  1287. const textarea = e.currentTarget;
  1288. textarea.classList.remove('border-blue-500', 'bg-blue-50');
  1289. const files = e.dataTransfer.files;
  1290. if (files && files.length > 0) {
  1291. const file = files[0];
  1292. if (file.type.startsWith('image/')) {
  1293. // 直接上传图片到题干
  1294. await uploadStemImageFile(file);
  1295. } else {
  1296. if (window.customAlert) {
  1297. window.customAlert('请拖拽图片文件!');
  1298. } else {
  1299. alert('请拖拽图片文件!');
  1300. }
  1301. }
  1302. }
  1303. }
  1304. // 解析拖拽处理函数
  1305. function handleSolutionDragOver(e) {
  1306. e.preventDefault();
  1307. e.stopPropagation();
  1308. e.currentTarget.classList.add('border-blue-500', 'bg-blue-50');
  1309. }
  1310. function handleSolutionDragLeave(e) {
  1311. e.preventDefault();
  1312. e.stopPropagation();
  1313. e.currentTarget.classList.remove('border-blue-500', 'bg-blue-50');
  1314. }
  1315. async function handleSolutionDrop(e) {
  1316. e.preventDefault();
  1317. e.stopPropagation();
  1318. const textarea = e.currentTarget;
  1319. textarea.classList.remove('border-blue-500', 'bg-blue-50');
  1320. const files = e.dataTransfer.files;
  1321. if (files && files.length > 0) {
  1322. const file = files[0];
  1323. if (file.type.startsWith('image/')) {
  1324. // 直接上传图片到解析
  1325. await uploadSolutionImageFile(file);
  1326. } else {
  1327. if (window.customAlert) {
  1328. window.customAlert('请拖拽图片文件!');
  1329. } else {
  1330. alert('请拖拽图片文件!');
  1331. }
  1332. }
  1333. }
  1334. }
  1335. // 题干图片上传函数
  1336. async function uploadStemImageFile(file) {
  1337. const stemTextarea = document.getElementById('stem-textarea');
  1338. const statusDiv = document.getElementById('upload-status');
  1339. // 显示上传中状态
  1340. const originalPlaceholder = stemTextarea.placeholder;
  1341. stemTextarea.placeholder = '正在上传图片...';
  1342. stemTextarea.style.opacity = '0.6';
  1343. if (statusDiv) {
  1344. statusDiv.classList.remove('hidden');
  1345. statusDiv.textContent = '正在上传图片...';
  1346. statusDiv.className = 'mt-2 text-sm text-blue-600';
  1347. }
  1348. try {
  1349. const formData = new FormData();
  1350. formData.append('file', file);
  1351. const response = await fetch('https://crmapi.dcjxb.yunzhixue.cn/file/upload', {
  1352. method: 'POST',
  1353. body: formData
  1354. });
  1355. if (!response.ok) {
  1356. throw new Error(`上传失败: ${response.status} ${response.statusText}`);
  1357. }
  1358. const result = await response.json();
  1359. // 检查返回结果,提取URL
  1360. let imageUrl = null;
  1361. if (typeof result === 'string') {
  1362. imageUrl = result;
  1363. } else if (result.url) {
  1364. imageUrl = result.url;
  1365. } else if (result.data && result.data.url) {
  1366. imageUrl = result.data.url;
  1367. } else if (result.data && typeof result.data === 'string') {
  1368. imageUrl = result.data;
  1369. } else {
  1370. const resultStr = JSON.stringify(result);
  1371. const urlMatch = resultStr.match(/https?:\/\/[^\s"']+/);
  1372. if (urlMatch) {
  1373. imageUrl = urlMatch[0];
  1374. }
  1375. }
  1376. if (!imageUrl) {
  1377. throw new Error('接口返回格式异常,未找到图片URL。返回结果:' + JSON.stringify(result));
  1378. }
  1379. // 构建 image 标签
  1380. const imageTag = `<image src="${imageUrl}"/>`;
  1381. // 在预览区域显示图片
  1382. const previewDiv = document.getElementById('stem-image-preview');
  1383. if (previewDiv) {
  1384. previewDiv.innerHTML = `<img src="${imageUrl}" alt="预览图片" class="w-full h-full object-contain rounded-lg" style="max-height: 100%; max-width: 100%;">`;
  1385. }
  1386. // 获取当前光标位置
  1387. const cursorPos = stemTextarea.selectionStart;
  1388. const textBefore = stemTextarea.value.substring(0, cursorPos);
  1389. const textAfter = stemTextarea.value.substring(cursorPos);
  1390. // 插入 image 标签
  1391. stemTextarea.value = textBefore + imageTag + textAfter;
  1392. // 设置光标位置到插入内容之后
  1393. const newCursorPos = cursorPos + imageTag.length;
  1394. stemTextarea.setSelectionRange(newCursorPos, newCursorPos);
  1395. stemTextarea.focus();
  1396. // 如果预览气泡正在显示,更新预览内容
  1397. if (currentPreviewElement === stemTextarea) {
  1398. updatePreviewContent(stemTextarea.value);
  1399. }
  1400. // 显示成功消息
  1401. if (statusDiv) {
  1402. statusDiv.textContent = '图片上传成功!已插入到题干中。';
  1403. statusDiv.className = 'mt-2 text-sm text-green-600';
  1404. setTimeout(() => {
  1405. statusDiv.classList.add('hidden');
  1406. }, 1500);
  1407. }
  1408. } catch (error) {
  1409. console.error('上传失败:', error);
  1410. if (statusDiv) {
  1411. statusDiv.textContent = '上传失败: ' + error.message;
  1412. statusDiv.className = 'mt-2 text-sm text-red-600';
  1413. }
  1414. if (window.customAlert) {
  1415. window.customAlert('图片上传失败: ' + error.message);
  1416. } else {
  1417. alert('图片上传失败: ' + error.message);
  1418. }
  1419. } finally {
  1420. stemTextarea.placeholder = originalPlaceholder;
  1421. stemTextarea.style.opacity = '1';
  1422. }
  1423. }
  1424. // 解析图片上传函数
  1425. async function uploadSolutionImageFile(file) {
  1426. const solutionTextarea = document.getElementById('solution-textarea');
  1427. await uploadImageToInput(file, solutionTextarea);
  1428. }
  1429. // 通用图片上传函数:上传图片并插入到指定输入框
  1430. async function uploadImageToInput(file, inputElement) {
  1431. if (!inputElement || !file) {
  1432. return;
  1433. }
  1434. // 检查文件类型
  1435. if (!file.type.startsWith('image/')) {
  1436. if (window.customAlert) {
  1437. window.customAlert('请选择图片文件!');
  1438. } else {
  1439. alert('请选择图片文件!');
  1440. }
  1441. return;
  1442. }
  1443. // 显示上传中状态
  1444. const originalPlaceholder = inputElement.placeholder || '';
  1445. const originalOpacity = inputElement.style.opacity || '1';
  1446. inputElement.placeholder = '正在上传图片...';
  1447. inputElement.style.opacity = '0.6';
  1448. inputElement.disabled = true;
  1449. try {
  1450. const formData = new FormData();
  1451. formData.append('file', file);
  1452. const response = await fetch('https://crmapi.dcjxb.yunzhixue.cn/file/upload', {
  1453. method: 'POST',
  1454. body: formData
  1455. });
  1456. if (!response.ok) {
  1457. throw new Error(`上传失败: ${response.status} ${response.statusText}`);
  1458. }
  1459. const result = await response.json();
  1460. // 检查返回结果,提取URL
  1461. let imageUrl = null;
  1462. if (typeof result === 'string') {
  1463. imageUrl = result;
  1464. } else if (result.url) {
  1465. imageUrl = result.url;
  1466. } else if (result.data && result.data.url) {
  1467. imageUrl = result.data.url;
  1468. } else if (result.data && typeof result.data === 'string') {
  1469. imageUrl = result.data;
  1470. } else {
  1471. const resultStr = JSON.stringify(result);
  1472. const urlMatch = resultStr.match(/https?:\/\/[^\s"']+/);
  1473. if (urlMatch) {
  1474. imageUrl = urlMatch[0];
  1475. }
  1476. }
  1477. if (!imageUrl) {
  1478. throw new Error('接口返回格式异常,未找到图片URL。返回结果:' + JSON.stringify(result));
  1479. }
  1480. // 构建 image 标签
  1481. const imageTag = `<image src="${imageUrl}"/>`;
  1482. // 获取当前光标位置
  1483. const cursorPos = inputElement.selectionStart || inputElement.value.length;
  1484. const textBefore = inputElement.value.substring(0, cursorPos);
  1485. const textAfter = inputElement.value.substring(cursorPos);
  1486. // 插入 image 标签
  1487. inputElement.value = textBefore + imageTag + textAfter;
  1488. // 设置光标位置到插入内容之后
  1489. const newCursorPos = cursorPos + imageTag.length;
  1490. if (inputElement.setSelectionRange) {
  1491. inputElement.setSelectionRange(newCursorPos, newCursorPos);
  1492. }
  1493. inputElement.focus();
  1494. // 触发input事件,更新预览
  1495. inputElement.dispatchEvent(new Event('input', { bubbles: true }));
  1496. // 如果预览气泡正在显示,更新预览内容
  1497. if (currentPreviewElement === inputElement) {
  1498. updatePreviewContent(inputElement.value);
  1499. }
  1500. // 如果是选项输入框,更新选项预览
  1501. if (inputElement.id && inputElement.id.startsWith('option-')) {
  1502. updateOptionsPreview();
  1503. }
  1504. } catch (error) {
  1505. console.error('上传失败:', error);
  1506. if (window.customAlert) {
  1507. window.customAlert('图片上传失败: ' + error.message);
  1508. } else {
  1509. alert('图片上传失败: ' + error.message);
  1510. }
  1511. } finally {
  1512. inputElement.placeholder = originalPlaceholder;
  1513. inputElement.style.opacity = originalOpacity;
  1514. inputElement.disabled = false;
  1515. }
  1516. }
  1517. // 为所有输入框设置粘贴图片功能
  1518. function setupPasteImageForAllInputs() {
  1519. // 获取所有文本输入框和文本域
  1520. const allInputs = document.querySelectorAll('textarea, input[type="text"]');
  1521. allInputs.forEach(input => {
  1522. // 添加粘贴事件监听
  1523. input.addEventListener('paste', async function(e) {
  1524. const clipboardData = e.clipboardData || window.clipboardData;
  1525. if (!clipboardData) {
  1526. return;
  1527. }
  1528. // 检查是否有图片数据
  1529. const items = clipboardData.items;
  1530. if (!items) {
  1531. return;
  1532. }
  1533. for (let i = 0; i < items.length; i++) {
  1534. const item = items[i];
  1535. // 如果是图片类型
  1536. if (item.type.indexOf('image') !== -1) {
  1537. e.preventDefault(); // 阻止默认粘贴行为
  1538. const file = item.getAsFile();
  1539. if (file) {
  1540. // 上传图片并插入到当前输入框
  1541. await uploadImageToInput(file, input);
  1542. }
  1543. break;
  1544. }
  1545. }
  1546. });
  1547. });
  1548. }
  1549. // 拖拽处理函数(用于上传区域)
  1550. function handleDragOver(e) {
  1551. e.preventDefault();
  1552. e.stopPropagation();
  1553. const uploadArea = document.getElementById('upload-area');
  1554. uploadArea.classList.add('border-blue-500', 'bg-blue-100');
  1555. }
  1556. function handleDragLeave(e) {
  1557. e.preventDefault();
  1558. e.stopPropagation();
  1559. const uploadArea = document.getElementById('upload-area');
  1560. uploadArea.classList.remove('border-blue-500', 'bg-blue-100');
  1561. }
  1562. function handleFileSelect(e) {
  1563. const file = e.target.files[0];
  1564. if (file) {
  1565. // 文件选择后不自动上传,需要点击提交按钮
  1566. }
  1567. }
  1568. // 上传图片函数
  1569. async function uploadImage(fileParam = null) {
  1570. const fileInput = document.getElementById('image-upload');
  1571. const uploadBtn = document.getElementById('upload-image-btn');
  1572. const statusDiv = document.getElementById('upload-status');
  1573. const stemTextarea = document.getElementById('stem-textarea');
  1574. // 获取文件:优先使用传入的参数,否则从 input 获取
  1575. let file = fileParam;
  1576. if (!file) {
  1577. // 检查是否选择了文件
  1578. if (!fileInput.files || fileInput.files.length === 0) {
  1579. if (window.customAlert) {
  1580. window.customAlert('请先选择图片文件!');
  1581. } else {
  1582. alert('请先选择图片文件!');
  1583. }
  1584. return;
  1585. }
  1586. file = fileInput.files[0];
  1587. }
  1588. // 检查文件类型
  1589. if (!file || !file.type.startsWith('image/')) {
  1590. if (window.customAlert) {
  1591. window.customAlert('请选择图片文件!');
  1592. } else {
  1593. alert('请选择图片文件!');
  1594. }
  1595. return;
  1596. }
  1597. // 创建 FormData
  1598. const formData = new FormData();
  1599. formData.append('file', file);
  1600. // 禁用按钮,显示上传中
  1601. uploadBtn.disabled = true;
  1602. uploadBtn.textContent = '上传中...';
  1603. statusDiv.classList.remove('hidden');
  1604. statusDiv.textContent = '正在上传图片...';
  1605. statusDiv.className = 'mt-2 text-sm text-blue-600';
  1606. try {
  1607. const response = await fetch('https://crmapi.dcjxb.yunzhixue.cn/file/upload', {
  1608. method: 'POST',
  1609. body: formData
  1610. });
  1611. if (!response.ok) {
  1612. throw new Error(`上传失败: ${response.status} ${response.statusText}`);
  1613. }
  1614. const result = await response.json();
  1615. // 检查返回结果,可能的结构:{url: "..."} 或 {data: {url: "..."}} 或直接是字符串
  1616. let imageUrl = null;
  1617. if (typeof result === 'string') {
  1618. imageUrl = result;
  1619. } else if (result.url) {
  1620. imageUrl = result.url;
  1621. } else if (result.data && result.data.url) {
  1622. imageUrl = result.data.url;
  1623. } else if (result.data && typeof result.data === 'string') {
  1624. imageUrl = result.data;
  1625. } else {
  1626. // 尝试从结果中提取 URL
  1627. const resultStr = JSON.stringify(result);
  1628. const urlMatch = resultStr.match(/https?:\/\/[^\s"']+/);
  1629. if (urlMatch) {
  1630. imageUrl = urlMatch[0];
  1631. }
  1632. }
  1633. if (!imageUrl) {
  1634. throw new Error('接口返回格式异常,未找到图片URL。返回结果:' + JSON.stringify(result));
  1635. }
  1636. // 构建 image 标签
  1637. const imageTag = `<image src="${imageUrl}"/>`;
  1638. // 获取当前光标位置
  1639. const cursorPos = stemTextarea.selectionStart;
  1640. const textBefore = stemTextarea.value.substring(0, cursorPos);
  1641. const textAfter = stemTextarea.value.substring(cursorPos);
  1642. // 插入 image 标签
  1643. stemTextarea.value = textBefore + imageTag + textAfter;
  1644. // 设置光标位置到插入内容之后
  1645. const newCursorPos = cursorPos + imageTag.length;
  1646. stemTextarea.setSelectionRange(newCursorPos, newCursorPos);
  1647. stemTextarea.focus();
  1648. // 如果预览气泡正在显示,更新预览内容
  1649. if (currentPreviewElement === stemTextarea) {
  1650. updatePreviewContent(stemTextarea.value);
  1651. }
  1652. // 显示成功消息(短暂显示后自动隐藏)
  1653. statusDiv.textContent = '图片上传成功!已插入到题干中。';
  1654. statusDiv.className = 'mt-2 text-sm text-green-600';
  1655. // 清空文件选择
  1656. if (fileInput) {
  1657. fileInput.value = '';
  1658. }
  1659. // 1.5秒后隐藏状态消息(不阻塞用户操作)
  1660. setTimeout(() => {
  1661. statusDiv.classList.add('hidden');
  1662. }, 1500);
  1663. } catch (error) {
  1664. console.error('上传失败:', error);
  1665. statusDiv.textContent = '上传失败: ' + error.message;
  1666. statusDiv.className = 'mt-2 text-sm text-red-600';
  1667. if (window.customAlert) {
  1668. window.customAlert('图片上传失败: ' + error.message);
  1669. } else {
  1670. alert('图片上传失败: ' + error.message);
  1671. }
  1672. } finally {
  1673. // 恢复按钮
  1674. uploadBtn.disabled = false;
  1675. uploadBtn.textContent = '提交';
  1676. }
  1677. }
  1678. </script>
  1679. {% endblock %}