// ==UserScript== // @name 小雅答答答 // @license MIT // @version 2.7.7 // @description 小雅平台学习助手 📖,智能整理归纳学习资料 📚,辅助完成练习 💪,并提供便捷的查阅和修改功能 📝! // @author Yi // @match https://*.ai-augmented.com/* // @icon https://www.ai-augmented.com/static/logo3.1dbbea8f.png // @grant GM_xmlhttpRequest // @grant GM_info // @run-at document-en // @require https://cdn.jsdmirror.com/npm/docx@7.1.0/build/index.min.js // @require https://cdn.jsdmirror.com/npm/file-saver@2.0.5/dist/FileSaver.min.js // @require https://cdn.jsdmirror.com/npm/js-md5@0.8.3/src/md5.min.js // @require https://cdn.jsdmirror.com/npm/crypto-js@4.2.0/crypto-js.js // @require https://cdn.jsdmirror.com/npm/crypto-js@4.2.0/hmac-sha1.js // @homepageURL https://xiaoya.zygame1314.site // ==/UserScript== (function () { 'use strict'; const defaultPrompts = { '1': ` 你是一个用于选择题的答题助手。请根据以下题目和选项,选择最合适的答案。 【题目类型】: {questionType} 【题目内容】: {questionTitle} 【选项】: {optionsText} 【输出要求】: - 选择唯一正确选项的字母。 - 你的回答必须严格遵守以下格式:仅包含选项字母(例如: "A"),不得包含任何其他文字、解释、标点符号或空格。 `.trim(), '2': ` 你是一个用于选择题的答题助手。请根据以下题目和选项,选择最合适的答案。 【题目类型】: {questionType} 【题目内容】: {questionTitle} 【选项】: {optionsText} 【输出要求】: - 选择所有正确选项的字母,并用逗号分隔。 - 你的回答必须严格遵守以下格式:仅包含选项字母(例如: "A,C"),不得包含任何其他文字、解释、标点符号或空格。 `.trim(), '5': ` 你是一个用于选择题的答题助手。请根据以下题目和选项,选择最合适的答案。 【题目类型】: {questionType} 【题目内容】: {questionTitle} 【选项】: {optionsText} 【输出要求】: - 选择唯一正确选项的字母。 - 你的回答必须严格遵守以下格式:仅包含选项字母(例如: "A"),不得包含任何其他文字、解释、标点符号或空格。 `.trim(), '4': ` 你是一个用于填空题的答题助手。请根据以下题目,为每一个空白处生成最合适的答案。 【题目类型】: {questionType} 【题目内容】: {questionTitle} 【输出要求】: - 你的回答必须是一个JSON数组,数组中的每个字符串元素按顺序对应题目中的每一个空白处。 - 例如,如果题目有两个空,答案分别是 "答案一" 和 "答案二",则输出 ["答案一", "答案二"]。 - 你的回答必须严格遵守此格式,不要包含任何其他文字、解释或代码块标记。 `.trim(), '6': ` 请按照以下要求生成【{questionType}】的答案: 题目: {questionTitle} 当前答案 (可参考或忽略): {answerContent} 生成要求: 1. 使用简体中文 2. 答案要清晰准确,符合题目要求 3. 适当使用专业术语 4. 分点论述,层次分明 5. 避免废话和重复内容 6. 请直接输出纯文本,不要使用markdown等特殊格式 7. 如需分点,使用数字加顿号格式 8. 根据【{questionType}】的特点组织答案结构 `.trim(), '10': ` 请根据以下【编程题】的要求生成代码答案: 题目描述: {questionTitle} 要求语言: {language} 时间限制: {max_time} ms 内存限制: {max_memory} KB 当前代码 (可参考或忽略): {answerContent} 生成要求: 1. 使用指定的编程语言: {language} 2. 代码必须能够解决题目描述中的问题。 3. 遵循良好的编程规范和风格。 4. 包含必要的注释以解释关键部分。 5. 直接输出完整的、可运行的代码,不要包含任何额外的解释或格式化标记。 `.trim(), '12': ` 你是一个用于排序题的答题助手。请根据以下题目和需要排序的选项,将它们排列成正确的顺序。 【题目类型】: {questionType} 【题目内容】: {questionTitle} 【需要排序的选项】: {optionsText} 【输出要求】: - 你的回答必须是一个JSON数组,其中包含表示正确顺序的选项字母。 - 例如,如果正确顺序是 C -> A -> B,则输出 ["C", "A", "B"]。 - 你的回答必须严格遵守此格式,不要包含任何其他文字、解释或代码块标记。 `.trim(), '13': ` 你是一个用于匹配题的答题助手。请根据以下左侧列表和右侧选项,为左侧的每一项选择最合适的匹配项。 【题目类型】: {questionType} 【题目内容】: {questionTitle} 【左侧列表 (需要匹配的项)】: {stemsText} 【右侧列表 (可用的选项)】: {optionsText} 【输出要求】: - 你的回答必须是一个JSON对象。 - JSON的键(key)是左侧列表的字母。 - JSON的值(value)是与之匹配的右侧列表的字母。 - 例如: {"A": "e", "B": "a", "C": "d"} - 你的回答必须严格遵守此格式,不要包含任何其他文字、解释或代码块标记。 `.trim() }; const SCRIPT_CONFIG = { priorityApiBaseUrl: 'https://xiaoya-get-cdn.zygame1314.site', remoteConfigUrls: [ 'https://gist.githubusercontent.com/zygame1314/5e8a64928374c3fcc88a235f8f75d6e7/raw/xiaoya-config.json', 'https://gh-proxy.com/gist.githubusercontent.com/zygame1314/5e8a64928374c3fcc88a235f8f75d6e7/raw/xiaoya-config.json', 'https://ghfast.top/gist.githubusercontent.com/zygame1314/5e8a64928374c3fcc88a235f8f75d6e7/raw/xiaoya-config.json' ], defaultApiBaseUrl: 'https://xiaoya-manage.zygame1314-666.top', cachedApiBaseUrl: null, lastFetchTimestamp: 0, cacheDuration: 300000 }; const HealthCheckVisualizer = { container: null, groups: {}, _createContainer() { if (this.container) return; this.container = document.createElement('div'); this.container.style.cssText = ` position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background-color: rgba(0, 0, 0, 0.75); padding: 12px 20px; border-radius: 20px; z-index: 100001; display: flex; flex-direction: column; align-items: center; gap: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.25); backdrop-filter: blur(6px); transition: opacity 0.4s ease, transform 0.4s ease; opacity: 0; transform: translateX(-50%) translateY(20px); font-family: Microsoft YaHei; `; document.body.appendChild(this.container); requestAnimationFrame(() => { this.container.style.opacity = '1'; this.container.style.transform = 'translateX(-50%) translateY(0)'; }); }, addGroup(groupId, label, urls, isPriority = false) { this._createContainer(); if (this.groups[groupId]) return; const groupDiv = document.createElement('div'); groupDiv.style.cssText = `display: flex; align-items: center; gap: 12px;`; const labelSpan = document.createElement('span'); labelSpan.textContent = label; labelSpan.style.color = '#fff'; labelSpan.style.fontSize = '13px'; labelSpan.style.fontWeight = 'bold'; groupDiv.appendChild(labelSpan); const dotsContainer = document.createElement('div'); dotsContainer.style.cssText = `display: flex; align-items: center; gap: 8px;`; groupDiv.appendChild(dotsContainer); const dots = urls.map(() => { const dot = document.createElement('div'); dot.style.cssText = ` width: ${isPriority ? '14px' : '12px'}; height: ${isPriority ? '14px' : '12px'}; border-radius: 50%; background-color: #9ca3af; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); ${isPriority ? 'border: 2px solid rgba(251, 191, 36, 0.5);' : ''} `; dotsContainer.appendChild(dot); return dot; }); this.container.appendChild(groupDiv); this.groups[groupId] = { groupDiv, labelSpan, dots }; }, updateDot(groupId, index, status) { if (!this.groups[groupId] || !this.groups[groupId].dots[index]) return; const group = this.groups[groupId]; const dot = group.dots[index]; dot.getAnimations().forEach(anim => anim.cancel()); const colors = { testing: '#f59e0b', success: '#22c55e', failure: '#ef4444' }; dot.style.backgroundColor = colors[status]; dot.style.transform = 'scale(1)'; switch (status) { case 'testing': group.labelSpan.textContent = group.labelSpan.textContent.replace('...', '中...'); dot.animate([ { transform: 'scale(1.0)', opacity: 0.7 }, { transform: 'scale(1.3)', opacity: 1 }, { transform: 'scale(1.0)', opacity: 0.7 } ], { duration: 1200, iterations: Infinity, easing: 'ease-in-out' }); break; case 'success': dot.animate([ { transform: 'scale(1.4)', backgroundColor: '#a7f3d0' }, { transform: 'scale(1)' } ], { duration: 400, easing: 'ease-out' }); break; case 'failure': dot.animate([ { transform: 'translateX(-3px)' }, { transform: 'translateX(3px)' }, { transform: 'translateX(-2px)' }, { transform: 'translateX(2px)' }, { transform: 'translateX(0)' } ], { duration: 300, easing: 'ease-in-out' }); break; } }, updateGroupLabel(groupId, newLabel) { if (this.groups[groupId]) { this.groups[groupId].labelSpan.textContent = newLabel; } }, destroy() { if (this.container) { this.container.style.opacity = '0'; this.container.style.transform = 'translateX(-50%) translateY(20px)'; setTimeout(() => { if (this.container && this.container.parentNode) { this.container.parentNode.removeChild(this.container); } this.container = null; this.groups = {}; }, 400); } } }; const ContributionProgressUI = { ring: null, progressCircle: null, radius: 36, circumference: 0, container: null, mainBall: null, originalTitle: '', init(menuContainer) { if (this.ring) return; this.container = menuContainer; this.mainBall = menuContainer.querySelector('.xiaoya-main-ball'); this.originalTitle = this.mainBall.title || '小雅答答答'; this.circumference = 2 * Math.PI * this.radius; const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', '80'); svg.setAttribute('height', '80'); svg.style.cssText = ` position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%) rotate(-90deg); z-index: -1; display: none; opacity: 0; transition: opacity 0.4s ease; `; this.ring = svg; const trackCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); trackCircle.setAttribute('cx', '40'); trackCircle.setAttribute('cy', '40'); trackCircle.setAttribute('r', this.radius); trackCircle.setAttribute('stroke', 'rgba(0, 0, 0, 0.1)'); trackCircle.setAttribute('stroke-width', '5'); trackCircle.setAttribute('fill', 'transparent'); svg.appendChild(trackCircle); this.progressCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); this.progressCircle.setAttribute('cx', '40'); this.progressCircle.setAttribute('cy', '40'); this.progressCircle.setAttribute('r', this.radius); this.progressCircle.setAttribute('stroke', '#4F46E5'); this.progressCircle.setAttribute('stroke-width', '5'); this.progressCircle.setAttribute('fill', 'transparent'); this.progressCircle.setAttribute('stroke-linecap', 'round'); this.progressCircle.style.strokeDasharray = `${this.circumference} ${this.circumference}`; this.progressCircle.style.strokeDashoffset = this.circumference; this.progressCircle.style.transition = 'stroke-dashoffset 0.5s ease-out, stroke 0.5s ease'; svg.appendChild(this.progressCircle); this.container.appendChild(svg); }, show(message = '开始后台扫描...') { if (!this.ring) return; this.ring.style.display = 'block'; requestAnimationFrame(() => this.ring.style.opacity = '1'); this.mainBall.style.animation = 'contribution-pulse 1.5s infinite'; this.mainBall.title = message; this.update(0, 1); }, update(current, total, courseName = '') { if (!this.ring) return; const percent = (current / total) * 100; const offset = this.circumference - (percent / 100) * this.circumference; this.progressCircle.style.strokeDashoffset = offset; this.mainBall.title = `[${current}/${total}] 正在扫描: ${courseName}`; }, complete(message) { if (!this.ring) return; this.progressCircle.style.stroke = '#22c55e'; this.mainBall.title = message; this._fadeOut(); }, error(message) { if (!this.ring) return; this.progressCircle.style.stroke = '#ef4444'; this.mainBall.title = `错误: ${message}`; this._fadeOut(3000); }, hide() { if (!this.ring) return; this._fadeOut(); }, _fadeOut(delay = 1500) { setTimeout(() => { this.ring.style.opacity = '0'; this.mainBall.style.animation = ''; setTimeout(() => { this.ring.style.display = 'none'; this.progressCircle.style.stroke = '#4F46E5'; this.mainBall.title = this.originalTitle; }, 400); }, delay); } }; const style = document.createElement('style'); style.textContent = ` @keyframes contribution-pulse { 0% { box-shadow: 0 0 0 0 rgba(79, 70, 229, 0.5); } 70% { box-shadow: 0 0 0 10px rgba(79, 70, 229, 0); } 100% { box-shadow: 0 0 0 0 rgba(79, 70, 229, 0); } } `; document.head.appendChild(style); const { Document, Packer, Paragraph, HeadingLevel, AlignmentType, ImageRun, TextRun } = window.docx; let autoFetchEnabled = localStorage.getItem('autoFetchEnabled') === 'true'; let autoFillEnabled = localStorage.getItem('autoFillEnabled') === 'true'; let autoContributeEnabled = localStorage.getItem('autoContributeEnabled') !== 'false'; let isProcessing = false; let currentBatchAbortController = null; const activeAIControllers = new Set(); let debounceTimer = null; let sttCache = {}; const mediaProcessingLocks = {}; const backgroundTaskManager = { isTaskRunning: false, isTaskScheduled: false, schedule() { if (sessionStorage.getItem('xiaoya_full_scan_done') === 'true') { console.log('[后台任务调度器] 本次会话已完成全量扫描,不再调度新任务。'); return; } if (this.isTaskRunning || this.isTaskScheduled) { console.log('[后台任务调度器] 任务已在运行或计划中,忽略新的调度请求。'); return; } console.log('[后台任务调度器] 收到新的后台任务请求,将在3秒后执行...'); this.isTaskScheduled = true; setTimeout(async () => { if (this.isTaskRunning || sessionStorage.getItem('xiaoya_full_scan_done') === 'true') { console.log('[后台任务调度器] 延迟后发现任务已运行或已完成,取消本次执行。'); this.isTaskScheduled = false; return; } this.isTaskRunning = true; this.isTaskScheduled = false; try { const scanCompleted = await backgroundContributeAllCourses(); if (scanCompleted) { this.markAsCompleted(); } } catch (error) { console.error('[后台任务调度器] 后台任务执行时发生未捕获的错误:', error); } finally { this.isTaskRunning = false; console.log('[后台任务调度器] 后台任务执行完毕,状态重置为空闲。'); } }, 3000); }, markAsCompleted() { console.log('[后台任务调度器] 全量扫描已成功完成,本次会话将不再触发。'); sessionStorage.setItem('xiaoya_full_scan_done', 'true'); } }; function registerAIController(controller) { if (!controller) return; activeAIControllers.add(controller); console.log(`注册了一个新的AI AbortController,当前总数: ${activeAIControllers.size}`); controller.signal.addEventListener('abort', () => { activeAIControllers.delete(controller); console.log(`一个AI AbortController已中止并移除,剩余总数: ${activeAIControllers.size}`); }, { once: true }); } function cancelAllAITasks() { console.log(`正在取消 ${activeAIControllers.size} 个活动的AI任务...`); activeAIControllers.forEach(controller => { if (!controller.signal.aborted) { controller.abort(); } }); activeAIControllers.clear(); if (currentBatchAbortController) { currentBatchAbortController = null; } } function areAITasksRunning() { return Array.from(activeAIControllers).some(c => !c.signal.aborted); } function getToken() { const cookies = document.cookie.split('; '); for (let cookie of cookies) { const [name, value] = cookie.split('='); if (name.includes('prd-access-token')) { return value; } } return null; } async function isUrlHealthy(url) { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); const response = await fetch(url, { method: 'HEAD', mode: 'cors', signal: controller.signal }); clearTimeout(timeoutId); if (response.status < 500) { console.log(`[健康检查] ✅ ${url} - 状态: ${response.status} (可用)`); return true; } else { console.warn(`[健康检查] ❌ ${url} - 状态: ${response.status} (服务器错误)`); return false; } } catch (error) { if (error.name === 'AbortError') { console.warn(`[健康检查] ❌ ${url} - 请求超时`); } else { console.warn(`[健康检查] ❌ ${url} - 连接失败: ${error.message}`); } return false; } } async function getApiBaseUrl() { const now = Date.now(); if (SCRIPT_CONFIG.cachedApiBaseUrl && (now - SCRIPT_CONFIG.lastFetchTimestamp < SCRIPT_CONFIG.cacheDuration)) { return SCRIPT_CONFIG.cachedApiBaseUrl; } if (SCRIPT_CONFIG.priorityApiBaseUrl) { HealthCheckVisualizer.addGroup('priority', '⚡️ 优先线路检测...', [SCRIPT_CONFIG.priorityApiBaseUrl], true); HealthCheckVisualizer.updateDot('priority', 0, 'testing'); if (await isUrlHealthy(SCRIPT_CONFIG.priorityApiBaseUrl)) { HealthCheckVisualizer.updateDot('priority', 0, 'success'); HealthCheckVisualizer.updateGroupLabel('priority', '✅ 优先线路连接成功!'); console.log(`[优先线路] ${SCRIPT_CONFIG.priorityApiBaseUrl} 已选定!`); SCRIPT_CONFIG.cachedApiBaseUrl = SCRIPT_CONFIG.priorityApiBaseUrl; SCRIPT_CONFIG.lastFetchTimestamp = now; setTimeout(() => HealthCheckVisualizer.destroy(), 1200); return SCRIPT_CONFIG.priorityApiBaseUrl; } else { HealthCheckVisualizer.updateDot('priority', 0, 'failure'); HealthCheckVisualizer.updateGroupLabel('priority', '❌ 优先线路不可用'); console.warn(`[优先线路] ${SCRIPT_CONFIG.priorityApiBaseUrl} 不可用,回退至动态获取...`); } } for (const url of SCRIPT_CONFIG.remoteConfigUrls) { try { const response = await fetch(url, { cache: 'no-cache' }); if (!response.ok) throw new Error(`状态: ${response.status}`); const config = await response.json(); if (config && Array.isArray(config.baseUrls) && config.baseUrls.length > 0) { HealthCheckVisualizer.addGroup('dynamic', '🌐 动态节点扫描...', config.baseUrls); for (let i = 0; i < config.baseUrls.length; i++) { const baseUrl = config.baseUrls[i]; HealthCheckVisualizer.updateDot('dynamic', i, 'testing'); if (await isUrlHealthy(baseUrl)) { HealthCheckVisualizer.updateDot('dynamic', i, 'success'); HealthCheckVisualizer.updateGroupLabel('dynamic', '✅ 动态节点连接成功!'); console.log(`[动态配置] 域名 ${baseUrl} 健康检查通过,选定此地址!`); SCRIPT_CONFIG.cachedApiBaseUrl = baseUrl; SCRIPT_CONFIG.lastFetchTimestamp = now; setTimeout(() => HealthCheckVisualizer.destroy(), 1200); return baseUrl; } else { HealthCheckVisualizer.updateDot('dynamic', i, 'failure'); } } HealthCheckVisualizer.updateGroupLabel('dynamic', '❌ 所有动态节点均不可用'); throw new Error("域名池中的所有地址都无法连接。"); } else { throw new Error("远程配置文件格式不正确或域名池为空。"); } } catch (error) { console.warn(`[动态配置] 路标 ${url} 尝试失败:`, error.message); } } console.error('[动态配置] 所有远程路标均获取失败!'); if (SCRIPT_CONFIG.cachedApiBaseUrl) { console.log(`[动态配置] 回退至上次成功的缓存地址: ${SCRIPT_CONFIG.cachedApiBaseUrl}`); HealthCheckVisualizer.addGroup('fallback', `🔄 回退至缓存: ${SCRIPT_CONFIG.cachedApiBaseUrl}`, []); SCRIPT_CONFIG.lastFetchTimestamp = now; setTimeout(() => HealthCheckVisualizer.destroy(), 2000); return SCRIPT_CONFIG.cachedApiBaseUrl; } console.log(`[动态配置] 回退至最终的默认备用地址: ${SCRIPT_CONFIG.defaultApiBaseUrl}`); HealthCheckVisualizer.addGroup('default', `‼️ 启用最终备用线路,功能可能受限`, []); showNotification('无法连接到更新服务器,脚本将使用备用线路,功能可能受限。', { type: 'warning' }); setTimeout(() => HealthCheckVisualizer.destroy(), 3000); return SCRIPT_CONFIG.defaultApiBaseUrl; } async function getCurrentUserInfo(token) { if (!token) { return null; } try { const cachedUserInfo = sessionStorage.getItem(`userInfo_${token}`); if (cachedUserInfo) { try { const parsedInfo = JSON.parse(cachedUserInfo); if (parsedInfo && parsedInfo.cacheTimestamp && (Date.now() - parsedInfo.cacheTimestamp < 5 * 60 * 1000)) { return parsedInfo.data; } } catch (e) { sessionStorage.removeItem(`userInfo_${token}`); } } const response = await fetch(`${window.location.origin}/api/jw-starcmooc/user/currentUserInfo`, { headers: { "authorization": `Bearer ${token}`, "content-type": "application/json; charset=utf-8" }, method: "GET", credentials: "include" }); if (!response.ok) { console.error(`获取用户信息失败,状态码: ${response.status}`); return null; } const data = await response.json(); if (data.code === 200 && data.result) { try { sessionStorage.setItem(`userInfo_${token}`, JSON.stringify({ data: data.result, cacheTimestamp: Date.now() })); } catch (e) { console.warn('缓存用户信息到 sessionStorage 失败:', e); } return data.result; } else { console.warn('获取用户信息API返回非成功状态:', data); return null; } } catch (error) { console.error('获取用户信息时发生网络错误:', error); return null; } } function addButtons() { const style = document.createElement('style'); style.textContent = ` :root { --menu-bg: rgba(248, 249, 252, 0.85); --menu-border: rgba(0, 0, 0, 0.08); --menu-shadow: 0 10px 30px rgba(0, 0, 0, 0.12); --primary-color: #4F46E5; --primary-color-hover: #4338CA; --text-color: #1f2937; --text-color-secondary: #4b5569; --separator-color: #e5e7eb; --button-hover-bg: rgba(79, 70, 229, 0.05); } .xiaoya-menu-container { position: fixed; top: 150px; left: 150px; z-index: 9999; user-select: none; } .xiaoya-main-ball { width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(145deg, #6366F1, #4F46E5); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); cursor: move; display: flex; align-items: center; justify-content: center; color: white; font-size: 24px; transition: transform 0.4s cubic-bezier(0.19, 1, 0.22, 1), box-shadow 0.3s; } .xiaoya-main-ball:not(.menu-open):hover { transform: scale(1.1); box-shadow: 0 6px 16px rgba(99, 102, 241, 0.4); } .xiaoya-main-ball.menu-open { transform: rotate(90deg) scale(0.9); } .xiaoya-menu-panel { position: absolute; top: 80px; left: -15px; width: 300px; background: var(--menu-bg); backdrop-filter: blur(12px) saturate(180%); -webkit-backdrop-filter: blur(12px) saturate(180%); border-radius: 16px; box-shadow: var(--menu-shadow); border: 1px solid var(--menu-border); transform-origin: top left; transition: transform 0.4s cubic-bezier(0.19, 1, 0.22, 1), opacity 0.3s; opacity: 0; transform: scale(0.9) translateY(-10px); pointer-events: none; display: flex; flex-direction: column; max-height: 70vh; } .xiaoya-menu-panel.visible { opacity: 1; transform: scale(1) translateY(0); pointer-events: auto; } .xiaoya-menu-header { padding: 12px 16px; border-bottom: 1px solid var(--separator-color); cursor: move; display: flex; justify-content: space-between; align-items: center; } .xiaoya-menu-header h3 { margin: 0; font-size: 16px; font-weight: 600; color: var(--text-color); } .xiaoya-menu-body { padding: 12px; overflow-y: auto; flex-grow: 1; } .xiaoya-menu-body::-webkit-scrollbar { width: 5px; } .xiaoya-menu-body::-webkit-scrollbar-track { background: transparent; } .xiaoya-menu-body::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; } .xiaoya-menu-button, .xiaoya-menu-toggle { display: flex; align-items: center; width: 100%; padding: 10px 12px; border: none; background: none; text-align: left; border-radius: 8px; cursor: pointer; transition: background-color 0.2s, color 0.2s; font-size: 14px; color: var(--text-color-secondary); } .xiaoya-menu-button:hover, .xiaoya-menu-toggle:hover { background-color: var(--button-hover-bg); color: var(--primary-color); } .xiaoya-menu-icon { font-size: 18px; width: 28px; text-align: center; margin-right: 12px; } .xiaoya-menu-separator { border: none; border-top: 1px solid var(--separator-color); margin: 8px 0; } .xiaoya-menu-toggle-switch { margin-left: auto; width: 42px; height: 24px; background-color: #e5e7eb; border-radius: 12px; position: relative; transition: background-color 0.3s; } .xiaoya-menu-toggle-switch::before { content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; background-color: white; border-radius: 50%; transition: transform 0.3s cubic-bezier(0.19, 1, 0.22, 1); box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .xiaoya-menu-toggle input:checked + .xiaoya-menu-toggle-switch { background-color: var(--primary-color); } .xiaoya-menu-toggle input:checked + .xiaoya-menu-toggle-switch::before { transform: translateX(18px); } .xiaoya-menu-toggle input { display: none; } .xiaoya-menu-button.special-action { background: linear-gradient(135deg, rgba(79, 70, 229, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%); color: var(--primary-color); font-weight: 500; } .xiaoya-menu-button.special-action:hover { background: linear-gradient(135deg, rgba(79, 70, 229, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%); } `; document.head.appendChild(style); const container = document.createElement('div'); container.className = 'xiaoya-menu-container'; const mainBall = document.createElement('div'); mainBall.className = 'xiaoya-main-ball'; mainBall.innerHTML = '✨'; const panel = document.createElement('div'); panel.className = 'xiaoya-menu-panel'; const header = document.createElement('div'); header.className = 'xiaoya-menu-header'; header.innerHTML = '

小雅答答答

'; const body = document.createElement('div'); body.className = 'xiaoya-menu-body'; panel.appendChild(header); panel.appendChild(body); container.appendChild(mainBall); container.appendChild(panel); document.body.appendChild(container); ContributionProgressUI.init(container); const buttonsConfig = [ { id: 'get-answers', icon: '🕷️', text: '获取答案 / 激活', onClick: () => getAndStoreAnswers(true), type: 'button', special: true }, { id: 'get-submitted', icon: '📜', text: '获取已提交作业', onClick: () => getSubmittedAnswers(), type: 'button', special: true }, { id: 'fill-answers', icon: '✍️', text: '填写答案', onClick: () => fillAnswers(), type: 'button', special: true }, { id: 'view-edit', icon: '🖋️', text: '查看 / 编辑答案', onClick: () => showAnswerEditor(), type: 'button' }, { id: 'export-hw', icon: '📄', text: '导出作业', onClick: () => exportHomework(), type: 'button' }, { type: 'separator' }, { id: 'auto-fetch', icon: { enabled: '🔄', disabled: '⭕' }, text: '自动获取答案', state: () => autoFetchEnabled, onClick: (el, iconEl) => { autoFetchEnabled = !autoFetchEnabled; localStorage.setItem('autoFetchEnabled', autoFetchEnabled); el.querySelector('input').checked = autoFetchEnabled; iconEl.textContent = autoFetchEnabled ? '🔄' : '⭕'; }, type: 'toggle' }, { id: 'auto-fill', icon: { enabled: '🔄', disabled: '⭕' }, text: '自动填写答案', state: () => autoFillEnabled, onClick: (el, iconEl) => { autoFillEnabled = !autoFillEnabled; localStorage.setItem('autoFillEnabled', autoFillEnabled); el.querySelector('input').checked = autoFillEnabled; iconEl.textContent = autoFillEnabled ? '🔄' : '⭕'; }, type: 'toggle' }, { type: 'separator' }, { id: 'ai-settings', icon: '⚙️', text: 'AI 设置', onClick: () => showAISettingsPanel(), type: 'button', special: true }, { id: 'check-usage', icon: '📊', text: '检查用量', onClick: () => checkUsage(), type: 'button' }, { id: 'show-guide', icon: '🧭', text: '使用指南', onClick: () => showTutorial(), type: 'button', special: true }, { type: 'separator' }, { id: 'contribute-current', icon: '💝', text: '贡献当前作业', onClick: async () => { if (!(await checkAccountConsistency())) { showNotification('操作中止:当前登录账号与脚本激活账号不一致。', { type: 'error', duration: 5000 }); return; } if (!(await isTaskPage())) { showNotification('当前不是有效的作业/测验页面,无法进行贡献。', { type: 'warning' }); return; } const groupId = getGroupIDFromUrl(window.location.href); const nodeId = getNodeIDFromUrl(window.location.href); if (!groupId || !nodeId) { showNotification('无法获取页面参数,操作中止。', { type: 'error' }); return; } showNotification('正在贡献答案到题库...', { type: 'info', duration: 5000 }); try { const result = await contributeSingleAssignment(groupId, nodeId); if (result.success) { showNotification(`✅ 贡献成功: ${result.message}`, { type: 'success', duration: 8000 }); } else { showNotification(`❌ 贡献失败: ${result.error}`, { type: 'error', duration: 8000 }); } } catch (error) { showNotification(`💥 贡献答案时发生严重错误: ${error.message}`, { type: 'error' }); } }, type: 'button' }, { id: 'auto-contribute', icon: { enabled: '💖', disabled: '🤍' }, text: '自动贡献答案', state: () => autoContributeEnabled, onClick: async (el, iconEl) => { if (autoContributeEnabled) { const confirmedToKeep = await showConfirmNotification('感谢你一直以来的贡献!💖', { animation: 'scale', confirmText: '继续贡献', cancelText: '仍要关闭', title: '请留步,有几句话想对你说', description: `

你开启的“自动贡献”功能是我们答案库成长的基石。每一次贡献,都在帮助更多和你一样的同学。

郑重承诺:

` }); if (confirmedToKeep) { showNotification('非常感谢!自动贡献功能将保持开启。', { type: 'success', animation: 'scale' }); el.querySelector('input').checked = true; iconEl.textContent = '💖'; return; } } autoContributeEnabled = !autoContributeEnabled; localStorage.setItem('autoContributeEnabled', autoContributeEnabled); el.querySelector('input').checked = autoContributeEnabled; iconEl.textContent = autoContributeEnabled ? '💖' : '🤍'; if (autoContributeEnabled) { showNotification('后台自动贡献功能已开启。脚本将在后台为你扫描并贡献所有课程的答案。', { type: 'info' }); sessionStorage.removeItem('xiaoya_full_scan_done'); backgroundTaskManager.schedule(); } else { showNotification('自动贡献功能已关闭。感谢你曾经的付出!', { type: 'info' }); } }, type: 'toggle' }, ]; buttonsConfig.forEach(config => { if (config.type === 'separator') { body.appendChild(document.createElement('hr')).className = 'xiaoya-menu-separator'; return; } if (config.type === 'button') { const button = document.createElement('button'); button.className = 'xiaoya-menu-button'; if (config.special) button.classList.add('special-action'); button.innerHTML = ` ${config.icon} ${config.text} `; button.onclick = config.onClick; body.appendChild(button); } else if (config.type === 'toggle') { const label = document.createElement('label'); label.className = 'xiaoya-menu-toggle'; const isEnabled = config.state(); label.innerHTML = ` ${isEnabled ? config.icon.enabled : config.icon.disabled} ${config.text}
`; const iconSpan = label.querySelector('.xiaoya-menu-icon'); label.onclick = (e) => { e.preventDefault(); config.onClick(label, iconSpan); }; body.appendChild(label); } }); let isPanelVisible = false; function togglePanel() { isPanelVisible = !isPanelVisible; panel.classList.toggle('visible', isPanelVisible); mainBall.classList.toggle('menu-open', isPanelVisible); } mainBall.addEventListener('click', (e) => { if (!hasDragged) { togglePanel(); } }); let isDragging = false, hasDragged = false; let initialX, initialY, xOffset = 0, yOffset = 0; const dragThreshold = 5; function dragStart(e) { hasDragged = false; const target = e.target; if (target === mainBall || target === header || header.contains(target)) { isDragging = true; const clientX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX; const clientY = e.type === 'touchstart' ? e.touches[0].clientY : e.clientY; xOffset = clientX - container.offsetLeft; yOffset = clientY - container.offsetTop; initialX = clientX; initialY = clientY; } } function drag(e) { if (isDragging) { e.preventDefault(); const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX; const clientY = e.type === 'touchmove' ? e.touches[0].clientY : e.clientY; if (!hasDragged) { const dx = clientX - initialX; const dy = clientY - initialY; if (Math.sqrt(dx * dx + dy * dy) > dragThreshold) { hasDragged = true; } } let newX = clientX - xOffset; let newY = clientY - yOffset; const containerRect = container.getBoundingClientRect(); newX = Math.max(0, Math.min(newX, window.innerWidth - containerRect.width)); newY = Math.max(0, Math.min(newY, window.innerHeight - containerRect.height)); container.style.left = newX + 'px'; container.style.top = newY + 'px'; } } function dragEnd() { isDragging = false; setTimeout(() => { hasDragged = false; }, 0); } header.addEventListener('mousedown', dragStart); document.addEventListener('mousemove', drag); document.addEventListener('mouseup', dragEnd); header.addEventListener('touchstart', dragStart, { passive: true }); document.addEventListener('touchmove', drag, { passive: false }); document.addEventListener('touchend', dragEnd); mainBall.addEventListener('mousedown', dragStart); mainBall.addEventListener('touchstart', dragStart, { passive: true }); } function createProgressBar() { const style = document.createElement('style'); style.textContent = ` .answer-progress { position: fixed; top: 0; left: 0; width: 100%; height: 6px; background: rgba(0, 0, 0, 0.05); z-index: 10000; opacity: 0; transition: opacity 0.4s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); pointer-events: none; } .answer-progress-bar { height: 100%; background: linear-gradient(90deg, #60a5fa, #818cf8); width: 0%; transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1); border-radius: 0 3px 3px 0; box-shadow: 0 0 8px rgba(96, 165, 250, 0.5); } .answer-progress-text { position: fixed; top: 12px; right: 20px; transform: translateY(-10px); background: #4f46e5; color: white; padding: 6px 12px; border-radius: 6px; font-size: 13px; opacity: 0; transition: all 0.4s ease; box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3); font-weight: bold; pointer-events: none; } `; document.head.appendChild(style); const progressContainer = document.createElement('div'); progressContainer.className = 'answer-progress'; const progressBar = document.createElement('div'); progressBar.className = 'answer-progress-bar'; const progressText = document.createElement('div'); progressText.className = 'answer-progress-text'; progressContainer.appendChild(progressBar); document.body.appendChild(progressContainer); document.body.appendChild(progressText); return { show: () => { progressContainer.style.opacity = '1'; progressText.style.opacity = '1'; progressText.style.transform = 'translateY(0)'; }, hide: () => { progressContainer.style.opacity = '0'; progressText.style.opacity = '0'; progressText.style.transform = 'translateY(-10px)'; setTimeout(() => { progressContainer.remove(); progressText.remove(); }, 300); }, update: (current, total, action = '正在填写', unit = '题') => { const percent = (current / total) * 100; progressBar.style.width = percent + '%'; const unitString = unit ? ` ${unit}` : ''; progressText.textContent = `${action}: ${current}/${total}${unitString}`; }, }; } addButtons(); function addGlobalStyles() { const style = document.createElement('style'); style.textContent = ` .image-upload-btn, .ai-assist-btn { padding: 8px 16px; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: bold; display: flex; align-items: center; gap: 6px; transition: all 0.2s ease; height: 36px; } .image-upload-btn { background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); box-shadow: 0 2px 4px rgba(22, 163, 74, 0.3); } .image-upload-btn:hover { transform: translateY(-1px); background: linear-gradient(135deg, #16a34a 0%, #15803d 100%); box-shadow: 0 4px 8px rgba(22, 163, 74, 0.4); } .image-upload-btn:active { transform: translateY(1px); } .image-upload-btn.loading, .ai-assist-btn.loading { background: #9ca3af; cursor: not-allowed; opacity: 0.8; } .image-upload-btn .icon, .ai-assist-btn .icon { font-size: 16px; } .image-upload-btn.loading .icon, .ai-assist-btn.loading .icon { animation: spin 1s linear infinite; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .ai-assist-btn { background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); box-shadow: 0 2px 4px rgba(79, 70, 229, 0.1); } .ai-assist-btn:hover { transform: translateY(-1px); background: linear-gradient(135deg, #4338ca 0%, #4f46e5 100%); box-shadow: 0 4px 8px rgba(79, 70, 229, 0.2); } .ai-assist-btn:active { transform: translateY(1px); } .char-count { font-size: 12px; color: #6b7280; margin-left: auto; padding: 4px 8px; background-color: #f9fafb; border-radius: 6px; border: 1px solid #e5e7eb; transition: all 0.2s ease; } .char-count.active { color: #4f46e5; border-color: #c7d2fe; background-color: #eef2ff; } `; document.head.appendChild(style); } addGlobalStyles(); class NotificationAnimator { static animations = { fadeSlide: { enter: { initial: { opacity: '0', transform: 'translateY(-20px)' }, final: { opacity: '1', transform: 'translateY(0)' } }, exit: { initial: { opacity: '1', transform: 'translateY(0)' }, final: { opacity: '0', transform: 'translateY(-20px)' } } }, scale: { enter: { initial: { opacity: '0', transform: 'scale(0.8)' }, final: { opacity: '1', transform: 'scale(1)' } }, exit: { initial: { opacity: '1', transform: 'scale(1)' }, final: { opacity: '0', transform: 'scale(0.8)' } } }, slideRight: { enter: { initial: { opacity: '0', transform: 'translateX(-100%)' }, final: { opacity: '1', transform: 'translateX(0)' } }, exit: { initial: { opacity: '1', transform: 'translateX(0)' }, final: { opacity: '0', transform: 'translateX(100%)' } } } }; static applyAnimation(element, animationType, isEnter) { const animation = this.animations[animationType]; if (!animation) return; const { initial, final } = isEnter ? animation.enter : animation.exit; Object.assign(element.style, { transition: 'all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55)', ...initial }); requestAnimationFrame(() => { Object.assign(element.style, final); }); } } function getNotificationContainer() { let container = document.getElementById('notification-container'); if (!container) { container = document.createElement('div'); container.id = 'notification-container'; container.style.cssText = ` position: fixed; top: 20px; left: 50%; transform: translateX(-50%); z-index: 100000; max-height: calc(100vh - 40px); overflow-y: auto; overflow-x: hidden; pointer-events: none; display: flex; flex-direction: column; align-items: center; opacity: 0; transition: opacity 0.3s ease; `; document.body.appendChild(container); container.offsetHeight; container.style.opacity = '1'; } return container; } function showNotification(message, options = {}) { const { type = 'info', duration = 3000, keywords = [], animation = 'fadeSlide', id = null } = options; const container = getNotificationContainer(); const existingNotifications = container.querySelectorAll('.message-container'); for (let i = 0; i < existingNotifications.length; i++) { if (existingNotifications[i].textContent === message) { return; } } if (id) { let existingNotification = container.querySelector(`[data-notification-id="${id}"]`); if (existingNotification) { const messageContainer = existingNotification.querySelector('.message-container'); const icon = existingNotification.querySelector('.notification-icon'); const typeStyles = { success: { icon: '🎉' }, error: { icon: '❌' }, warning: { icon: '⚠️' }, info: { icon: 'ℹ️' } }; const currentType = typeStyles[type] || typeStyles.info; if (icon) icon.textContent = currentType.icon; if (messageContainer) messageContainer.innerHTML = message; if (duration > 0) { if (existingNotification.hideTimeout) clearTimeout(existingNotification.hideTimeout); existingNotification.hideTimeout = setTimeout(() => { hideNotification(existingNotification); }, duration); } return; } } const highlightColors = { success: '#ffba08', error: '#14b8a6', warning: '#8b5cf6', info: '#f472b6' }; const highlightColor = highlightColors[type] || highlightColors.info; const highlightStyle = ` color: ${highlightColor}; font-weight: bold; border-bottom: 2px solid ${highlightColor}50; transition: all 0.3s ease; border-radius: 3px; `; let highlightedMessage = message; if (keywords && keywords.length > 0) { const uniqueKeywords = [...new Set(keywords)].map(k => String(k).trim()).filter(Boolean); if (uniqueKeywords.length > 0) { uniqueKeywords.sort((a, b) => b.length - a.length); const escapedKeywords = uniqueKeywords.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const regex = new RegExp(`\\b(${escapedKeywords.join('|')})\\b`, 'g'); highlightedMessage = message.replace(regex, (match) => `${match}` ); } } const notification = document.createElement('div'); if (id) notification.dataset.notificationId = id; notification.style.cssText = ` position: relative; margin-bottom: 10px; padding: 15px 20px; border-radius: 12px; color: #333; font-size: 16px; font-weight: bold; box-shadow: 0 8px 16px rgba(0,0,0,0.08), 0 4px 8px rgba(0,0,0,0.06); pointer-events: auto; opacity: 0; transform: translateY(-20px); transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); display: flex; align-items: center; backdrop-filter: blur(8px); `; const typeStyles = { success: { background: 'linear-gradient(145deg, rgba(104, 214, 156, 0.95), rgba(89, 186, 134, 0.95))', icon: '🎉' }, error: { background: 'linear-gradient(145deg, rgba(248, 113, 113, 0.95), rgba(220, 38, 38, 0.95))', icon: '❌' }, warning: { background: 'linear-gradient(145deg, rgba(251, 191, 36, 0.95), rgba(245, 158, 11, 0.95))', icon: '⚠️' }, info: { background: 'linear-gradient(145deg, rgba(96, 165, 250, 0.95), rgba(59, 130, 246, 0.95))', icon: 'ℹ️' } }; const currentType = typeStyles[type] || typeStyles.info; notification.style.background = currentType.background; notification.style.color = type === 'info' || type === 'success' ? '#fff' : '#000'; const progressBar = document.createElement('div'); progressBar.style.cssText = ` position: absolute; bottom: 0; left: 0; height: 4px; width: 100%; background: rgba(255, 255, 255, 0.3); border-radius: 0 0 12px 12px; transition: width ${duration}ms cubic-bezier(0.4, 0, 0.2, 1); `; const icon = document.createElement('span'); icon.className = 'notification-icon'; icon.style.cssText = 'margin-right: 12px; font-size: 20px; filter: saturate(1.2);'; icon.textContent = currentType.icon; const messageContainer = document.createElement('div'); messageContainer.className = 'message-container'; messageContainer.innerHTML = highlightedMessage; messageContainer.textContent = message; messageContainer.style.cssText = 'flex: 1; font-weight: bold;'; const closeButton = document.createElement('button'); closeButton.innerHTML = ``; closeButton.style.cssText = ` margin-left: 12px; background: #f3f4f6; border: none; width: 32px; height: 32px; border-radius: 50%; cursor: pointer; color: #6b7280; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; flex-shrink: 0; `; closeButton.onmouseover = () => { }; closeButton.onmouseout = () => { }; notification.appendChild(icon); notification.appendChild(messageContainer); notification.appendChild(closeButton); notification.appendChild(progressBar); container.prepend(notification); requestAnimationFrame(() => { NotificationAnimator.applyAnimation(notification, animation, true); if (duration > 0) { requestAnimationFrame(() => { progressBar.style.width = '0'; }); } }); function hideNotification(notificationElement) { if (!container.contains(notificationElement)) return; NotificationAnimator.applyAnimation(notificationElement, animation, false); setTimeout(() => { if (container.contains(notificationElement)) { container.removeChild(notificationElement); } if (container.children.length === 0 && document.body.contains(container)) { document.body.removeChild(container); } }, 300); } const hideThisNotification = () => hideNotification(notification); closeButton.addEventListener('click', (e) => { e.stopPropagation(); clearTimeout(notification.hideTimeout); hideThisNotification(); }); if (duration > 0) { notification.addEventListener('click', hideThisNotification); notification.hideTimeout = setTimeout(() => { hideThisNotification(); }, duration); } } function showConfirmNotification(message, options = {}) { const { animation = 'scale', confirmText = '确认', cancelText = '取消', title = null, description = null } = options; return new Promise((resolve) => { const container = getNotificationContainer(); const notification = document.createElement('div'); notification.style.cssText = ` position: relative; margin-bottom: 10px; padding: 20px 25px; border-radius: 16px; color: #333; font-size: 16px; font-weight: bold; box-shadow: 0 10px 25px rgba(0,0,0,0.1), 0 5px 10px rgba(0,0,0,0.05); pointer-events: auto; opacity: 0; transform: translateY(-20px); transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); display: flex; flex-direction: column; gap: 15px; background: linear-gradient(145deg, #ffffff, #f8f9fa); backdrop-filter: blur(8px); border: 1px solid rgba(0,0,0,0.05); max-width: 450px; `; if (title) { const titleDiv = document.createElement('h3'); titleDiv.textContent = title; titleDiv.style.cssText = ` margin: 0; font-size: 18px; font-weight: 700; color: #1f2937; text-align: center; `; notification.appendChild(titleDiv); } const messageDiv = document.createElement('div'); messageDiv.innerHTML = message; messageDiv.style.fontWeight = '600'; messageDiv.style.textAlign = 'center'; notification.appendChild(messageDiv); if (description) { const descriptionDiv = document.createElement('div'); descriptionDiv.innerHTML = description; descriptionDiv.style.cssText = ` margin-top: 5px; font-size: 15px; font-weight: normal; color: #4b5569; line-height: 1.5; text-align: center; `; notification.appendChild(descriptionDiv); } const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = ` display: flex; gap: 12px; justify-content: center; margin-top: 10px; `; const confirmBtn = document.createElement('button'); confirmBtn.textContent = confirmText; confirmBtn.style.cssText = ` padding: 8px 18px; border: none; border-radius: 8px; background: #4f46e5; color: white; cursor: pointer; font-weight: bold; transition: all 0.2s ease; `; const cancelBtn = document.createElement('button'); cancelBtn.textContent = cancelText; cancelBtn.style.cssText = ` padding: 8px 18px; border: 1px solid #d1d5db; border-radius: 8px; background: transparent; color: #4b5569; cursor: pointer; font-weight: bold; transition: all 0.2s ease; `; [confirmBtn, cancelBtn].forEach(btn => { btn.onmouseover = () => { btn.style.transform = 'translateY(-1px)'; btn.style.filter = 'brightness(1.1)'; }; btn.onmouseout = () => { btn.style.transform = 'translateY(0)'; btn.style.filter = 'brightness(1)'; }; }); buttonContainer.appendChild(cancelBtn); buttonContainer.appendChild(confirmBtn); notification.appendChild(buttonContainer); container.appendChild(notification); requestAnimationFrame(() => { notification.style.opacity = '1'; notification.style.transform = 'translateY(0)'; }); requestAnimationFrame(() => { requestAnimationFrame(() => { NotificationAnimator.applyAnimation(notification, animation, true); }); }); const hideNotification = (result) => { NotificationAnimator.applyAnimation(notification, animation, false); setTimeout(() => { if (container.contains(notification)) { container.removeChild(notification); } if (container.children.length === 0 && document.body.contains(container)) { document.body.removeChild(container); } resolve(result); }, 300); }; confirmBtn.onclick = () => hideNotification(true); cancelBtn.onclick = () => hideNotification(false); }); } function promptActivationCode() { const modalOverlay = document.createElement('div'); modalOverlay.style.position = 'fixed'; modalOverlay.style.top = '0'; modalOverlay.style.left = '0'; modalOverlay.style.width = '100%'; modalOverlay.style.height = '100%'; modalOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.75)'; modalOverlay.style.zIndex = '9999'; modalOverlay.style.display = 'flex'; modalOverlay.style.alignItems = 'center'; modalOverlay.style.justifyContent = 'center'; modalOverlay.style.opacity = '0'; modalOverlay.style.transition = 'opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1)'; modalOverlay.style.backdropFilter = 'blur(8px)'; const modalContainer = document.createElement('div'); modalContainer.style.backgroundColor = '#ffffff'; modalContainer.style.padding = '40px'; modalContainer.style.borderRadius = '20px'; modalContainer.style.boxShadow = '0 20px 50px rgba(0,0,0,0.15), 0 0 20px rgba(0,0,0,0.1)'; modalContainer.style.width = '420px'; modalContainer.style.maxWidth = '90%'; modalContainer.style.textAlign = 'center'; modalContainer.style.position = 'relative'; modalContainer.style.transform = 'scale(0.8) translateY(20px)'; modalContainer.style.transition = 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)'; modalContainer.style.border = '1px solid rgba(255, 255, 255, 0.1)'; const modalHeader = document.createElement('div'); modalHeader.style.marginBottom = '30px'; modalHeader.style.position = 'relative'; const icon = document.createElement('div'); icon.innerHTML = ` `; icon.style.marginBottom = '15px'; icon.style.color = '#4CAF50'; const closeButton = document.createElement('button'); closeButton.innerHTML = ` `; closeButton.style.cssText = ` position: absolute; top: 15px; right: 15px; background: #f3f4f6; border: none; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; color: #6b7280; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; box-shadow: 0 2px 5px rgba(0,0,0,0.08); `; closeButton.onmouseover = () => { closeButton.style.backgroundColor = '#e5e7eb'; closeButton.style.transform = 'rotate(90deg)'; closeButton.style.color = '#000'; closeButton.style.boxShadow = '0 4px 8px rgba(0,0,0,0.12)'; }; closeButton.onmouseout = () => { closeButton.style.backgroundColor = '#f3f4f6'; closeButton.style.transform = 'rotate(0deg)'; closeButton.style.color = '#6b7280'; closeButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.08)'; }; const title = document.createElement('h2'); title.textContent = '输入激活码'; title.style.fontSize = '24px'; title.style.fontWeight = '600'; title.style.color = '#333'; title.style.margin = '0 0 8px 0'; const subtitle = document.createElement('p'); subtitle.textContent = '请输入激活码以继续使用完整功能'; subtitle.style.color = '#666'; subtitle.style.fontSize = '14px'; subtitle.style.margin = '0'; const infoMessage = document.createElement('p'); infoMessage.innerHTML = '关于激活码获取,请移步我的主页或者直接访问爱发电'; infoMessage.style.color = '#666'; infoMessage.style.fontSize = '14px'; infoMessage.style.margin = '10px 0 0 0'; const inputContainer = document.createElement('div'); inputContainer.style.position = 'relative'; inputContainer.style.marginTop = '25px'; const input = document.createElement('input'); input.type = 'text'; input.placeholder = '请输入激活码'; input.style.width = '100%'; input.style.padding = '15px 20px'; input.style.border = '2px solid #e0e0e0'; input.style.borderRadius = '12px'; input.style.fontSize = '16px'; input.style.backgroundColor = '#f8f9fa'; input.style.transition = 'all 0.3s ease'; input.style.boxSizing = 'border-box'; input.style.outline = 'none'; input.addEventListener('focus', () => { input.style.border = '2px solid #4CAF50'; input.style.backgroundColor = '#ffffff'; input.style.boxShadow = '0 0 0 4px rgba(76, 175, 80, 0.1)'; }); input.addEventListener('blur', () => { input.style.border = '2px solid #e0e0e0'; input.style.backgroundColor = '#f8f9fa'; input.style.boxShadow = 'none'; }); const confirmButton = document.createElement('button'); confirmButton.textContent = '激活'; confirmButton.style.width = '100%'; confirmButton.style.padding = '15px'; confirmButton.style.marginTop = '20px'; confirmButton.style.border = 'none'; confirmButton.style.borderRadius = '12px'; confirmButton.style.backgroundColor = '#4CAF50'; confirmButton.style.color = '#fff'; confirmButton.style.fontSize = '16px'; confirmButton.style.fontWeight = '600'; confirmButton.style.cursor = 'pointer'; confirmButton.style.transition = 'all 0.3s ease'; confirmButton.style.transform = 'translateY(0)'; confirmButton.style.boxShadow = '0 4px 12px rgba(76, 175, 80, 0.2)'; let isLoading = false; const setLoadingState = (loading) => { isLoading = loading; if (loading) { confirmButton.innerHTML = '验证中...'; confirmButton.style.backgroundColor = '#45a049'; confirmButton.disabled = true; } else { confirmButton.textContent = '激活'; confirmButton.style.backgroundColor = '#4CAF50'; confirmButton.disabled = false; } }; const style = document.createElement('style'); style.textContent = ` .loading { display: inline-block; width: 20px; height: 20px; border: 3px solid rgba(255,255,255,.3); border-radius: 50%; border-top-color: #fff; animation: spin 1s ease-in-out infinite; margin-right: 8px; vertical-align: middle; } @keyframes spin { to { transform: rotate(360deg); } } `; document.head.appendChild(style); modalHeader.appendChild(icon); modalHeader.appendChild(title); modalHeader.appendChild(subtitle); modalHeader.appendChild(infoMessage); modalContainer.appendChild(modalHeader); modalContainer.appendChild(closeButton); inputContainer.appendChild(input); modalContainer.appendChild(inputContainer); modalContainer.appendChild(confirmButton); modalOverlay.appendChild(modalContainer); document.body.appendChild(modalOverlay); requestAnimationFrame(() => { modalOverlay.style.opacity = '1'; modalContainer.style.transform = 'scale(1) translateY(0)'; }); function closeModal() { modalOverlay.style.opacity = '0'; modalContainer.style.transform = 'scale(0.8) translateY(20px)'; setTimeout(() => { document.body.removeChild(modalOverlay); document.head.removeChild(style); }, 400); } closeButton.addEventListener('click', () => { closeModal(); showNotification('请输入激活码。', { type: 'warning', keywords: ['激活码'], animation: 'scale' }); }); modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) { closeModal(); showNotification('请输入激活码。', { type: 'warning', keywords: ['激活码'], animation: 'scale' }); } }); confirmButton.addEventListener('click', () => { const userCode = input.value.trim(); if (isLoading) return; if (userCode) { setLoadingState(true); const token = getToken(); getCurrentUserInfo(token).then(userInfo => { if (!userInfo || !userInfo.id) { showNotification('无法获取小雅用户信息,请先登录小雅。', { type: 'error' }); setLoadingState(false); return; } getApiBaseUrl().then(baseUrl => { fetch(`${baseUrl}/api/activate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ activation_code: userCode, platform_user_id: userInfo.id.toString(), xiaoyaToken: token, origin: window.location.origin }) }) .then(res => res.json()) .then(data => { setLoadingState(false); if (data.success) { localStorage.setItem('xiaoya_access_token', data.access_token); localStorage.setItem('xiaoya_refresh_token', data.refresh_token); localStorage.setItem('xiaoya_bound_user_id', userInfo.id.toString()); showNotification('激活成功!', { type: 'success', animation: 'scale' }); closeModal(); getAndStoreAnswers(); } else { showNotification(`激活失败: ${data.error}`, { type: 'error' }); } }) .catch(err => { setLoadingState(false); showNotification(`网络错误: ${err.message}`, { type: 'error' }); }); }); }); } else { input.style.border = '2px solid #ff4444'; input.style.backgroundColor = '#fff8f8'; showNotification('请输入激活码。', { type: 'warning', keywords: ['激活码'], animation: 'fadeSlide' }); input.focus(); } }); input.addEventListener('keypress', (e) => { if (e.key === 'Enter' && !isLoading) { confirmButton.click(); } }); } async function authedFetch(action, payload) { let accessToken = localStorage.getItem('xiaoya_access_token'); if (!accessToken) { throw new Error('需要激活'); } const xiaoyaToken = getToken(); if (!xiaoyaToken) throw new Error('无法获取小雅 Token'); const currentUserInfo = await getCurrentUserInfo(xiaoyaToken); if (!currentUserInfo || !currentUserInfo.id) { showNotification('无法获取当前小雅用户信息,请确保已登录。', { type: 'error' }); throw new Error('无法获取当前小雅用户信息'); } const finalPayload = { ...payload, xiaoyaToken, origin: window.location.origin, current_platform_user_id: currentUserInfo.id.toString(), script_version: GM_info.script.version }; const baseUrl = await getApiBaseUrl(); async function doFetch(token) { return fetch(`${baseUrl}/api/action/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(finalPayload) }); } let response = await doFetch(accessToken); if (response.status === 401 || response.status === 403) { const errorData = await response.json(); const errorMessage = errorData.error || ''; if (errorData.code === 'FRAUD_DETECTED') { console.error(`[欺诈检测] 后端返回欺诈警告: ${errorMessage}`); throw new Error(`欺诈行为警告: ${errorMessage}`); } if (errorMessage.includes('重新激活') || errorMessage.includes('用户不存在') || errorMessage.includes('已到期')) { console.warn(`后端要求重新激活: ${errorMessage}`); throw new Error(`凭证失效,请重新激活: ${errorMessage}`); } if (errorData.code === 'TOKEN_EXPIRED') { console.log('Access Token 过期,尝试刷新...'); const refreshToken = localStorage.getItem('xiaoya_refresh_token'); if (!refreshToken) { throw new Error('刷新令牌不存在,请重新激活'); } const refreshResponse = await fetch(`${baseUrl}/api/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken }) }); if (refreshResponse.ok) { const refreshData = await refreshResponse.json(); accessToken = refreshData.access_token; localStorage.setItem('xiaoya_access_token', accessToken); console.log('Token 刷新成功,重试请求...'); response = await doFetch(accessToken); } else { const refreshErrorData = await refreshResponse.json(); const message = refreshErrorData.error || '刷新令牌失败'; if (message.includes('数据库中无效')) { throw new Error('检测到你可能在其他设备上激活,请重新在此设备上激活。'); } throw new Error('刷新令牌失败,请重新激活'); } } else { if (errorMessage.includes('无效的令牌')) { throw new Error(`凭证无效,请重新激活: ${errorMessage}`); } throw new Error(`认证失败: ${errorMessage || '未知错误'}`); } } if (!response.ok) { const errorData = await response.json(); throw new Error(`请求失败 (${response.status}): ${errorData.error || response.statusText}`); } return response.json(); } function showUsagePanel() { const overlay = document.createElement('div'); overlay.id = 'usage-panel-overlay'; overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.7); z-index: 10001; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1); backdrop-filter: blur(8px); `; const modal = document.createElement('div'); modal.style.cssText = ` background: linear-gradient(145deg, #f9fafb, #f3f4f6); padding: 32px 40px; border-radius: 20px; width: 480px; max-width: 90%; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25); transform: scale(0.9) translateY(20px); opacity: 0; transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); position: relative; border: 1px solid rgba(255, 255, 255, 0.2); display: flex; flex-direction: column; `; const closeModal = () => { modal.style.transform = 'scale(0.9) translateY(20px)'; modal.style.opacity = '0'; overlay.style.opacity = '0'; setTimeout(() => { if (document.body.contains(overlay)) { document.body.removeChild(overlay); } }, 400); }; const closeButton = document.createElement('button'); closeButton.innerHTML = ` `; closeButton.style.cssText = ` position: absolute; top: 18px; right: 18px; background: #e5e7eb; border: none; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; color: #6b7280; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; `; closeButton.onmouseover = () => { closeButton.style.transform = 'rotate(90deg) scale(1.1)'; closeButton.style.backgroundColor = '#d1d5db'; }; closeButton.onmouseout = () => { closeButton.style.transform = 'rotate(0deg) scale(1)'; closeButton.style.backgroundColor = '#e5e7eb'; }; closeButton.onclick = closeModal; const title = document.createElement('h2'); title.innerHTML = ` 用量状态 `; title.style.cssText = 'margin-top: 0; margin-bottom: 30px; text-align: center; color: #1f2937; font-size: 24px; font-weight: 700;'; const contentArea = document.createElement('div'); contentArea.style.cssText = ` min-height: 180px; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; `; const spinnerStyle = document.createElement('style'); spinnerStyle.textContent = `@keyframes usage-spinner { to { transform: rotate(360deg); } }`; document.head.appendChild(spinnerStyle); contentArea.innerHTML = `
`; modal.appendChild(closeButton); modal.appendChild(title); modal.appendChild(contentArea); overlay.appendChild(modal); document.body.appendChild(overlay); requestAnimationFrame(() => { overlay.style.opacity = '1'; modal.style.opacity = '1'; modal.style.transform = 'scale(1) translateY(0)'; }); overlay.onclick = (e) => { if (e.target === overlay) closeModal(); }; return { contentArea, closeModal }; } function populateUsagePanel(contentArea, usageData, closeModal) { const { expires_at, total_queries, total_query_limit, daily_queries, daily_query_limit } = usageData; contentArea.innerHTML = ''; contentArea.style.alignItems = 'stretch'; contentArea.style.justifyContent = 'flex-start'; contentArea.style.flexDirection = 'column'; const createUsageBar = (label, used, limit, color) => { const container = document.createElement('div'); container.style.marginBottom = '25px'; const labelElement = document.createElement('div'); labelElement.style.cssText = 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; font-size: 15px; color: #374151;'; const labelText = document.createElement('span'); labelText.textContent = label; labelText.style.fontWeight = '600'; const usageText = document.createElement('span'); usageText.style.cssText = `font-weight: 700; color: #374151; font-family: Microsoft YaHei; font-size: 16px;`; usageText.textContent = `${used.toLocaleString()} / ${limit.toLocaleString()}`; labelElement.appendChild(labelText); labelElement.appendChild(usageText); const progressBarBg = document.createElement('div'); progressBarBg.style.cssText = 'height: 12px; background-color: #e5e7eb; border-radius: 6px; overflow: hidden; box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);'; const progressBarFill = document.createElement('div'); const percentage = limit > 0 ? (used / limit) * 100 : 0; progressBarFill.style.cssText = ` height: 100%; width: 0%; background: ${color}; border-radius: 6px; transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); `; progressBarBg.appendChild(progressBarFill); container.appendChild(labelElement); container.appendChild(progressBarBg); setTimeout(() => { progressBarFill.style.width = `${percentage}%`; }, 100); return container; }; const dailyUsageBar = createUsageBar('今日已用额度', daily_queries, daily_query_limit, 'linear-gradient(90deg, #5eead4, #3b82f6)'); const totalUsageBar = createUsageBar('总剩余额度', total_queries, total_query_limit, 'linear-gradient(90deg, #f87171, #ec4899)'); const expiryContainer = document.createElement('div'); expiryContainer.style.cssText = ` margin-top: 10px; padding: 15px; text-align: center; background-color: #eef2ff; border: 1px solid #c7d2fe; border-radius: 12px; `; const expiryLabel = document.createElement('span'); expiryLabel.textContent = '授权到期时间: '; expiryLabel.style.color = '#4338ca'; expiryLabel.style.fontWeight = '600'; const expiryDate = document.createElement('span'); expiryDate.textContent = expires_at ? new Date(expires_at * 1000).toLocaleString('zh-CN', { hour12: false }) : 'N/A'; expiryDate.style.fontWeight = '700'; expiryDate.style.color = '#4f46e5'; expiryContainer.appendChild(expiryLabel); expiryContainer.appendChild(expiryDate); const actionsContainer = document.createElement('div'); actionsContainer.style.cssText = 'text-align: center; margin-top: 25px; margin-bottom: 10px;'; const renewButton = document.createElement('button'); renewButton.textContent = '续费 / 激活'; renewButton.style.cssText = ` padding: 12px 28px; border: none; border-radius: 10px; background: linear-gradient(145deg, #4f46e5, #3b82f6); color: white; cursor: pointer; font-size: 16px; font-weight: bold; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 4px 15px rgba(79, 70, 229, 0.25); `; renewButton.onmouseover = () => { renewButton.style.transform = 'translateY(-3px)'; renewButton.style.boxShadow = '0 7px 20px rgba(79, 70, 229, 0.35)'; }; renewButton.onmouseout = () => { renewButton.style.transform = 'translateY(0)'; renewButton.style.boxShadow = '0 4px 15px rgba(79, 70, 229, 0.25)'; }; renewButton.onclick = () => { if (closeModal) { closeModal(); setTimeout(() => { promptActivationCode(); }, 300); } }; actionsContainer.appendChild(renewButton); const announcementContainer = document.createElement('div'); announcementContainer.style.cssText = ` margin-top: 25px; margin-bottom: 10px; padding: 15px 20px; text-align: left; background-color: #fffbeb; border: 1px solid #fde68a; border-radius: 12px; font-size: 14px; line-height: 1.7; color: #78350f; `; const announcementTitle = document.createElement('h4'); announcementTitle.innerHTML = '📢 额度规则调整'; announcementTitle.style.cssText = 'margin-top: 0; margin-bottom: 12px; color: #b45309; font-weight: bold; font-size: 16px;'; const announcementBody = document.createElement('div'); announcementBody.style.cssText = 'margin: 0;'; announcementBody.innerHTML = `
1. 总查询额度调整
2. 每日查询限额

为保障系统稳定,所有用户每日上限统一为 1,000 题,无任何例外。该额度足以满足绝大多数使用场景。

3. 续费与额度规则
4. 老用户福利

作为感谢,所有在 2025年6月30日前 激活的用户,总额度已统一免费提升至 300,000 题!

`; announcementContainer.appendChild(announcementTitle); announcementContainer.appendChild(announcementBody); contentArea.appendChild(dailyUsageBar); contentArea.appendChild(totalUsageBar); contentArea.appendChild(expiryContainer); contentArea.appendChild(actionsContainer); contentArea.appendChild(announcementContainer); } async function checkUsage() { if (!(await checkAccountConsistency())) { console.warn("[操作中止] 因账号不一致,已取消检查用量。"); return; } const { contentArea, closeModal } = showUsagePanel(); try { const data = await authedFetch('checkUsage', {}); if (data.success) { populateUsagePanel(contentArea, data, closeModal); } else { throw new Error(data.error || '获取用量失败'); } } catch (error) { console.error('检查用量失败:', error); if (error.message.includes('激活')) { closeModal(); setTimeout(promptActivationCode, 300); } else { contentArea.innerHTML = `
获取用量失败:${error.message}
`; } } } let taskNoticesCache = { groupId: null, data: null, timestamp: null, CACHE_DURATION: 5 * 60 * 1000 }; const CONTRIBUTED_ASSIGNMENTS_KEY = 'xiaoya_contributed_assignments'; const CONTRIBUTION_RESCAN_THRESHOLD = 7 * 24 * 60 * 60 * 1000; function getContributedAssignmentsData() { try { const storedData = localStorage.getItem(CONTRIBUTED_ASSIGNMENTS_KEY); if (!storedData) return {}; const parsedData = JSON.parse(storedData); if (Object.values(parsedData).some(val => typeof val === 'number')) { console.log('[贡献数据迁移] 检测到旧的课程级冷却数据,将清空以使用新的作业级冷却机制。'); localStorage.removeItem(CONTRIBUTED_ASSIGNMENTS_KEY); return {}; } return (typeof parsedData === 'object' && parsedData !== null) ? parsedData : {}; } catch (error) { console.error('读取已贡献作业数据失败,将重置:', error); localStorage.removeItem(CONTRIBUTED_ASSIGNMENTS_KEY); return {}; } } function markAssignmentAsContributed(groupId, nodeId) { if (!groupId || !nodeId) return; const contributedData = getContributedAssignmentsData(); const groupIdStr = groupId.toString(); const nodeIdStr = nodeId.toString(); if (!contributedData[groupIdStr]) { contributedData[groupIdStr] = {}; } contributedData[groupIdStr][nodeIdStr] = Date.now(); localStorage.setItem(CONTRIBUTED_ASSIGNMENTS_KEY, JSON.stringify(contributedData)); console.log(`[本地记录] 作业 (课程 ${groupId}, 节点 ${nodeId}) 的贡献时间戳已更新。`); } async function checkAccountConsistency() { const boundUserId = localStorage.getItem('xiaoya_bound_user_id'); if (!boundUserId) { return true; } const token = getToken(); if (!token) { showNotification('无法获取小雅 Token,请刷新页面或重新登录。', { type: 'error' }); return false; } const currentUserInfo = await getCurrentUserInfo(token); if (!currentUserInfo || !currentUserInfo.id) { showNotification('无法获取当前小雅用户信息,请刷新或重新登录。', { type: 'error' }); return false; } if (currentUserInfo.id.toString() !== boundUserId) { showNotification( '检测到账号不一致!当前操作需要使用激活时绑定的账号。', { type: 'error', duration: 8000 } ); const confirmed = await showConfirmNotification( '脚本检测到当前登录的小雅账号与激活脚本时使用的账号不一致。是否要清除当前激活信息,以便使用新账号重新激活?(你的激活码依旧有效)', { animation: 'scale', title: '账号不一致警告', confirmText: '清除并重新激活', cancelText: '取消操作' } ); if (confirmed) { localStorage.removeItem('xiaoya_access_token'); localStorage.removeItem('xiaoya_refresh_token'); localStorage.removeItem('xiaoya_bound_user_id'); setTimeout(() => promptActivationCode(), 300); } return false; } return true; } async function getTaskNotices(groupId) { const now = Date.now(); if ( taskNoticesCache.groupId === groupId && taskNoticesCache.data && (now - taskNoticesCache.timestamp) < taskNoticesCache.CACHE_DURATION ) { return taskNoticesCache.data; } try { const response = await fetch( `${window.location.origin}/api/jx-stat/group/task/queryTaskNotices?group_id=${groupId}&role=1`, { headers: { 'authorization': `Bearer ${getToken()}`, 'content-type': 'application/json; charset=utf-8' } } ); const data = await response.json(); if (!data.success) { throw new Error('获取作业信息失败'); } taskNoticesCache = { groupId, data: data.data, timestamp: now, CACHE_DURATION: taskNoticesCache.CACHE_DURATION }; return data.data; } catch (error) { console.error('获取任务信息失败:', error); return null; } } async function checkAssignmentStatus(groupId, nodeId) { try { const data = await getTaskNotices(groupId); if (!data) return null; const tasks = data.student_tasks || []; const task = tasks.find(t => t.node_id === nodeId); if (task) { const endTime = new Date(task.end_time); const now = new Date(); const isExpired = now > endTime; const isCompleted = task.finish === 2; return { isExpired, isCompleted, canSubmitAfterExpired: task.is_allow_after_submitted, endTime, status: isCompleted ? '已完成' : (isExpired ? '已截止' : '进行中') }; } throw new Error('未找到作业信息'); } catch (error) { console.error('检查作业状态失败:', error); return null; } } async function isTaskPage() { const groupId = getGroupIDFromUrl(window.location.href); const nodeId = getNodeIDFromUrl(window.location.href); if (!groupId || !nodeId) { return false; } const taskData = await getTaskNotices(groupId); if (!taskData || !taskData.student_tasks) { return false; } const currentTask = taskData.student_tasks.find(task => task.node_id === nodeId); if (!currentTask) { return false; } const validTaskTypes = [2, 3, 4, 5]; return validTaskTypes.includes(currentTask.task_type); } async function getAnswerRecordId(nodeId, groupId, token) { try { const status = await checkAssignmentStatus(groupId, nodeId); if (status) { if (status.isCompleted) { showNotification(`该作业已完成,将不会获取答题记录,仅可查看答案。`, { type: 'warning', keywords: ['已完成'], animation: 'scale' }); return null; } if (status.isExpired) { if (!status.canSubmitAfterExpired) { showNotification(`作业已于 ${status.endTime.toLocaleString()} 截止,且不允许补交,仅可查看答案。`, { type: 'warning', keywords: ['截止', '不允许补交'], animation: 'fadeSlide' }); return null; } showNotification(`作业已于 ${status.endTime.toLocaleString()} 截止,但允许补交。`, { type: 'info', keywords: ['截止', '允许补交'], animation: 'slideRight' }); } } } catch (error) { console.warn("检查作业状态时发生错误,将继续尝试获取记录ID:", error); } const url = `${window.location.origin}/api/jx-iresource/survey/course/task/flow/v2?node_id=${nodeId}&group_id=${groupId}`; console.log('[答题记录] 正在请求任务流程信息:', url); try { const response = await fetch(url, { method: 'GET', headers: { 'authorization': `Bearer ${token}`, 'content-type': 'application/json; charset=utf-8' }, credentials: 'include' }); if (!response.ok) { let errorMsg = `获取答题记录ID失败,服务器状态: ${response.status}`; try { const errorData = await response.json(); errorMsg = errorData.message || errorMsg; } catch (e) { } throw new Error(errorMsg); } const data = await response.json(); if (data.success && data.data) { let recordId = null; if (data.data.task_flow_record && Array.isArray(data.data.task_flow_record) && data.data.task_flow_record.length > 0) { const record = data.data.task_flow_record[0]; if (record && record.answer_record_id) { recordId = record.answer_record_id; console.log(`[答题记录] 从 task_flow_record 成功获取 answer_record_id: ${recordId}`); return recordId; } } if (!recordId && data.data.task_flow_template && Array.isArray(data.data.task_flow_template) && data.data.task_flow_template.length > 0) { const template = data.data.task_flow_template[0]; if (template && template.answer_record_id) { recordId = template.answer_record_id; console.log(`[答题记录] 从 task_flow_template (兼容模式) 成功获取 answer_record_id: ${recordId}`); return recordId; } } } throw new Error('未找到有效的答题记录。请先进入该作业的答题页面以生成它,然后再返回此页面重试。'); } catch (error) { console.error('获取 answer_record_id 时发生错误:', error); throw error; } } async function getAndStoreAnswers() { if (!(await isTaskPage())) { showNotification('当前不是有效的作业/测验页面,或者脚本无法识别。', { type: 'warning' }); return false; } const token = getToken(); if (!token) { showNotification('无法获取token,请确保已登录。', { type: 'error' }); return false; } if (!(await checkAccountConsistency())) { console.warn("[操作中止] 因账号不一致,已取消获取答案。"); return false; } const currentUrl = window.location.href; const node_id = getNodeIDFromUrl(currentUrl); const group_id = getGroupIDFromUrl(currentUrl); if (!node_id || !group_id) { showNotification('无法获取必要参数,请确保在正确的页面。', { type: 'error' }); return false; } const progress = createProgressBar(); progress.show(); let overallSuccess = false; let hitCount = 0; let missCount = 0; let totalQueryableQuestions = 0; try { progress.update(0, 100, '正在获取试卷结构', '%'); const resourceResponse = await fetch(`${window.location.origin}/api/jx-iresource/resource/queryResource/v3?node_id=${node_id}`, { headers: { 'authorization': `Bearer ${token}` }, credentials: 'include' }); const resourceData = await resourceResponse.json(); if (!resourceData.success || !resourceData.data || !resourceData.data.resource) { throw new Error('获取试卷资源失败: ' + (resourceData.message || '返回数据结构不正确')); } progress.update(5, 100, '试卷结构获取成功', '%'); const paperId = resourceData.data.resource.id; const assignmentTitle = resourceData.data.resource.title || '作业答案'; const paperDescription = resourceData.data.resource.description || null; if (paperDescription) { localStorage.setItem('paperDescription', paperDescription); console.log('[全局上下文] 已保存作业头部描述信息。'); } else { localStorage.removeItem('paperDescription'); } let questionsFromResource = JSON.parse(JSON.stringify(resourceData.data.resource.questions || [])); progress.update(7, 100, '正在获取答题记录', '%'); const recordId = await getAnswerRecordId(node_id, group_id, token); localStorage.setItem('recordId', recordId || ''); progress.update(10, 100, '答题记录获取成功', '%'); localStorage.setItem('groupId', group_id); localStorage.setItem('paperId', paperId); localStorage.setItem('assignmentTitle', assignmentTitle); function mergeAnswerIntoQuestion(question, detailedQuestionInfo) { if (detailedQuestionInfo.title && detailedQuestionInfo.title !== '{}' && detailedQuestionInfo.title !== question.title) { question.title = detailedQuestionInfo.title; } if (!Array.isArray(question.answer_items) || !Array.isArray(detailedQuestionInfo.answer_items)) { console.warn(`问题 ${question.id}: 原始题目或数据库答案的 answer_items 格式不正确,无法合并。`); return; } switch (question.type) { case 1: case 2: { const valueToAnswerInfoMap = new Map(); detailedQuestionInfo.answer_items.forEach(apiItem => { const identifier = getCanonicalContent(apiItem.value); if (identifier) { valueToAnswerInfoMap.set(identifier, { answer_checked: apiItem.answer_checked }); } }); question.answer_items.forEach(qItem => { const identifier = getCanonicalContent(qItem.value); const answerInfo = valueToAnswerInfoMap.get(identifier); if (answerInfo) { qItem.answer_checked = answerInfo.answer_checked; } else { qItem.answer_checked = 1; console.warn(`问题 ${question.id} (选择题): 无法根据内容匹配选项。`, qItem.value); } }); break; } case 5: { const dbCorrectAnswer = detailedQuestionInfo.answer_items.find(item => item.answer_checked === 2); if (!dbCorrectAnswer) { console.warn(`问题 ${question.id} (判断题): 题库中未找到正确答案。`); break; } const isDbAnswerTrue = dbCorrectAnswer.value === 'true'; question.answer_items.forEach(qItem => { const pageOptionText = getCanonicalContent(qItem.value) || parseRichTextToPlainText(qItem.value); const isPageOptionTrue = pageOptionText.includes('正确') || pageOptionText.toLowerCase().includes('true'); if (isDbAnswerTrue === isPageOptionTrue) { qItem.answer_checked = 2; } else { qItem.answer_checked = 1; } }); break; } case 4: { if (question.answer_items.length !== detailedQuestionInfo.answer_items.length) { console.warn(`问题 ${question.id} (填空题): 原始题目与答案的空的数量不匹配,可能导致答案错位。原始: ${question.answer_items.length}, 答案: ${detailedQuestionInfo.answer_items.length}`); } const minLength = Math.min(question.answer_items.length, detailedQuestionInfo.answer_items.length); for (let i = 0; i < minLength; i++) { if (detailedQuestionInfo.answer_items[i] && detailedQuestionInfo.answer_items[i].answer !== undefined) { question.answer_items[i].answer = detailedQuestionInfo.answer_items[i].answer; } } break; } case 12: case 13: { if (question.type === 12) { const valueToAnswerInfoMap = new Map(); detailedQuestionInfo.answer_items.forEach(apiItem => { const identifier = getCanonicalContent(apiItem.value); if (identifier) { valueToAnswerInfoMap.set(identifier, { answer: apiItem.answer }); } }); question.answer_items.forEach(qItem => { const identifier = getCanonicalContent(qItem.value); const answerInfo = valueToAnswerInfoMap.get(identifier); if (answerInfo && answerInfo.answer !== null && answerInfo.answer !== undefined) { qItem.answer = answerInfo.answer; } }); } else { const currentOptionContentToIdMap = new Map(); question.answer_items.forEach(item => { if (item.is_target_opt) { const identifier = getCanonicalContent(item.value); if (identifier) { currentOptionContentToIdMap.set(identifier, item.id); } } }); const dbStemValueToAnswerContentMap = new Map(); detailedQuestionInfo.answer_items.forEach(apiItem => { if (!apiItem.is_target_opt) { const keyIdentifier = getCanonicalContent(apiItem.value); const valueIdentifier = getCanonicalContent(apiItem.answer); if (keyIdentifier) { dbStemValueToAnswerContentMap.set(keyIdentifier, valueIdentifier); } } }); question.answer_items.forEach(qItem => { if (!qItem.is_target_opt) { const stemIdentifier = getCanonicalContent(qItem.value); const correctOptionIdentifier = dbStemValueToAnswerContentMap.get(stemIdentifier); if (correctOptionIdentifier) { const currentCorrectOptionId = currentOptionContentToIdMap.get(correctOptionIdentifier); if (currentCorrectOptionId) { qItem.answer = currentCorrectOptionId; } else { console.warn(`问题 ${question.id} (匹配题): 找到了答案内容 "${correctOptionIdentifier}",但在当前页面选项中找不到匹配项。`); } } } }); } break; } case 6: case 10: { if (detailedQuestionInfo.answer_items?.[0]?.answer !== null && detailedQuestionInfo.answer_items?.[0]?.answer !== undefined) { let rawAnswer = detailedQuestionInfo.answer_items[0].answer; let finalAnswerObject = deepParseJsonString(rawAnswer); let finalAnswerString = (typeof finalAnswerObject === 'object') ? JSON.stringify(finalAnswerObject) : String(finalAnswerObject); if (!question.answer_items || question.answer_items.length === 0) { question.answer_items = [{ answer: finalAnswerString }]; } else { question.answer_items[0].answer = finalAnswerString; } } if (question.type === 10 && detailedQuestionInfo.program_setting) { question.program_setting = detailedQuestionInfo.program_setting; } break; } default: console.log(`问题 ${question.id}: 类型 ${question.type} 暂无特殊答案处理逻辑。`); break; } } const questionsToQuery = []; const SUPPORTED_QUERY_TYPES = [1, 2, 4, 5, 6, 10, 12, 13]; const processQuestionForQuery = (q) => { if (!q) return; if (q.type === 9) { if (q.subQuestions) { q.subQuestions.forEach(processQuestionForQuery); } } else if (SUPPORTED_QUERY_TYPES.includes(q.type)) { const hash = generateContentHash(q); if (hash) { questionsToQuery.push({ question_id: q.id, content_hash: hash, paper_id: paperId, group_id: group_id }); } else { console.warn(`[答案获取] 无法为题目 ${q.id} 生成哈希,跳过查询。`); } } else { console.warn(`[答案获取] 跳过不支持的题型 ${q.id} (类型: ${q.type})`); } }; questionsFromResource.forEach(processQuestionForQuery); totalQueryableQuestions = questionsToQuery.length; if (totalQueryableQuestions === 0) { throw new Error('试卷中没有支持查询的题目'); } const chunkSize = 30; let allAggregatedAnswers = []; progress.update(10, 100, `分批请求答案 (共 ${totalQueryableQuestions} 题)...`, '%'); for (let i = 0; i < questionsToQuery.length; i += chunkSize) { const chunk = questionsToQuery.slice(i, i + chunkSize); const currentProgress = 10 + (i / questionsToQuery.length) * 80; progress.update(currentProgress, 100, `请求第 ${Math.floor(i / chunkSize) + 1} 批答案...`, '%'); const batchResult = await authedFetch('queryAllAnswers', { questionsToQuery: chunk }); if (!batchResult.success || !Array.isArray(batchResult.allAnswers)) { throw new Error(`获取批次答案失败: ${batchResult.error || '后端返回数据格式不正确'}`); } allAggregatedAnswers.push(...batchResult.allAnswers); } progress.update(90, 100, `所有批次请求成功,处理数据...`, '%'); const allAnswersMap = new Map(); hitCount = 0; allAggregatedAnswers.forEach(item => { if (!item || !item.result) { console.warn(`获取问题 ${item?.question_id} 答案失败: 无效的返回项`); return; } const questionData = item.result; if (questionData && questionData.type) { hitCount++; allAnswersMap.set(item.question_id, questionData); } else { console.warn(`获取问题 ${item?.question_id} 答案失败:`, questionData.error || '无法识别的数据格式或未找到答案'); } }); missCount = totalQueryableQuestions - hitCount; progress.update(95, 100, '正在合并答案...', '%'); questionsFromResource.forEach(question => { const detailedQuestionInfo = allAnswersMap.get(question.id); if (detailedQuestionInfo) { mergeAnswerIntoQuestion(question, detailedQuestionInfo); } if (question.type === 9 && question.subQuestions) { question.subQuestions.forEach(subQuestion => { const detailedSubQuestionInfo = allAnswersMap.get(subQuestion.id); if (detailedSubQuestionInfo) { mergeAnswerIntoQuestion(subQuestion, detailedSubQuestionInfo); } }); } }); localStorage.setItem('answerData', JSON.stringify(questionsFromResource)); progress.update(100, 100, '所有答案信息获取完成', '!'); overallSuccess = true; } catch (error) { console.error('获取或处理答案失败:', error); const errorMessage = error.message.toLowerCase(); if (errorMessage.includes('欺诈行为警告')) { showNotification('检测到异常操作,你的授权已被吊销,请重新激活。', { type: 'error', duration: 8000, animation: 'scale' }); localStorage.removeItem('xiaoya_access_token'); localStorage.removeItem('xiaoya_refresh_token'); setTimeout(promptActivationCode, 1000); } else if (errorMessage.includes('激活')) { showNotification('你的凭证已失效或需要激活,请操作...', { type: 'warning', duration: 5000, animation: 'scale' }); setTimeout(promptActivationCode, 500); } else { showNotification(`获取答案数据失败:${error.message}`, { type: 'error' }); } overallSuccess = false; } finally { progress.hide(); if (overallSuccess) { let message; let type; let keywords = [String(hitCount), String(missCount), String(totalQueryableQuestions)]; if (hitCount === totalQueryableQuestions && totalQueryableQuestions > 0) { message = `答案获取成功!题库精准命中全部 ${totalQueryableQuestions} 道题!`; type = 'success'; } else if (hitCount > 0) { message = `答案获取成功!共命中 ${hitCount} 道,未命中 ${missCount} 道。`; type = 'success'; keywords.push('命中', '未命中'); } else { message = `答案获取完成,但题库暂无收录 (共查询 ${totalQueryableQuestions} 道题)。`; type = 'warning'; keywords.push('暂无收录'); } showNotification(message, { type: type, keywords: keywords, animation: 'slideRight', duration: 8000 }); } } return overallSuccess; } const SUPPORTED_CONTRIBUTION_TYPES = [1, 2, 4, 5, 6, 10, 12, 13]; function hasValidAnswer_frontEnd(questionData) { if (!questionData || !SUPPORTED_CONTRIBUTION_TYPES.includes(questionData.type)) { return false; } if (!Array.isArray(questionData.answer_items)) { return false; } switch (questionData.type) { case 1: case 2: case 5: return questionData.answer_items.some(item => item.answer_checked === 2); case 4: return questionData.answer_items.some(item => { const answer = item.answer; if (answer === null || answer === undefined || answer === '' || answer === '{}') return false; try { const parsed = JSON.parse(answer); if (parsed.blocks && parsed.blocks.length === 1 && parsed.blocks[0].text === '') { return false; } } catch (e) { } return true; }); case 12: return questionData.answer_items.length > 0 && questionData.answer_items.every( item => item.answer !== null && item.answer !== undefined && item.answer !== '' ); case 13: return questionData.answer_items.some( item => !item.is_target_opt && item.answer !== null && item.answer !== undefined && item.answer !== '' ); case 6: case 10: { if (questionData.answer_items.length === 0) return false; const answer = questionData.answer_items[0]?.answer; if (answer === null || answer === undefined || answer === '') return false; try { const parsed = JSON.parse(answer); if (parsed.blocks && Array.isArray(parsed.blocks)) { if (parsed.blocks.length === 0) return false; if (parsed.blocks.length === 1 && parsed.blocks[0].text === '') { return parsed.blocks[0].type === 'atomic'; } } } catch (e) { } return true; } default: return false; } } async function contributeSingleAssignment(groupId, nodeId) { const token = getToken(); if (!token) return { success: false, error: '无法获取token' }; try { const resourceResponse = await fetch(`${window.location.origin}/api/jx-iresource/resource/queryResource/v3?node_id=${nodeId}`, { headers: { 'authorization': `Bearer ${token}` } }); const resourceData = await resourceResponse.json(); if (!resourceData.success) return { success: false, error: '获取试卷资源失败' }; const paperId = resourceData.data?.resource?.id; if (!paperId) return { success: false, error: '无法从资源中获取 paperId' }; const answerSheetResponse = await fetch(`${window.location.origin}/api/jx-iresource/survey/course/queryStuPaper/v2?paper_id=${paperId}&group_id=${groupId}&node_id=${nodeId}`, { headers: { 'authorization': `Bearer ${token}` } }); const answerSheetData = await answerSheetResponse.json(); if (!answerSheetData.success || !answerSheetData.data || !answerSheetData.data.questions || answerSheetData.data.questions.length === 0) { return { success: false, error: '获取答案数据失败: ' + (answerSheetData.message || '无题目信息') }; } const flattenQuestions = (questionList) => { let flatList = []; if (!Array.isArray(questionList)) { console.warn("[flattenQuestions] 输入不是一个数组:", questionList); return flatList; } questionList.forEach(q => { if (!q) return; flatList.push(q); if (q.type === 9 && Array.isArray(q.subQuestions)) { flatList.push(...flattenQuestions(q.subQuestions)); } }); return flatList; }; const clonedQuestions = JSON.parse(JSON.stringify(answerSheetData.data.questions)); const allClonedQuestionsMap = new Map(flattenQuestions(clonedQuestions).map(q => [q.id, q])); const originalQuestionsData = answerSheetData.data.questions; const allOriginalQuestionsMap = new Map(flattenQuestions(originalQuestionsData).map(q => [q.id, q])); const studentCorrectAnswers = new Map(); if (answerSheetData.data.answer_record && answerSheetData.data.answer_record.answers) { answerSheetData.data.answer_record.answers.forEach(ans => { if (ans.correct === 2 || ans.score > 0) { studentCorrectAnswers.set(ans.question_id, ans.answer); } }); } allClonedQuestionsMap.forEach(question => { const studentAnswer = studentCorrectAnswers.get(question.id); if (studentAnswer !== undefined) { console.log(`[贡献] 题目 ${question.id} (类型 ${question.type}) 使用【学生正确作答记录】填充。`); switch (question.type) { case 1: case 5: question.answer_items.forEach(item => { item.answer_checked = (item.id === String(studentAnswer)) ? 2 : 1; }); break; case 2: { let selectedItemIds = []; if (Array.isArray(studentAnswer)) selectedItemIds = studentAnswer.map(String); else if (typeof studentAnswer === 'string') selectedItemIds = studentAnswer.split(',').map(id => id.trim()).filter(id => id); question.answer_items.forEach(item => { item.answer_checked = selectedItemIds.includes(item.id) ? 2 : 1; }); break; } case 4: try { const fillAnswersObject = JSON.parse(studentAnswer); question.answer_items.forEach(item => { if (fillAnswersObject.hasOwnProperty(item.id)) { item.answer = JSON.stringify({ blocks: [{ text: fillAnswersObject[item.id] || '' }] }); } }); } catch (e) { console.warn(`[贡献] 解析学生填空题答案失败 (ID: ${question.id})`, e); } break; case 6: if (question.answer_items && question.answer_items.length > 0) question.answer_items[0].answer = JSON.stringify({ blocks: [{ text: studentAnswer }] }); break; case 10: try { const parsedAnswer = JSON.parse(studentAnswer); if (!question.program_setting) question.program_setting = {}; question.program_setting.code_answer = parsedAnswer.code || studentAnswer; } catch (e) { if (!question.program_setting) question.program_setting = {}; question.program_setting.code_answer = studentAnswer; } break; case 12: { let sortedItemIds = []; if (Array.isArray(studentAnswer)) sortedItemIds = studentAnswer.map(String); else if (typeof studentAnswer === 'string') sortedItemIds = studentAnswer.split(',').map(id => id.trim()).filter(id => id); question.answer_items.forEach(item => { const order = sortedItemIds.indexOf(item.id); item.answer = (order !== -1) ? (order + 1).toString() : ''; }); break; } case 13: try { const matchObject = (typeof studentAnswer === 'string' ? JSON.parse(studentAnswer) : studentAnswer)[0] || (typeof studentAnswer === 'string' ? JSON.parse(studentAnswer) : studentAnswer); const optionIdToValueMap = new Map(); question.answer_items.forEach(item => { if (item.is_target_opt) { optionIdToValueMap.set(item.id, item.value); } }); question.answer_items.forEach(item => { if (!item.is_target_opt && matchObject.hasOwnProperty(item.id)) { const matchedOptionId = matchObject[item.id]; if (optionIdToValueMap.has(matchedOptionId)) { item.answer = optionIdToValueMap.get(matchedOptionId); } } }); } catch (e) { console.warn(`[贡献] 解析学生匹配题答案失败 (ID: ${question.id})`, e); } break; } } else { const originalQuestion = allOriginalQuestionsMap.get(question.id); if (originalQuestion && hasValidAnswer_frontEnd(originalQuestion)) { console.log(`[贡献] 题目 ${question.id} (类型 ${question.type}) 无学生作答记录,但使用【原始官方答案】。`); } else { console.log(`[贡献] 题目 ${question.id} (类型 ${question.type}) 无任何有效答案源,将在后续被过滤。`); } } }); const contributedQuestions = Array.from(allClonedQuestionsMap.values()).filter(q => hasValidAnswer_frontEnd(q)); console.log(`[贡献] 准备贡献 ${contributedQuestions.length} 道高质量题目。`); if (contributedQuestions.length === 0) { return { success: false, error: '未解析到任何有效答案' }; } const finalContributedData = contributedQuestions.map(q => { const hash = generateContentHash(q); if (!hash) { console.warn(`[贡献] 无法为题目 ${q.id} 生成哈希,跳过贡献。`); return null; } return { question_id: q.id, paper_id: q.paper_id, content_hash: hash, answer_data: q }; }).filter(Boolean); if (finalContributedData.length === 0) { return { success: false, error: '所有可贡献题目都无法生成有效哈希' }; } const response = await authedFetch('contributeAnswers', { contributedQuestions: finalContributedData }); if (response.success) { markAssignmentAsContributed(groupId, nodeId); return { success: true, message: response.message }; } else { return { success: false, error: response.error || '上传贡献失败' }; } } catch (error) { console.error(`贡献作业 (nodeId: ${nodeId}) 时出错:`, error); return { success: false, error: error.message }; } } async function asyncPool(poolLimit, array, iteratorFn) { const ret = []; const executing = []; for (const item of array) { const p = Promise.resolve().then(() => iteratorFn(item, array)); ret.push(p); if (poolLimit <= array.length) { const e = p.then(() => executing.splice(executing.indexOf(e), 1)); executing.push(e); if (executing.length >= poolLimit) { await Promise.race(executing); } } } return Promise.all(ret); } async function scanAndContributeCourse(course) { const groupId = course.id; const contributedData = getContributedAssignmentsData(); const now = Date.now(); try { const tasksData = await getTaskNotices(groupId); if (!tasksData || !tasksData.student_tasks) { console.error(`[后台扫描] 获取课程 "${course.name}" (ID: ${groupId}) 的任务列表失败。`); return { success: 0, failed: 0 }; } const validTaskTypes = [2, 3, 4, 5]; const allAssignments = tasksData.student_tasks.filter(task => validTaskTypes.includes(task.task_type) ); const assignmentsToScan = allAssignments.filter(task => { const lastScanTimestamp = contributedData[groupId.toString()]?.[task.node_id.toString()]; if (!lastScanTimestamp) return true; if (now - lastScanTimestamp > CONTRIBUTION_RESCAN_THRESHOLD) return true; return false; }); if (assignmentsToScan.length === 0) { console.log(`[后台扫描] 课程 "${course.name}" (ID: ${groupId}) 中没有需要贡献的新作业。`); return { success: 0, failed: 0 }; } console.log(`[后台扫描] 课程 "${course.name}" (ID: ${groupId}) 中发现 ${assignmentsToScan.length} 个需要处理的作业。`); let successCount = 0; let failCount = 0; const CONCURRENCY_LIMIT = 2; await asyncPool(CONCURRENCY_LIMIT, assignmentsToScan, async (task) => { const result = await contributeSingleAssignment(groupId, task.node_id); if (result.success) { successCount++; } else { if (result.error === '未解析到任何有效答案') { console.log(`[后台扫描] 作业 (ID: ${task.node_id}) 无有效答案可贡献,标记为已检查。`); markAssignmentAsContributed(groupId, task.node_id); } else { failCount++; console.warn(`[后台扫描] 贡献作业 (ID: ${task.node_id}) 失败: ${result.error}`); } } await new Promise(resolve => setTimeout(resolve, 800)); }); return { success: successCount, failed: failCount }; } catch (error) { console.error(`[后台扫描] 处理课程 "${course.name}" (ID: ${groupId}) 时发生严重错误:`, error); return { success: 0, failed: 1 }; } } async function backgroundContributeAllCourses() { if (!autoContributeEnabled) { return false; } if (!(await checkAccountConsistency())) { console.log("[后台扫描] 因账号不一致,已中止全量扫描。"); return false; } const token = getToken(); if (!token) { return false; } ContributionProgressUI.show('正在准备后台贡献任务...'); console.log('[后台扫描] 开始执行全量课程扫描...'); try { const MAX_RETRIES = 3; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { console.log(`[后台扫描] 正在进行用量预检 (尝试 ${attempt}/${MAX_RETRIES})...`); await authedFetch('checkUsage', {}); console.log(`[后台扫描] 用量预检成功。`); break; } catch (error) { console.warn(`[后台扫描] 用量预检尝试 ${attempt} 失败:`, error.message); if (attempt < MAX_RETRIES) { const delay = 1500 * attempt; console.log(`[后台扫描] 将在 ${delay / 1000} 秒后重试...`); await new Promise(resolve => setTimeout(resolve, delay)); } else { console.error(`[后台扫描] 用量预检在 ${MAX_RETRIES} 次尝试后彻底失败,后台贡献任务中止。`); throw error; } } } const fetchCourses = async (timeFlag) => { const url = `${window.location.origin}/api/jx-iresource/group/student/groups?time_flag=${timeFlag}`; const response = await fetch(url, { headers: { 'authorization': `Bearer ${token}` } }); if (!response.ok) throw new Error(`获取课程列表失败 (flag=${timeFlag})`); const data = await response.json(); return data.success ? data.data : []; }; const [currentCourses, pastCourses] = await Promise.all([fetchCourses(1), fetchCourses(3)]); const allCoursesMap = new Map(); [...currentCourses, ...pastCourses].forEach(course => course && course.id && allCoursesMap.set(course.id, course)); const allCourses = Array.from(allCoursesMap.values()); if (allCourses.length === 0) { console.log('[后台扫描] 未获取到任何课程列表,任务结束。'); ContributionProgressUI.complete('未找到任何课程。'); return true; } ContributionProgressUI.show(`后台将检查 ${allCourses.length} 门课程中的新作业...`); showNotification(`后台将为你检查所有课程,寻找可贡献的新答案...`, { type: 'info', duration: 7000 }); let totalNewContributions = 0; for (let i = 0; i < allCourses.length; i++) { const course = allCourses[i]; console.log(`[后台扫描] [${i + 1}/${allCourses.length}] 正在检查课程: ${course.name}`); ContributionProgressUI.update(i + 1, allCourses.length, course.name); const result = await scanAndContributeCourse(course); totalNewContributions += result.success; } console.log(`[后台扫描] 全部完成!本次共贡献了 ${totalNewContributions} 个新作业。`); ContributionProgressUI.complete(`扫描完成!感谢你的 ${totalNewContributions} 个新贡献!`); if (totalNewContributions > 0) { showNotification(`后台扫描完成,感谢你为答案库贡献了 ${totalNewContributions} 个新作业!`, { type: 'success', duration: 10000 }); } return true; } catch (error) { const errorMessage = error.message.toLowerCase(); if (errorMessage.includes('激活') || errorMessage.includes('失效') || errorMessage.includes('到期') || errorMessage.includes('欺诈')) { console.error(`[后台扫描] 因授权问题中止: "${error.message}"`); } else { console.error('[后台扫描] 操作失败:', error); } ContributionProgressUI.error(error.message); return false; } } async function getSubmittedAnswers() { if (!(await isTaskPage())) { showNotification('当前不是有效的作业/测验页面,或者脚本无法识别。', { type: 'warning', keywords: ['作业', '测验'], animation: 'scale' }); return; } try { const token = getToken(); if (!token) { showNotification('无法获取token,请确保已登录。', { type: 'error', keywords: ['token', '登录'], animation: 'fadeSlide' }); return; } const currentUrl = window.location.href; const node_id = getNodeIDFromUrl(currentUrl); const group_id = getGroupIDFromUrl(currentUrl); if (!node_id || !group_id) { showNotification('无法获取必要参数,请确保在正确的页面。', { type: 'error', keywords: ['参数'], animation: 'slideRight' }); return; } const progress = createProgressBar(); progress.show(); progress.update(0, 1, '正在获取已提交作业'); const resourceData = await fetch( `${window.location.origin}/api/jx-iresource/resource/queryResource/v3?node_id=${node_id}`, { headers: { 'authorization': `Bearer ${token}`, 'content-type': 'application/json; charset=utf-8' }, credentials: 'include' } ).then(res => res.json()); if (!resourceData.success) { throw new Error('获取试卷资源失败'); } const paperDescription = resourceData.data?.resource?.description || null; if (paperDescription) { localStorage.setItem('paperDescription', paperDescription); console.log('[全局上下文 - 已提交] 已同步更新作业头部描述信息。'); } else { localStorage.removeItem('paperDescription'); console.log('[全局上下文 - 已提交] 当前作业无头部描述,已清除旧的缓存。'); } const paper_id = resourceData.data.resource.id; const submittedAnswerResponse = await fetch( `${window.location.origin}/api/jx-iresource/survey/course/queryStuPaper/v2?paper_id=${paper_id}&group_id=${group_id}&node_id=${node_id}`, { headers: { 'authorization': `Bearer ${token}`, 'content-type': 'application/json; charset=utf-8' }, credentials: 'include' } ); const submittedAnswerData = await submittedAnswerResponse.json(); progress.update(1, 1, '已获取提交答案'); if (!submittedAnswerData.success) { throw new Error('获取已提交作业失败'); } if (submittedAnswerData.data && submittedAnswerData.data.answer_record && submittedAnswerData.data.answer_record.answers && submittedAnswerData.data.answer_record.answers.length > 0) { localStorage.setItem('submittedAnswerData', JSON.stringify(submittedAnswerData.data.answer_record.answers)); const questionsData = resourceData.data.resource.questions; const allQuestionsMap = new Map(); questionsData.forEach(q => { allQuestionsMap.set(q.id, q); if (q.type === 9 && q.subQuestions) { q.subQuestions.forEach(sq => { allQuestionsMap.set(sq.id, sq); sq.parent_question_id = q.id; }); } }); submittedAnswerData.data.answer_record.answers.forEach(submittedAnswer => { const questionId = submittedAnswer.question_id; const question = allQuestionsMap.get(questionId); if (question) { if (question.type === 1 || question.type === 5) { const selectedItemId = String(submittedAnswer.answer); if (question.answer_items) { question.answer_items.forEach(item => { item.answer_checked = (item.id === selectedItemId) ? 2 : 1; }); } } else if (question.type === 2) { let selectedItemIds = []; if (Array.isArray(submittedAnswer.answer)) { selectedItemIds = submittedAnswer.answer.map(String); } else if (typeof submittedAnswer.answer === 'string') { if (submittedAnswer.answer.includes(',')) { selectedItemIds = submittedAnswer.answer.split(',').map(id => id.trim()).filter(id => id); } else if (submittedAnswer.answer.length > 0) { selectedItemIds = [submittedAnswer.answer]; } } if (question.answer_items) { question.answer_items.forEach(item => { item.answer_checked = selectedItemIds.includes(item.id) ? 2 : 1; }); } } else if (question.type === 4) { try { const fillAnswersObject = JSON.parse(submittedAnswer.answer); if (question.answer_items && typeof fillAnswersObject === 'object' && fillAnswersObject !== null) { question.answer_items.forEach(item => { if (fillAnswersObject.hasOwnProperty(item.id)) { const plainTextStudentAnswer = fillAnswersObject[item.id]; item.answer = JSON.stringify({ blocks: [{ key: `ans-${item.id}`, text: plainTextStudentAnswer, type: 'unstyled', depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} }], entityMap: {} }); } else { item.answer = JSON.stringify({ blocks: [{ key: `empty-${item.id}`, text: "", type: 'unstyled', depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} }], entityMap: {} }); } }); } } catch (e) { console.error(`解析填空题已提交作业失败 (questionId: ${questionId}):`, e, "Raw answer:", submittedAnswer.answer); if (question.answer_items) { question.answer_items.forEach(item => { item.answer = JSON.stringify({ blocks: [{ key: `error-${item.id}`, text: "", type: 'unstyled', depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} }], entityMap: {} }); }); } } } else if (question.type === 6) { if (question.answer_items && question.answer_items.length > 0) { const plainTextStudentAnswer = submittedAnswer.answer; try { JSON.parse(plainTextStudentAnswer); question.answer_items[0].answer = plainTextStudentAnswer; } catch (err) { question.answer_items[0].answer = JSON.stringify({ blocks: [{ key: `ans-${question.id}`, text: plainTextStudentAnswer, type: 'unstyled', depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} }], entityMap: {} }); } } } else if (question.type === 10) { try { const parsedAnswer = JSON.parse(submittedAnswer.answer); if (parsedAnswer && parsedAnswer.code) { if (!question.program_setting) question.program_setting = {}; question.program_setting.code_answer = parsedAnswer.code; } else if (typeof submittedAnswer.answer === 'string' && !submittedAnswer.answer.startsWith('{')) { if (!question.program_setting) question.program_setting = {}; question.program_setting.code_answer = submittedAnswer.answer; } } catch (e) { if (question.program_setting) { question.program_setting.code_answer = submittedAnswer.answer; } else { question.program_setting = { code_answer: submittedAnswer.answer }; } console.warn(`解析编程题已获取答案可能不是标准JSON (questionId: ${questionId}):`, e, "Raw answer:", submittedAnswer.answer); } } else if (question.type === 12) { let sortedItemIds = []; if (Array.isArray(submittedAnswer.answer)) { sortedItemIds = submittedAnswer.answer.map(String); } else if (typeof submittedAnswer.answer === 'string') { if (submittedAnswer.answer.includes(',')) { sortedItemIds = submittedAnswer.answer.split(',').map(id => id.trim()).filter(id => id); } else if (submittedAnswer.answer.length > 0) { sortedItemIds = [submittedAnswer.answer]; } } if (question.answer_items && sortedItemIds.length > 0) { question.answer_items.forEach(item => { const order = sortedItemIds.indexOf(item.id); if (order !== -1) { item.answer = (order + 1).toString(); } else { item.answer = ''; console.warn(`排序题 (questionId: ${questionId}) 的选项 item.id: ${item.id} 未在提交的答案中找到:`, sortedItemIds); } }); } } else if (question.type === 13) { try { let matchObject = null; const parsedAnswerData = JSON.parse(submittedAnswer.answer); if (Array.isArray(parsedAnswerData) && parsedAnswerData.length > 0 && typeof parsedAnswerData[0] === 'object') { matchObject = parsedAnswerData[0]; } else if (typeof parsedAnswerData === 'object' && !Array.isArray(parsedAnswerData)) { matchObject = parsedAnswerData; } if (matchObject && question.answer_items) { question.answer_items.forEach(item => { if (!item.is_target_opt) { if (matchObject.hasOwnProperty(item.id)) { item.answer = matchObject[item.id]; } else { item.answer = ''; } } }); } } catch (e) { console.error(`解析匹配题已获取答案失败 (questionId: ${questionId}):`, e, "Raw answer:", submittedAnswer.answer); } } } else { console.warn(`在 submittedAnswers 中找到一个答案,但其 question_id (${questionId}) 在 questionsData 或其子问题中均未找到。`); } }); localStorage.setItem('answerData', JSON.stringify(questionsData)); progress.hide(); showNotification('已提交作业获取成功!', { type: 'success', keywords: ['已提交', '答案', '获取'], animation: 'scale' }); return true; } else { progress.hide(); showNotification('未找到已提交的答案,可能尚未提交或无权限查看。', { type: 'warning', keywords: ['未找到', '已提交'], animation: 'fadeSlide' }); return false; } } catch (error) { console.error('获取已提交作业失败:', error); showNotification('获取已提交作业失败:' + (error.message || '未知错误'), { type: 'error', keywords: ['获取', '失败'], animation: 'scale' }); return false; } } async function fillAnswers() { const answerData = JSON.parse(localStorage.getItem('answerData')); const recordId = localStorage.getItem('recordId'); const groupId = localStorage.getItem('groupId'); const paperId = localStorage.getItem('paperId'); if (!answerData || !recordId || !groupId || !paperId) { showNotification('缺少必要数据,请先获取答案或检查作业状态。', { type: 'error', keywords: ['数据', '获取', '检查'], animation: 'scale' }); return; } const token = getToken(); if (!token) { showNotification('无法获取token。', { type: 'error', keywords: ['token'], animation: 'slideRight' }); return; } const progress = createProgressBar(); progress.show(); try { let completedCount = 0; const totalQuestions = answerData.length; const batchSize = 10; for (let i = 0; i < answerData.length; i += batchSize) { const batch = answerData.slice(i, i + batchSize); let localCompletedCount = completedCount; await Promise.all(batch.map(async question => { await submitAnswer(question, recordId, groupId, paperId, token); localCompletedCount++; progress.update(localCompletedCount, totalQuestions); })); completedCount = localCompletedCount; } progress.hide(); showNotification('答案填写完成!页面将于0.5s后刷新。', { type: 'success', keywords: ['答案', '填写', '刷新'], animation: 'slideRight' }); const nodeId = getNodeIDFromUrl(window.location.href); const currentGroupId = getGroupIDFromUrl(window.location.href); if (nodeId && currentGroupId) sessionStorage.setItem(`xiaoya_autofilled_${currentGroupId}_${nodeId}`, 'true'); setTimeout(() => { location.reload(); }, 500); } catch (error) { progress.hide(); console.error('填写答案失败:', error); showNotification('填写答案失败,请查看控制台。', { type: 'error', keywords: ['填写', '失败'], animation: 'scale' }); } } async function submitAnswer(question, recordId, groupId, paperId, token) { let answer; let extAnswer = ''; switch (question.type) { case 1: { answer = [question.answer_items.find(item => item.answer_checked === 2)?.id]; break; } case 2: { answer = question.answer_items.filter(item => item.answer_checked === 2).map(item => item.id); break; } case 4: { const fillObject = {}; question.answer_items.forEach(item => { fillObject[item.id] = parseRichTextToPlainText(item.answer); }); answer = [fillObject]; break; } case 5: { answer = [question.answer_items.find(item => item.answer_checked === 2)?.id]; break; } case 6: { answer = [question.answer_items[0].answer]; break; } case 9: { if (question.subQuestions && question.subQuestions.length > 0) { for (const subQuestion of question.subQuestions) { await submitAnswer(subQuestion, recordId, groupId, paperId, token); } } return; } case 10: { const progSetting = question.program_setting || {}; const answerItem = question.answer_items?.[0]; answer = [{ language: progSetting.language?.[0] || 'c', code: progSetting.code_answer || '', answer_item_id: answerItem?.id || '' }]; break; } case 12: { answer = question.answer_items .sort((a, b) => parseInt(a.answer) - parseInt(b.answer)) .map(item => item.id); break; } case 13: { const matchObject = {}; question.answer_items .filter(item => !item.is_target_opt && item.answer) .forEach(item => { matchObject[item.id] = item.answer; }); if (Object.keys(matchObject).length > 0) { answer = [matchObject]; } else { return; } break; } default: return; } const requestBody = { record_id: recordId, question_id: question.id, answer: answer, ext_answer: extAnswer, group_id: groupId, paper_id: paperId, is_try: 0 }; return fetch(`${window.location.origin}/api/jx-iresource/survey/answer`, { method: 'POST', headers: { 'accept': '*/*', 'authorization': `Bearer ${token}`, 'content-type': 'application/json; charset=UTF-8' }, body: JSON.stringify(requestBody) }); } async function parseRichTextContentAsync(content) { if (!content || typeof content !== 'string') return content || ''; try { const jsonContent = JSON.parse(content); if (!jsonContent || !Array.isArray(jsonContent.blocks)) { return content; } let htmlResult = ''; const aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}'); for (const block of jsonContent.blocks) { if (block.type === 'atomic' && block.data) { switch (block.data.type) { case 'IMAGE': if (block.data.src) { const fileIdMatch = block.data.src.match(/\/cloud\/file_access\/(\d+)/); if (fileIdMatch && fileIdMatch[1]) { const fileId = fileIdMatch[1]; const imageUrl = `${window.location.origin}/api/jx-oresource/cloud/file_access/${fileId}?random=${Date.now()}`; htmlResult += `
内容图片
[图片加载失败]
`; } else { htmlResult += `
[图片链接格式无法解析]
`; } } break; case 'AUDIO': if (block.data.data && block.data.data.quote_id) { const fileId = block.data.data.quote_id; const cacheKey = `audio_url_${fileId}`; let audioUrl = sessionStorage.getItem(cacheKey); if (!audioUrl) { audioUrl = await getAudioUrl(fileId); if (audioUrl) sessionStorage.setItem(cacheKey, audioUrl); } if (audioUrl) { htmlResult += `
`; } else { htmlResult += `
[音频加载失败]
`; } } break; case 'VIDEO': if (block.data.data && block.data.data.video_id) { const videoId = block.data.data.video_id; const cacheKey = `video_urls_${videoId}`; let urls = JSON.parse(sessionStorage.getItem(cacheKey) || 'null'); if (!urls) { urls = await getVideoUrl(videoId); if (urls.videoUrl) sessionStorage.setItem(cacheKey, JSON.stringify(urls)); } if (urls && urls.videoUrl) { let videoHtml = `
`; if (aiConfig.sttEnabled && aiConfig.sttVideoEnabled) { videoHtml += `
`; } videoHtml += `
`; htmlResult += videoHtml; } else { htmlResult += `
[视频加载失败: ${videoId}]
`; } } break; } } else { const textContent = block.text.replace(/\n/g, '
'); htmlResult += `
${textContent || ' '}
`; } } return htmlResult; } catch (e) { return content.replace(//g, ">"); } } function getNodeIDFromUrl(url) { let nodeId = null; let urlObj = new URL(url); let pathParts = urlObj.pathname.split('/').filter(part => part); nodeId = pathParts[pathParts.length - 1]; return nodeId; } function getGroupIDFromUrl(url) { const match = url.match(/mycourse\/(\d+)/); return match ? match[1] : null; } function addKeyboardShortcuts() { document.addEventListener('keydown', function (e) { if (e.ctrlKey && e.shiftKey && !e.altKey) { switch (e.key.toLowerCase()) { case 'a': e.preventDefault(); getAndStoreAnswers(); break; case 'f': e.preventDefault(); fillAnswers(); break; case 'e': e.preventDefault(); showAnswerEditor(); break; case 'q': e.preventDefault(); exportHomework(); break; default: break; } } }); } addKeyboardShortcuts(); function showTutorial() { const style = document.createElement('style'); style.textContent = ` @keyframes modalFadeIn { from { opacity: 0; transform: scale(0.8); } to { opacity: 1; transform: scale(1); } } @keyframes floatAnimation { 0% { transform: translateY(0px); } 50% { transform: translateY(-10px); } 100% { transform: translateY(0px); } } .highlight-text { background: linear-gradient(120deg, rgba(255,223,186,0.6) 0%, rgba(255,223,186,0) 100%); padding: 0 4px; } .feature-icon { display: inline-block; width: 24px; height: 24px; margin-right: 8px; vertical-align: middle; animation: floatAnimation 3s ease-in-out infinite; } `; document.head.appendChild(style); let modalOverlay = document.createElement('div'); modalOverlay.style.position = 'fixed'; modalOverlay.style.top = '0'; modalOverlay.style.left = '0'; modalOverlay.style.width = '100%'; modalOverlay.style.height = '100%'; modalOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.65)'; modalOverlay.style.zIndex = '10000'; modalOverlay.style.display = 'flex'; modalOverlay.style.alignItems = 'center'; modalOverlay.style.justifyContent = 'center'; modalOverlay.style.opacity = '0'; modalOverlay.style.backdropFilter = 'blur(5px)'; modalOverlay.style.transition = 'opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1)'; let modalContent = document.createElement('div'); modalContent.style.backgroundColor = '#fff'; modalContent.style.borderRadius = '16px'; modalContent.style.width = '90%'; modalContent.style.maxWidth = '680px'; modalContent.style.maxHeight = '85vh'; modalContent.style.overflowY = 'auto'; modalContent.style.padding = '32px'; modalContent.style.boxShadow = '0 20px 50px rgba(0, 0, 0, 0.2)'; modalContent.style.position = 'relative'; modalContent.style.transform = 'scale(0.8)'; modalContent.style.opacity = '0'; modalContent.style.animation = 'modalFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards'; modalContent.style.background = 'linear-gradient(135deg, #fff 0%, #f8f9fa 100%)'; let closeButton = document.createElement('button'); closeButton.innerHTML = ` `; closeButton.style.cssText = ` position: absolute; top: 15px; right: 15px; background: #f3f4f6; border: none; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; color: #6b7280; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; box-shadow: 0 2px 5px rgba(0,0,0,0.08); `; closeButton.onmouseover = () => { closeButton.style.backgroundColor = '#e5e7eb'; closeButton.style.transform = 'rotate(90deg)'; closeButton.style.color = '#000'; closeButton.style.boxShadow = '0 4px 8px rgba(0,0,0,0.12)'; }; closeButton.onmouseout = () => { closeButton.style.backgroundColor = '#f3f4f6'; closeButton.style.transform = 'rotate(0deg)'; closeButton.style.color = '#6b7280'; closeButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.08)'; }; closeButton.onclick = () => { modalContent.style.transform = 'scale(0.8)'; modalContent.style.opacity = '0'; modalOverlay.style.opacity = '0'; setTimeout(() => document.body.removeChild(modalOverlay), 400); }; let tutorialContent = document.createElement('div'); tutorialContent.innerHTML = `

✨ 使用指南

欢迎使用 小雅答答答 答题助手! 探索以下功能,让你的学习事半功倍~

🎯 核心功能

  1. 获取答案 - 快速从题库获取参考答案。
  2. 填写答案 - 一键自动填充答案到页面。
  3. 编辑答案 - 灵活修改,支持图片和音频展示。
  4. 导出作业 - 将作业保存为 Word 文档,方便复习。

🤖 AI 助手 (大语言模型)

🎤 特色功能:AI 语音转文本 (STT)

📝 脚本支持题型

🤖 AI 支持题型

⌨️ 快捷键

💡 使用提示

🤝 需要帮助?

遇到问题或有任何建议,欢迎发送邮件至 zygame1314@gmail.com 或访问 我的个人主页

别太依赖脚本哦,多动脑才是真本事!😉

版权 © zygame1314 保留所有权利。

`; tutorialContent.style.fontSize = '16px'; tutorialContent.style.lineHeight = '1.6'; modalContent.style.scrollbarWidth = 'thin'; modalContent.style.scrollbarColor = '#4e4376 #f1f1f1'; const scrollbarStyles = ` .tutorial-modal::-webkit-scrollbar { width: 8px; } .tutorial-modal::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; } .tutorial-modal::-webkit-scrollbar-thumb { background: #4e4376; border-radius: 4px; } `; style.textContent += scrollbarStyles; modalContent.classList.add('tutorial-modal'); modalContent.appendChild(closeButton); modalContent.appendChild(tutorialContent); modalOverlay.appendChild(modalContent); document.body.appendChild(modalOverlay); setTimeout(() => { modalOverlay.style.opacity = '1'; }, 10); } function aliyunEncodeURI(str) { var result = encodeURIComponent(str); result = result.replace(/\+/g, "%20"); result = result.replace(/\*/g, "%2A"); result = result.replace(/%7E/g, "~"); return result; } function makeUTF8sort(params) { var sortedKeys = Object.keys(params).sort(); var sortedParams = []; for (var i = 0; i < sortedKeys.length; i++) { var key = sortedKeys[i]; if (key && params[key]) { sortedParams.push(aliyunEncodeURI(key) + "=" + aliyunEncodeURI(params[key])); } } return sortedParams.join("&"); } function makeChangeSiga(params, accessSecret) { const stringToSign = "GET&%2F&" + aliyunEncodeURI(makeUTF8sort(params)); const signature = CryptoJS.HmacSHA1(stringToSign, accessSecret + "&"); return signature.toString(CryptoJS.enc.Base64); } const SignatureUtil = { NONCE_STR_MAX: 32, createNonceStr(len = 16) { len = len > this.NONCE_STR_MAX ? this.NONCE_STR_MAX : len; let str = ""; const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; for (let i = 0; i < len; i++) { str += chars[Math.floor(Math.random() * chars.length)]; } return str; }, createSignature(params) { const message = params.message; const timestamp = params.timestamp || new Date().getTime().toString(); const nonce = params.nonce || this.createNonceStr(); const elements = [ encodeURIComponent(message), timestamp, nonce, "--xy-create-signature--" ]; const signature = CryptoJS.SHA1(elements.sort().join("")).toString(); return { message: message, signature: signature, timestamp: timestamp, nonce: nonce, }; } }; async function getAudioUrl(fileId) { try { const token = getToken(); if (!token) throw new Error("无法获取Token"); const message = JSON.stringify({ file_id: fileId }); const signedPayload = SignatureUtil.createSignature({ message }); const response = await fetch(`${window.location.origin}/api/jx-oresource/cloud/file/audio`, { method: 'POST', headers: { 'authorization': `Bearer ${token}`, 'content-type': 'application/json; charset=UTF-8' }, body: JSON.stringify(signedPayload) }); if (!response.ok) { throw new Error(`获取音频URL失败, 状态: ${response.status}`); } const data = await response.json(); if (data.success && data.data) { return data.data.audio_transcode_url || data.data.url; } else { throw new Error(data.message || '返回数据格式不正确'); } } catch (error) { console.error(`获取音频URL时出错 (File ID: ${fileId}):`, error); return null; } } function gmFetch(url, onProgress) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'arraybuffer', onload: (response) => { if (response.status >= 200 && response.status < 300) { if (onProgress) onProgress(1); resolve(response.response); } else { reject(new Error(`gmFetch 请求失败: 状态 ${response.status}`)); } }, onerror: (response) => { reject(new Error(`gmFetch 网络错误: ${response.statusText}`)); }, onprogress: (event) => { if (event.lengthComputable && onProgress) { onProgress(event.loaded / event.total); } } }); }); } async function getVideoUrl(videoId) { try { const token = getToken(); if (!token) throw new Error("无法获取Token"); const authResponse = await fetch(`${window.location.origin}/api/jx-oresource/vod/video/play_auth/${videoId}?is_public=1`, { headers: { 'authorization': `Bearer ${token}` } }); if (!authResponse.ok) throw new Error(`获取视频凭证失败, 状态: ${authResponse.status}`); const authData = await authResponse.json(); if (!authData.success || !authData.data || !authData.data.play_auth) { throw new Error(authData.message || '返回的播放凭证数据格式不正确'); } const playAuthData = JSON.parse(atob(authData.data.play_auth)); const s = { vid: playAuthData.VideoMeta.VideoId, accessId: playAuthData.AccessKeyId, accessSecret: playAuthData.AccessKeySecret, stsToken: playAuthData.SecurityToken, domainRegion: playAuthData.Region, authInfo: playAuthData.AuthInfo, format: "mp4", mediaType: "video" }; const signatureNonce = crypto.randomUUID(); const params = { AccessKeyId: s.accessId, Action: "GetPlayInfo", VideoId: s.vid, Formats: s.format, SecurityToken: s.stsToken, StreamType: s.mediaType, Format: "JSON", Version: "2017-03-21", SignatureMethod: "HMAC-SHA1", SignatureVersion: "1.0", SignatureNonce: signatureNonce, AuthInfo: s.authInfo }; const signature = makeChangeSiga(params, s.accessSecret); const queryString = makeUTF8sort(params) + "&Signature=" + aliyunEncodeURI(signature); const finalUrl = `https://vod.${s.domainRegion}.aliyuncs.com/?${queryString}`; const playInfoResponse = await fetch(finalUrl); if (!playInfoResponse.ok) { const errorText = await playInfoResponse.text(); console.error('从阿里云获取播放信息失败,原始响应:', errorText); throw new Error(`从阿里云获取播放信息失败, 状态: ${playInfoResponse.status}`); } const playInfoData = await playInfoResponse.json(); if (playInfoData && playInfoData.PlayInfoList && playInfoData.PlayInfoList.PlayInfo && playInfoData.PlayInfoList.PlayInfo.length > 0) { const playInfos = playInfoData.PlayInfoList.PlayInfo; const videoInfo = playInfos .filter(p => p.Format === 'mp4') .sort((a, b) => (b.Width || 0) - (a.Width || 0))[0]; const audioInfo = playInfos.find(p => p.Format === 'm4a'); return { videoUrl: videoInfo ? videoInfo.PlayURL : null, audioUrl: audioInfo ? audioInfo.PlayURL : null, }; } else if (playInfoData.Code) { throw new Error(`阿里云API错误: ${playInfoData.Code} - ${playInfoData.Message}`); } else { throw new Error('播放信息列表中没有可用的地址'); } } catch (error) { console.error(`获取视频/音频URL时出错 (Video ID: ${videoId}):`, error); return { videoUrl: null, audioUrl: null }; } } async function extractAndEncodeAudio(videoUrl, onProgress) { let worker = null; try { if (onProgress) onProgress(0.05, "下载中"); const videoData = await gmFetch(videoUrl, (progress) => { if (onProgress) onProgress(0.05 + progress * 0.25, "下载中"); }); if (onProgress) onProgress(0.3, "解码中"); const audioContext = new (window.AudioContext || window.webkitAudioContext)(); const audioBuffer = await audioContext.decodeAudioData(videoData); await audioContext.close(); if (onProgress) onProgress(0.6, "编码中"); return await new Promise((resolve, reject) => { const workerBlob = new Blob([WavEncoderWorker], { type: 'application/javascript' }); worker = new Worker(URL.createObjectURL(workerBlob)); worker.onmessage = (e) => { if (onProgress) onProgress(1, "完成"); resolve(e.data); worker.terminate(); }; worker.onerror = (e) => { console.error("WAV 编码 Worker 出错:", e); reject(new Error(`WAV 编码失败: ${e.message}`)); worker.terminate(); }; const channels = []; for (let i = 0; i < audioBuffer.numberOfChannels; i++) { channels.push(audioBuffer.getChannelData(i)); } worker.postMessage({ channels: channels, sampleRate: audioBuffer.sampleRate, length: audioBuffer.length }); }); } catch (error) { if (worker) worker.terminate(); console.error("从视频提取音频失败:", error); throw error; } } async function callSttApi(audioSource, sttConfig) { const { sttProvider, sttEndpoint, sttApiKey, sttModel, apiKey: llmApiKey } = sttConfig; if (!sttEndpoint) throw new Error("STT API 地址未配置。"); const finalApiKey = sttApiKey || llmApiKey; if (!finalApiKey) throw new Error("STT API Key 未配置(也未提供备用的 LLM Key)。"); console.log(`[STT] 使用 [${sttProvider}] 提供商开始转录...`); showNotification('🎧 语音转录中...', { type: 'info', duration: 10000 }); try { switch (sttProvider) { case 'openai_compatible': return await callWhisperCompatibleApi(audioSource, sttEndpoint, finalApiKey, sttModel); case 'gemini': return await callGeminiSttApi(audioSource, sttEndpoint, finalApiKey, sttModel); default: throw new Error(`未知的 STT 提供商: ${sttProvider}`); } } catch (error) { console.error('[STT] 语音转录失败:', error); showNotification(`语音转录失败: ${error.message}`, { type: 'error', duration: 8000 }); throw error; } } async function callWhisperCompatibleApi(audioSource, endpoint, apiKey, model) { let audioBlob; let fileName = 'audio.wav'; if (typeof audioSource === 'string') { const audioResponse = await fetch(audioSource); if (!audioResponse.ok) { throw new Error(`下载音频文件失败, 状态: ${audioResponse.status}`); } audioBlob = await audioResponse.blob(); fileName = audioSource.split('/').pop().split('?')[0] || 'audio.mp3'; } else if (audioSource instanceof Blob) { audioBlob = audioSource; } else { throw new Error('无效的音频源类型'); } const formData = new FormData(); formData.append('file', audioBlob, fileName); formData.append('model', model || 'whisper-1'); const sttApiResponse = await fetch(endpoint, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}` }, body: formData }); if (!sttApiResponse.ok) { const errorText = await sttApiResponse.text(); throw new Error(`STT API 请求失败 (${sttApiResponse.status}): ${errorText}`); } const result = await sttApiResponse.json(); if (typeof result.text === 'string') { showNotification('🎤 转录完成!', { type: 'success', duration: 2000 }); return result.text; } else { throw new Error("STT API 返回的数据格式不正确,未找到 'text' 字段。"); } } async function callGeminiSttApi(audioSource, endpoint, apiKey, model) { let audioBlob; let mimeType; if (typeof audioSource === 'string') { const audioResponse = await fetch(audioSource); if (!audioResponse.ok) { throw new Error(`下载音频文件失败, 状态: ${audioResponse.status}`); } audioBlob = await audioResponse.blob(); mimeType = audioBlob.type || 'audio/mp3'; } else if (audioSource instanceof Blob) { audioBlob = audioSource; mimeType = audioBlob.type; } else { throw new Error('无效的音频源类型'); } const base64Audio = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result.split(',')[1]); reader.onerror = reject; reader.readAsDataURL(audioBlob); }); const requestBody = { contents: [ { parts: [ { text: "Please provide a transcript for this audio." }, { inlineData: { mimeType: mimeType, data: base64Audio, }, }, ], }, ], }; let finalEndpoint = endpoint.endsWith('/') ? endpoint : endpoint + '/'; finalEndpoint += `${model}:generateContent?key=${apiKey}`; const sttApiResponse = await fetch(finalEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), }); if (!sttApiResponse.ok) { const errorText = await sttApiResponse.text(); throw new Error(`Gemini STT API 请求失败 (${sttApiResponse.status}): ${errorText}`); } const result = await sttApiResponse.json(); const transcription = result.candidates?.[0]?.content?.parts?.[0]?.text; if (typeof transcription === 'string') { showNotification('🎤 转录完成 (Gemini)!', { type: 'success', duration: 2000 }); return transcription; } else { console.error('[STT-Gemini] 返回数据格式不正确:', result); throw new Error("Gemini STT API 返回的数据格式不正确。"); } } function containsAudio(richTextContent) { if (!richTextContent || typeof richTextContent !== 'string') return false; try { const jsonContent = JSON.parse(richTextContent); if (jsonContent && Array.isArray(jsonContent.blocks)) { return jsonContent.blocks.some(block => block.type === 'atomic' && block.data?.type === 'AUDIO' ); } } catch (e) { return false; } return false; } function containsVideo(richTextContent) { if (!richTextContent || typeof richTextContent !== 'string') return false; try { const jsonContent = JSON.parse(richTextContent); if (jsonContent && Array.isArray(jsonContent.blocks)) { return jsonContent.blocks.some(block => block.type === 'atomic' && block.data?.type === 'VIDEO' ); } } catch (e) { return false; } return false; } function getQuestionType(typeCode) { const typeMap = { 1: "单选题", 2: "多选题", 4: "填空题", 5: "判断题", 6: "简答题", 9: "数组题", 10: "编程题", 12: "排序题", 13: "匹配题" }; return typeMap[typeCode] || "未知题型"; } async function callXiaoyaStream(userPrompt, onChunk, onComplete, onError, signal) { const effectiveSignal = signal || new AbortController().signal; let timeoutId = null; if (!signal) { timeoutId = setTimeout(() => { console.error("Xiaoya Stream fetch 超时 (内部)"); if (typeof onError === 'function') { onError(new Error("小雅流式 API 网络错误: 请求超时 (内部)")); } }, 60000); } else { effectiveSignal.addEventListener('abort', () => { console.log("Xiaoya Stream 请求被外部信号中止。"); if (typeof onError === 'function') { onError(new DOMException('请求被中止', 'AbortError')); } }, { once: true }); } try { const bearerToken = getToken(); if (!bearerToken) { throw new Error("无法获取 Bearer Token"); } let jwtToken = null; try { const xyGlobalConfig = localStorage.getItem('XY_GLOBAL_CONFIG'); if (xyGlobalConfig) { jwtToken = JSON.parse(xyGlobalConfig).xy_ai_token; } } catch (e) { console.warn("解析 XY_GLOBAL_CONFIG 失败:", e); } if (!jwtToken) { console.warn("无法从 localStorage 获取小雅 JWT Token,将尝试使用 Bearer Token"); jwtToken = bearerToken; } const groupId = getGroupIDFromUrl(window.location.href) || ""; const aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}'); const xiaoyaAiMode = aiConfig.xiaoyaAiMode || 'deep_think'; const useDeepThink = xiaoyaAiMode === 'deep_think'; const requestBody = { token: jwtToken, ask_key: "chat_scene_dialogue", ask_object: { question: userPrompt, multilingual_description: "" }, deep_think_mode: useDeepThink, group_id: groupId }; console.log(`调用 Xiaoya Stream API (模式: ${useDeepThink ? '深度思考' : '快速'})`, { body: requestBody }); const response = await fetch(`${window.location.origin}/api/jx-oresource/assistant/chat/stream`, { method: "POST", headers: { "accept": "*/*", "authorization": `Bearer ${bearerToken}`, "content-type": "application/json", }, body: JSON.stringify(requestBody), signal: effectiveSignal, }); if (timeoutId) clearTimeout(timeoutId); if (!response.ok) { let errorMsg = `小雅流式 API 错误 (${response.status}): ${response.statusText}`; try { const errorData = await response.json(); errorMsg = `小雅流式 API 错误 (${response.status}): ${errorData.message || response.statusText}`; } catch (e) { } console.error("Xiaoya Stream fetch 错误:", errorMsg); if (typeof onError === 'function') { onError(new Error(errorMsg)); } return; } if (response.body) { const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let accumulatedContent = ''; let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) { console.log("Xiaoya Stream finished."); break; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.startsWith('data: ')) { const dataJson = line.substring(6).trim(); if (dataJson === '[DONE]') { console.log("Xiaoya Stream received [DONE]"); continue; } try { const data = JSON.parse(dataJson); const delta = data.choices?.[0]?.delta; if (delta) { const deltaContent = delta.content; const reasoningContent = delta.reasoning_content || delta.reasoning; if (reasoningContent) { if (typeof onChunk === 'function') { onChunk(`${reasoningContent}`); } } else if (deltaContent) { accumulatedContent += deltaContent; if (typeof onChunk === 'function') { onChunk(deltaContent); } } } } catch (parseError) { if (dataJson) { console.warn("Xiaoya Stream SSE JSON parsing error:", parseError, "Data:", dataJson); } } } } } if (typeof onComplete === 'function') { onComplete(accumulatedContent); } } else { console.error("Xiaoya Stream 响应体为空"); if (typeof onError === 'function') { onError(new Error("小雅流式 API 错误: 响应体为空")); } } } catch (error) { if (timeoutId) clearTimeout(timeoutId); if (error.name === 'AbortError') { console.log("Xiaoya Stream 请求被中止."); if (!signal && typeof onError === 'function') { onError(error); } } else { console.error("Xiaoya Stream 调用/处理失败:", error); if (typeof onError === 'function') { onError(new Error(`小雅流式 API 网络或处理错误: ${error.message}`)); } } } } async function callOpenAI(endpoint, apiKey, userPrompt, modelId, temperature = 0.7, max_tokens = 8000, onChunk = null, onComplete = null, onError = null, signal = null, visionEnabled = false) { const effectiveSignal = signal || new AbortController().signal; let timeoutId = null; if (!signal) { timeoutId = setTimeout(() => { console.error("OpenAI fetch 超时 (内部)"); if (typeof onError === 'function') { onError(new Error("OpenAI API 网络错误: 请求超时 (内部)")); } }, 60000); } else { effectiveSignal.addEventListener('abort', () => { console.log("OpenAI 请求被外部信号中止。"); if (typeof onError === 'function') { onError(new DOMException('请求被中止', 'AbortError')); } }, { once: true }); } try { const aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}'); const disableMaxTokens = aiConfig.disableMaxTokens || false; const modelToUse = modelId || "gpt-4o"; const payloadData = { model: modelToUse, messages: [{ role: "user", content: visionEnabled ? userPrompt : String(userPrompt) }], temperature: temperature, stream: true }; if (!disableMaxTokens) { payloadData.max_tokens = max_tokens; } const payload = JSON.stringify(payloadData); console.log("调用 OpenAI (流式 Fetch):", { endpoint, model: modelToUse, temperature, max_tokens: disableMaxTokens ? 'unlimited' : max_tokens }); const disableCorrection = aiConfig.disableCorrection || false; let finalEndpoint = endpoint; if (!disableCorrection) { let cleanEndpoint = endpoint.split('?')[0].replace(/\/$/, ''); const targetPath = '/v1/chat/completions'; if (!cleanEndpoint.endsWith(targetPath)) { if (cleanEndpoint.includes('/v1')) { cleanEndpoint = cleanEndpoint.substring(0, cleanEndpoint.indexOf('/v1')) + targetPath; } else { cleanEndpoint += targetPath; } console.warn("OpenAI Endpoint 已自动修正为:", cleanEndpoint); } finalEndpoint = cleanEndpoint + (endpoint.includes('?') ? endpoint.substring(endpoint.indexOf('?')) : ''); } const response = await fetch(finalEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, body: payload, signal: effectiveSignal }); if (timeoutId) clearTimeout(timeoutId); if (!response.ok) { let errorMsg = `OpenAI API 错误 (${response.status}): ${response.statusText}`; try { const errorData = await response.json(); errorMsg = `OpenAI API 错误 (${response.status}): ${errorData.error?.message || errorData.message || response.statusText}`; } catch (e) { try { const textError = await response.text(); console.error("OpenAI 原始错误响应:", textError); errorMsg += ` - ${textError.substring(0, 100)}`; } catch (textE) { } } console.error("OpenAI fetch 错误:", errorMsg); if (typeof onError === 'function') { onError(new Error(errorMsg)); } return; } if (response.body) { const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let accumulatedContent = ''; let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) { console.log("OpenAI Stream finished."); break; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (let j = 0; j < lines.length; j++) { const line = lines[j]; if (line.startsWith('data: ')) { const dataJson = line.substring(6).trim(); if (dataJson === '[DONE]') { console.log("OpenAI Stream received [DONE]"); continue; } try { const data = JSON.parse(dataJson); const delta = data.choices?.[0]?.delta; if (delta) { const deltaContent = delta.content; const reasoningContent = delta.reasoning_content || delta.reasoning; if (reasoningContent) { if (typeof onChunk === 'function') { onChunk(`${reasoningContent}`); } } else if (deltaContent) { accumulatedContent += deltaContent; if (typeof onChunk === 'function') { onChunk(deltaContent); } } } } catch (parseError) { if (dataJson) { console.warn("SSE JSON parsing error:", parseError, "Data:", dataJson); } } } } } if (typeof onComplete === 'function') { onComplete(accumulatedContent); } } else { console.error("OpenAI 响应体为空"); if (typeof onError === 'function') { onError(new Error("OpenAI API 错误: 响应体为空")); } } } catch (error) { if (timeoutId) clearTimeout(timeoutId); if (error.name === 'AbortError') { console.log("OpenAI 请求被中止."); if (!signal && typeof onError === 'function') { onError(error); } } else { console.error("OpenAI 调用/处理失败:", error); if (typeof onError === 'function') { onError(new Error(`OpenAI API 网络或处理错误: ${error.message}`)); } } } } async function callGemini(endpoint, apiKey, userPrompt, modelId, temperature = 0.7, max_tokens = 8000, onChunk = null, onComplete = null, onError = null, signal = null, visionEnabled = false) { const effectiveSignal = signal || new AbortController().signal; let timeoutId = null; if (!signal) { timeoutId = setTimeout(() => { console.error("Gemini fetch 超时 (内部)"); if (typeof onError === 'function') { onError(new Error("Gemini API 网络错误: 请求超时 (内部)")); } }, 60000); } else { effectiveSignal.addEventListener('abort', () => { console.log("Gemini 请求被外部信号中止。"); if (typeof onError === 'function') { onError(new DOMException('请求被中止', 'AbortError')); } }, { once: true }); } try { const aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}'); const disableCorrection = aiConfig.disableCorrection || false; const disableMaxTokens = aiConfig.disableMaxTokens || false; const modelToUse = modelId || "gemini-2.5-flash"; const apiVersion = "v1beta"; let finalEndpoint; if (disableCorrection) { finalEndpoint = endpoint; if (!finalEndpoint.includes('key=')) { finalEndpoint += (finalEndpoint.includes('?') ? '&' : '?') + `key=${apiKey}`; } if (!finalEndpoint.includes('alt=sse')) { finalEndpoint += (finalEndpoint.includes('?') ? '&' : '?') + 'alt=sse'; } } else { let cleanBaseEndpoint = endpoint.replace(/\/v\d+(beta)?\/models\/.*$/, '').replace(/\/models\/.*$/, '').replace(/\/$/, ''); finalEndpoint = `${cleanBaseEndpoint}/${apiVersion}/models/${modelToUse}:streamGenerateContent?key=${apiKey}&alt=sse`; } console.log("调用 Gemini (流式 Fetch):", { fullEndpoint: finalEndpoint, model: modelToUse, temperature, max_tokens: disableMaxTokens ? 'unlimited' : max_tokens }); const generationConfig = { temperature: temperature }; if (!disableMaxTokens) { generationConfig.maxOutputTokens = max_tokens; } let finalParts; if (visionEnabled) { finalParts = userPrompt.map(part => { if (part.type === 'image_url') { const base64Data = part.image_url.url; const parts = base64Data.split(','); const mimeMatch = parts[0].match(/:(.*?);/); return { inline_data: { mime_type: mimeMatch[1], data: parts[1] } }; } return { text: part.text }; }); } else { finalParts = [{ text: userPrompt }]; } const payload = JSON.stringify({ contents: [{ parts: finalParts }], generationConfig: generationConfig }); const response = await fetch(finalEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload, signal: effectiveSignal }); if (timeoutId) clearTimeout(timeoutId); if (!response.ok) { let errorMsg = `Gemini API 错误 (${response.status}): ${response.statusText}`; try { const errorData = await response.json(); errorMsg = `Gemini API 错误 (${response.status}): ${errorData.error?.message || errorData.message || response.statusText}`; } catch (e) { try { const textError = await response.text(); console.error("Gemini 原始错误响应:", textError); errorMsg += ` - ${textError.substring(0, 100)}`; } catch (textE) { } } console.error("Gemini fetch 错误:", errorMsg); if (typeof onError === 'function') { onError(new Error(errorMsg)); } return; } if (response.body) { const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let accumulatedContent = ''; let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) { console.log("Gemini Stream finished."); break; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; function handleGeminiLine(line) { if (line.startsWith('data: ')) { const dataJson = line.substring(6).trim(); try { const data = JSON.parse(dataJson); const delta = data.candidates?.[0]?.content?.parts?.[0]?.text; if (delta) { accumulatedContent += delta; if (typeof onChunk === 'function') { onChunk(delta); } } const finishReason = data.candidates?.[0]?.finishReason; if (finishReason && finishReason !== "STOP") { console.warn("Gemini stream finished with reason:", finishReason); if (finishReason === "SAFETY") { const safetyError = new Error("Gemini API 错误: 响应因安全设置被阻止。"); if (typeof onError === 'function') onError(safetyError); } } const promptFeedback = data.promptFeedback; if (promptFeedback?.blockReason) { console.error(`Gemini API 错误: 提示因 ${promptFeedback.blockReason} 被阻止`, data); const promptError = new Error(`Gemini API 错误: 提示因 ${promptFeedback.blockReason} 被阻止`); if (typeof onError === 'function') onError(promptError); } } catch (parseError) { if (dataJson) { console.warn("Gemini SSE JSON parsing error:", parseError, "Data:", dataJson); } } } } lines.forEach(handleGeminiLine); } if (typeof onComplete === 'function') { onComplete(accumulatedContent); } } else { console.error("Gemini 响应体为空"); if (typeof onError === 'function') { onError(new Error("Gemini API 错误: 响应体为空")); } } } catch (error) { if (timeoutId) clearTimeout(timeoutId); if (error.name === 'AbortError') { console.log("Gemini 请求被中止."); if (!signal && typeof onError === 'function') { onError(error); } } else { console.error("Gemini 调用/处理失败:", error); if (typeof onError === 'function') { onError(new Error(`Gemini API 网络或处理错误: ${error.message}`)); } } } } async function callAnthropic(endpoint, apiKey, userPrompt, modelId, temperature = 0.7, max_tokens = 8000, onChunk = null, onComplete = null, onError = null, signal = null, visionEnabled = false) { const effectiveSignal = signal || new AbortController().signal; let timeoutId = null; if (!signal) { timeoutId = setTimeout(() => { console.error("Anthropic fetch 超时 (内部)"); if (typeof onError === 'function') { onError(new Error("Anthropic API 网络错误: 请求超时 (内部)")); } }, 60000); } else { effectiveSignal.addEventListener('abort', () => { console.log("Anthropic 请求被外部信号中止。"); if (typeof onError === 'function') { onError(new DOMException('请求被中止', 'AbortError')); } }, { once: true }); } try { const aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}'); const disableMaxTokens = aiConfig.disableMaxTokens || false; const modelToUse = modelId || "claude-sonnet-4-20250514"; let finalContent; if (visionEnabled) { finalContent = userPrompt.map(part => { if (part.type === 'image_url') { const base64Data = part.image_url.url; const parts = base64Data.split(','); const mimeMatch = parts[0].match(/:(.*?);/); return { type: 'image', source: { type: 'base64', media_type: mimeMatch[1], data: parts[1] } }; } return { type: 'text', text: part.text }; }); } else { finalContent = [{ type: 'text', text: userPrompt }]; } const payloadData = { model: modelToUse, messages: [{ role: "user", content: finalContent }], temperature: temperature, stream: true }; if (!disableMaxTokens) { payloadData.max_tokens = max_tokens; } const payload = JSON.stringify(payloadData); console.log("调用 Anthropic (流式 Fetch):", { endpoint, model: modelToUse, temperature, max_tokens: disableMaxTokens ? 'unlimited' : max_tokens }); const disableCorrection = aiConfig.disableCorrection || false; let finalEndpoint = endpoint; if (!disableCorrection) { let cleanEndpoint = endpoint.split('?')[0].replace(/\/$/, ''); const targetPath = '/v1/messages'; if (!cleanEndpoint.endsWith(targetPath)) { if (cleanEndpoint.includes('/v1')) { cleanEndpoint = cleanEndpoint.substring(0, cleanEndpoint.indexOf('/v1')) + targetPath; } else { cleanEndpoint += targetPath; } console.warn("Anthropic Endpoint 已自动修正为:", cleanEndpoint); } finalEndpoint = cleanEndpoint + (endpoint.includes('?') ? endpoint.substring(endpoint.indexOf('?')) : ''); } const response = await fetch(finalEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, }, body: payload, signal: effectiveSignal }); if (timeoutId) clearTimeout(timeoutId); if (!response.ok) { let errorMsg = `Anthropic API 错误 (${response.status}): ${response.statusText}`; try { const errorData = await response.json(); errorMsg = `Anthropic API 错误 (${response.status}): ${errorData.error?.type || errorData.type || response.statusText} - ${errorData.error?.message || errorData.message || ''}`; } catch (e) { try { const textError = await response.text(); console.error("Anthropic 原始错误响应:", textError); errorMsg += ` - ${textError.substring(0, 100)}`; } catch (textE) { } } console.error("Anthropic fetch 错误:", errorMsg); if (typeof onError === 'function') { onError(new Error(errorMsg)); } return; } if (response.body) { const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let accumulatedContent = ''; let buffer = ''; let streamEnded = false; while (!streamEnded) { const { done, value } = await reader.read(); if (done) { console.log("Anthropic Stream finished."); streamEnded = true; break; } buffer += decoder.decode(value, { stream: true }); const blocks = buffer.split('\n\n'); buffer = blocks.pop() || ''; blocks.forEach(block => { if (!block.trim()) return; let eventType = null; let dataJson = null; const lines = block.split('\n'); for (let k = 0; k < lines.length; k++) { const line = lines[k]; if (line.startsWith('event: ')) { eventType = line.substring(7).trim(); } else if (line.startsWith('data: ')) { dataJson = line.substring(6).trim(); } } if (eventType && dataJson) { try { const data = JSON.parse(dataJson); if (eventType === 'content_block_delta') { if (data.type === 'content_block_delta' && data.delta?.type === 'text_delta') { const delta = data.delta.text; accumulatedContent += delta; if (typeof onChunk === 'function') { onChunk(delta); } } else if (data.type === 'content_block_delta' && data.delta?.type === 'thinking_delta') { const thinkingDelta = data.delta.thinking; if (thinkingDelta && typeof onChunk === 'function') { onChunk(`${thinkingDelta}`); } } } else if (eventType === 'message_start') { } else if (eventType === 'message_delta') { } else if (eventType === 'message_stop') { console.log("Anthropic 流式传输已停止 (收到 message_stop 事件)"); streamEnded = true; } else if (eventType === 'ping') { } else if (eventType === 'error') { console.error("Anthropic 流式传输错误事件:", data); { const streamError = new Error(`Anthropic API 错误: ${data.error?.type} - ${data.error?.message}`); if (typeof onError === 'function') onError(streamError); streamEnded = true; } } else { console.warn("未知的 Anthropic 事件类型:", eventType, data); } } catch (parseError) { console.warn("Anthropic SSE JSON 解析错误:", parseError, "数据:", dataJson); } } }); } if (typeof onComplete === 'function') { onComplete(accumulatedContent); } } else { console.error("Anthropic 响应体为空"); if (typeof onError === 'function') { onError(new Error("Anthropic API 错误: 响应体为空")); } } } catch (error) { if (timeoutId) clearTimeout(timeoutId); if (error.name === 'AbortError') { console.log("Anthropic 请求被中止."); if (!signal && typeof onError === 'function') { onError(error); } } else { console.error("Anthropic 调用/处理失败:", error); if (typeof onError === 'function') { onError(new Error(`Anthropic API 网络或处理错误: ${error.message}`)); } } } } async function callAzureOpenAI(endpoint, apiKey, apiVersion, modelId, userPrompt, temperature = 0.7, max_tokens = 8000, onChunk = null, onComplete = null, onError = null, signal = null, visionEnabled = false) { const effectiveSignal = signal || new AbortController().signal; let timeoutId = null; if (!signal) { timeoutId = setTimeout(() => { console.error("Azure OpenAI fetch 超时 (内部)"); if (typeof onError === 'function') { onError(new Error("Azure OpenAI API 网络错误: 请求超时 (内部)")); } }, 60000); } else { effectiveSignal.addEventListener('abort', () => { console.log("Azure OpenAI 请求被外部信号中止。"); if (typeof onError === 'function') { onError(new DOMException('请求被中止', 'AbortError')); } }, { once: true }); } try { const aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}'); const disableCorrection = aiConfig.disableCorrection || false; const disableMaxTokens = aiConfig.disableMaxTokens || false; const version = apiVersion || '2024-05-01-preview'; let finalEndpoint; let cleanEndpointBase = endpoint.split('?')[0].replace(/\/$/, ''); const urlParams = new URLSearchParams(endpoint.split('?')[1] || ''); if (!urlParams.has('api-version')) { urlParams.set('api-version', version); } if (disableCorrection) { finalEndpoint = `${cleanEndpointBase}?${urlParams.toString()}`; } else { const isOpenAIStyle = cleanEndpointBase.includes('.openai.azure.com'); const isAIServicesStyle = cleanEndpointBase.includes('.services.ai.azure.com') || cleanEndpointBase.includes('.inference.ai.azure.com'); if (!isOpenAIStyle && !isAIServicesStyle) { console.warn("Azure Endpoint URL hostname does not seem standard (expected '*.openai.azure.com' or '*.services.ai.azure.com' or '*.inference.ai.azure.com'):", cleanEndpointBase); } if (isOpenAIStyle) { if (!cleanEndpointBase.includes('/openai/deployments/')) { console.warn("Azure OpenAI-style endpoint path might be incomplete. Expected format: '.../openai/deployments//chat/completions'. Current:", cleanEndpointBase); } else if (!cleanEndpointBase.endsWith('/chat/completions')) { console.warn("Azure OpenAI-style endpoint path might be incomplete. Ensuring it ends with '/chat/completions'. Current:", cleanEndpointBase); if (/\/openai\/deployments\/[^/]+$/.test(cleanEndpointBase)) { cleanEndpointBase += '/chat/completions'; } } } else if (isAIServicesStyle) { if (!cleanEndpointBase.endsWith('/models/chat/completions')) { console.warn("Azure AI Services-style endpoint path might be incomplete. Expected format: '.../models/chat/completions'. Current:", cleanEndpointBase); if (cleanEndpointBase.endsWith('/models/chat')) { cleanEndpointBase += '/completions'; } } } finalEndpoint = `${cleanEndpointBase}?${urlParams.toString()}`; } console.log("调用 Azure OpenAI (流式 Fetch):", { fullEndpoint: finalEndpoint, model: modelId, temperature, max_tokens: disableMaxTokens ? 'unlimited' : max_tokens }); const requestBody = { model: modelId, messages: [{ role: "user", content: visionEnabled ? userPrompt : [{ type: "text", text: String(userPrompt) }] }], temperature: temperature, stream: true }; if (!disableMaxTokens) { requestBody.max_tokens = max_tokens; } const payload = JSON.stringify(requestBody); const response = await fetch(finalEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'api-key': apiKey }, body: payload, signal: effectiveSignal }); if (timeoutId) clearTimeout(timeoutId); if (!response.ok) { let errorMsg = `Azure OpenAI API 错误 (${response.status}): ${response.statusText}`; try { const errorData = await response.json(); errorMsg = `Azure OpenAI API 错误 (${response.status}): ${errorData.error?.message || errorData.message || response.statusText}`; } catch (e) { try { const textError = await response.text(); console.error("Azure OpenAI 原始错误响应:", textError); errorMsg += ` - ${textError.substring(0, 100)}`; } catch (textE) { } } console.error("Azure OpenAI fetch 错误:", errorMsg); if (typeof onError === 'function') { onError(new Error(errorMsg)); } return; } if (response.body) { const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let accumulatedContent = ''; let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) { console.log("Azure OpenAI Stream finished."); break; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; function handleAzureLine(line) { if (line.startsWith('data: ')) { const dataJson = line.substring(6).trim(); if (dataJson === '[DONE]') { console.log("Azure OpenAI Stream received [DONE]"); return; } try { const data = JSON.parse(dataJson); const choice = data.choices?.[0]; if (choice) { const delta = choice.delta; if (delta) { const deltaContent = delta.content; const reasoningContent = delta.reasoning_content || delta.reasoning; if (reasoningContent) { if (typeof onChunk === 'function') { onChunk(`${reasoningContent}`); } } else if (deltaContent) { accumulatedContent += deltaContent; if (typeof onChunk === 'function') { onChunk(deltaContent); } } } const finishReason = choice.finish_reason; if (finishReason && finishReason !== "stop") { console.warn("Azure OpenAI stream finished with reason:", finishReason, "Data:", dataJson); if (finishReason === "content_filter") { let filterMessage = "Azure OpenAI API 错误: 响应因内容过滤器被阻止。"; if (data.prompt_filter_results && data.prompt_filter_results.length > 0) { filterMessage = `Azure OpenAI API 错误: 提示因内容过滤器 (${data.prompt_filter_results[0].content_filter_results.hate.filtered ? 'hate' : ''}...)被阻止。`; } else if (choice.content_filter_results) { const results = choice.content_filter_results; let reasons = []; if (results.hate?.filtered) reasons.push("hate"); if (results.self_harm?.filtered) reasons.push("self_harm"); if (results.sexual?.filtered) reasons.push("sexual"); if (results.violence?.filtered) reasons.push("violence"); if (reasons.length > 0) filterMessage += ` 检测到: ${reasons.join(', ')}.`; } const filterError = new Error(filterMessage); if (typeof onError === 'function') onError(filterError); } } } } catch (parseError) { if (dataJson) { console.warn("Azure SSE JSON parsing error:", parseError, "Data:", dataJson); } } } } lines.forEach(handleAzureLine); } if (typeof onComplete === 'function') { onComplete(accumulatedContent); } } else { console.error("Azure OpenAI 响应体为空"); if (typeof onError === 'function') { onError(new Error("Azure OpenAI API 错误: 响应体为空")); } } } catch (error) { if (timeoutId) clearTimeout(timeoutId); if (error.name === 'AbortError') { console.log("Azure OpenAI 请求被中止."); if (!signal && typeof onError === 'function') { onError(error); } } else { console.error("Azure OpenAI 调用/处理失败:", error); if (typeof onError === 'function') { onError(new Error(`Azure OpenAI API 网络或处理错误: ${error.message}`)); } } } } class ThinkingHandler { constructor(container) { this.container = container; this.thinkingProcessDiv = null; this.preElement = null; this.detailsElement = null; this.arrowSvg = null; } _ensureDiv() { if (!this.container) return false; if (!this.thinkingProcessDiv || !this.container.contains(this.thinkingProcessDiv)) { this.thinkingProcessDiv = this.container.querySelector('.ai-thinking-process'); if (!this.thinkingProcessDiv) { this.thinkingProcessDiv = document.createElement('div'); this.thinkingProcessDiv.className = 'ai-thinking-process'; this.thinkingProcessDiv.style.marginTop = '15px'; this.thinkingProcessDiv.style.display = 'none'; this.container.appendChild(this.thinkingProcessDiv); } this.detailsElement = this.thinkingProcessDiv.querySelector('details'); if (!this.detailsElement) { this.thinkingProcessDiv.innerHTML = `
查看 AI 思考过程

                        
`; this.detailsElement = this.thinkingProcessDiv.querySelector('details'); const summaryElement = this.thinkingProcessDiv.querySelector('summary'); this.arrowSvg = summaryElement.querySelector('svg'); this.detailsElement.addEventListener('toggle', () => { if (this.arrowSvg) { this.arrowSvg.style.transform = this.detailsElement.open ? 'rotate(90deg)' : 'rotate(0deg)'; } }); } else { const summaryElement = this.detailsElement.querySelector('summary'); this.arrowSvg = summaryElement ? summaryElement.querySelector('svg') : null; } this.preElement = this.thinkingProcessDiv.querySelector('pre'); } return true; } update(contentToAdd) { if (!this._ensureDiv() || !this.preElement) return; requestAnimationFrame(() => { this.preElement.appendChild(document.createTextNode(contentToAdd)); this.preElement.scrollTop = this.preElement.scrollHeight; }); } show(makeOpen = true) { if (!this._ensureDiv()) return; this.thinkingProcessDiv.style.display = 'block'; if (makeOpen && this.detailsElement && !this.detailsElement.open) { this.detailsElement.open = true; } } hide() { if (this.thinkingProcessDiv) { this.thinkingProcessDiv.style.display = 'none'; } } reset() { if (this._ensureDiv() && this.preElement) { this.preElement.textContent = ''; } this.hide(); } displayFinal(thinkingProcess) { if (!thinkingProcess || !thinkingProcess.trim()) { this.reset(); return; } if (!this._ensureDiv() || !this.preElement) return; this.preElement.textContent = thinkingProcess; this.show(false); } } class StreamProcessor { constructor(targetElement, questionTypeNum, thinkingHandler, onUpdateTarget, onFinalizeTarget) { this.targetElement = targetElement; this.questionTypeNum = questionTypeNum; this.thinkingHandler = thinkingHandler; this.onUpdateTarget = onUpdateTarget; this.onFinalizeTarget = onFinalizeTarget; this.buffer = ''; this.isThinking = false; this.currentMainContent = ''; this.currentThinkingContent = ''; this.thinkStartTag = ''; this.thinkEndTag = ''; } processChunk(delta) { if (!delta) return; this.buffer += delta; let thinkStartIndex, thinkEndIndex; while (true) { if (!this.isThinking) { thinkStartIndex = this.buffer.indexOf(this.thinkStartTag); if (thinkStartIndex !== -1) { const beforeThink = this.buffer.substring(0, thinkStartIndex); this.currentMainContent += beforeThink; if (typeof this.onUpdateTarget === 'function') { this.onUpdateTarget(beforeThink); } this.isThinking = true; this.buffer = this.buffer.substring(thinkStartIndex + this.thinkStartTag.length); this.thinkingHandler.show(); } else { this.currentMainContent += this.buffer; if (typeof this.onUpdateTarget === 'function') { this.onUpdateTarget(this.buffer); } this.buffer = ''; break; } } else { thinkEndIndex = this.buffer.indexOf(this.thinkEndTag); if (thinkEndIndex !== -1) { const thinkingPart = this.buffer.substring(0, thinkEndIndex); this.currentThinkingContent += thinkingPart; this.thinkingHandler.update(thinkingPart); this.isThinking = false; this.buffer = this.buffer.substring(thinkEndIndex + this.thinkEndTag.length); } else { this.currentThinkingContent += this.buffer; this.thinkingHandler.update(this.buffer); this.buffer = ''; break; } } } } processComplete() { console.log("Stream complete in StreamProcessor."); if (this.buffer) { if (this.isThinking) { this.currentThinkingContent += this.buffer; this.thinkingHandler.update(this.buffer); } else { this.currentMainContent += this.buffer; if (typeof this.onUpdateTarget === 'function') { if (this.questionTypeNum !== 4) this.onUpdateTarget(this.buffer); } } this.buffer = ''; } if (!this.currentThinkingContent.trim()) { this.thinkingHandler.hide(); } return { mainContent: this.currentMainContent, thinkingContent: this.currentThinkingContent }; } reset() { this.buffer = ''; this.isThinking = false; this.currentMainContent = ''; this.currentThinkingContent = ''; this.thinkingHandler.reset(); } } function isEmptyRichText(content) { try { let jsonContent = JSON.parse(content); if (jsonContent.blocks.length === 1 && jsonContent.blocks[0].text === "" && Object.keys(jsonContent.entityMap).length === 0) { return true; } return false; } catch (e) { return false; } } async function uploadImage(file) { try { const token = getToken(); if (!token) { throw new Error('无法获取授权,请确保已登录'); } const uploadId = `rc-upload-${Date.now()}-${Math.floor(Math.random() * 1000)}`; const credentialResponse = await fetch(`${window.location.origin}/api/jx-oresource/disk/files`, { method: 'POST', headers: { "accept": "*/*", "authorization": `Bearer ${token}`, "content-type": "application/json; charset=UTF-8" }, body: JSON.stringify({ uploadId: uploadId, filename: file.name, file_size: file.size }) }); const credentialData = await credentialResponse.json(); if (!credentialData.success || !credentialData.data) { console.error('上传凭证数据不完整:', credentialData); throw new Error(credentialData.message || '获取上传凭证失败,返回的数据结构不完整'); } const formData = new FormData(); formData.append('key', credentialData.data.multipart.key); for (const key in credentialData.data.multipart) { if (key !== 'key') { formData.append(key, credentialData.data.multipart[key]); } } formData.append('file', file); console.log('上传地址:', credentialData.data.host); console.log('表单数据:', Object.keys(credentialData.data.multipart)); const uploadResponse = await fetch(credentialData.data.host, { method: 'POST', body: formData }); if (!uploadResponse.ok) { const errorText = await uploadResponse.text(); console.error('上传失败响应:', errorText); throw new Error(`文件上传失败,状态码: ${uploadResponse.status}, 错误信息: ${errorText}`); } if (!credentialData.data.multipart.id) { console.error('缺少文件ID:', credentialData); throw new Error('上传成功但缺少文件ID'); } return `${window.location.origin}/api/jx-oresource/cloud/file_access/${credentialData.data.multipart.id}`; } catch (error) { console.error('上传图片失败:', error); throw error; } } function insertImageToEditor(editor, imageUrl) { const imgElement = `
上传图片
`; if (window.getSelection) { const selection = window.getSelection(); if (selection.rangeCount > 0) { const range = selection.getRangeAt(0); if (range.commonAncestorContainer === editor || editor.contains(range.commonAncestorContainer)) { const tempDiv = document.createElement('div'); tempDiv.innerHTML = imgElement; const imgNode = tempDiv.firstElementChild; if (imgNode) { range.deleteContents(); range.insertNode(imgNode); try { range.setStartAfter(imgNode); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } catch (error) { console.warn('设置光标位置失败,但图片已成功插入:', error); } return; } } } } editor.innerHTML += imgElement; } function updateAnswerWithContent(question, htmlContent) { const tempDiv = document.createElement('div'); tempDiv.innerHTML = htmlContent; const blocks = []; let currentTextBlock = ""; let blockKey = 0; function processNodes() { const allNodes = []; const walkNodes = (node, isRoot = false) => { if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() !== '') { allNodes.push({ type: 'text', node: node }); } else if (node.nodeType === Node.ELEMENT_NODE) { if (node.nodeName === 'IMG') { allNodes.push({ type: 'image', node: node }); } else if (node.nodeName === 'BR') { allNodes.push({ type: 'linebreak' }); } else if (!isRoot && (node.nodeName === 'DIV' || node.style.display === 'block')) { const childNodes = Array.from(node.childNodes); childNodes.forEach(child => walkNodes(child)); if (node.nextSibling) { allNodes.push({ type: 'linebreak' }); } } else { Array.from(node.childNodes).forEach(child => walkNodes(child)); } } }; walkNodes(tempDiv, true); return allNodes; } const nodes = processNodes(); for (let i = 0; i < nodes.length; i++) { const nodeInfo = nodes[i]; if (nodeInfo.type === 'text') { currentTextBlock += nodeInfo.node.textContent; } else if (nodeInfo.type === 'linebreak') { if (i < nodes.length - 1) { currentTextBlock += '\n'; } } else if (nodeInfo.type === 'image') { if (currentTextBlock) { blocks.push({ key: `block${blockKey++}`, text: currentTextBlock, type: 'unstyled', depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} }); currentTextBlock = ""; } const img = nodeInfo.node; if (img && img.src) { const fileIdMatch = img.src.match(/\/cloud\/file_access\/(\d+)/); if (fileIdMatch && fileIdMatch[1]) { blocks.push({ key: `block${blockKey++}`, text: "", type: "atomic", depth: 0, inlineStyleRanges: [], entityRanges: [], data: { type: "IMAGE", src: `${window.location.origin}/api/jx-oresource/cloud/file_access/${fileIdMatch[1]}` } }); } } } } if (currentTextBlock) { blocks.push({ key: `block${blockKey++}`, text: currentTextBlock, type: 'unstyled', depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} }); } if (blocks.length === 0) { blocks.push({ key: 'empty', text: '', type: 'unstyled', depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} }); } const richTextContent = { blocks: blocks, entityMap: {} }; question.answer_items[0].answer = JSON.stringify(richTextContent); } async function buildMultimodalPrompt(provider, question, promptTemplate, customPrompts, currentAnswerContent, extraText = '', paperDescription = null, temporaryPrompt = '') { const questionTypeNum = question.type; let multimodalContent = []; if (temporaryPrompt) { multimodalContent.push({ type: 'text', text: `【临时指令】:\n${temporaryPrompt}\n\n---\n\n` }); } if (paperDescription) { multimodalContent.push({ type: 'text', text: '【作业说明及公共材料】:\n' }); multimodalContent.push(...await parseRichTextToMultimodalContent(paperDescription)); multimodalContent.push({ type: 'text', text: '\n\n---\n\n' }); } const placeholderRegex = /(\{questionTitle\}|\{optionsText\}|\{stemsText\}|\{answerContent\})/g; const templateParts = promptTemplate.split(placeholderRegex); const parseToStandardFormat = async (richText) => { return await parseRichTextToMultimodalContent(richText); }; for (const part of templateParts) { switch (part) { case '{questionTitle}': if (question.parentQuestion && question.parentQuestion.title) { multimodalContent.push(...await parseToStandardFormat(question.parentQuestion.title)); multimodalContent.push({ type: 'text', text: '\n\n--- (子题目) ---\n\n' }); } multimodalContent.push(...await parseToStandardFormat(question.title)); break; case '{optionsText}': if ([1, 2, 5, 12].includes(questionTypeNum)) { for (const [idx, item] of question.answer_items.entries()) { const letter = String.fromCharCode(65 + idx); const prefix = questionTypeNum === 5 ? (idx === 0 ? '正确' : '错误') : ''; multimodalContent.push({ type: 'text', text: `\n${letter}. ${prefix}` }); if (questionTypeNum !== 5) { multimodalContent.push(...await parseToStandardFormat(item.value)); } } } else if (questionTypeNum === 13) { const rightItems = question.answer_items.filter(item => item.is_target_opt); for (const [idx, item] of rightItems.entries()) { const letter = String.fromCharCode(97 + idx); multimodalContent.push({ type: 'text', text: `\n${letter}. ` }); multimodalContent.push(...await parseToStandardFormat(item.value)); } } break; case '{stemsText}': if (questionTypeNum === 13) { const leftItems = question.answer_items.filter(item => !item.is_target_opt); for (const [idx, item] of leftItems.entries()) { const letter = String.fromCharCode(65 + idx); multimodalContent.push({ type: 'text', text: `\n${letter}. ` }); multimodalContent.push(...await parseToStandardFormat(item.value)); } } break; case '{answerContent}': if ([4, 6, 10].includes(questionTypeNum)) { const content = currentAnswerContent !== null ? currentAnswerContent : parseRichTextToPlainText(question.answer_items[0]?.answer || ''); multimodalContent.push({ type: 'text', text: content }); } break; default: if (part) { let textPart = part; textPart = textPart.replace('{questionType}', getQuestionType(question.type)); if (question.type === 10) { const progSetting = question.program_setting || {}; textPart = textPart.replace('{language}', progSetting.language?.join(', ') || '未指定'); textPart = textPart.replace('{max_time}', progSetting.max_time || 'N/A'); textPart = textPart.replace('{max_memory}', progSetting.max_memory || 'N/A'); } multimodalContent.push({ type: 'text', text: textPart }); } } } if (extraText) { multimodalContent.push({ type: 'text', text: `\n\n【听力原文】:\n${extraText}` }); } const mergedContent = []; let textBuffer = ''; for (const item of multimodalContent) { if (item.type === 'text') { textBuffer += item.text; } else { if (textBuffer) { mergedContent.push({ type: 'text', text: textBuffer }); textBuffer = ''; } mergedContent.push(item); } } if (textBuffer) { mergedContent.push({ type: 'text', text: textBuffer }); } return mergedContent; } async function _getAIAnswer(question, aiConfig, customPrompts, temporaryPrompt = '', currentAnswerContent = null, onChunk = null, onComplete = null, signal = null) { if (signal?.aborted) { return Promise.resolve({ cancelled: true }); } const { provider = 'default', endpoint, apiKey, model, temperature, max_tokens, azureApiVersion, visionEnabled, sttEnabled } = aiConfig; const questionTypeNum = question.type; const questionType = getQuestionType(questionTypeNum); const typeCodeStr = String(questionTypeNum); let promptTemplate = customPrompts[typeCodeStr] || defaultPrompts[typeCodeStr]; if (!promptTemplate) { console.warn(`未找到题型 ${questionTypeNum} (${questionType}) 的 Prompt 模板!将跳过此题。`); return Promise.resolve({ skipped: true, reason: `不支持的题型 (${questionType})` }); } const paperDescription = localStorage.getItem('paperDescription'); let transcriptionText = ''; const questionIdForLog = question.parentQuestion ? `${question.parentQuestion.id} (子问题: ${question.id})` : question.id; const hasAudioInSelf = containsAudio(question.title); const hasAudioInParent = question.parentQuestion && containsAudio(question.parentQuestion.title); const hasAudioInPaper = paperDescription && containsAudio(paperDescription); const videoCheckEnabled = aiConfig.sttVideoEnabled === true; const hasVideoInSelf = videoCheckEnabled && containsVideo(question.title); const hasVideoInParent = videoCheckEnabled && question.parentQuestion && containsVideo(question.parentQuestion.title); const hasVideoInPaper = videoCheckEnabled && paperDescription && containsVideo(paperDescription); const isListeningTest = hasAudioInSelf || hasAudioInParent || hasAudioInPaper || hasVideoInSelf || hasVideoInParent || hasVideoInPaper; if (isListeningTest && sttEnabled) { const STT_PROGRESS_ID = `stt-progress-${question.id}`; try { let allMediaBlocks = []; const addedMediaIds = new Set(); const collectMediaBlocks = (richText) => { if (!richText) return; try { const jsonContent = JSON.parse(richText); const mediaBlocks = jsonContent.blocks.filter(block => block.type === 'atomic' && block.data && (block.data.type === 'AUDIO' || block.data.type === 'VIDEO') ); mediaBlocks.forEach(block => { const mediaType = block.data.type; const mediaId = (mediaType === 'AUDIO') ? block.data.data?.quote_id : block.data.data?.video_id; if (mediaId && !addedMediaIds.has(mediaId)) { allMediaBlocks.push(block); addedMediaIds.add(mediaId); } }); } catch (e) { console.error('解析富文本失败:', e, richText); } }; if (hasAudioInPaper || hasVideoInPaper) collectMediaBlocks(paperDescription); if (hasAudioInParent || hasVideoInParent) collectMediaBlocks(question.parentQuestion.title); if (hasAudioInSelf || hasVideoInSelf) collectMediaBlocks(question.title); if (allMediaBlocks.length > 0) { console.log(`[STT流程] 题 ${questionIdForLog}: 发现 ${allMediaBlocks.length} 个媒体文件,开始处理...`); const transcriptionPromises = allMediaBlocks.map(async (mediaBlock, mapIndex) => { const mediaType = mediaBlock.data.type; const mediaId = (mediaType === 'AUDIO') ? mediaBlock.data.data.quote_id : mediaBlock.data.data.video_id; const cacheKey = `${mediaType.toLowerCase()}_transcription_${mediaId}`; if (sttCache[cacheKey]) { console.log(`[STT Cache] HIT for ${mediaType}: ${mediaId}`); return sttCache[cacheKey]; } if (mediaProcessingLocks[mediaId]) { console.log(`[STT Lock] 题 ${questionIdForLog}: 媒体 ${mediaId} 正在被其他任务处理,等待结果...`); return await mediaProcessingLocks[mediaId]; } const processingPromise = (async () => { try { let mediaSource; if (mediaType === 'AUDIO') { mediaSource = await getAudioUrl(mediaId); } else { const urls = await getVideoUrl(mediaId); if (!urls || !urls.videoUrl) throw new Error(`无法获取Video ID ${mediaId}的播放地址`); const progressCallback = (progress, stage) => { const message = `🎬 [${mapIndex + 1}/${allMediaBlocks.length}] 提取视频音轨: ${stage}...(${(progress * 100).toFixed(0)}%)`; showNotification(message, { id: STT_PROGRESS_ID, type: 'info', duration: 0 }); }; mediaSource = await extractAndEncodeAudio(urls.videoUrl, progressCallback); } if (!mediaSource) throw new Error(`无法获取 ${mediaType} ID ${mediaId} 的媒体源`); if (!signal?.aborted) { showNotification(`☁️ [${mapIndex + 1}/${allMediaBlocks.length}] 上传转录 ${mediaType}...`, { id: STT_PROGRESS_ID, type: 'info', duration: 0 }); const transcription = await callSttApi(mediaSource, aiConfig); sttCache[cacheKey] = transcription; return transcription; } return `[${mediaType}转录取消]`; } catch (err) { console.error(`[STT Worker] 媒体 ${mediaId} 处理失败:`, err); throw err; } finally { delete mediaProcessingLocks[mediaId]; } })(); mediaProcessingLocks[mediaId] = processingPromise; return await processingPromise; }); const allTranscriptions = await Promise.all(transcriptionPromises); showNotification('媒体处理完成', { id: STT_PROGRESS_ID, type: 'success', duration: 500 }); if (allTranscriptions.length === 1) { transcriptionText = allTranscriptions[0]; } else { transcriptionText = allTranscriptions .map((text, i) => `【媒体内容 ${i + 1}】:\n${text}`) .join('\n\n---\n\n'); } console.log('[STT流程] 所有媒体处理完成,合并后的文本:', transcriptionText); } else { console.warn(`[STT流程] 听力题 ${questionIdForLog} 中未找到有效的媒体块。`); } } catch (error) { showNotification(`媒体处理失败`, { id: STT_PROGRESS_ID, type: 'error', duration: 3000 }); console.error(`[STT流程] 为题目 ${questionIdForLog} 处理媒体时发生严重错误: ${error.message}`); showNotification(`处理媒体失败,将仅使用题目文本进行AI辅助。`, { type: 'warning' }); transcriptionText = "[语音/视频转录失败]"; } } let finalPrompt; const isVisionRequest = visionEnabled && ['openai', 'gemini', 'anthropic', 'azure'].includes(provider); if (isVisionRequest) { finalPrompt = await buildMultimodalPrompt(provider, question, promptTemplate, customPrompts, currentAnswerContent, transcriptionText, paperDescription, temporaryPrompt); } else { let parentTitleText = question.parentQuestion ? parseRichTextToPlainText(question.parentQuestion.title) : ''; let currentTitleText = parseRichTextToPlainText(question.title); let paperDescriptionText = paperDescription ? parseRichTextToPlainText(paperDescription) : ''; let fullContext = ''; if (paperDescriptionText) { fullContext += `【作业说明及公共材料】:\n${paperDescriptionText}\n\n---\n\n`; } if (parentTitleText) { fullContext += `${parentTitleText}\n\n--- (子题目) ---\n\n`; } let questionTitle = `${fullContext}${currentTitleText}`; if (transcriptionText) { questionTitle += `\n\n【听力/视频原文】:\n${transcriptionText}`; } let optionsText = '', answerContent = '', language = '', max_time = '', max_memory = '', stemsText = ''; if ([1, 2, 5, 12].includes(questionTypeNum)) { optionsText = question.answer_items.map((item, idx) => { const letter = String.fromCharCode(65 + idx); const valueText = questionTypeNum === 5 ? (idx === 0 ? '正确' : '错误') : parseRichTextToPlainText(item.value); return `${letter}. ${valueText}`; }).join('\n'); } else if (questionTypeNum === 4) { answerContent = ''; } else if ([6].includes(questionTypeNum)) { answerContent = currentAnswerContent !== null ? currentAnswerContent : parseRichTextToPlainText(question.answer_items[0]?.answer || ''); } else if (questionTypeNum === 10) { const progSetting = question.program_setting || {}; answerContent = currentAnswerContent !== null ? currentAnswerContent : (progSetting.code_answer || ''); language = progSetting.language?.join(', ') || '未指定'; max_time = progSetting.max_time || 'N/A'; max_memory = progSetting.max_memory || 'N/A'; } else if (questionTypeNum === 13) { const leftItems = question.answer_items.filter(item => !item.is_target_opt); const rightItems = question.answer_items.filter(item => item.is_target_opt); stemsText = leftItems.map((item, idx) => `${String.fromCharCode(65 + idx)}. ${parseRichTextToPlainText(item.value)}`).join('\n'); optionsText = rightItems.map((item, idx) => `${String.fromCharCode(97 + idx)}. ${parseRichTextToPlainText(item.value)}`).join('\n'); } else { return Promise.resolve({ skipped: true, reason: `不支持的题型 (${questionType})` }); } const placeholderValues = { '{questionType}': questionType, '{questionTitle}': questionTitle, '{optionsText}': optionsText, '{stemsText}': stemsText, '{answerContent}': answerContent, '{language}': language, '{max_time}': max_time, '{max_memory}': max_memory }; let promptStr = promptTemplate; for (const placeholder in placeholderValues) { promptStr = promptStr.replace(new RegExp(placeholder.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'g'), placeholderValues[placeholder]); } if (temporaryPrompt) { finalPrompt = `【临时指令】:\n${temporaryPrompt}\n\n---\n\n${promptStr}`; } else { finalPrompt = promptStr; } } console.log(`[AI Helper] 题 ${question.id} (${questionType}) | Provider: ${provider} | Vision: ${isVisionRequest} | STT: ${isListeningTest && sttEnabled} | Final Prompt:`, finalPrompt); return new Promise(async (resolve, reject) => { const handleInternalComplete = (content) => { if (typeof onComplete === 'function') onComplete(content); resolve({ aiResult: content }); }; const handleInternalError = (error) => reject(error); try { if (signal?.aborted) { reject(new DOMException('请求在发送前被中止', 'AbortError')); return; } if (provider === 'default') { await callXiaoyaStream(typeof finalPrompt === 'string' ? finalPrompt : finalPrompt.map(p => p.text).join('\n'), onChunk, handleInternalComplete, handleInternalError, signal); } else if (!endpoint || !apiKey) { throw new Error('请先在 AI 设置中配置 API 地址和 Key'); } else { switch (provider) { case 'openai': await callOpenAI(endpoint, apiKey, finalPrompt, model, temperature, max_tokens, onChunk, handleInternalComplete, handleInternalError, signal, isVisionRequest); break; case 'gemini': await callGemini(endpoint, apiKey, finalPrompt, model, temperature, max_tokens, onChunk, handleInternalComplete, handleInternalError, signal, isVisionRequest); break; case 'anthropic': await callAnthropic(endpoint, apiKey, finalPrompt, model, temperature, max_tokens, onChunk, handleInternalComplete, handleInternalError, signal, isVisionRequest); break; case 'azure': await callAzureOpenAI(endpoint, apiKey, azureApiVersion, model, finalPrompt, temperature, max_tokens, onChunk, handleInternalComplete, handleInternalError, signal, isVisionRequest); break; default: throw new Error(`不支持的 AI 提供商: ${provider}`); } } } catch (error) { reject(error); } }); } async function promptReport(question) { const overlay = document.createElement('div'); overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.7); z-index: 10001; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.3s ease; backdrop-filter: blur(5px); `; const modal = document.createElement('div'); modal.style.cssText = ` background: #ffffff; padding: 32px 40px; border-radius: 20px; width: 500px; max-width: 90%; box-shadow: 0 20px 40px rgba(0,0,0,0.15); transform: scale(0.95); opacity: 0; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); position: relative; `; const title = document.createElement('h2'); title.innerHTML = ` 举报/纠错 `; title.style.cssText = `margin-top: 0; margin-bottom: 25px; text-align: center; color: #1f2937; font-size: 22px;`; const description = document.createElement('p'); description.textContent = '请选择问题类型,并提供简要说明(可选):'; description.style.cssText = 'margin-bottom: 20px; color: #4b5569; text-align: center; font-size: 15px;'; const reportTypes = [ { id: 'wrong_answer', text: '答案错误' }, { id: 'format_error', text: '格式问题' }, { id: 'spam_or_abuse', text: '违规内容' }, { id: 'other', text: '其他问题' } ]; let selectedType = ''; const typeContainer = document.createElement('div'); typeContainer.style.cssText = 'display: flex; justify-content: center; gap: 10px; margin-bottom: 20px; flex-wrap: wrap;'; reportTypes.forEach(type => { const btn = document.createElement('button'); btn.textContent = type.text; btn.dataset.type = type.id; btn.style.cssText = ` padding: 8px 16px; border: 1px solid #d1d5db; border-radius: 8px; cursor: pointer; background-color: #f9fafb; color: #374151; font-weight: 500; transition: all 0.2s ease; `; btn.onclick = () => { selectedType = type.id; typeContainer.querySelectorAll('button').forEach(b => { b.style.backgroundColor = '#f9fafb'; b.style.color = '#374151'; b.style.borderColor = '#d1d5db'; }); btn.style.backgroundColor = '#eef2ff'; btn.style.color = '#4f46e5'; btn.style.borderColor = '#6366f1'; }; typeContainer.appendChild(btn); }); const commentTextarea = document.createElement('textarea'); commentTextarea.placeholder = '请在此处详细说明问题(选填)...'; commentTextarea.rows = 4; commentTextarea.style.cssText = ` width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 10px; font-size: 14px; resize: vertical; margin-bottom: 25px; box-sizing: border-box; outline: none; transition: all 0.2s ease; `; commentTextarea.onfocus = () => { commentTextarea.style.borderColor = '#6366f1'; commentTextarea.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.15)'; }; commentTextarea.onblur = () => { commentTextarea.style.borderColor = '#d1d5db'; commentTextarea.style.boxShadow = 'none'; }; const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = 'display: flex; justify-content: flex-end; gap: 12px;'; const submitButton = document.createElement('button'); submitButton.textContent = '提交反馈'; submitButton.style.cssText = ` padding: 10px 20px; background: #ef4444; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; transition: all 0.2s ease; `; submitButton.onmouseover = () => { submitButton.style.backgroundColor = '#dc2626'; }; submitButton.onmouseout = () => { submitButton.style.backgroundColor = '#ef4444'; }; const cancelButton = document.createElement('button'); cancelButton.textContent = '取消'; cancelButton.style.cssText = ` padding: 10px 20px; background-color: #f3f4f6; color: #4b5563; border: 1px solid #d1d5db; border-radius: 8px; cursor: pointer; font-weight: 500; transition: all 0.2s ease; `; cancelButton.onmouseover = () => { cancelButton.style.backgroundColor = '#e5e7eb'; }; cancelButton.onmouseout = () => { cancelButton.style.backgroundColor = '#f3f4f6'; }; modal.appendChild(title); modal.appendChild(description); modal.appendChild(typeContainer); modal.appendChild(commentTextarea); buttonContainer.appendChild(cancelButton); buttonContainer.appendChild(submitButton); modal.appendChild(buttonContainer); overlay.appendChild(modal); document.body.appendChild(overlay); requestAnimationFrame(() => { overlay.style.opacity = '1'; modal.style.opacity = '1'; modal.style.transform = 'scale(1)'; }); const closeModal = () => { modal.style.transform = 'scale(0.95)'; modal.style.opacity = '0'; overlay.style.opacity = '0'; setTimeout(() => document.body.removeChild(overlay), 300); }; cancelButton.onclick = closeModal; overlay.onclick = (e) => { if (e.target === overlay) closeModal(); }; submitButton.onclick = async () => { if (!selectedType) { showNotification('请至少选择一个问题类型!', { type: 'warning' }); return; } const contentHash = generateContentHash(question); if (!contentHash) { showNotification('无法为此题生成唯一标识,举报失败。', { type: 'error' }); return; } submitButton.disabled = true; submitButton.textContent = '提交中...'; submitButton.style.opacity = '0.7'; submitButton.style.cursor = 'not-allowed'; try { const response = await authedFetch('reportAnswer', { content_hash: contentHash, report_type: selectedType, comment: commentTextarea.value.trim() }); if (response.success) { showNotification(response.message, { type: 'success' }); closeModal(); } else { throw new Error(response.error); } } catch (error) { showNotification(`举报失败: ${error.message}`, { type: 'error' }); submitButton.disabled = false; submitButton.textContent = '提交反馈'; submitButton.style.opacity = '1'; submitButton.style.cursor = 'pointer'; } }; } function createReportButton(question) { const reportButton = document.createElement('button'); reportButton.textContent = '答案有误?'; reportButton.style.cssText = ` background: none; border: none; color: #9ca3af; font-size: 13px; cursor: pointer; transition: color 0.2s; `; reportButton.onmouseover = () => { reportButton.style.color = '#ef4444'; }; reportButton.onmouseout = () => { reportButton.style.color = '#9ca3af'; }; reportButton.onclick = async () => { if (await checkAccountConsistency()) { promptReport(question); } else { console.warn("[操作中止] 因账号不一致,已取消举报操作。"); } }; const actionsContainer = document.createElement('div'); actionsContainer.style.textAlign = 'right'; actionsContainer.style.marginTop = '15px'; actionsContainer.appendChild(reportButton); return actionsContainer; } function attachSttOnlyButtonListeners(container) { const buttons = container.querySelectorAll('[id^="stt-only-btn-"]'); buttons.forEach(button => { if (button.dataset.listenerAttached) return; button.dataset.listenerAttached = 'true'; button.onclick = async () => { const fileId = button.dataset.fileId; const resultContainer = container.querySelector(`#stt-result-container-${fileId}`); const aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}'); if (!aiConfig.sttEnabled) { showNotification('请先在 AI 设置中启用 STT 功能。', { type: 'warning' }); return; } button.disabled = true; button.textContent = '🔄 转录中...'; try { const audioUrl = await getAudioUrl(fileId); if (!audioUrl) throw new Error("无法获取音频URL"); const transcription = await callSttApi(audioUrl, aiConfig); const pre = document.createElement('pre'); pre.textContent = transcription; pre.style.cssText = `white-space: pre-wrap; word-wrap: break-word; margin: 0; font-size: 14px; color: #334155; line-height: 1.6;`; resultContainer.innerHTML = ''; resultContainer.appendChild(pre); resultContainer.style.display = 'block'; button.textContent = '✅ 转录完成'; } catch (error) { console.error('仅转录音频时失败:', error); showNotification(`转录失败: ${error.message}`, { type: 'error' }); button.disabled = false; button.textContent = '🎤 重新尝试转录'; } }; }); } function attachVideoSttButtonListeners(container) { const buttons = container.querySelectorAll('[id^="video-stt-btn-"]'); buttons.forEach(button => { if (button.dataset.listenerAttached) return; button.dataset.listenerAttached = 'true'; button.onclick = async () => { const videoId = button.id.replace('video-stt-btn-', ''); const videoUrl = button.dataset.videoUrl; const resultContainer = container.querySelector(`#video-stt-result-container-${videoId}`); const aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}'); if (!aiConfig.sttEnabled) { showNotification('请先在 AI 设置中启用 STT 功能。', { type: 'warning' }); return; } button.disabled = true; button.innerHTML = '🔄 处理中...'; const progressTextSpan = button.querySelector('.progress-text'); const updateProgress = (progress, stage) => { if (progressTextSpan) { const percentage = (progress * 100).toFixed(0); progressTextSpan.textContent = `${stage}... (${percentage}%)`; } }; try { if (!videoUrl) throw new Error("无效的视频URL"); const audioBlob = await extractAndEncodeAudio(videoUrl, updateProgress); if (progressTextSpan) progressTextSpan.textContent = '上传转录中...'; const transcription = await callSttApi(audioBlob, aiConfig); const pre = document.createElement('pre'); pre.textContent = transcription; pre.style.cssText = `white-space: pre-wrap; word-wrap: break-word; margin: 0; font-size: 14px; color: #334155; line-height: 1.6;`; resultContainer.innerHTML = ''; resultContainer.appendChild(pre); resultContainer.style.display = 'block'; button.textContent = '✅ 转录完成'; } catch (error) { console.error('视频音频转录失败:', error); showNotification(`视频音频转录失败: ${error.message}`, { type: 'error' }); button.disabled = false; button.innerHTML = '🎬 重新尝试转录'; } }; }); } function showAnswerEditor() { const questionTypeStyles = { '1': { text: '单选题', bg: '#eef2ff', color: '#4338ca' }, '2': { text: '多选题', bg: '#e0f2fe', color: '#0369a1' }, '4': { text: '填空题', bg: '#f0fdf4', color: '#15803d' }, '5': { text: '判断题', bg: '#fdf2f8', color: '#9d174d' }, '6': { text: '简答题', bg: '#fffbeb', color: '#b45309' }, '9': { text: '数组题', bg: '#f3f4f6', color: '#475569' }, '10': { text: '编程题', bg: '#1f2937', color: '#e5e7eb' }, '12': { text: '排序题', bg: '#f5f3ff', color: '#6d28d9' }, '13': { text: '匹配题', bg: '#fefce8', color: '#a16207' }, 'default': { text: '未知', bg: '#f1f5f9', color: '#475569' } }; sttCache = {}; let storedData = localStorage.getItem('answerData'); if (!storedData) { showNotification('未找到存储的数据,请先点击"获取答案"按钮。', { type: 'error', keywords: ['存储', '答案', '获取'], animation: 'fadeSlide' }); return; } let isContentModified = false; let answerData = JSON.parse(storedData); let overlay = document.createElement('div'); let modalContainer = document.createElement('div'); let resizeHandle = document.createElement('div'); let dragHandle = document.createElement('div'); let closeButton = document.createElement('button'); let modalContentWrapper = document.createElement('div'); let title = document.createElement('h2'); let saveButton = document.createElement('button'); overlay.style.position = 'fixed'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.width = '100%'; overlay.style.height = '100%'; overlay.style.backgroundColor = 'transparent'; overlay.style.pointerEvents = 'none'; overlay.style.zIndex = '9999'; overlay.style.opacity = '0'; overlay.style.transition = 'opacity 0.3s ease-in-out'; modalContainer.id = 'modal-container'; modalContainer.style.cssText = ` position: fixed; top: 50%; left: 50%; z-index: 10000; width: 90%; max-width: 1500px; height: 85vh; min-width: 400px; background-color: #ffffff; border-radius: 20px; padding: 48px 32px 32px 32px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); opacity: 0; transition: opacity 0.3s ease; display: flex; flex-direction: column; `; resizeHandle.style.cssText = ` position: absolute; right: 2px; bottom: 2px; width: 20px; height: 20px; cursor: nw-resize; border-radius: 0 0 18px 0; background: linear-gradient(135deg, transparent 25%, #e2e8f0 25%, #e2e8f0 37%, #6366f1 37%, #6366f1 50%, transparent 50%, transparent 62%, #6366f1 62%, #6366f1 75%, transparent 75% ); opacity: 0.6; `; resizeHandle.addEventListener('mouseenter', () => { resizeHandle.style.opacity = '1'; resizeHandle.style.transform = 'scale(1.1)'; }); resizeHandle.addEventListener('mouseleave', () => { resizeHandle.style.opacity = '0.6'; resizeHandle.style.transform = 'scale(1)'; }); let isResizing = false; let originalWidth, originalHeight, originalX, originalY; resizeHandle.addEventListener('mousedown', (e) => { isResizing = true; const rect = modalContainer.getBoundingClientRect(); originalWidth = rect.width; originalHeight = rect.height; originalX = e.clientX; originalY = e.clientY; modalContainer.style.transform = 'none'; modalContainer.style.top = rect.top + 'px'; modalContainer.style.left = rect.left + 'px'; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!isResizing) return; const newWidth = originalWidth + (e.clientX - originalX); const newHeight = originalHeight + (e.clientY - originalY); const minWidth = 400; const minHeight = 300; if (newWidth >= minWidth) { modalContainer.style.width = newWidth + 'px'; } if (newHeight >= minHeight) { modalContainer.style.height = newHeight + 'px'; } }); document.addEventListener('mouseup', () => { isResizing = false; }); dragHandle.style.cssText = ` position: absolute; top: 0; left: 0; right: 0; height: 48px; cursor: move; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; background: linear-gradient(to right, rgba(243, 244, 246, 0.95), rgba(243, 244, 246, 0.5)); border-radius: 20px 20px 0 0; user-select: none; transition: all 0.3s ease; `; dragHandle.innerHTML = `
点击此处拖动窗口
`; dragHandle.onmouseover = () => { dragHandle.style.background = 'linear-gradient(to right, rgba(243, 244, 246, 1), rgba(243, 244, 246, 0.8))'; dragHandle.style.transform = 'translateY(1px)'; }; dragHandle.onmouseout = () => { dragHandle.style.background = 'linear-gradient(to right, rgba(243, 244, 246, 0.95), rgba(243, 244, 246, 0.5))'; dragHandle.style.transform = 'translateY(0)'; }; let isDragging = false; let currentX; let currentY; let initialX; let initialY; function onDragStart(e) { if (e.target !== dragHandle) return; isDragging = true; const point = e.touches ? e.touches[0] : e; const rect = modalContainer.getBoundingClientRect(); initialX = point.clientX - rect.left; initialY = point.clientY - rect.top; modalContainer.style.transition = 'none'; modalContainer.style.transform = 'none'; modalContainer.style.left = `${rect.left}px`; modalContainer.style.top = `${rect.top}px`; } function onDragMove(e) { if (!isDragging) return; e.preventDefault(); const point = e.touches ? e.touches[0] : e; currentX = point.clientX - initialX; currentY = point.clientY - initialY; const maxX = window.innerWidth - modalContainer.offsetWidth; const maxY = window.innerHeight - modalContainer.offsetHeight; currentX = Math.min(Math.max(0, currentX), maxX); currentY = Math.min(Math.max(0, currentY), maxY); modalContainer.style.left = `${currentX}px`; modalContainer.style.top = `${currentY}px`; } function onDragEnd() { isDragging = false; modalContainer.style.transition = 'opacity 0.3s ease'; } dragHandle.addEventListener('mousedown', onDragStart, false); dragHandle.addEventListener('touchstart', onDragStart, { passive: false }); document.addEventListener('mousemove', onDragMove, false); document.addEventListener('touchmove', onDragMove, { passive: false }); document.addEventListener('mouseup', onDragEnd, false); document.addEventListener('touchend', onDragEnd, false); function cleanup() { dragHandle.removeEventListener('mousedown', onDragStart); dragHandle.removeEventListener('touchstart', onDragStart); document.removeEventListener('mousemove', onDragMove); document.removeEventListener('touchmove', onDragMove); document.removeEventListener('mouseup', onDragEnd); document.removeEventListener('touchend', onDragEnd); } overlay.onclick = (e) => { if (e.target === overlay) { cleanup(); closeModal(); } }; modalContentWrapper.id = 'modal-content-wrapper'; modalContentWrapper.style.cssText = ` display: flex; gap: 20px; flex: 1; overflow: hidden; `; closeButton.innerHTML = ` `; closeButton.style.cssText = ` position: absolute; top: 15px; right: 15px; background: #f3f4f6; border: none; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; color: #6b7280; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; box-shadow: 0 2px 5px rgba(0,0,0,0.08); `; const closeModal = async (forceClose = false) => { if (isContentModified && !forceClose) { const confirmed = await showConfirmNotification( '你有未保存的修改,确定要关闭吗?所有改动将会丢失。', { animation: 'scale', title: '确认关闭', confirmText: '仍要关闭', cancelText: '取消' } ); if (!confirmed) { showNotification('操作已取消。', { type: 'info' }); return; } } if (areAITasksRunning()) { const confirmed = await showConfirmNotification( 'AI 任务正在进行中。确定要关闭并中止所有 AI 请求吗?', { animation: 'scale' } ); if (!confirmed) { showNotification('操作已取消,AI 任务将继续。', { type: 'info' }); return; } cancelAllAITasks(); showNotification('所有 AI 任务已中止。', { type: 'warning' }); } modalContainer.style.transition = 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)'; modalContainer.style.transform = 'none'; modalContainer.style.left = '50%'; modalContainer.style.top = '50%'; requestAnimationFrame(() => { overlay.style.opacity = '0'; modalContainer.style.opacity = '0'; modalContainer.style.transform = 'translate(-50%, -50%) scale(0.95)'; }); sttCache = {}; setTimeout(() => { if (document.body.contains(overlay)) { document.body.removeChild(overlay); } if (document.body.contains(modalContainer)) { document.body.removeChild(modalContainer); } document.removeEventListener('keydown', handleEscapeKey); }, 400); }; function createAIButton(targetInput, question) { let aiButton = document.createElement('button'); aiButton.innerHTML = '🤖AI 辅助'; aiButton.className = 'ai-assist-btn'; aiButton.title = '使用 AI 生成答案建议'; let isLoading = false; aiButton.abortController = null; aiButton.onclick = async () => { if (isLoading) { if (aiButton.abortController) { aiButton.abortController.abort(); console.log("AI request aborted by user."); } return; } const aiConfig = { provider: 'default', ...JSON.parse(localStorage.getItem('aiConfig') || '{}') }; const customPrompts = JSON.parse(localStorage.getItem('aiCustomPrompts') || '{}'); const provider = aiConfig.provider || 'default'; const abortController = new AbortController(); aiButton.abortController = abortController; registerAIController(abortController); isLoading = true; aiButton.className = 'ai-assist-btn loading'; aiButton.innerHTML = '取消'; aiButton.title = '点击取消生成'; try { const temporaryPrompt = document.getElementById('temporary-ai-prompt-textarea')?.value.trim() || ''; const result = await callAIForQuestion(question, targetInput, aiConfig, customPrompts, abortController.signal, temporaryPrompt); if (result.skipped) { showNotification(`题型 "${getQuestionType(question.type)}" 暂不支持 AI 辅助`, { type: 'warning' }); } else if (result.success !== false && !result.cancelled) { showNotification(`AI (${provider === 'default' ? '小雅' : provider}) 已成功生成答案建议。`, { type: 'success', animation: 'scale' }); } } catch (error) { if (error.name === 'AbortError') { showNotification('AI 生成已取消', { type: 'warning', animation: 'scale' }); } else { console.error('AI 请求失败 (来自 createAIButton):', error); showNotification(`AI 生成失败 (${provider}): ${error.message}`, { type: 'error' }); } } finally { isLoading = false; aiButton.className = 'ai-assist-btn'; aiButton.innerHTML = '🤖AI 辅助'; aiButton.title = '使用 AI 生成答案建议'; if (aiButton.abortController) { activeAIControllers.delete(aiButton.abortController); } aiButton.abortController = null; } }; return aiButton; } async function callAIForQuestion(question, targetElement, aiConfig, customPrompts, signal = null, temporaryPrompt = '') { if (signal?.aborted) { console.log(`[AI辅助] 题 ${question.id} 请求在 callAIForQuestion 开始前已取消`); return { cancelled: true }; } const questionTypeNum = question.type; let originalContent = null; let thinkingContainer = null; if (targetElement) { const subQuestionContainer = targetElement.closest('div[data-subquestion-id]'); if (subQuestionContainer) { thinkingContainer = subQuestionContainer; console.log(`[思维链容器] 已定位到子题目容器:`, subQuestionContainer); } else { thinkingContainer = targetElement.closest('.question-editor-container'); console.log(`[思维链容器] 已定位到主题目容器:`, thinkingContainer); } if (!thinkingContainer) { thinkingContainer = targetElement.parentElement || document.body; console.warn(`[思维链容器] 未找到标准容器,回退至父元素。`); } if (questionTypeNum === 4) { originalContent = Array.from(targetElement.querySelectorAll('input')).map(input => input.value); } else if (questionTypeNum === 6) { originalContent = targetElement.innerHTML || ''; } else if (questionTypeNum === 10) { originalContent = targetElement.value || ''; } } if (!thinkingContainer) { console.warn(`[AI辅助] 题 ${question.id}: 未找到 thinkingContainer`); thinkingContainer = document.body; } const thinkingHandler = new ThinkingHandler(thinkingContainer); thinkingHandler.reset(); const onUpdateTarget = (contentToAdd) => { if (questionTypeNum === 4) return; requestAnimationFrame(() => { if (!targetElement) return; if (questionTypeNum === 6) { targetElement.appendChild(document.createTextNode(contentToAdd)); targetElement.scrollTop = targetElement.scrollHeight; } else if (questionTypeNum === 10) { targetElement.value += contentToAdd; targetElement.scrollTop = targetElement.scrollHeight; } if (targetElement.dispatchEvent) { targetElement.dispatchEvent(new Event('input', { bubbles: true })); } }); }; const onFinalizeTarget = (finalContent) => { console.log(`[AI辅助] Finalizing target for question ${question.id}`); if (questionTypeNum === 4) { if (!targetElement) return { success: false, reason: "Target element for fill-in-blanks not found" }; try { const cleanedContent = finalContent.replace(/```json\n?([\s\S]+?)\n?```/g, '$1').trim(); const answers = JSON.parse(cleanedContent); if (!Array.isArray(answers)) { throw new Error("AI返回的不是一个数组"); } const inputs = targetElement.querySelectorAll('input'); inputs.forEach((input, index) => { if (answers[index] !== undefined) { input.value = answers[index]; input.dispatchEvent(new Event('input', { bubbles: true })); } }); return { success: true }; } catch (error) { console.error(`[AI填空题] 解析或应用答案失败 (题 ${question.id}):`, error, "原始返回:", finalContent); showNotification('AI返回的填空题答案格式错误,无法应用。请检查是否为JSON数组。', { type: 'error' }); return { success: false, reason: "JSON parsing failed or invalid format" }; } } else if (questionTypeNum === 1 || questionTypeNum === 2 || questionTypeNum === 5) { if (!targetElement) { console.error(`[AI辅助] 题 ${question.id}: 无法最终确定选项,因为 targetElement 为空`); return { success: false, reason: "Target element for choices not found during finalization" }; } const selectedLetters = finalContent.toUpperCase().replace(/[^A-Z,]/g, '').split(',').filter(l => l); if (selectedLetters.length > 0) { const optionLabels = targetElement.querySelectorAll('label'); let changed = false; question.answer_items.forEach(item => { item.answer_checked = 1; }); optionLabels.forEach((label) => { const input = label.querySelector('input'); if (input) input.checked = false; const customCheckbox = label.querySelector('span[style*="background-color"]'); if (customCheckbox) { customCheckbox.style.backgroundColor = '#e5e7eb'; const toggleCircle = customCheckbox.firstChild; if (toggleCircle) toggleCircle.style.left = '2px'; const icon = toggleCircle ? toggleCircle.firstChild : null; if (icon) icon.innerHTML = ''; } }); optionLabels.forEach((label, idx) => { const currentLetter = String.fromCharCode(65 + idx); if (selectedLetters.includes(currentLetter)) { question.answer_items[idx].answer_checked = 2; const input = label.querySelector('input'); if (input) { input.checked = true; } changed = true; const customCheckbox = label.querySelector('span[style*="background-color"]'); if (customCheckbox) { customCheckbox.style.backgroundColor = '#6366f1'; const toggleCircle = customCheckbox.firstChild; if (toggleCircle) toggleCircle.style.left = '22px'; const icon = toggleCircle ? toggleCircle.firstChild : null; if (icon) icon.innerHTML = ''; } } }); if (!changed) { console.warn(`[AI辅助] 题 ${question.id}: AI 未能识别出有效选项字母: ${finalContent}`); return { success: false, reason: `AI 未识别有效选项: ${finalContent}` }; } return { success: true }; } else { console.warn(`[AI辅助] 题 ${question.id}: AI 未能识别出有效选项字母: ${finalContent}`); return { success: false, reason: `AI 未识别有效选项: ${finalContent}` }; } } else if (questionTypeNum === 10 && targetElement) { const codeBlockMatch = finalContent.match(/^```(?:\w*\n)?([\s\S]*?)```$/); const code = codeBlockMatch ? codeBlockMatch[1].trim() : finalContent.trim(); targetElement.value = code; if (question.program_setting) { question.program_setting.code_answer = code; } else { question.program_setting = { code_answer: code }; } targetElement.dispatchEvent(new Event('input', { bubbles: true })); } else if (questionTypeNum === 12 && targetElement) { try { const cleanedContent = finalContent.replace(/```json\n?([\s\S]+?)\n?```/g, '$1').trim(); const orderedLetters = JSON.parse(cleanedContent); if (!Array.isArray(orderedLetters)) { throw new Error("AI返回的不是一个数组"); } const itemMap = new Map(question.answer_items.map((item, index) => { return [String.fromCharCode(65 + index), { id: item.id, item: item }]; })); const newSortedItems = orderedLetters.map(letter => itemMap.get(letter.toUpperCase())?.item).filter(Boolean); if (newSortedItems.length !== question.answer_items.length) { console.warn(`[AI排序题] AI返回的项数量与原始项数量不匹配。AI: ${newSortedItems.length}, 原始: ${question.answer_items.length}`); } newSortedItems.forEach((item, newIndex) => { item.answer = (newIndex + 1).toString(); }); const currentUiItems = Array.from(targetElement.children); const uiItemMap = new Map(currentUiItems.map(uiItem => { const itemText = uiItem.querySelector('div[style*="flex: 1"]').textContent.trim(); return [itemText, uiItem]; })); targetElement.innerHTML = ''; newSortedItems.forEach((sortedItemData, index) => { const itemTextContent = parseRichTextToPlainText(sortedItemData.value).trim(); const correspondingUiItem = uiItemMap.get(itemTextContent); if (correspondingUiItem) { correspondingUiItem.querySelector('div[style*="width: 28px"]').textContent = index + 1; correspondingUiItem.dataset.index = index; targetElement.appendChild(correspondingUiItem); } else { console.error(`[AI排序题] 无法在现有UI中找到与数据匹配的项: "${itemTextContent}"`); } }); } catch (error) { console.error(`[AI排序题] 解析或应用排序题答案失败 (题 ${question.id}):`, error, "原始返回:", finalContent); showNotification('AI返回的排序结果格式错误,无法应用。', { type: 'error' }); return { success: false, reason: "JSON parsing failed or invalid format" }; } } else if (questionTypeNum === 13 && targetElement) { try { const cleanedContent = finalContent.replace(/```json\n?([\s\S]+?)\n?```/g, '$1').trim(); const matches = JSON.parse(cleanedContent); const leftItems = question.answer_items.filter(item => !item.is_target_opt); const rightItems = question.answer_items.filter(item => item.is_target_opt); Object.entries(matches).forEach(([leftLetter, rightLetter]) => { const leftIndex = leftLetter.toUpperCase().charCodeAt(0) - 65; const rightIndex = rightLetter.toLowerCase().charCodeAt(0) - 97; if (leftItems[leftIndex] && rightItems[rightIndex]) { leftItems[leftIndex].answer = rightItems[rightIndex].id; } }); const matchItemsUI = targetElement.querySelectorAll('div[data-matching-item="true"]'); matchItemsUI.forEach((matchItemUI, index) => { if (typeof matchItemUI._updateUI === 'function') { matchItemUI._updateUI(); } }); targetElement.dispatchEvent(new Event('input', { bubbles: true })); } catch (error) { console.error(`[AI匹配题] 解析或应用匹配题答案失败:`, error, "原始返回:", finalContent); showNotification('AI返回的匹配结果格式错误。', { type: 'error' }); return { success: false, reason: "JSON parsing failed" }; } } else if ([6].includes(questionTypeNum) && targetElement) { updateAnswerWithContent(question, targetElement.innerHTML); } return { success: true }; }; const streamProcessor = new StreamProcessor(targetElement, questionTypeNum, thinkingHandler, onUpdateTarget, onFinalizeTarget); streamProcessor.reset(); if (targetElement) { if (questionTypeNum === 4) { targetElement.querySelectorAll('input').forEach(input => input.value = ''); } else if (questionTypeNum === 6) { targetElement.textContent = ''; } else if (questionTypeNum === 10) { targetElement.value = ''; } } try { const result = await _getAIAnswer( question, aiConfig, customPrompts, temporaryPrompt, originalContent, streamProcessor.processChunk.bind(streamProcessor), () => streamProcessor.processComplete(), signal ); if (signal?.aborted) { console.log(`[AI辅助] 题 ${question.id} 请求在 _getAIAnswer 返回后检测到取消`); return { cancelled: true }; } if (result.cancelled) { console.log(`[AI辅助] 题 ${question.id} 在 _getAIAnswer 中被取消`); return { cancelled: true }; } if (result.skipped) { return { skipped: true, reason: result.reason }; } isContentModified = true; const finalizationOutcome = onFinalizeTarget(result.aiResult); return finalizationOutcome || { success: true }; } catch (error) { console.error(`[AI辅助] 题 ${question.id} (${getQuestionType(question.type)}) 处理失败:`, error); showNotification(`AI辅助失败: ${error.message}`, { type: 'error', animation: 'scale' }); thinkingHandler.hide(); const restoreOriginalContent = () => { if (originalContent !== null && targetElement) { console.log(`[AI辅助] 恢复问题 ${question.id} 的原始内容 (因错误或取消)`); if (questionTypeNum === 4) { targetElement.querySelectorAll('input').forEach((input, index) => { if (originalContent[index] !== undefined) { input.value = originalContent[index]; input.dispatchEvent(new Event('input', { bubbles: true })); } }); } else if (questionTypeNum === 6) { targetElement.innerHTML = originalContent; } else if (questionTypeNum === 10) { targetElement.value = originalContent; } if (targetElement.dispatchEvent) { targetElement.dispatchEvent(new Event('input', { bubbles: true })); } } }; restoreOriginalContent(); if (error.name === 'AbortError') { console.log(`[AI辅助] 题 ${question.id} 请求被取消 (捕获于 callAIForQuestion catch)`); return { cancelled: true }; } return { success: false, reason: error.message }; } } async function startAIAssistAll(answerData, modalContainer) { const confirmed = await showConfirmNotification( '即将为所有支持的题目触发 AI 请求。请确保你的 AI 设置正确。是否继续?', { animation: 'scale' } ); if (!confirmed) return; if (currentBatchAbortController) { console.log("[AI辅助] 检测到正在进行的任务,正在取消..."); currentBatchAbortController.abort(); await new Promise(resolve => setTimeout(resolve, 100)); } currentBatchAbortController = new AbortController(); registerAIController(currentBatchAbortController); const signal = currentBatchAbortController.signal; const aiConfig = { provider: 'default', ...JSON.parse(localStorage.getItem('aiConfig') || '{}') }; const provider = aiConfig.provider; if (provider !== 'default' && (!aiConfig.endpoint || !aiConfig.apiKey)) { showNotification('请先在 AI 设置中配置 API 地址和 Key', { type: 'error' }); currentBatchAbortController = null; return; } const temporaryPrompt = document.getElementById('temporary-ai-prompt-textarea')?.value.trim() || ''; if (temporaryPrompt) { showNotification('批量任务将使用您提供的临时提示词。', { type: 'info' }); } const concurrencyValue = parseInt(aiConfig.batchConcurrency, 10) || 1; const customPrompts = JSON.parse(localStorage.getItem('aiCustomPrompts') || '{}'); const progress = createProgressBar(); progress.show(); let processedCount = 0; let skippedCount = 0; let errorCount = 0; let cancelledCount = 0; let stopProcessing = false; const questionElements = modalContainer.querySelectorAll('.question-editor-container'); const questionsToProcess = []; const individualAiButtons = []; questionElements.forEach((qContainer, index) => { const aiButton = qContainer.querySelector('.ai-assist-btn'); if (aiButton) { aiButton.disabled = true; aiButton.style.opacity = '0.5'; aiButton.style.cursor = 'not-allowed'; individualAiButtons.push(aiButton); } const question = answerData[index]; if (!question) return; let targetElement = null; const questionTypeNum = question.type; if ([1, 2, 5].includes(questionTypeNum)) { targetElement = qContainer.querySelector('div[style*="display: grid"]'); } else if (questionTypeNum === 4) { targetElement = qContainer.querySelector('input[id^="blank-input-"]')?.closest('div[style*="display: flex; flex-direction: column;"]'); } else if (questionTypeNum === 6) { targetElement = qContainer.querySelector('div[contenteditable="true"]'); } else if (questionTypeNum === 10) { targetElement = qContainer.querySelector('textarea'); } else if (questionTypeNum === 12) { targetElement = qContainer.querySelector('div[data-sortable-container="true"]'); } else if (questionTypeNum === 13) { targetElement = qContainer.querySelector('div[data-matching-container="true"]'); } else if (question.type === 9 && question.subQuestions) { const subQuestionContainers = qContainer.querySelectorAll('div[data-subquestion-id]'); subQuestionContainers.forEach(subContainer => { const subIndex = parseInt(subContainer.dataset.subquestionIndex, 10); const subQuestion = question.subQuestions[subIndex]; if (!subQuestion) return; let subTargetElement = null; const subTypeNum = subQuestion.type; if ([1, 2, 5].includes(subTypeNum)) { subTargetElement = subContainer.querySelector('div[style*="display: grid"]'); } else if (subTypeNum === 4) { subTargetElement = subContainer.querySelector('input[id^="blank-input-"]')?.closest('div[style*="display: flex; flex-direction: column;"]'); } else if (subTypeNum === 6) { subTargetElement = subContainer.querySelector('div[contenteditable="true"]'); } else if (subTypeNum === 10) { subTargetElement = subContainer.querySelector('textarea'); } if (subTargetElement) { questionsToProcess.push({ question: subQuestion, element: subTargetElement }); } else { console.warn(`跳过数组题的子问题 ${index + 1}.${subIndex + 1} (类型 ${subTypeNum}),未找到目标元素`); skippedCount++; } }); return; } if (targetElement) { questionsToProcess.push({ question, element: targetElement }); } else { console.warn(`跳过问题 ${index + 1} (类型 ${questionTypeNum}),未找到目标元素或不支持`); skippedCount++; } }); const totalQuestions = questionsToProcess.length; const aiAssistAllButton = modalContainer.querySelector('#ai-assist-all-btn'); const saveButton = modalContainer.querySelector('button[style*="background-color: #4f46e5;"]'); const originalButtonHTML = aiAssistAllButton.innerHTML; aiAssistAllButton.disabled = true; aiAssistAllButton.style.opacity = '0.6'; aiAssistAllButton.style.cursor = 'not-allowed'; aiAssistAllButton.innerHTML = ` 处理中... `; if (!document.getElementById('spin-animation-style')) { const style = document.createElement('style'); style.id = 'spin-animation-style'; style.textContent = `@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`; document.head.appendChild(style); } const cancelButton = document.createElement('button'); cancelButton.textContent = '取消处理'; cancelButton.style.cssText = ` width: 100%; margin-bottom: 15px; padding: 12px 24px; font-size: 16px; border: none; border-radius: 12px; background-color: #ef4444; color: white; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 4px 6px rgba(239, 68, 68, 0.2); `; cancelButton.onclick = () => { stopProcessing = true; if (currentBatchAbortController) { console.log('[AI辅助] 请求取消,发送 abort 信号...'); currentBatchAbortController.abort(); } else { console.log('[AI辅助] 请求取消,但没有进行中的批量任务。'); } showNotification('AI 批量处理取消中...', { type: 'warning' }); cancelButton.disabled = true; cancelButton.style.opacity = '0.6'; cancelButton.style.cursor = 'not-allowed'; }; if (saveButton && saveButton.parentNode) { saveButton.parentNode.insertBefore(cancelButton, saveButton); } else { modalContainer.appendChild(cancelButton); } try { if (concurrencyValue <= 1) { progress.update(0, totalQuestions, `开始顺序处理`); aiAssistAllButton.innerHTML = `...顺序处理中...`; for (let i = 0; i < totalQuestions; i++) { if (stopProcessing || signal.aborted) { showNotification('手动停止处理成功。', { type: 'warning' }); cancelledCount = totalQuestions - (processedCount + skippedCount + errorCount); break; } const { question, element } = questionsToProcess[i]; const questionType = getQuestionType(question.type); const currentProgress = i + 1; progress.update(currentProgress, totalQuestions, `[顺序] 处理 ${questionType}`); try { const result = await callAIForQuestion(question, element, aiConfig, customPrompts, signal, temporaryPrompt); if (result.cancelled) { cancelledCount++; console.log(`[顺序] 问题 ${i + 1} 已取消`); } else if (result.skipped) { console.warn(`[顺序] callAIForQuestion 内部跳过了问题 ${i + 1}: ${result.reason}`); errorCount++; } else if (result.success === false) { errorCount++; console.error(`[顺序] 处理问题 ${i + 1} 失败: ${result.reason}`); } else { processedCount++; } } catch (error) { if (error.name === 'AbortError') { cancelledCount++; console.log(`[顺序] 问题 ${i + 1} 请求被取消 (捕获于 startAIAssistAll loop catch)`); } else { errorCount++; console.error(`[顺序] 处理问题 ${i + 1} 时发生严重错误:`, error); } } const requestInterval = parseInt(aiConfig.requestInterval, 10) || 200; if (requestInterval > 0 && !signal.aborted) { await new Promise(resolve => setTimeout(resolve, requestInterval)); } } } else { progress.update(0, totalQuestions, `开始并发处理`); aiAssistAllButton.innerHTML = `...并发处理中... (并发: 0/${concurrencyValue})`; const queue = [...questionsToProcess]; let running = 0; let resolveCompletion; const completionPromise = new Promise(resolve => { resolveCompletion = resolve; }); const processNext = async () => { while (running < concurrencyValue && queue.length > 0) { if (stopProcessing || signal.aborted) return; const task = queue.shift(); const completedCount = processedCount + errorCount + cancelledCount; const { question, element } = task; const questionType = getQuestionType(question.type); running++; progress.update(completedCount, totalQuestions, `[并发] 处理 ${questionType}`); aiAssistAllButton.innerHTML = `...并发处理中... (并发: ${running}/${concurrencyValue})`; function handleResult(result) { if (result.cancelled) cancelledCount++; else if (result.skipped) errorCount++; else if (result.success === false) errorCount++; else processedCount++; } function handleError(error) { if (error.name === 'AbortError') cancelledCount++; else errorCount++; } function handleFinally() { running--; const finalCompleted = processedCount + errorCount + cancelledCount; progress.update(finalCompleted, totalQuestions, `[并发] 处理完成`); aiAssistAllButton.innerHTML = `...并发处理中... (并发: ${running}/${concurrencyValue})`; if (queue.length === 0 && running === 0) { if (resolveCompletion) resolveCompletion(); } else if (!stopProcessing && !signal.aborted) { processNext(); } else if (resolveCompletion) { resolveCompletion(); } } callAIForQuestion(question, element, aiConfig, customPrompts, signal, temporaryPrompt) .then(handleResult) .catch(handleError) .finally(handleFinally); } }; const initialTasks = Math.min(concurrencyValue, queue.length); for (let i = 0; i < initialTasks; i++) { if (signal.aborted) break; processNext(); } if (initialTasks === 0 && queue.length === 0) { resolveCompletion(); } await completionPromise; if (stopProcessing || signal.aborted) { cancelledCount = totalQuestions - (processedCount + errorCount); } } } catch (e) { console.error("AI 批量处理主逻辑出错:", e); showNotification("AI 批量处理过程中发生意外错误。", { type: 'error' }); errorCount = totalQuestions - processedCount - skippedCount - cancelledCount; } finally { progress.hide(); aiAssistAllButton.disabled = false; aiAssistAllButton.style.opacity = '1'; aiAssistAllButton.style.cursor = 'pointer'; aiAssistAllButton.innerHTML = originalButtonHTML; if (cancelButton.parentNode) { cancelButton.parentNode.removeChild(cancelButton); } individualAiButtons.forEach(btn => { btn.disabled = false; btn.style.opacity = '1'; btn.style.cursor = 'pointer'; }); const finalProcessed = processedCount; const finalSkipped = skippedCount; const finalCancelled = cancelledCount; const finalError = errorCount; let summaryMessage = `AI 批量处理完成:成功 ${finalProcessed} 个`; if (finalSkipped > 0) summaryMessage += `,跳过 ${finalSkipped} 个`; if (finalCancelled > 0) summaryMessage += `,取消 ${finalCancelled} 个`; if (finalError > 0) summaryMessage += `,失败 ${finalError} 个`; summaryMessage += '。请检查结果。'; showNotification(summaryMessage, { type: finalError > 0 ? 'warning' : (finalCancelled > 0 ? 'info' : 'success'), duration: 8000, keywords: ['批量处理', '完成', '成功', '跳过', '取消', '失败'] }); if (currentBatchAbortController && !currentBatchAbortController.signal.aborted) { activeAIControllers.delete(currentBatchAbortController); } currentBatchAbortController = null; } } async function handleChoiceQuestion(question, container) { let optionsContainer = document.createElement('div'); optionsContainer.style.display = 'grid'; optionsContainer.style.gap = '12px'; const stats = question.statistics; let totalOptionVotes = 0; if (stats && stats.options) { totalOptionVotes = stats.options.reduce((sum, opt) => sum + opt.count, 0); } for (const [idx, item] of question.answer_items.entries()) { let optionLabel = document.createElement('label'); optionLabel.style.cssText = ` display: flex; align-items: center; padding: 20px; background-color: #ffffff; border-radius: 16px; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative; border: 2px solid #f1f5f9; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); overflow: hidden; min-height: 60px; `; optionLabel.onmouseover = () => { optionLabel.style.borderColor = '#c7d2fe'; optionLabel.style.transform = 'translateY(-2px)'; optionLabel.style.boxShadow = '0 8px 16px rgba(0, 0, 0, 0.08)'; }; optionLabel.onmouseout = () => { optionLabel.style.borderColor = '#f1f5f9'; optionLabel.style.transform = 'translateY(0)'; optionLabel.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.04)'; }; const stats = question.statistics; let percentage = 0; let count = 0; if (stats && stats.totalRespondents > 0) { const optionStat = stats.options.find(opt => opt.id === item.id); if (optionStat) { count = optionStat.count; if (totalOptionVotes > 0) { percentage = (count / totalOptionVotes) * 100; } } } if (stats) { const isCorrectAnswer = item.answer_checked === 2; const statsBarContainer = document.createElement('div'); statsBarContainer.style.cssText = ` position: absolute; left: 0; top: 0; height: 100%; width: 100%; background-color: transparent; z-index: 0; `; const statsBarFill = document.createElement('div'); statsBarFill.style.cssText = ` height: 100%; width: ${Math.max(percentage, 2)}%; background: ${isCorrectAnswer ? 'linear-gradient(90deg, rgba(34, 197, 94, 0.08) 0%, rgba(34, 197, 94, 0.15) 100%)' : 'linear-gradient(90deg, rgba(148, 163, 184, 0.05) 0%, rgba(148, 163, 184, 0.12) 100%)' }; transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); border-radius: 16px; `; const statsText = document.createElement('div'); statsText.innerHTML = ` ${count} ${percentage.toFixed(1)}% `; statsText.style.cssText = ` position: absolute; right: 20px; top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; align-items: flex-end; z-index: 2; font-family: Microsoft YaHei; `; if (!document.querySelector('#choice-stats-styles')) { const style = document.createElement('style'); style.id = 'choice-stats-styles'; style.textContent = ` .count { font-size: 11px; font-weight: 500; color: #64748b; line-height: 1.2; } .percentage { font-size: 14px; font-weight: 700; color: ${isCorrectAnswer ? '#059669' : '#475569'}; line-height: 1.2; } `; document.head.appendChild(style); } statsBarContainer.appendChild(statsBarFill); optionLabel.appendChild(statsBarContainer); optionLabel.appendChild(statsText); } let optionContentWrapper = document.createElement('div'); optionContentWrapper.style.cssText = ` display: flex; align-items: center; position: relative; z-index: 1; width: 100%; padding-right: ${stats ? '50px' : '0px'}; `; let optionInput = document.createElement('input'); optionInput.type = question.type === 2 ? 'checkbox' : 'radio'; optionInput.name = `question_${question.id}`; optionInput.value = item.id; optionInput.checked = item.answer_checked === 2; optionInput.style.display = 'none'; let customCheckbox = document.createElement('span'); customCheckbox.style.cssText = ` width: 48px; height: 28px; background-color: ${optionInput.checked ? '#6366f1' : '#e5e7eb'}; border-radius: 28px; position: relative; margin-right: 20px; transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: ${optionInput.checked ? '0 4px 12px rgba(99, 102, 241, 0.3)' : '0 2px 6px rgba(0, 0, 0, 0.08)' }; flex-shrink: 0; `; let toggleCircle = document.createElement('span'); toggleCircle.style.cssText = ` width: 24px; height: 24px; background-color: #ffffff; border-radius: 50%; position: absolute; top: 2px; left: ${optionInput.checked ? '22px' : '2px'}; transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15); transform: ${optionInput.checked ? 'scale(1.05)' : 'scale(1)'}; `; let icon = document.createElement('span'); icon.style.cssText = ` position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); transition: all 0.3s ease; color: #6366f1; `; icon.innerHTML = optionInput.checked ? '' : ''; toggleCircle.appendChild(icon); customCheckbox.appendChild(toggleCircle); optionLabel.onclick = () => { isContentModified = true; if (question.type !== 2) { question.answer_items.forEach(answerItem => { answerItem.answer_checked = 1; }); item.answer_checked = 2; let siblingInputs = optionsContainer.querySelectorAll(`input[name="question_${question.id}"]`); siblingInputs.forEach(sibling => { const siblingLabel = sibling.closest('label'); const siblingToggle = siblingLabel.querySelector('span[style*="background-color"]'); const siblingCircle = siblingToggle.firstChild; const siblingIcon = siblingCircle.firstChild; sibling.checked = false; siblingToggle.style.backgroundColor = '#e5e7eb'; siblingToggle.style.boxShadow = '0 2px 6px rgba(0, 0, 0, 0.08)'; siblingCircle.style.left = '2px'; siblingCircle.style.transform = 'scale(1)'; siblingIcon.innerHTML = ''; }); optionInput.checked = true; } else { optionInput.checked = !optionInput.checked; item.answer_checked = optionInput.checked ? 2 : 1; } if (optionInput.checked) { customCheckbox.style.backgroundColor = '#6366f1'; customCheckbox.style.boxShadow = '0 4px 12px rgba(99, 102, 241, 0.3)'; toggleCircle.style.left = '22px'; toggleCircle.style.transform = 'scale(1.05)'; icon.innerHTML = ''; } else { customCheckbox.style.backgroundColor = '#e5e7eb'; customCheckbox.style.boxShadow = '0 2px 6px rgba(0, 0, 0, 0.08)'; toggleCircle.style.left = '2px'; toggleCircle.style.transform = 'scale(1)'; icon.innerHTML = ''; } }; let optionText = document.createElement('span'); optionText.innerHTML = question.type === 5 ? (idx === 0 ? '正确' : '错误') : await parseRichTextContentAsync(item.value); optionText.style.cssText = ` color: #1f2937; flex: 1; font-size: 16px; font-weight: 500; line-height: 1.5; word-break: break-word; `; optionContentWrapper.appendChild(optionInput); optionContentWrapper.appendChild(customCheckbox); optionContentWrapper.appendChild(optionText); optionLabel.appendChild(optionContentWrapper); optionsContainer.appendChild(optionLabel); } container.appendChild(optionsContainer); return optionsContainer; } function handleFillInBlankQuestion(question, container, createAIButton) { const fillContainer = document.createElement('div'); fillContainer.style.cssText = 'display: flex; flex-direction: column; gap: 15px; margin-top: 10px;'; question.answer_items.forEach((item, index) => { const blankGroup = document.createElement('div'); blankGroup.style.display = 'flex'; blankGroup.style.alignItems = 'center'; blankGroup.style.gap = '10px'; const label = document.createElement('label'); label.textContent = `空 ${index + 1}:`; label.style.fontWeight = '600'; label.style.color = '#4f46e5'; label.style.minWidth = '50px'; label.htmlFor = `blank-input-${item.id}`; const input = document.createElement('input'); input.type = 'text'; input.id = `blank-input-${item.id}`; input.value = parseRichTextToPlainText(item.answer); input.style.cssText = ` flex-grow: 1; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; transition: all 0.2s ease; outline: none; `; input.onfocus = () => { input.style.borderColor = '#6366f1'; input.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.15)'; }; input.onblur = () => { input.style.borderColor = '#d1d5db'; input.style.boxShadow = 'none'; }; input.oninput = () => { isContentModified = true; const newText = input.value; item.answer = JSON.stringify({ blocks: [{ key: `ans-${item.id}`, text: newText, type: 'unstyled', depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} }], entityMap: {} }); }; blankGroup.appendChild(label); blankGroup.appendChild(input); fillContainer.appendChild(blankGroup); }); const aiButtonContainer = document.createElement('div'); aiButtonContainer.style.textAlign = 'right'; aiButtonContainer.style.marginTop = '15px'; const aiButton = createAIButton(fillContainer, question); if (aiButton) { aiButtonContainer.appendChild(aiButton); fillContainer.appendChild(aiButtonContainer); } container.appendChild(fillContainer); return fillContainer; } function handleTextQuestion(question, container, createAIButton) { let inputContainer = document.createElement('div'); inputContainer.style.position = 'relative'; inputContainer.style.width = '100%'; inputContainer.style.marginTop = '8px'; inputContainer.style.paddingBottom = '40px'; let answerInput = document.createElement('div'); answerInput.contentEditable = true; answerInput.style.width = '100%'; answerInput.style.minHeight = '160px'; answerInput.style.maxHeight = '400px'; answerInput.style.padding = '16px'; answerInput.style.paddingTop = '24px'; answerInput.style.border = '1px solid #e5e7eb'; answerInput.style.borderRadius = '12px'; answerInput.style.fontSize = '15px'; answerInput.style.lineHeight = '1.6'; answerInput.style.color = '#1f2937'; answerInput.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; answerInput.style.backgroundColor = '#ffffff'; answerInput.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.05)'; answerInput.style.outline = 'none'; answerInput.style.display = 'block'; answerInput.style.boxSizing = 'border-box'; answerInput.style.overflow = 'auto'; answerInput.style.whiteSpace = 'pre-wrap'; answerInput.style.wordBreak = 'break-word'; let buttonContainer = document.createElement('div'); buttonContainer.style.position = 'absolute'; buttonContainer.style.bottom = '-5px'; buttonContainer.style.display = 'flex'; buttonContainer.style.gap = '12px'; buttonContainer.style.alignItems = 'center'; buttonContainer.style.width = '100%'; buttonContainer.style.justifyContent = 'center'; buttonContainer.style.zIndex = '10'; let imageUploadButton = null; let fileInput = null; if (question.type !== 4) { imageUploadButton = document.createElement('button'); imageUploadButton.innerHTML = '🖼️插入图片'; imageUploadButton.className = 'image-upload-btn'; imageUploadButton.title = '插入图片到答案中'; fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = 'image/*'; fileInput.style.display = 'none'; inputContainer.appendChild(fileInput); imageUploadButton.onclick = (e) => { e.preventDefault(); fileInput.click(); }; fileInput.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; if (!file.type.startsWith('image/')) { showNotification('请选择图片文件', { type: 'error', keywords: ['图片'], animation: 'scale' }); return; } if (file.size > 5 * 1024 * 1024) { showNotification('图片大小不能超过5MB', { type: 'error', keywords: ['图片', '大小'], animation: 'scale' }); return; } try { imageUploadButton.disabled = true; imageUploadButton.className = 'image-upload-btn loading'; imageUploadButton.innerHTML = '🔄上传中...'; const imageUrl = await uploadImage(file); if (imageUrl) { insertImageToEditor(answerInput, imageUrl); answerInput.dispatchEvent(new Event('input', { bubbles: true })); showNotification('图片上传成功', { type: 'success', keywords: ['图片', '上传', '成功'], animation: 'scale' }); } else { throw new Error('图片上传失败'); } } catch (error) { console.error('图片上传失败:', error); showNotification(`图片上传失败: ${error.message}`, { type: 'error', keywords: ['图片', '上传', '失败'], animation: 'scale' }); } finally { imageUploadButton.disabled = false; imageUploadButton.className = 'image-upload-btn'; imageUploadButton.innerHTML = '🖼️插入图片'; fileInput.value = ''; } }; } let charCount = document.createElement('div'); charCount.className = 'char-count'; charCount.style.pointerEvents = 'none'; answerInput.onfocus = () => { answerInput.style.borderColor = '#6366f1'; answerInput.style.backgroundColor = '#ffffff'; answerInput.style.boxShadow = '0 4px 6px rgba(99, 102, 241, 0.1)'; charCount.classList.add('active'); const scrollPos = window.scrollY; setTimeout(() => { window.scrollTo(0, scrollPos); }, 10); }; answerInput.onblur = () => { answerInput.style.borderColor = '#e5e7eb'; answerInput.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.05)'; charCount.classList.remove('active'); }; answerInput.oninput = () => { isContentModified = true; let textLength = answerInput.textContent.length; charCount.textContent = `${textLength} 个字符`; updateAnswerWithContent(question, answerInput.innerHTML); }; let answerContent = ''; question.answer_items.forEach(item => { try { const jsonContent = JSON.parse(item.answer); if (jsonContent && jsonContent.blocks) { jsonContent.blocks.forEach(block => { if (block.type === 'atomic' && block.data && block.data.type === 'IMAGE') { let imageSrc = block.data.src; let fileIdMatch = imageSrc.match(/\/cloud\/file_access\/(\d+)/); if (fileIdMatch && fileIdMatch[1]) { let fileId = fileIdMatch[1]; let randomParam = Date.now(); let imageUrl = `${window.location.origin}/api/jx-oresource/cloud/file_access/${fileId}?random=${randomParam}`; answerContent += `
选项图片
`; } else { answerContent += '
[图片加载失败]
'; } } else { answerContent += block.text.replace(/\n/g, '
'); } }); } else { answerContent += item.answer || ''; } } catch (e) { answerContent += item.answer || ''; } answerContent += '

'; }); answerInput.innerHTML = answerContent.trim(); let initialTextLength = answerInput.textContent.length; charCount.textContent = `${initialTextLength} 个字符`; let decorativeLine = document.createElement('div'); decorativeLine.style.position = 'absolute'; decorativeLine.style.left = '16px'; decorativeLine.style.right = '16px'; decorativeLine.style.bottom = '40px'; decorativeLine.style.height = '1px'; decorativeLine.style.background = 'linear-gradient(to right, #e5e7eb 50%, transparent)'; decorativeLine.style.opacity = '0.5'; if (imageUploadButton) { buttonContainer.appendChild(imageUploadButton); } if (createAIButton) { let aiButton = createAIButton(answerInput, question); buttonContainer.appendChild(aiButton); } buttonContainer.appendChild(charCount); inputContainer.appendChild(answerInput); inputContainer.appendChild(buttonContainer); inputContainer.appendChild(decorativeLine); let thinkingProcessDiv = document.createElement('div'); thinkingProcessDiv.className = 'ai-thinking-process'; thinkingProcessDiv.style.marginTop = '15px'; thinkingProcessDiv.style.display = 'none'; inputContainer.appendChild(thinkingProcessDiv); container.appendChild(inputContainer); return answerInput; } closeButton.onmouseover = () => { closeButton.style.backgroundColor = '#e5e7eb'; closeButton.style.transform = 'rotate(90deg)'; closeButton.style.color = '#000'; closeButton.style.boxShadow = '0 4px 8px rgba(0,0,0,0.12)'; }; closeButton.onmouseout = () => { closeButton.style.backgroundColor = '#f3f4f6'; closeButton.style.transform = 'rotate(0deg)'; closeButton.style.color = '#6b7280'; closeButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.08)'; }; closeButton.onclick = (e) => { e.stopPropagation(); cleanup(); closeModal(); }; title.style.cssText = ` margin: 20px 0 28px 0; color: #111827; font-size: 24px; font-weight: 600; text-align: center; `; title.textContent = '查看/编辑答案'; saveButton.style.cssText = ` width: 100%; margin-bottom: 24px; padding: 12px 24px; font-size: 16px; border: none; border-radius: 12px; background-color: #4f46e5; color: #ffffff; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 4px 6px -1px rgba(79, 70, 229, 0.1), 0 2px 4px -1px rgba(79, 70, 229, 0.06); `; saveButton.innerHTML = ` 保存修改 `; saveButton.onmouseover = () => { saveButton.style.backgroundColor = '#4338ca'; saveButton.style.transform = 'translateY(-1px)'; saveButton.style.boxShadow = '0 6px 8px -1px rgba(79, 70, 229, 0.1), 0 4px 6px -1px rgba(79, 70, 229, 0.06)'; }; saveButton.onmouseout = () => { saveButton.style.backgroundColor = '#4f46e5'; saveButton.style.transform = 'translateY(0)'; saveButton.style.boxShadow = '0 4px 6px -1px rgba(79, 70, 229, 0.1), 0 2px 4px -1px rgba(79, 70, 229, 0.06)'; }; saveButton.title = '保存修改后的答案到本地存储'; let aiAssistAllButton = document.createElement('button'); aiAssistAllButton.id = 'ai-assist-all-btn'; aiAssistAllButton.innerHTML = ` AI 批量处理 `; aiAssistAllButton.style.cssText = ` width: 100%; margin-bottom: 15px; padding: 12px 24px; font-size: 16px; border: none; border-radius: 12px; background: #10b981; color: #ffffff; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 4px 6px -1px rgba(16, 185, 129, 0.2), 0 2px 4px -1px rgba(16, 185, 129, 0.1); display: flex; align-items: center; justify-content: center; `; aiAssistAllButton.title = '使用 AI 尝试完成所有支持的题目(消耗 Token 较多)'; aiAssistAllButton.onmouseover = () => { aiAssistAllButton.style.background = '#059669'; aiAssistAllButton.style.transform = 'translateY(-1px)'; aiAssistAllButton.style.boxShadow = '0 6px 8px -1px rgba(16, 185, 129, 0.2), 0 4px 6px -1px rgba(16, 185, 129, 0.1)'; }; aiAssistAllButton.onmouseout = () => { aiAssistAllButton.style.background = '#10b981'; aiAssistAllButton.style.transform = 'translateY(0)'; aiAssistAllButton.style.boxShadow = '0 4px 6px -1px rgba(16, 185, 129, 0.2), 0 2px 4px -1px rgba(16, 185, 129, 0.1)'; }; aiAssistAllButton.onclick = () => { startAIAssistAll(answerData, modalContainer); }; saveButton.onclick = () => { answerData.forEach(question => { if (question.subQuestions && Array.isArray(question.subQuestions)) { question.subQuestions.forEach(subQuestion => { if (subQuestion.parentQuestion) { delete subQuestion.parentQuestion; } }); } }); localStorage.setItem('answerData', JSON.stringify(answerData)); showNotification('答案已保存,旧答案已被替换', { type: 'success', keywords: ['答案', '保存', '替换'], animation: 'scale' }); closeModal(true); }; let tocContainer = document.createElement('div'); tocContainer.id = 'toc-container'; tocContainer.style.cssText = ` width: 230px; position: sticky; max-height: 680px; overflow-y: auto; padding: 16px; border: 1px solid #e5e7eb; border-radius: 12px; background: #f9fafb; box-shadow: 0 1px 3px rgba(0,0,0,0.1); transition: all 0.3s ease; `; tocContainer.addEventListener('mouseenter', () => { tocContainer.style.borderColor = '#d1d5db'; tocContainer.style.boxShadow = '0 4px 6px rgba(0,0,0,0.05)'; }); tocContainer.addEventListener('mouseleave', () => { tocContainer.style.borderColor = '#e5e7eb'; tocContainer.style.boxShadow = '0 1px 3px rgba(0,0,0,0.1)'; }); let tocTitle = document.createElement('h3'); tocTitle.textContent = '目录'; tocTitle.style.cssText = ` font-size: 18px; font-weight: 600; margin-bottom: 12px; color: #111827; `; let tocList = document.createElement('ul'); tocList.style.cssText = ` list-style: none; display: flex; flex-wrap: wrap; gap: 8px; `; let tocLinks = []; let questionContainers = []; answerData.forEach((_, index) => { let tocItem = document.createElement('li'); let tocLink = document.createElement('a'); tocLink.textContent = `${index + 1}`; tocLink.href = '#'; tocLink.style.cssText = ` display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; background-color: #f3f4f6; border-radius: 8px; color: #1f2937; font-size: 16px; font-weight: 600; text-decoration: none; transition: background-color 0.2s ease; `; tocLink.isActive = false; tocLink.onmouseover = () => { if (!tocLink.isActive) { tocLink.style.backgroundColor = '#e5e7eb'; } }; tocLink.onmouseout = () => { if (!tocLink.isActive) { tocLink.style.backgroundColor = '#f3f4f6'; } }; tocLink.onclick = (e) => { e.preventDefault(); let targetQuestion = document.getElementById(`question_${index}`); if (targetQuestion) { targetQuestion.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }; tocItem.appendChild(tocLink); tocList.appendChild(tocItem); tocLinks.push(tocLink); }); tocContainer.appendChild(tocTitle); tocContainer.appendChild(tocList); let content = document.createElement('div'); content.style.cssText = ` flex: 1; display: grid; gap: 20px; overflow-y: auto; padding-right: 16px; `; const renderTemporaryPromptUI = () => { const promptContainer = document.createElement('div'); promptContainer.id = 'temporary-prompt-container'; promptContainer.style.cssText = ` margin-bottom: 25px; border-radius: 16px; border: 1px dashed #a5b4fc; background-color: #fafaff; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04); `; const details = document.createElement('details'); const summary = document.createElement('summary'); summary.style.cssText = ` padding: 16px 20px; font-size: 16px; font-weight: 600; color: #4338ca; cursor: pointer; list-style: none; display: flex; align-items: center; transition: background-color 0.2s ease; `; summary.innerHTML = ` ✍️ 临时AI提示词 (对当前作业生效) `; const summaryArrow = summary.querySelector('svg'); details.addEventListener('toggle', () => { summaryArrow.style.transform = details.open ? 'rotate(90deg)' : 'rotate(0deg)'; }); const promptContent = document.createElement('div'); promptContent.style.cssText = ` padding: 0 20px 20px 20px; border-top: 1px solid #ddd6fe; color: #374151; `; const description = document.createElement('p'); description.textContent = '在此处输入补充信息或特定指令(如解题思路、关键公式等),AI在处理本页所有题目时都会参考。'; description.style.cssText = 'font-size: 14px; color: #6b7280; margin-top: 15px; margin-bottom: 10px; line-height: 1.6;'; const textarea = document.createElement('textarea'); textarea.id = 'temporary-ai-prompt-textarea'; textarea.rows = 4; textarea.placeholder = '例如:听力原文如下...,请根据内容回答后续问题。'; textarea.style.cssText = ` width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; resize: vertical; box-sizing: border-box; outline: none; transition: all 0.2s ease; `; textarea.onfocus = () => { textarea.style.borderColor = '#6366f1'; textarea.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.15)'; }; textarea.onblur = () => { textarea.style.borderColor = '#d1d5db'; textarea.style.boxShadow = 'none'; }; promptContent.appendChild(description); promptContent.appendChild(textarea); details.appendChild(summary); details.appendChild(promptContent); promptContainer.appendChild(details); const descriptionContainer = content.querySelector('#paper-description-container'); if (descriptionContainer) { descriptionContainer.insertAdjacentElement('afterend', promptContainer); } else { content.prepend(promptContainer); } }; const renderQuestions = async () => { for (const [index, question] of answerData.entries()) { const questionContainer = document.createElement('div'); questionContainer.id = `question_${index}`; questionContainer.className = 'question-editor-container'; questionContainer.dataset.contentHash = generateContentHash(question); questionContainer.style.padding = '24px'; questionContainer.style.backgroundColor = '#ffffff'; questionContainer.style.borderRadius = '16px'; questionContainer.style.border = '1px solid #e5e7eb'; questionContainer.style.transition = 'box-shadow 0.3s ease, margin-top 0.3s ease'; questionContainer.style.marginTop = '0'; questionContainer.style.boxShadow = '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)'; questionContainer.onmouseover = () => { questionContainer.style.boxShadow = '0 8px 16px rgba(0, 0, 0, 0.1)'; }; questionContainer.onmouseout = () => { questionContainer.style.boxShadow = '0 1px 3px rgba(0, 0, 0, 0.1)'; }; const questionTitle = document.createElement('div'); const typeInfo = questionTypeStyles[question.type] || questionTypeStyles['default']; const badgeHtml = ` ${typeInfo.text} `; const titleHtml = await parseRichTextContentAsync(question.title); questionTitle.innerHTML = `题目 ${index + 1}:${badgeHtml}${titleHtml}`; questionTitle.style.marginBottom = '16px'; questionTitle.style.color = '#111827'; questionTitle.style.fontSize = '16px'; questionTitle.style.lineHeight = '1.6'; questionContainer.appendChild(questionTitle); attachSttOnlyButtonListeners(questionContainer); attachVideoSttButtonListeners(questionContainer); if ([1, 2, 5].includes(question.type)) { const aiButtonContainer = document.createElement('div'); aiButtonContainer.style.textAlign = 'right'; aiButtonContainer.style.marginBottom = '10px'; const aiButton = createAIButton(questionContainer, question); if (aiButton) { aiButtonContainer.appendChild(aiButton); questionContainer.appendChild(aiButtonContainer); } await handleChoiceQuestion(question, questionContainer); let thinkingProcessDivChoice = document.createElement('div'); thinkingProcessDivChoice.className = 'ai-thinking-process'; thinkingProcessDivChoice.style.marginTop = '15px'; thinkingProcessDivChoice.style.display = 'none'; questionContainer.appendChild(thinkingProcessDivChoice); } else if (question.type === 4) { handleFillInBlankQuestion(question, questionContainer, createAIButton); } else if ([6].includes(question.type)) { handleTextQuestion(question, questionContainer, createAIButton); } else if (question.type === 9 && question.subQuestions?.length) { let subQuestionsContainer = document.createElement('div'); subQuestionsContainer.style.display = 'flex'; subQuestionsContainer.style.flexDirection = 'column'; subQuestionsContainer.style.gap = '24px'; subQuestionsContainer.style.marginTop = '20px'; subQuestionsContainer.style.padding = '20px'; subQuestionsContainer.style.backgroundColor = '#f8fafc'; subQuestionsContainer.style.borderRadius = '12px'; subQuestionsContainer.style.border = '1px solid #e2e8f0'; let subQuestionTitle = document.createElement('div'); subQuestionTitle.textContent = '子题目:'; subQuestionTitle.style.fontSize = '16px'; subQuestionTitle.style.fontWeight = '600'; subQuestionTitle.style.color = '#475569'; subQuestionTitle.style.marginBottom = '16px'; subQuestionsContainer.appendChild(subQuestionTitle); for (const [subIndex, subQuestion] of question.subQuestions.entries()) { subQuestion.parentQuestion = question; let subQuestionBox = document.createElement('div'); subQuestionBox.dataset.subquestionId = subQuestion.id; subQuestionBox.dataset.subquestionIndex = subIndex; subQuestionBox.style.padding = '20px'; subQuestionBox.style.backgroundColor = '#ffffff'; subQuestionBox.style.borderRadius = '10px'; subQuestionBox.style.border = '1px solid #e5e7eb'; subQuestionBox.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.05)'; let subQuestionHeader = document.createElement('div'); const subTypeInfo = questionTypeStyles[subQuestion.type] || questionTypeStyles['default']; const subBadgeHtml = ` ${subTypeInfo.text} `; const subTitleHtml = await parseRichTextContentAsync(subQuestion.title); subQuestionHeader.innerHTML = `${subIndex + 1}. ${subBadgeHtml}${subTitleHtml}`; subQuestionHeader.style.marginBottom = '16px'; subQuestionHeader.style.color = '#1e293b'; subQuestionHeader.style.fontSize = '15px'; subQuestionBox.appendChild(subQuestionHeader); attachSttOnlyButtonListeners(subQuestionBox); attachVideoSttButtonListeners(subQuestionBox); if ([1, 2, 5].includes(subQuestion.type)) { const aiButtonContainer = document.createElement('div'); aiButtonContainer.style.textAlign = 'right'; aiButtonContainer.style.marginBottom = '10px'; const aiButton = createAIButton(subQuestionBox, subQuestion); if (aiButton) { aiButtonContainer.appendChild(aiButton); subQuestionBox.appendChild(aiButtonContainer); } await handleChoiceQuestion(subQuestion, subQuestionBox); } else if (subQuestion.type === 4) { handleFillInBlankQuestion(subQuestion, subQuestionBox, createAIButton); } else if ([6].includes(subQuestion.type)) { handleTextQuestion(subQuestion, subQuestionBox, createAIButton); } subQuestionBox.appendChild(createReportButton(subQuestion)); subQuestionsContainer.appendChild(subQuestionBox); } questionContainer.appendChild(subQuestionsContainer); } else if (question.type === 10) { let programmingContainer = document.createElement('div'); programmingContainer.style.display = 'flex'; programmingContainer.style.flexDirection = 'column'; programmingContainer.style.gap = '16px'; programmingContainer.style.marginTop = '16px'; const progSetting = question.program_setting; let infoContainer = document.createElement('div'); infoContainer.style.display = 'flex'; infoContainer.style.gap = '16px'; infoContainer.style.fontSize = '14px'; infoContainer.style.color = '#4b5563'; infoContainer.style.padding = '10px'; infoContainer.style.backgroundColor = '#f9fafb'; infoContainer.style.borderRadius = '8px'; infoContainer.style.border = '1px solid #e5e7eb'; infoContainer.innerHTML = ` 语言: ${progSetting?.language?.join(', ') || 'N/A'} 时间限制: ${progSetting?.max_time || 'N/A'} ms 内存限制: ${progSetting?.max_memory || 'N/A'} KB `; programmingContainer.appendChild(infoContainer); if (progSetting?.example_code) { let exampleCodeContainer = document.createElement('div'); exampleCodeContainer.innerHTML = '示例代码:'; exampleCodeContainer.style.fontWeight = '600'; exampleCodeContainer.style.marginBottom = '8px'; let exampleCodeBlock = document.createElement('pre'); exampleCodeBlock.textContent = progSetting.example_code; exampleCodeBlock.style.padding = '12px'; exampleCodeBlock.style.backgroundColor = '#f3f4f6'; exampleCodeBlock.style.borderRadius = '8px'; exampleCodeBlock.style.border = '1px solid #e5e7eb'; exampleCodeBlock.style.whiteSpace = 'pre-wrap'; exampleCodeBlock.style.wordBreak = 'break-all'; exampleCodeBlock.style.maxHeight = '200px'; exampleCodeBlock.style.overflowY = 'auto'; exampleCodeContainer.appendChild(exampleCodeBlock); programmingContainer.appendChild(exampleCodeContainer); } let answerCodeContainer = document.createElement('div'); answerCodeContainer.innerHTML = '答案代码:'; answerCodeContainer.style.fontWeight = '600'; answerCodeContainer.style.marginBottom = '8px'; let answerCodeInput = document.createElement('textarea'); answerCodeInput.value = progSetting?.code_answer || ''; answerCodeInput.style.width = '100%'; answerCodeInput.style.minHeight = '200px'; answerCodeInput.style.padding = '12px'; answerCodeInput.style.border = '1px solid #d1d5db'; answerCodeInput.style.borderRadius = '8px'; answerCodeInput.style.fontSize = '14px'; answerCodeInput.style.lineHeight = '1.5'; answerCodeInput.style.resize = 'vertical'; answerCodeInput.style.boxSizing = 'border-box'; answerCodeInput.oninput = () => { isContentModified = true; if (question.program_setting) { question.program_setting.code_answer = answerCodeInput.value; } else { question.program_setting = { code_answer: answerCodeInput.value }; } }; answerCodeContainer.appendChild(answerCodeInput); programmingContainer.appendChild(answerCodeContainer); let aiButtonContainer = document.createElement('div'); aiButtonContainer.style.marginTop = '10px'; aiButtonContainer.style.textAlign = 'right'; let aiButton = createAIButton(answerCodeInput, question); aiButtonContainer.appendChild(aiButton); programmingContainer.appendChild(aiButtonContainer); if (question.answer_items?.[0]?.answer) { let testCasesContainer = document.createElement('div'); testCasesContainer.innerHTML = '测试用例:'; testCasesContainer.style.fontWeight = '600'; testCasesContainer.style.marginBottom = '8px'; let testCasesBlock = document.createElement('div'); testCasesBlock.style.padding = '12px'; testCasesBlock.style.backgroundColor = '#f3f4f6'; testCasesBlock.style.borderRadius = '8px'; testCasesBlock.style.border = '1px solid #e5e7eb'; testCasesBlock.style.maxHeight = '150px'; testCasesBlock.style.overflowY = 'auto'; try { const testCases = JSON.parse(question.answer_items[0].answer); if (Array.isArray(testCases)) { testCases.forEach((tc, i) => { let tcDiv = document.createElement('div'); tcDiv.style.marginBottom = '8px'; tcDiv.innerHTML = `
用例 ${i + 1}:
输入: ${tc.in}
输出: ${tc.out}
`; testCasesBlock.appendChild(tcDiv); }); } else { testCasesBlock.textContent = question.answer_items[0].answer; } } catch (e) { testCasesBlock.textContent = question.answer_items[0].answer; } testCasesContainer.appendChild(testCasesBlock); programmingContainer.appendChild(testCasesContainer); } questionContainer.appendChild(programmingContainer); let thinkingProcessDivProg = document.createElement('div'); thinkingProcessDivProg.className = 'ai-thinking-process'; thinkingProcessDivProg.style.marginTop = '15px'; thinkingProcessDivProg.style.display = 'none'; questionContainer.appendChild(thinkingProcessDivProg); } else if (question.type === 12) { question.answer_items.sort((a, b) => { const answerA = parseInt(a.answer, 10); const answerB = parseInt(b.answer, 10); if (isNaN(answerA) || isNaN(answerB)) { return 0; } return answerA - answerB; }); let sortableContainer = document.createElement('div'); sortableContainer.dataset.sortableContainer = "true"; sortableContainer.style.display = 'flex'; sortableContainer.style.flexDirection = 'column'; sortableContainer.style.gap = '12px'; sortableContainer.style.marginTop = '16px'; for (const [index, item] of question.answer_items.entries()) { let sortableItem = document.createElement('div'); sortableItem.setAttribute('draggable', 'true'); sortableItem.dataset.id = item.id; sortableItem.dataset.index = index; sortableItem.style.display = 'flex'; sortableItem.style.alignItems = 'center'; sortableItem.style.padding = '16px'; sortableItem.style.backgroundColor = '#ffffff'; sortableItem.style.borderRadius = '12px'; sortableItem.style.border = '1px solid #e5e7eb'; sortableItem.style.cursor = 'move'; sortableItem.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; sortableItem.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.05)'; sortableItem.style.userSelect = 'none'; let orderNumber = document.createElement('div'); orderNumber.textContent = index + 1; orderNumber.style.width = '28px'; orderNumber.style.height = '28px'; orderNumber.style.borderRadius = '50%'; orderNumber.style.backgroundColor = '#6366f1'; orderNumber.style.color = '#ffffff'; orderNumber.style.display = 'flex'; orderNumber.style.alignItems = 'center'; orderNumber.style.justifyContent = 'center'; orderNumber.style.marginRight = '16px'; orderNumber.style.fontWeight = '600'; orderNumber.style.fontSize = '14px'; orderNumber.style.flexShrink = '0'; let dragHandle = document.createElement('div'); dragHandle.innerHTML = ` `; dragHandle.style.marginRight = '12px'; dragHandle.style.flexShrink = '0'; dragHandle.style.opacity = '0.5'; dragHandle.style.transition = 'opacity 0.2s ease'; let itemText = document.createElement('div'); itemText.innerHTML = await parseRichTextContentAsync(item.value); itemText.style.flex = '1'; itemText.style.color = '#1f2937'; itemText.style.fontSize = '15px'; itemText.style.fontWeight = '500'; sortableItem.ondragstart = (e) => { e.stopPropagation(); sortableItem.style.opacity = '0.6'; sortableItem.style.transform = 'scale(1.02)'; e.dataTransfer.setData('text/plain', sortableItem.dataset.index); sortableItem.style.backgroundColor = '#f8fafc'; }; sortableItem.ondragend = (e) => { e.stopPropagation(); sortableItem.style.opacity = '1'; sortableItem.style.transform = 'scale(1)'; sortableItem.style.backgroundColor = '#ffffff'; }; sortableItem.ondragover = (e) => { e.preventDefault(); e.stopPropagation(); sortableItem.style.transform = 'scale(1.02)'; sortableItem.style.borderColor = '#6366f1'; sortableItem.style.boxShadow = '0 4px 6px rgba(99, 102, 241, 0.1)'; }; sortableItem.ondragleave = (e) => { e.preventDefault(); e.stopPropagation(); sortableItem.style.transform = 'scale(1)'; sortableItem.style.borderColor = '#e5e7eb'; sortableItem.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.05)'; }; sortableItem.ondrop = (e) => { e.preventDefault(); e.stopPropagation(); const fromIndex = parseInt(e.dataTransfer.getData('text/plain')); const toIndex = parseInt(sortableItem.dataset.index); if (fromIndex !== toIndex) { isContentModified = true; const items = Array.from(sortableContainer.children); const movingItem = items[fromIndex]; const targetItem = items[toIndex]; if (fromIndex < toIndex) { targetItem.parentNode.insertBefore(movingItem, targetItem.nextSibling); } else { targetItem.parentNode.insertBefore(movingItem, targetItem); } const newOrder = Array.from(sortableContainer.children).map((item, idx) => { item.querySelector('div:nth-child(2)').textContent = idx + 1; item.dataset.index = idx; return item.dataset.id; }); newOrder.forEach((id, idx) => { const answerItem = question.answer_items.find(item => item.id === id); if (answerItem) { answerItem.answer = (idx + 1).toString(); } }); } sortableItem.style.transform = 'scale(1)'; sortableItem.style.borderColor = '#e5e7eb'; sortableItem.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.05)'; }; sortableItem.onmouseover = () => { sortableItem.style.backgroundColor = '#f8fafc'; sortableItem.style.transform = 'translateY(-1px)'; sortableItem.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.08)'; dragHandle.style.opacity = '1'; }; sortableItem.onmouseout = () => { sortableItem.style.backgroundColor = '#ffffff'; sortableItem.style.transform = 'translateY(0)'; sortableItem.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.05)'; dragHandle.style.opacity = '0.5'; }; sortableItem.appendChild(dragHandle); sortableItem.appendChild(orderNumber); sortableItem.appendChild(itemText); sortableContainer.appendChild(sortableItem); } const aiButtonContainer = document.createElement('div'); aiButtonContainer.style.textAlign = 'right'; aiButtonContainer.style.marginBottom = '10px'; const aiButton = createAIButton(sortableContainer, question); if (aiButton) { aiButtonContainer.appendChild(aiButton); questionContainer.appendChild(aiButtonContainer); } questionContainer.appendChild(sortableContainer); } else if (question.type === 13) { let matchingContainer = document.createElement('div'); matchingContainer.dataset.matchingContainer = "true"; matchingContainer.style.cssText = ` display: flex; flex-direction: column; gap: 16px; margin-top: 20px; padding: 16px; background-color: #f8fafc; border-radius: 16px; `; const leftItems = question.answer_items.filter(item => !item.is_target_opt); const rightItems = question.answer_items.filter(item => item.is_target_opt); const rightItemMap = new Map(rightItems.map((item, idx) => [item.id, { letter: String.fromCharCode(97 + idx), content: item.value }])); for (const [idx, leftItem] of leftItems.entries()) { let matchItem = document.createElement('div'); matchItem.dataset.matchingItem = "true"; matchItem.style.cssText = ` display: flex; flex-direction: column; padding: 20px; background-color: #ffffff; border-radius: 12px; border: 1px solid #e2e8f0; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06); transition: all 0.3s ease; position: relative; `; let headerContainer = document.createElement('div'); headerContainer.style.cssText = 'display: flex; align-items: flex-start; margin-bottom: 16px;'; let leftLabel = document.createElement('div'); leftLabel.textContent = String.fromCharCode(65 + idx) + '.'; leftLabel.style.cssText = 'margin-right: 12px; font-weight: 600; color: #6366f1; font-size: 16px; width: 24px;'; let leftContent = document.createElement('div'); leftContent.innerHTML = await parseRichTextContentAsync(leftItem.value); leftContent.style.cssText = 'flex: 1; color: #1e293b; font-size: 15px; font-weight: 500; line-height: 1.6;'; let chipContainer = document.createElement('div'); chipContainer.style.cssText = 'display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; min-height: 36px;'; let dropdownButton = document.createElement('button'); dropdownButton.innerHTML = ` 添加匹配项 `; dropdownButton.style.cssText = ` display: flex; align-items: center; justify-content: center; margin-top: 16px; padding: 10px 16px; background-color: #4f46e5; color: #ffffff; border: none; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; font-size: 14px; font-weight: 500; width: 100%; `; dropdownButton.onmouseover = () => { dropdownButton.style.backgroundColor = '#4338ca'; dropdownButton.style.transform = 'translateY(-1px)'; }; dropdownButton.onmouseout = () => { dropdownButton.style.backgroundColor = '#4f46e5'; dropdownButton.style.transform = 'translateY(0)'; }; let dropdownList = document.createElement('div'); dropdownList.style.cssText = ` position: absolute; top: 100%; left: 0; width: 100%; max-height: 300px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 12px; background-color: #ffffff; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1); z-index: 1000; margin-top: 8px; display: none; opacity: 0; transform: scaleY(0.9) translateY(-10px); transform-origin: top; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); `; const updateUI = async () => { const currentAnswerIds = new Set(leftItem.answer ? String(leftItem.answer).split(',').filter(id => id) : []); chipContainer.innerHTML = ''; for (const answerId of currentAnswerIds) { if (rightItemMap.has(answerId)) { const rightItemData = rightItemMap.get(answerId); let chip = document.createElement('div'); chip.style.cssText = `display: flex; align-items: center; padding: 6px 12px; background-color: #eef2ff; border: 1px solid #e0e7ff; border-radius: 8px; color: #4f46e5; font-size: 14px; font-weight: 500; transition: all 0.2s ease;`; let chipText = document.createElement('span'); chipText.innerHTML = `${rightItemData.letter}. ${await parseRichTextContentAsync(rightItemData.content)}`; chipText.style.marginRight = '8px'; let removeIcon = document.createElement('span'); removeIcon.innerHTML = ``; removeIcon.style.cssText = 'cursor: pointer; display: flex; align-items: center; padding: 2px; border-radius: 4px; transition: all 0.2s ease;'; removeIcon.onmouseover = () => { removeIcon.style.backgroundColor = '#e0e7ff'; }; removeIcon.onmouseout = () => { removeIcon.style.backgroundColor = 'transparent'; }; removeIcon.onclick = (e) => { e.stopPropagation(); const ids = new Set(leftItem.answer ? String(leftItem.answer).split(',') : []); ids.delete(answerId); leftItem.answer = Array.from(ids).join(','); updateUI(); }; chip.appendChild(chipText); chip.appendChild(removeIcon); chipContainer.appendChild(chip); } } const checkboxes = dropdownList.querySelectorAll('input[type="checkbox"]'); checkboxes.forEach((cb, cbIndex) => { const rightItemId = rightItems[cbIndex].id; cb.checked = currentAnswerIds.has(rightItemId); }); }; matchItem._updateUI = updateUI; for (const [rIdx, rightItem] of rightItems.entries()) { let dropdownOption = document.createElement('div'); dropdownOption.style.cssText = ` padding: 12px 16px; cursor: pointer; display: flex; align-items: center; transition: all 0.2s ease; position: relative; border-bottom: ${rIdx < rightItems.length - 1 ? '1px solid #f1f5f9' : 'none'}; `; dropdownOption.onmouseover = () => { dropdownOption.style.backgroundColor = '#f8fafc'; }; dropdownOption.onmouseout = () => { dropdownOption.style.backgroundColor = '#ffffff'; }; let checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.style.cssText = 'margin-right: 12px; width: 16px; height: 16px; accent-color: #4f46e5;'; checkbox.onchange = (e) => { isContentModified = true; e.stopPropagation(); const selectedIds = new Set(leftItem.answer ? String(leftItem.answer).split(',').filter(id => id) : []); if (checkbox.checked) { selectedIds.add(rightItem.id); } else { selectedIds.delete(rightItem.id); } leftItem.answer = Array.from(selectedIds).join(','); updateUI(); }; let optionContent = document.createElement('div'); optionContent.style.cssText = 'flex: 1; display: flex; align-items: center;'; optionContent.innerHTML = ` ${String.fromCharCode(97 + rIdx)}. ${await parseRichTextContentAsync(rightItem.value)} `; dropdownOption.appendChild(checkbox); dropdownOption.appendChild(optionContent); dropdownOption.onclick = (e) => { if (e.target !== checkbox) { checkbox.checked = !checkbox.checked; checkbox.dispatchEvent(new Event('change')); } }; dropdownList.appendChild(dropdownOption); } dropdownButton.onclick = (e) => { e.stopPropagation(); if (dropdownList.style.display === 'none') { dropdownList.style.display = 'block'; requestAnimationFrame(() => { dropdownList.style.opacity = '1'; dropdownList.style.transform = 'scaleY(1) translateY(0)'; }); } else { dropdownList.style.opacity = '0'; dropdownList.style.transform = 'scaleY(0.9) translateY(-10px)'; setTimeout(() => { dropdownList.style.display = 'none'; }, 200); } }; document.addEventListener('click', (e) => { if (!matchItem.contains(e.target)) { dropdownList.style.opacity = '0'; dropdownList.style.transform = 'scaleY(0.9) translateY(-10px)'; setTimeout(() => { dropdownList.style.display = 'none'; }, 200); } }); headerContainer.appendChild(leftLabel); headerContainer.appendChild(leftContent); matchItem.appendChild(headerContainer); matchItem.appendChild(chipContainer); matchItem.appendChild(dropdownButton); matchItem.appendChild(dropdownList); matchingContainer.appendChild(matchItem); updateUI(); } const aiButtonContainer = document.createElement('div'); aiButtonContainer.style.textAlign = 'right'; aiButtonContainer.style.marginBottom = '10px'; const aiButton = createAIButton(matchingContainer, question); if (aiButton) { aiButtonContainer.appendChild(aiButton); } if (aiButton) { questionContainer.appendChild(aiButtonContainer); } questionContainer.appendChild(matchingContainer); } else { let notSupportedMessage = document.createElement('div'); notSupportedMessage.style.padding = '20px'; notSupportedMessage.style.backgroundColor = '#fff3cd'; notSupportedMessage.style.border = '1px solid #ffeeba'; notSupportedMessage.style.borderRadius = '8px'; notSupportedMessage.style.color = '#856404'; notSupportedMessage.style.fontSize = '15px'; notSupportedMessage.style.marginTop = '16px'; notSupportedMessage.style.textAlign = 'center'; notSupportedMessage.innerHTML = `

该题型暂不支持查看答案

`; questionContainer.appendChild(notSupportedMessage); } content.appendChild(questionContainer); questionContainers.push(questionContainer); if (SUPPORTED_CONTRIBUTION_TYPES.includes(question.type)) { questionContainer.appendChild(createReportButton(question)); } } }; modalContainer.appendChild(resizeHandle); modalContainer.appendChild(dragHandle); modalContainer.appendChild(closeButton); modalContainer.appendChild(title); modalContainer.appendChild(aiAssistAllButton); modalContainer.appendChild(saveButton); modalContainer.appendChild(modalContentWrapper); modalContentWrapper.appendChild(tocContainer); modalContentWrapper.appendChild(content); const handleEscapeKey = (e) => { if (e.key === 'Escape') { closeModal(); } }; document.addEventListener('keydown', handleEscapeKey); document.body.appendChild(overlay); document.body.appendChild(modalContainer); function updateCurrentQuestionHighlight() { const contentRect = content.getBoundingClientRect(); const viewportTop = contentRect.top; const viewportHeight = contentRect.height; const viewportCenter = viewportTop + (viewportHeight / 2); let currentQuestionIndex = -1; let minDistance = Infinity; questionContainers.forEach((qc, index) => { const qcRect = qc.getBoundingClientRect(); const qcCenter = qcRect.top + (qcRect.height / 2); const distance = Math.abs(qcCenter - viewportCenter); if (distance < minDistance) { minDistance = distance; currentQuestionIndex = index; } }); if (currentQuestionIndex !== -1) { tocLinks.forEach((tocLink, idx) => { if (idx === currentQuestionIndex) { tocLink.isActive = true; tocLink.style.backgroundColor = '#6366f1'; tocLink.style.color = '#ffffff'; tocLink.style.transform = 'scale(1.05)'; tocLink.style.boxShadow = '0 4px 6px -1px rgba(99, 102, 241, 0.1)'; } else { tocLink.isActive = false; tocLink.style.backgroundColor = '#f3f4f6'; tocLink.style.color = '#1f2937'; tocLink.style.transform = 'scale(1)'; tocLink.style.boxShadow = 'none'; } }); } } const renderPaperDescription = async () => { const paperDescription = localStorage.getItem('paperDescription'); if (!paperDescription || paperDescription === '{}') { return; } const descriptionContainer = document.createElement('div'); descriptionContainer.id = 'paper-description-container'; descriptionContainer.style.cssText = ` margin-bottom: 25px; border-radius: 16px; border: 1px solid #c7d2fe; background-color: #f5f3ff; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); `; const details = document.createElement('details'); details.open = containsAudio(paperDescription); const summary = document.createElement('summary'); summary.style.cssText = ` padding: 16px 20px; font-size: 16px; font-weight: 600; color: #4338ca; cursor: pointer; list-style: none; display: flex; align-items: center; transition: background-color 0.2s ease; `; summary.innerHTML = ` 作业头部材料 `; const summaryArrow = summary.querySelector('svg'); details.addEventListener('toggle', () => { summaryArrow.style.transform = details.open ? 'rotate(90deg)' : 'rotate(0deg)'; }); const descriptionContent = document.createElement('div'); descriptionContent.style.cssText = ` padding: 20px; border-top: 1px solid #ddd6fe; line-height: 1.7; color: #374151; font-size: 15px; `; descriptionContent.innerHTML = await parseRichTextContentAsync(paperDescription); attachSttOnlyButtonListeners(descriptionContent); attachVideoSttButtonListeners(descriptionContent); details.appendChild(summary); details.appendChild(descriptionContent); descriptionContainer.appendChild(details); content.prepend(descriptionContainer); }; renderQuestions().then(() => { renderPaperDescription().then(() => { renderTemporaryPromptUI(); updateCurrentQuestionHighlight(); requestAnimationFrame(() => { overlay.style.opacity = '1'; modalContainer.style.transform = 'translate(-50%, -50%) scale(1)'; modalContainer.style.opacity = '1'; }); }); }); } async function exportHomework() { console.log('调用 exportHomework 函数 (带头部信息增强版)'); let storedData = localStorage.getItem('answerData'); if (!storedData) { showNotification('未找到存储的数据,请先点击"获取答案"按钮。', { type: 'error', keywords: ['存储', '答案', '获取'], animation: 'fadeSlide' }); return; } const answerData = JSON.parse(storedData); let assignmentTitle = localStorage.getItem('assignmentTitle') || '作业答案'; const paperDescription = localStorage.getItem('paperDescription'); const progress = createProgressBar(); progress.show(); try { const docContent = []; showNotification('开始导出作业,正在准备内容...', { type: 'info', keywords: ['导出', '准备'], animation: 'scale' }); docContent.push( new Paragraph({ text: assignmentTitle, heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER, spacing: { after: 400 }, }), new Paragraph({ text: `导出时间:${new Date().toLocaleString()}`, alignment: AlignmentType.CENTER, spacing: { after: 400 }, }) ); if (paperDescription && paperDescription !== '{}' && !isEmptyRichText(paperDescription)) { console.log('发现作业头部信息,开始处理并添加到文档...'); progress.update(0, answerData.length, '正在处理头部信息'); docContent.push(new Paragraph({ text: "作业说明 / 公共材料", heading: HeadingLevel.HEADING_1, style: "Heading1", spacing: { before: 400, after: 200 }, })); const descriptionParagraphs = await parseRichTextToParagraphs(paperDescription); docContent.push(...descriptionParagraphs); docContent.push(new Paragraph({ children: [new TextRun("__________________________________________________________")], alignment: AlignmentType.CENTER, spacing: { after: 600 }, })); docContent.push(new Paragraph({ children: [new TextRun("__________________________________________________________")], alignment: AlignmentType.CENTER, spacing: { after: 600 }, })); } for (let index = 0; index < answerData.length; index++) { try { const question = answerData[index]; const questionNumber = `${index + 1}、`; const titleRuns = await parseRichTextToParagraphs(question.title); const titleParagraph = new Paragraph({ children: [ new TextRun({ text: questionNumber, bold: true, }), ...titleRuns, ], }); docContent.push(titleParagraph); switch (question.type) { case 1: case 2: { const options = question.answer_items.map((item, idx) => ({ letter: String.fromCharCode(65 + idx), content: item.value, })); for (const option of options) { const optionRuns = await parseRichTextToParagraphs(option.content); docContent.push(new Paragraph({ children: [ new TextRun({ text: `${option.letter}. `, bold: true }), ...optionRuns, ], })); } const correctOptions = question.answer_items .map((item, idx) => (item.answer_checked === 2 ? String.fromCharCode(65 + idx) : null)) .filter(Boolean) .join(''); docContent.push(new Paragraph({ text: `答案:${correctOptions}`, spacing: { before: 100, after: 100 } })); if (question.description && question.description !== '{}' && !isEmptyRichText(question.description)) { docContent.push(new Paragraph({ children: [new TextRun({ text: '解析:', bold: true })], spacing: { before: 100, after: 0 }, })); const descriptionParagraphs = await parseRichTextToParagraphs(question.description); docContent.push(...descriptionParagraphs); } break; } case 5: { const isCorrect = question.answer_items.some(item => item.answer_checked === 2 && (item.value === '正确' || item.value.toLowerCase() === 'true')); docContent.push(new Paragraph({ text: `答案:${isCorrect ? '对' : '错'}`, spacing: { before: 100, after: 100 } })); if (question.description && question.description !== '{}' && !isEmptyRichText(question.description)) { const descriptionRuns = await parseRichTextToParagraphs(question.description); docContent.push(new Paragraph({ children: [ new TextRun({ text: '解析:', bold: true }), ...descriptionRuns, ], spacing: { before: 100, after: 100 }, })); } break; } case 4: { let blanks = '(____)'.repeat(question.answer_items.length); docContent.push(new Paragraph({ text: blanks, spacing: { before: 100, after: 100 } })); const answers = question.answer_items.map(item => parseRichTextToPlainText(item.answer)).join(' | '); docContent.push(new Paragraph({ text: `答案:${answers}`, spacing: { before: 100, after: 100 } })); if (question.description && question.description !== '{}' && !isEmptyRichText(question.description)) { const descriptionRuns = await parseRichTextToParagraphs(question.description); docContent.push(new Paragraph({ children: [ new TextRun({ text: '解析:', bold: true }), ...descriptionRuns, ], spacing: { before: 100, after: 100 }, })); } break; } case 6: { for (const item of question.answer_items) { const answerRuns = await parseRichTextToParagraphs(item.answer); docContent.push(new Paragraph({ children: [ new TextRun({ text: '答案:', bold: true }), ...answerRuns, ], spacing: { before: 100, after: 100 }, })); } if (question.description && question.description !== '{}' && !isEmptyRichText(question.description)) { const descriptionRuns = await parseRichTextToParagraphs(question.description); docContent.push(new Paragraph({ children: [ new TextRun({ text: '解析:', bold: true }), ...descriptionRuns, ], spacing: { before: 100, after: 100 }, })); } break; } case 9: { if (question.subQuestions && question.subQuestions.length > 0) { for (let subIndex = 0; subIndex < question.subQuestions.length; subIndex++) { const subQuestion = question.subQuestions[subIndex]; const subQuestionNumber = `${index + 1}.${subIndex + 1}、`; const subTitleRuns = await parseRichTextToParagraphs(subQuestion.title); docContent.push( new Paragraph({ children: [ new TextRun({ text: subQuestionNumber, bold: true, }), ...subTitleRuns ], spacing: { before: 200 } }) ); switch (subQuestion.type) { case 1: case 2: { for (const [idx, item] of subQuestion.answer_items.entries()) { const optionLetter = String.fromCharCode(65 + idx); const optionRuns = await parseRichTextToParagraphs(item.value); const optionParagraph = new Paragraph({ children: [ new TextRun({ text: `${optionLetter}. `, bold: true, }), ...optionRuns, ], }); docContent.push(optionParagraph); } const correctOptions = subQuestion.answer_items .map((item, idx) => item.answer_checked === 2 ? String.fromCharCode(65 + idx) : null) .filter(item => item !== null) .join(''); docContent.push( new Paragraph({ text: `答案:${correctOptions}`, spacing: { before: 100, after: 100 }, }) ); break; } case 4: { const blankCount = subQuestion.answer_items.length; let blanks = ''; for (let i = 0; i < blankCount; i++) { blanks += '(____)'; } docContent.push( new Paragraph({ text: blanks, spacing: { before: 100, after: 100 } }) ); const answers = subQuestion.answer_items .map(item => parseRichTextToPlainText(item.answer)) .join('|'); docContent.push( new Paragraph({ text: `答案:${answers}`, spacing: { before: 100, after: 100 } }) ); break; } case 5: { const isCorrect = subQuestion.answer_items .some(item => item.answer_checked === 2 && (item.value === '正确' || item.value.toLowerCase() === 'true')); const answerText = isCorrect ? '对' : '错'; docContent.push( new Paragraph({ text: `答案:${answerText}`, spacing: { before: 100, after: 100 } }) ); break; } case 6: { const answers = subQuestion.answer_items .map(item => parseRichTextToPlainText(item.answer)) .join(';'); docContent.push( new Paragraph({ text: `答案:${answers}`, spacing: { before: 100, after: 100 } }) ); break; } } if (subQuestion.description && subQuestion.description !== '{}') { const descriptionRuns = await parseRichTextToParagraphs(subQuestion.description); docContent.push( new Paragraph({ children: [ new TextRun({ text: '解析:', bold: true }), ...descriptionRuns ], spacing: { before: 100, after: 100 } }) ); } docContent.push( new Paragraph({ text: '', spacing: { after: 200 } }) ); } } break; } case 10: { docContent.push( new Paragraph({ text: `语言:${question.program_setting?.language?.join(', ') || '未指定'}`, spacing: { before: 100, after: 100 }, }) ); if (question.program_setting?.example_code) { docContent.push( new Paragraph({ text: "示例代码:", bold: true, spacing: { before: 100 } }), new Paragraph({ text: question.program_setting.example_code, style: "CodeStyle" }) ); } if (question.program_setting?.code_answer) { docContent.push( new Paragraph({ text: "答案代码:", bold: true, spacing: { before: 100 } }), new Paragraph({ text: question.program_setting.code_answer, style: "CodeStyle" }) ); } if (question.answer_items?.[0]?.answer) { try { const testCases = JSON.parse(question.answer_items[0].answer); if (Array.isArray(testCases) && testCases.length > 0) { docContent.push(new Paragraph({ text: "测试用例:", bold: true, spacing: { before: 100 } })); testCases.forEach((tc, i) => { docContent.push(new Paragraph({ text: ` 用例 ${i + 1}:`, spacing: { before: 50 } })); docContent.push(new Paragraph({ text: ` 输入: ${tc.in}`, style: "CodeStyle" })); docContent.push(new Paragraph({ text: ` 输出: ${tc.out}`, style: "CodeStyle" })); }); } } catch (e) { console.warn("解析测试用例失败:", e); docContent.push(new Paragraph({ text: `测试用例数据:${question.answer_items[0].answer}`, spacing: { before: 100 } })); } } if (question.description && question.description !== '{}' && !isEmptyRichText(question.description)) { const descriptionRuns = await parseRichTextToParagraphs(question.description); const descriptionParagraph = new Paragraph({ children: [ new TextRun({ text: '解析:', bold: true }), ...descriptionRuns, ], spacing: { before: 100, after: 100 }, }); docContent.push(descriptionParagraph); } break; } case 12: { const options = question.answer_items.map((item, idx) => { const optionLetter = String.fromCharCode(65 + idx); return { letter: optionLetter, content: item.value, originalIndex: idx, }; }); for (const option of options) { const optionRuns = await parseRichTextToParagraphs(option.content); const optionParagraph = new Paragraph({ children: [ new TextRun({ text: `${option.letter}. `, bold: true, }), ...optionRuns, ], }); docContent.push(optionParagraph); } const sortedItems = question.answer_items.slice().sort((a, b) => parseInt(a.answer) - parseInt(b.answer)); const answerLetters = sortedItems.map(item => { const originalIndex = question.answer_items.indexOf(item); return String.fromCharCode(65 + originalIndex); }).join(''); docContent.push( new Paragraph({ text: `答案:${answerLetters}`, spacing: { before: 100, after: 100 }, }) ); if (question.description && question.description !== '{}' && !isEmptyRichText(question.description)) { const descriptionRuns = await parseRichTextToParagraphs(question.description); const descriptionParagraph = new Paragraph({ children: [ new TextRun({ text: '解析:', bold: true, }), ...descriptionRuns, ], spacing: { before: 100, after: 100 }, }); docContent.push(descriptionParagraph); } break; } case 13: { const leftItems = question.answer_items.filter(item => !item.is_target_opt); const rightItems = question.answer_items.filter(item => item.is_target_opt); docContent.push(new Paragraph({ text: "左侧选项:" })); leftItems.forEach((leftItem, index) => { const leftContent = parseRichTextToPlainText(leftItem.value); docContent.push(new Paragraph({ text: `左${index + 1}:${leftContent}`, })); }); docContent.push(new Paragraph({ text: "右侧选项:" })); rightItems.forEach((rightItem, index) => { const rightContent = parseRichTextToPlainText(rightItem.value); docContent.push(new Paragraph({ text: `右${index + 1}:${rightContent}`, })); }); const answerText = '答案:' + leftItems.map((leftItem, leftIndex) => { const leftOptionNumber = `左${leftIndex + 1}`; const matchedRightIds = leftItem.answer ? leftItem.answer.toString().split(',') : []; const matchedRightNumbers = matchedRightIds.map((id) => { const rightIndex = rightItems.findIndex(item => item.id === id); return rightIndex >= 0 ? `右${rightIndex + 1}` : ''; }).join('、'); return `${leftOptionNumber} - ${matchedRightNumbers}`; }).join('|'); docContent.push( new Paragraph({ text: answerText, spacing: { before: 100, after: 100 }, }) ); if (question.description && question.description !== '{}' && !isEmptyRichText(question.description)) { const descriptionRuns = await parseRichTextToParagraphs(question.description); const descriptionParagraph = new Paragraph({ children: [ new TextRun({ text: '解析:', bold: true, }), ...descriptionRuns, ], spacing: { before: 100, after: 100 }, }); docContent.push(descriptionParagraph); } break; } default: { docContent.push(new Paragraph({ text: "该题型暂不支持查看答案。", spacing: { before: 100, after: 100 }, })); if (question.description && question.description !== '{}' && !isEmptyRichText(question.description)) { const descriptionRuns = await parseRichTextToParagraphs(question.description); docContent.push(new Paragraph({ children: [ new TextRun({ text: '解析:', bold: true }), ...descriptionRuns, ], spacing: { before: 100, after: 100 }, })); } break; } } } catch (questionError) { console.error(`处理第 ${index + 1} 题时发生错误:`, questionError, "题目数据:", answerData[index]); docContent.push(new Paragraph({ children: [ new TextRun({ text: `${index + 1}、`, bold: true }), new TextRun({ text: "处理此题时发生错误,已跳过。请打开浏览器控制台(F12)查看详细错误信息。", color: "FF0000", italics: true }) ] })); } progress.update(index + 1, answerData.length, '正在导出'); docContent.push(new Paragraph({ text: "", spacing: { after: 200 } })); } console.log("所有题目处理完毕,准备生成文档..."); progress.update(answerData.length, answerData.length, '正在生成文档'); const doc = new Document({ creator: "小雅答答答", description: `导出的作业答案 - ${assignmentTitle}`, title: assignmentTitle, styles: { paragraphStyles: [ { id: "Normal", name: "Normal", run: { font: "Microsoft YaHei", size: 24 }, paragraph: { spacing: { line: 360, before: 0, after: 0 } }, }, { id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", run: { font: "Microsoft YaHei", size: 32, bold: true }, paragraph: { spacing: { before: 240, after: 120 } }, }, { id: "CodeStyle", name: "Code Style", basedOn: "Normal", run: { font: "Consolas", size: 20 }, paragraph: { indentation: { left: 400 }, spacing: { before: 100, after: 100 } }, }, ], }, sections: [ { properties: {}, children: docContent, }, ], }); const blob = await Packer.toBlob(doc); let safeTitle = assignmentTitle.replace(/[\\/:*?"<>|]/g, '_'); window.saveAs(blob, `${safeTitle}.docx`); progress.hide(); showNotification('作业导出成功,如需导入其他题库,请手动编辑保存一次以确保被准确识别。', { type: 'success', keywords: ['导出', '成功', '题库'], animation: 'fadeSlide' }); } catch (error) { progress.hide(); console.error('导出作业时发生严重错误 (非题目处理阶段):', error); showNotification('导出失败,请查看控制台日志以获取详细信息。', { type: 'error', keywords: ['导出', '失败', '日志'], animation: 'scale' }); } } async function parseRichTextToParagraphs(content) { if (!content || typeof content !== 'string' || content === '{}' || isEmptyRichText(content)) { return []; } let paragraphs = []; try { let jsonContent = JSON.parse(content); if (!jsonContent.blocks || !Array.isArray(jsonContent.blocks)) { paragraphs.push(new Paragraph({ children: [new TextRun({ text: content, font: "Microsoft YaHei" })], })); return paragraphs; } for (const block of jsonContent.blocks) { if (block.type === 'atomic' && block.data && block.data.type === 'IMAGE') { let imageSrc = block.data.src; let fileIdMatch = imageSrc.match(/.*cloud\/file_access\/(\d+)/); if (fileIdMatch && fileIdMatch[1]) { let fileId = fileIdMatch[1]; let randomParam = Date.now(); let imageUrl = `${window.location.origin}/api/jx-oresource/cloud/file_access/${fileId}?random=${randomParam}`; const imageData = await fetchImageData(imageUrl); if (imageData) { const imageSize = await getImageSize(imageData); if (imageSize) { let { width, height } = imageSize; const maxWidth = 450; if (width > maxWidth) { const ratio = maxWidth / width; width = maxWidth; height = height * ratio; } paragraphs.push(new Paragraph({ children: [new ImageRun({ data: imageData, transformation: { width, height }, })], alignment: AlignmentType.CENTER, })); } else { paragraphs.push(new Paragraph({ text: '[图片加载失败]' })); } } else { paragraphs.push(new Paragraph({ text: '[图片加载失败]' })); } } else { paragraphs.push(new Paragraph({ text: '[无法解析图片链接]' })); } } else { const sanitizedText = (block.text || '').replace(/[\x00-\x1F\x7F]/g, ''); paragraphs.push(new Paragraph({ children: [new TextRun({ text: sanitizedText, font: "Microsoft YaHei", eastAsia: "Microsoft YaHei" })], })); } } } catch (e) { console.error("解析富文本到段落时出错:", e, "原始内容:", content); const sanitizedContent = content.replace(/[\x00-\x1F\x7F\u200B-\u200D\uFEFF]/g, ''); if (sanitizedContent) { paragraphs.push(new Paragraph({ children: [new TextRun({ text: `[解析错误] ${sanitizedContent}`, font: "Microsoft YaHei" })], })); } } return paragraphs; } function parseRichTextToPlainText(content) { if (!content) return ''; try { const jsonContent = JSON.parse(content); if (jsonContent && Array.isArray(jsonContent.blocks)) { return jsonContent.blocks.map(block => block.text || '').join('\n').trim(); } } catch (e) { } return String(content).trim(); } function deepParseJsonString(str) { if (typeof str !== 'string' || str.trim() === '') { return str; } try { const parsed = JSON.parse(str); if (typeof parsed === 'string') { return deepParseJsonString(parsed); } if (typeof parsed === 'object' && parsed !== null) { if (Array.isArray(parsed.blocks) && parsed.blocks.length > 0 && parsed.blocks[0].text) { const innerText = parsed.blocks[0].text; if (typeof innerText === 'string' && innerText.startsWith('{') && innerText.endsWith('}')) { return deepParseJsonString(innerText); } } } return parsed; } catch (e) { return str; } } async function parseRichTextToMultimodalContent(richTextContent) { const content = []; if (!richTextContent || richTextContent === '{}') return content; try { const jsonContent = JSON.parse(richTextContent); if (!jsonContent || !Array.isArray(jsonContent.blocks)) { content.push({ type: 'text', text: String(richTextContent) }); return content; } for (const block of jsonContent.blocks) { if (block.text) { content.push({ type: 'text', text: block.text }); } if (block.type === 'atomic' && block.data?.type === 'IMAGE' && block.data.src) { let imageSrc = block.data.src; let fileIdMatch = imageSrc.match(/.*cloud\/file_access\/(\d+)/); if (fileIdMatch && fileIdMatch[1]) { let fileId = fileIdMatch[1]; let randomParam = Date.now(); let imageUrl = `${window.location.origin}/api/jx-oresource/cloud/file_access/${fileId}?random=${randomParam}`; const base64Data = await imageToBase64(imageUrl); if (base64Data) { content.push({ type: 'image_url', image_url: { url: base64Data } }); } else { content.push({ type: 'text', text: '[图片加载失败]' }); } } else { console.warn('[Vision] 无法从src中解析出图片fileId:', imageSrc); content.push({ type: 'text', text: '[无法解析图片链接]' }); } } } } catch (e) { content.push({ type: 'text', text: String(richTextContent) }); } if (content.length <= 1) return content; const mergedContent = []; let textBuffer = ''; for (const item of content) { if (item.type === 'text') { textBuffer += (textBuffer ? '\n' : '') + item.text; } else { if (textBuffer) { mergedContent.push({ type: 'text', text: textBuffer.trim() }); textBuffer = ''; } mergedContent.push(item); } } if (textBuffer) { mergedContent.push({ type: 'text', text: textBuffer.trim() }); } return mergedContent; } async function imageToBase64(url) { try { const response = await fetch(url); if (!response.ok) { console.error(`获取图片失败: ${response.status} ${response.statusText}`, url); return null; } const blob = await response.blob(); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(blob); }); } catch (error) { console.error("图片转Base64时发生错误:", error, url); return null; } } const getCanonicalContent = (richText) => { if (!richText) return ''; try { const jsonContent = JSON.parse(richText); if (jsonContent && Array.isArray(jsonContent.blocks)) { return jsonContent.blocks.map(block => { if (block.type === 'atomic' && block.data) { if (block.data.type === 'IMAGE' && block.data.src) { const fileIdMatch = block.data.src.match(/file_access\/(\d+)/); if (fileIdMatch && fileIdMatch[1]) { return `[IMAGE:${fileIdMatch[1]}]`; } } else if (block.data.type === 'AUDIO' && block.data.data?.quote_id) { return `[AUDIO:${block.data.data.quote_id}]`; } } if (block.text && block.text.trim()) { return block.text.trim().replace(/\s+/g, ' '); } return ''; }).filter(Boolean).join(''); } } catch (e) { return String(richText || '').replace(/<[^>]+>/g, '').trim().replace(/\s+/g, ' '); } return ''; }; function generateContentHash(rawQuestionData) { if (!rawQuestionData || typeof rawQuestionData !== 'object') { return null; } const cleanQuestion = { type: rawQuestionData.type, title: rawQuestionData.title, answer_items: [], subQuestions: [] }; if (!cleanQuestion.type || typeof cleanQuestion.title === 'undefined' || cleanQuestion.title === null) { console.warn("无法生成哈希:缺少 type 或 title 为 null/undefined", rawQuestionData); return null; } const title = getCanonicalContent(cleanQuestion.title); if (title === '' && (!Array.isArray(rawQuestionData.answer_items) || rawQuestionData.answer_items.length === 0)) { console.warn("无法生成哈希:title 为空且没有 answer_items", rawQuestionData); return null; } if (Array.isArray(rawQuestionData.answer_items)) { cleanQuestion.answer_items = rawQuestionData.answer_items.map(item => ({ value: item.value, is_target_opt: item.is_target_opt })); } if (Array.isArray(rawQuestionData.subQuestions)) { cleanQuestion.subQuestions = rawQuestionData.subQuestions.map(subQ => generateContentHash(subQ)); } const type = cleanQuestion.type; let keyParts = [type, title]; if ([1, 2, 5, 12, 13].includes(type) && Array.isArray(cleanQuestion.answer_items)) { if (type === 13) { const leftOptions = cleanQuestion.answer_items.filter(item => !item.is_target_opt).map(item => getCanonicalContent(item.value)).sort(); const rightOptions = cleanQuestion.answer_items.filter(item => item.is_target_opt).map(item => getCanonicalContent(item.value)).sort(); keyParts.push('LEFT:', ...leftOptions, 'RIGHT:', ...rightOptions); } else { const sortedOptions = cleanQuestion.answer_items.map(item => getCanonicalContent(item.value)).sort(); keyParts.push(...sortedOptions); } } if (cleanQuestion.subQuestions.length > 0) { keyParts.push('SUB:', ...cleanQuestion.subQuestions.filter(Boolean).sort()); } const canonicalString = keyParts.join('|'); return md5(canonicalString); } async function getImageSize(imageData) { return new Promise((resolve, reject) => { const blob = new Blob([imageData]); const url = URL.createObjectURL(blob); const img = new Image(); img.onload = function () { const width = img.width; const height = img.height; URL.revokeObjectURL(url); resolve({ width, height }); }; img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('无法加载图片')); }; img.src = url; }); } async function fetchImageData(url) { try { const response = await fetch(url, { method: 'GET' }); if (response.ok) { const blob = await response.blob(); return await blob.arrayBuffer(); } else { console.error('获取图片失败:', response.statusText); return null; } } catch (error) { console.error('fetchImageData 错误:', error); return null; } } async function checkAndExecuteAuto() { if (isProcessing) { return; } if (debounceTimer) { clearTimeout(debounceTimer); } debounceTimer = setTimeout(async () => { const nodeId = getNodeIDFromUrl(window.location.href); const groupId = getGroupIDFromUrl(window.location.href); const flagKey = `xiaoya_autofilled_${groupId}_${nodeId}`; if (nodeId && groupId && sessionStorage.getItem(flagKey)) { sessionStorage.removeItem(flagKey); showNotification('自动填写完成。', { type: 'success' }); console.log('[自动执行] 检测到自动填写后的重载,本次跳过。'); return; } if (autoFetchEnabled && (await isTaskPage())) { try { isProcessing = true; showNotification('正在自动获取答案...', { type: 'info', keywords: ['自动', '获取', '答案'], animation: 'fadeSlide' }); await getAndStoreAnswers(); if (autoFillEnabled) { await new Promise(resolve => setTimeout(resolve, 1000)); await fillAnswers(); } } catch (error) { console.error('自动执行出错:', error); } finally { isProcessing = false; debounceTimer = null; } } else { debounceTimer = null; } }, 500); } function detectPageChange() { let lastUrl = location.href; const observer = new MutationObserver(async () => { if (location.href !== lastUrl) { lastUrl = location.href; isProcessing = false; if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } setTimeout(() => { checkAndExecuteAuto(); }, 1000); if (autoContributeEnabled) { backgroundTaskManager.schedule(); } } }); observer.observe(document, { subtree: true, childList: true }); checkAndExecuteAuto(); if (autoContributeEnabled) { backgroundTaskManager.schedule(); } } detectPageChange(); let modelListCache = {}; function showAISettingsPanel() { let aiConfig = JSON.parse(localStorage.getItem('aiConfig') || '{}'); const currentProvider = aiConfig.provider || 'default'; const currentEndpoint = aiConfig.endpoint || ''; const currentApiKey = aiConfig.apiKey || ''; const currentAzureApiVersion = aiConfig.azureApiVersion || '2023-07-01-preview'; const currentModelId = aiConfig.model || ''; const currentTemperature = aiConfig.temperature !== undefined ? aiConfig.temperature : 0.7; const currentMaxTokens = aiConfig.max_tokens !== undefined ? aiConfig.max_tokens : 8000; const currentDisableCorrection = aiConfig.disableCorrection || false; const currentDisableMaxTokens = aiConfig.disableMaxTokens || false; const currentBatchConcurrency = aiConfig.batchConcurrency || 'sequential'; const currentXiaoyaAiMode = aiConfig.xiaoyaAiMode || 'deep_think'; const overlay = document.createElement('div'); overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.7); z-index: 10001; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.5s cubic-bezier(0.19, 1, 0.22, 1); backdrop-filter: blur(8px); `; const modal = document.createElement('div'); modal.style.cssText = ` background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); padding: 32px 40px; border-radius: 20px; width: 650px; max-width: 95%; box-shadow: 0 25px 50px rgba(0, 0, 0, 0.18), 0 0 25px rgba(0,0,0,0.12); transform: scale(0.95) translateY(15px); opacity: 0; transition: all 0.5s cubic-bezier(0.19, 1, 0.22, 1); position: relative; max-height: 90vh; display: flex; flex-direction: column; border: 1px solid rgba(255, 255, 255, 0.2); `; const title = document.createElement('h2'); title.innerHTML = ` AI 助手设置 `; title.style.cssText = ` margin-top: 0; margin-bottom: 35px; text-align: center; color: #1f2937; font-size: 26px; font-weight: 700; display: flex; align-items: center; justify-content: center; position: relative; `; const titleUnderline = document.createElement('div'); titleUnderline.style.cssText = ` position: absolute; bottom: -12px; left: 50%; transform: translateX(-50%); width: 80px; height: 3px; background: linear-gradient(to right, #6366f1, #8b5cf6); border-radius: 3px; `; title.appendChild(titleUnderline); const form = document.createElement('div'); form.style.cssText = ` overflow-y: auto; padding-right: 18px; padding-left: 18px; margin-bottom: 25px; flex-grow: 1; scrollbar-width: thin; scrollbar-color: #cbd5e1 #f1f5f9; `; form.innerHTML = ` `; form.classList.add('ai-settings-form'); const closeButton = document.createElement('button'); closeButton.innerHTML = ` `; closeButton.style.cssText = ` position: absolute; top: 15px; right: 15px; background: #f3f4f6; border: none; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; color: #6b7280; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; box-shadow: 0 2px 5px rgba(0,0,0,0.08); `; closeButton.onmouseover = () => { closeButton.style.backgroundColor = '#e5e7eb'; closeButton.style.transform = 'rotate(90deg)'; closeButton.style.color = '#000'; closeButton.style.boxShadow = '0 4px 8px rgba(0,0,0,0.12)'; }; closeButton.onmouseout = () => { closeButton.style.backgroundColor = '#f3f4f6'; closeButton.style.transform = 'rotate(0deg)'; closeButton.style.color = '#6b7280'; closeButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.08)'; }; closeButton.onclick = () => closeModal(); const fields = [ { id: 'ai-provider', label: 'AI 提供商:', type: 'select', options: [ { value: 'default', text: '默认 - 小雅 AI (无需配置)' }, { value: 'openai', text: 'OpenAI / 兼容 OpenAI 接口' }, { value: 'gemini', text: 'Google Gemini' }, { value: 'anthropic', text: 'Anthropic Claude' }, { value: 'azure', text: 'Azure OpenAI' } ], value: currentProvider }, { id: 'xiaoya-ai-mode', label: '小雅 AI 模式:', type: 'select', options: [ { value: 'deep_think', text: '深度思考模式 (默认,推理模型)' }, { value: 'no_deep_think', text: '快速模式 (速度快,质量一般)' } ], value: currentXiaoyaAiMode, dependsOn: ['default'] }, { id: 'ai-endpoint', label: 'API 地址:', type: 'text', placeholder: '例如: https://api.openai.com/v1/chat/completions', value: currentEndpoint, dependsOn: ['openai', 'gemini', 'anthropic', 'azure'] }, { id: 'ai-key', label: 'API Key:', type: 'password', placeholder: '请输入你的 API Key', value: currentApiKey, dependsOn: ['openai', 'gemini', 'anthropic', 'azure'] }, { id: 'ai-model', label: '模型 ID:', type: 'text', placeholder: '例如: gpt-4o, gemini-2.0-flash, claude-3-7-sonnet', value: currentModelId, dependsOn: ['openai', 'gemini', 'anthropic', 'azure'], hasFetchButton: true }, { id: 'ai-vision-enabled', label: '模型具备图片识别 (Vision) 能力:', type: 'checkbox', value: aiConfig.visionEnabled || false, dependsOn: ['openai', 'gemini', 'anthropic', 'azure'] }, { id: 'stt-enabled', label: '启用语音转文本(STT)功能 (用于听力题):', type: 'checkbox', value: aiConfig.sttEnabled || false, }, { id: 'stt-video-enabled', label: '启用视频音轨转录:', type: 'checkbox', value: aiConfig.sttVideoEnabled || false, dependsOn: ['stt-enabled'] }, { id: 'stt-provider', label: 'STT 提供商', type: 'select', options: [ { value: 'openai_compatible', text: 'OpenAI Whisper / 兼容接口 (如 SiliconFlow)' }, { value: 'gemini', text: 'Google Gemini' }, ], value: aiConfig.sttProvider || 'openai_compatible', dependsOn: ['stt-enabled'] }, { id: 'stt-endpoint', label: 'STT API 地址', type: 'text', placeholder: '例如: https://api.siliconflow.cn/v1/audio/transcriptions', value: aiConfig.sttEndpoint || '', dependsOn: ['stt-enabled'] }, { id: 'stt-api-key', label: 'STT API Key', type: 'password', placeholder: '请输入 STT 服务的 API Key', value: aiConfig.sttApiKey || '', dependsOn: ['stt-enabled'] }, { id: 'stt-model', label: 'STT 模型 ID', type: 'text', placeholder: '例如: whisper-1 或 FunAudioLLM/SenseVoiceSmall', value: aiConfig.sttModel || 'whisper-1', dependsOn: ['stt-enabled'] }, { id: 'ai-temperature', label: 'Temperature (随机性):', type: 'range', min: 0, max: 1, step: 0.1, value: currentTemperature, dependsOn: ['openai', 'gemini', 'anthropic', 'azure'] }, { id: 'ai-max-tokens', label: 'Max Tokens (最大长度):', type: 'number', min: 10, max: 8000, step: 10, value: currentMaxTokens, dependsOn: ['openai', 'gemini', 'anthropic', 'azure'] }, { id: 'ai-azure-apiversion', label: 'Azure API Version (可选):', type: 'text', placeholder: '例如: 2024-05-01-preview', value: currentAzureApiVersion, dependsOn: ['azure'] }, { id: 'ai-disable-correction', label: '禁用 API 地址自动修正/补全:', type: 'checkbox', value: currentDisableCorrection, dependsOn: ['openai', 'gemini', 'anthropic', 'azure'] }, { id: 'ai-disable-max-tokens', label: '不限制 Max Tokens (可能导致费用增加或API出错):', type: 'checkbox', value: currentDisableMaxTokens, dependsOn: ['openai', 'gemini', 'anthropic', 'azure'] }, { id: 'ai-batch-concurrency', label: 'AI 批量处理并发数:', type: 'number', min: 1, placeholder: '输入数字 (1 = 顺序处理, >1 = 并发处理)', value: currentBatchConcurrency === 'sequential' ? 1 : (parseInt(currentBatchConcurrency, 10) || 2), dependsOn: ['openai', 'gemini', 'anthropic', 'azure', 'default'] }, { id: 'ai-request-interval', label: 'AI 顺序处理请求间隔 (毫秒):', type: 'number', min: 0, placeholder: '例如: 500 (表示 0.5 秒)', value: aiConfig.requestInterval || 1000, dependsOn: ['openai', 'gemini', 'anthropic', 'azure', 'default'] }, ]; const promptPlaceholders = { '1': ['{questionType}', '{questionTitle}', '{optionsText}'], '2': ['{questionType}', '{questionTitle}', '{optionsText}'], '5': ['{questionType}', '{questionTitle}', '{optionsText}'], '4': ['{questionType}', '{questionTitle}', '{answerContent}'], '6': ['{questionType}', '{questionTitle}', '{answerContent}'], '10': ['{questionType}', '{questionTitle}', '{language}', '{max_time}', '{max_memory}', '{answerContent}'], '12': ['{questionType}', '{questionTitle}', '{optionsText}'], '13': ['{questionType}', '{questionTitle}', '{stemsText}', '{optionsText}'] }; let customPrompts = JSON.parse(localStorage.getItem('aiCustomPrompts') || '{}'); const inputElements = {}; fields.forEach(field => { const group = document.createElement('div'); group.className = 'form-group'; group.dataset.dependsOn = field.dependsOn ? JSON.stringify(field.dependsOn) : ''; const label = document.createElement('label'); label.textContent = field.label; label.htmlFor = field.id; let input; if (field.type === 'select') { input = document.createElement('select'); field.options.forEach(opt => { const option = document.createElement('option'); option.value = opt.value; option.textContent = opt.text; if (opt.value === field.value) option.selected = true; input.appendChild(option); }); input.onchange = () => updateFieldVisibility(); } else if (field.type === 'range') { const rangeGroup = document.createElement('div'); rangeGroup.className = 'range-group'; input = document.createElement('input'); input.type = 'range'; input.min = field.min; input.max = field.max; input.step = field.step; input.value = field.value; const valueDisplay = document.createElement('span'); valueDisplay.className = 'range-value'; valueDisplay.textContent = parseFloat(input.value).toFixed(1); input.oninput = () => { valueDisplay.textContent = parseFloat(input.value).toFixed(1); }; rangeGroup.appendChild(input); rangeGroup.appendChild(valueDisplay); group.appendChild(label); group.appendChild(rangeGroup); form.appendChild(group); inputElements[field.id] = input; return; } else if (field.type === 'checkbox') { input = document.createElement('input'); input.type = 'checkbox'; input.checked = field.value; input.style.width = '20px'; input.style.height = '20px'; input.style.marginRight = '10px'; input.style.verticalAlign = 'middle'; const checkboxLabel = document.createElement('span'); if (field.id === 'ai-vision-enabled') { checkboxLabel.innerHTML = '勾选后,AI 将能够识别题目或选项中的图片内容。请确保模型支持。'; } else if (field.id === 'stt-enabled') { checkboxLabel.innerHTML = '勾选后,AI 将能够识别语音输入。请填入可用的 STT 模型。'; } else if (field.id === 'stt-video-enabled') { checkboxLabel.innerHTML = '勾选后,将自动提取视频中的音轨进行转录。这会消耗更多资源和时间。'; } else if (field.id === 'ai-disable-correction') { checkboxLabel.textContent = '强制使用填写的地址,不进行任何自动修改。'; } else if (field.id === 'ai-disable-max-tokens') { checkboxLabel.textContent = '勾选后将不发送 max_tokens 参数,某些 API 可能不支持。'; } checkboxLabel.style.verticalAlign = 'middle'; checkboxLabel.style.fontSize = '13px'; checkboxLabel.style.color = '#555'; const checkboxContainer = document.createElement('div'); checkboxContainer.style.display = 'flex'; checkboxContainer.style.alignItems = 'center'; checkboxContainer.appendChild(input); checkboxContainer.appendChild(checkboxLabel); group.appendChild(label); group.appendChild(checkboxContainer); form.appendChild(group); inputElements[field.id] = input; return; } else if (field.type === 'number') { input = document.createElement('input'); input.type = 'number'; input.min = field.min; input.max = field.max; input.step = field.step; input.placeholder = field.placeholder || ''; input.value = field.value; } else { input = document.createElement('input'); input.type = field.type; input.placeholder = field.placeholder || ''; input.value = field.value; } input.id = field.id; group.appendChild(label); if (field.hasFetchButton) { const inputContainer = document.createElement('div'); inputContainer.style.display = 'flex'; inputContainer.style.gap = '10px'; inputContainer.style.alignItems = 'center'; input.style.flexGrow = '1'; inputContainer.appendChild(input); const fetchButton = document.createElement('button'); fetchButton.innerHTML = ` 获取`; fetchButton.type = 'button'; fetchButton.style.cssText = ` padding: 8px 12px; background-color: #e5e7eb; color: #374151; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer; font-size: 13px; transition: all 0.2s; display: inline-flex; align-items: center; `; fetchButton.onmouseover = () => { fetchButton.style.backgroundColor = '#d1d5db'; }; fetchButton.onmouseout = () => { fetchButton.style.backgroundColor = '#e5e7eb'; }; fetchButton.onclick = () => fetchModelsAndPopulateDropdown(field.id); inputContainer.appendChild(fetchButton); group.appendChild(inputContainer); const modelSearchInput = document.createElement('input'); modelSearchInput.type = 'text'; modelSearchInput.id = `${field.id}-search`; modelSearchInput.placeholder = '搜索模型...'; modelSearchInput.style.marginTop = '8px'; modelSearchInput.style.display = 'none'; modelSearchInput.style.width = 'calc(100% - 30px)'; modelSearchInput.style.padding = '8px 12px'; modelSearchInput.style.border = '1px solid #d1d5db'; modelSearchInput.style.borderRadius = '6px'; modelSearchInput.style.fontSize = '13px'; const modelSelect = document.createElement('select'); modelSelect.id = `${field.id}-select`; modelSelect.style.marginTop = '8px'; modelSelect.style.display = 'none'; modelSelect.innerHTML = ''; modelSelect.onchange = () => { if (modelSelect.value) { input.value = modelSelect.value; } }; modelSearchInput.oninput = () => { const searchTerm = modelSearchInput.value.toLowerCase(); let firstVisibleOption = null; for (let i = 0; i < modelSelect.options.length; i++) { const option = modelSelect.options[i]; if (option.value === "") { option.style.display = ''; continue; } const optionText = option.textContent.toLowerCase(); const isVisible = optionText.includes(searchTerm); option.style.display = isVisible ? '' : 'none'; if (isVisible && !firstVisibleOption) { firstVisibleOption = option; } } }; group.appendChild(modelSearchInput); group.appendChild(modelSelect); inputElements[`${field.id}-search`] = modelSearchInput; inputElements[`${field.id}-select`] = modelSelect; } else { group.appendChild(input); } form.appendChild(group); inputElements[field.id] = input; }); const advancedDetails = document.createElement('details'); const advancedSummary = document.createElement('summary'); advancedSummary.textContent = '高级设置'; advancedDetails.appendChild(advancedSummary); const advancedContentWrapper = document.createElement('div'); advancedContentWrapper.className = 'advanced-content-wrapper'; advancedDetails.appendChild(advancedContentWrapper); const advancedFieldIds = [ 'ai-temperature', 'ai-max-tokens', 'ai-azure-apiversion', 'ai-disable-correction', 'ai-disable-max-tokens', 'ai-batch-concurrency', 'ai-request-interval' ]; advancedFieldIds.forEach(id => { const element = inputElements[id]; if (element) { const group = element.closest('.form-group'); if (group) { advancedContentWrapper.appendChild(group); } } }); const promptSectionTitle = document.createElement('h3'); promptSectionTitle.textContent = '自定义 Prompt'; promptSectionTitle.style.cssText = ` margin-top: 35px; margin-bottom: 20px; font-size: 20px; font-weight: 600; color: #1f2937; border-top: 1px solid #e5e7eb; padding-top: 25px; text-align: center; position: relative; `; const titleIcon = document.createElement('span'); titleIcon.innerHTML = ` `; promptSectionTitle.insertBefore(titleIcon, promptSectionTitle.firstChild); advancedContentWrapper.appendChild(promptSectionTitle); const promptDescription = document.createElement('p'); promptDescription.style.cssText = ` margin-bottom: 25px; color: #6b7280; font-size: 14px; line-height: 1.5; text-align: center; max-width: 80%; margin-left: auto; margin-right: auto; `; promptDescription.innerHTML = '自定义各题型的 AI 提示模板,使用占位符来插入题目内容。高质量的提示将带来更准确的 AI 回答。'; advancedContentWrapper.appendChild(promptDescription); const promptEditContainer = document.createElement('div'); promptEditContainer.style.cssText = ` display: flex; flex-direction: column; gap: 25px; background: #f9fafb; padding: 20px; border-radius: 12px; border: 1px solid #e5e7eb; box-shadow: 0 2px 5px rgba(0,0,0,0.05); `; Object.keys(defaultPrompts).forEach(typeCode => { const questionTypeName = getQuestionType(parseInt(typeCode, 10)); const promptGroup = document.createElement('div'); promptGroup.className = 'form-group prompt-group'; promptGroup.style.cssText = ` padding: 15px; background: #ffffff; border-radius: 10px; border: 1px solid #e5e7eb; transition: all 0.3s ease; box-shadow: 0 1px 3px rgba(0,0,0,0.02); `; promptGroup.addEventListener('mouseenter', () => { promptGroup.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)'; promptGroup.style.borderColor = '#d1d5db'; promptGroup.style.transform = 'translateY(-2px)'; }); promptGroup.addEventListener('mouseleave', () => { promptGroup.style.boxShadow = '0 1px 3px rgba(0,0,0,0.02)'; promptGroup.style.borderColor = '#e5e7eb'; promptGroup.style.transform = 'translateY(0)'; }); const promptHeader = document.createElement('div'); promptHeader.style.cssText = ` display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; `; const promptLabel = document.createElement('label'); promptLabel.htmlFor = `prompt-type-${typeCode}`; promptLabel.style.cssText = ` font-weight: 600; color: #374151; font-size: 16px; display: flex; align-items: center; `; const typeIcon = document.createElement('span'); typeIcon.style.cssText = ` display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; margin-right: 10px; background: #eef2ff; border-radius: 8px; color: #6366f1; font-size: 16px; font-weight: bold; `; switch (parseInt(typeCode)) { case 1: typeIcon.innerHTML = '单'; break; case 2: typeIcon.innerHTML = '多'; break; case 5: typeIcon.innerHTML = '判'; break; case 4: typeIcon.innerHTML = '填'; break; case 6: typeIcon.innerHTML = '简'; break; case 10: typeIcon.innerHTML = '编'; break; case 12: typeIcon.innerHTML = '排'; break; case 13: typeIcon.innerHTML = '匹'; break; default: typeIcon.innerHTML = typeCode; } promptLabel.appendChild(typeIcon); promptLabel.appendChild(document.createTextNode(`${questionTypeName} Prompt`)); const restoreButton = document.createElement('button'); restoreButton.type = 'button'; restoreButton.style.cssText = ` padding: 6px 12px; font-size: 13px; background: linear-gradient(to bottom, #f8fafc, #eef2ff); color: #4f46e5; border: 1px solid #c7d2fe; border-radius: 8px; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 1px 2px rgba(99, 102, 241, 0.1); display: flex; align-items: center; font-weight: 500; `; restoreButton.innerHTML = ` 恢复默认 `; restoreButton.onmouseover = () => { restoreButton.style.background = 'linear-gradient(to bottom, #eef2ff, #dbeafe)'; restoreButton.style.transform = 'translateY(-2px)'; restoreButton.style.boxShadow = '0 3px 6px rgba(99, 102, 241, 0.2)'; }; restoreButton.onmouseout = () => { restoreButton.style.background = 'linear-gradient(to bottom, #f8fafc, #eef2ff)'; restoreButton.style.transform = 'translateY(0)'; restoreButton.style.boxShadow = '0 1px 2px rgba(99, 102, 241, 0.1)'; }; restoreButton.onclick = (e) => { e.preventDefault(); const textarea = inputElements[`prompt-type-${typeCode}`]; if (textarea) { restoreButton.style.transition = 'all 0.2s'; restoreButton.style.background = '#818cf8'; restoreButton.style.color = 'white'; restoreButton.innerHTML = ` 已恢复`; textarea.value = defaultPrompts[typeCode]; textarea.style.borderColor = '#818cf8'; textarea.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.15)'; setTimeout(() => { restoreButton.style.transition = 'all 0.3s ease'; restoreButton.style.background = 'linear-gradient(to bottom, #f8fafc, #eef2ff)'; restoreButton.style.color = '#4f46e5'; restoreButton.innerHTML = ` 恢复默认 `; setTimeout(() => { textarea.style.borderColor = '#d1d5db'; textarea.style.boxShadow = 'none'; }, 300); }, 1000); showNotification(`${questionTypeName} Prompt 已恢复默认`, { type: 'success', duration: 1500, animation: 'scale' }); } }; promptHeader.appendChild(promptLabel); promptHeader.appendChild(restoreButton); promptGroup.appendChild(promptHeader); const promptTextarea = document.createElement('textarea'); promptTextarea.id = `prompt-type-${typeCode}`; promptTextarea.rows = 8; promptTextarea.style.cssText = ` width: 100%; padding: 12px 15px; border: 1px solid #d1d5db; border-radius: 10px; font-size: 14px; line-height: 1.6; resize: vertical; background-color: #f9fafb; transition: all 0.25s ease; outline: none; box-sizing: border-box; min-height: 160px; color: #374151; `; promptTextarea.value = customPrompts[typeCode] || defaultPrompts[typeCode]; promptTextarea.onfocus = () => { promptTextarea.style.borderColor = '#6366f1'; promptTextarea.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.15)'; promptTextarea.style.backgroundColor = '#ffffff'; }; promptTextarea.onblur = () => { promptTextarea.style.borderColor = '#d1d5db'; promptTextarea.style.boxShadow = 'none'; promptTextarea.style.backgroundColor = '#f9fafb'; }; const placeholderInfo = document.createElement('div'); placeholderInfo.style.cssText = ` margin-top: 10px; color: #6b7280; font-size: 13px; display: flex; align-items: center; flex-wrap: wrap; gap: 6px; `; const infoIcon = document.createElement('span'); infoIcon.innerHTML = ` `; infoIcon.style.marginRight = '5px'; placeholderInfo.appendChild(infoIcon); const placeholderText = document.createElement('span'); placeholderText.textContent = '可用占位符: '; placeholderInfo.appendChild(placeholderText); promptPlaceholders[typeCode].forEach((placeholder, index) => { const placeholderChip = document.createElement('code'); placeholderChip.textContent = placeholder; placeholderChip.style.cssText = ` background: #e0e7ff; color: #4338ca; padding: 3px 6px; border-radius: 4px; font-family: Microsoft YaHei; font-size: 12px; display: inline-block; cursor: pointer; transition: all 0.2s ease; border: 1px solid #c7d2fe; `; placeholderChip.onclick = () => { const start = promptTextarea.selectionStart; const end = promptTextarea.selectionEnd; const text = promptTextarea.value; promptTextarea.value = text.substring(0, start) + placeholder + text.substring(end); promptTextarea.focus(); promptTextarea.setSelectionRange(start + placeholder.length, start + placeholder.length); placeholderChip.style.backgroundColor = '#818cf8'; placeholderChip.style.color = 'white'; setTimeout(() => { placeholderChip.style.backgroundColor = '#e0e7ff'; placeholderChip.style.color = '#4338ca'; }, 200); }; placeholderChip.onmouseover = () => { placeholderChip.style.backgroundColor = '#c7d2fe'; placeholderChip.style.transform = 'translateY(-1px)'; placeholderChip.style.boxShadow = '0 2px 4px rgba(99, 102, 241, 0.2)'; }; placeholderChip.onmouseout = () => { placeholderChip.style.backgroundColor = '#e0e7ff'; placeholderChip.style.transform = 'translateY(0)'; placeholderChip.style.boxShadow = 'none'; }; placeholderInfo.appendChild(placeholderChip); }); promptGroup.appendChild(promptTextarea); promptGroup.appendChild(placeholderInfo); promptEditContainer.appendChild(promptGroup); inputElements[`prompt-type-${typeCode}`] = promptTextarea; }); advancedContentWrapper.appendChild(promptEditContainer); advancedContentWrapper.appendChild(promptEditContainer); form.appendChild(advancedDetails); const urlPreviewContainer = document.createElement('div'); urlPreviewContainer.className = 'url-preview-container'; urlPreviewContainer.innerHTML = ` 请求 URL 预览:
-
-
`; form.appendChild(urlPreviewContainer); const urlDisplayElement = urlPreviewContainer.querySelector('.url-display'); const statusElement = urlPreviewContainer.querySelector('.status'); const suggestionElement = urlPreviewContainer.querySelector('.correction-suggestion'); const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = ` display: flex; justify-content: flex-end; gap: 15px; margin-top: 20px; padding-top: 25px; border-top: 1px solid #e5e7eb; `; const saveButton = document.createElement('button'); saveButton.innerHTML = ` 保存设置 `; saveButton.style.cssText = ` padding: 12px 24px; background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%); color: white; border: none; border-radius: 10px; cursor: pointer; font-size: 15px; font-weight: 600; transition: all 0.3s ease; display: inline-flex; align-items: center; box-shadow: 0 4px 12px rgba(79, 70, 229, 0.2); `; saveButton.onmouseover = () => { saveButton.style.transform = 'translateY(-2px)'; saveButton.style.boxShadow = '0 6px 15px rgba(79, 70, 229, 0.3)'; }; saveButton.onmouseout = () => { saveButton.style.transform = 'translateY(0)'; saveButton.style.boxShadow = '0 4px 12px rgba(79, 70, 229, 0.2)'; }; saveButton.onclick = () => { const newConfig = { provider: inputElements['ai-provider'].value, endpoint: inputElements['ai-endpoint'].value.trim(), apiKey: inputElements['ai-key'].value.trim(), model: inputElements['ai-model'].value.trim(), temperature: parseFloat(inputElements['ai-temperature'].value), max_tokens: parseInt(inputElements['ai-max-tokens'].value, 10), azureApiVersion: inputElements['ai-azure-apiversion'].value.trim(), disableCorrection: inputElements['ai-disable-correction'].checked, disableMaxTokens: inputElements['ai-disable-max-tokens'].checked, visionEnabled: inputElements['ai-vision-enabled'].checked, batchConcurrency: inputElements['ai-batch-concurrency'].value, requestInterval: parseInt(inputElements['ai-request-interval'].value, 10) || 200, xiaoyaAiMode: inputElements['xiaoya-ai-mode'].value, sttEnabled: inputElements['stt-enabled'].checked, sttVideoEnabled: inputElements['stt-video-enabled'].checked, sttProvider: inputElements['stt-provider'].value, sttEndpoint: inputElements['stt-endpoint'].value.trim(), sttApiKey: inputElements['stt-api-key'].value.trim(), sttModel: inputElements['stt-model'].value.trim() }; if (isNaN(newConfig.temperature)) newConfig.temperature = 0.7; if (isNaN(newConfig.max_tokens) || newConfig.max_tokens <= 0) newConfig.max_tokens = 8000; const newCustomPrompts = {}; Object.keys(defaultPrompts).forEach(typeCode => { const textarea = inputElements[`prompt-type-${typeCode}`]; if (textarea) { if (textarea.value.trim() !== defaultPrompts[typeCode].trim()) { newCustomPrompts[typeCode] = textarea.value; } } }); localStorage.setItem('aiCustomPrompts', JSON.stringify(newCustomPrompts)); localStorage.setItem('aiConfig', JSON.stringify(newConfig)); showNotification('AI 设置已保存!', { type: 'success', animation: 'scale' }); closeModal(); }; const cancelButton = document.createElement('button'); cancelButton.innerHTML = ` 取消 `; cancelButton.style.cssText = ` padding: 12px 24px; background-color: #f3f4f6; color: #4b5563; border: 1px solid #d1d5db; border-radius: 10px; cursor: pointer; font-size: 15px; font-weight: 500; transition: all 0.3s ease; display: inline-flex; align-items: center; `; cancelButton.onmouseover = () => { cancelButton.style.backgroundColor = '#e5e7eb'; cancelButton.style.transform = 'translateY(-1px)'; }; cancelButton.onmouseout = () => { cancelButton.style.backgroundColor = '#f3f4f6'; cancelButton.style.transform = 'translateY(0)'; }; cancelButton.onclick = () => closeModal(); buttonContainer.appendChild(cancelButton); buttonContainer.appendChild(saveButton); modal.appendChild(closeButton); modal.appendChild(title); modal.appendChild(form); modal.appendChild(buttonContainer); overlay.appendChild(modal); document.body.appendChild(overlay); requestAnimationFrame(() => { overlay.style.opacity = '1'; modal.style.opacity = '1'; modal.style.transform = 'scale(1) translateY(0)'; }); async function fetchAvailableModels(provider, endpoint, apiKey, azureApiVersion) { const cacheKey = `${provider}-${endpoint}-${apiKey}-${azureApiVersion || ''}`; const cachedData = modelListCache[cacheKey]; if (cachedData) { console.log(`从缓存加载 ${provider} 模型列表`); showNotification(`从缓存加载 ${provider} 模型列表`, { type: 'info', duration: 1000 }); return Promise.resolve(cachedData.models); } console.log(`正在为 ${provider} 获取可用模型...`); showNotification(`正在为 ${provider} 获取可用模型...`, { type: 'info', duration: 2000 }); try { switch (provider) { case 'openai': { let modelsEndpoint = endpoint.split('?')[0].replace(/\/$/, ''); if (modelsEndpoint.endsWith('/chat/completions')) { modelsEndpoint = modelsEndpoint.replace('/chat/completions', '/models'); } else if (!modelsEndpoint.endsWith('/models')) { if (modelsEndpoint.includes('/v1')) { modelsEndpoint = modelsEndpoint.substring(0, modelsEndpoint.indexOf('/v1')) + '/v1/models'; } else { modelsEndpoint += '/v1/models'; } } console.log("OpenAI 模型端点:", modelsEndpoint); return new Promise((resolve, reject) => { fetch(modelsEndpoint, { method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}` }, signal: AbortSignal.timeout(15000) }) .then(response => { if (response.ok) { return response.json(); } else { const errorMsg = `获取 OpenAI 模型列表失败 (${response.status})`; showNotification(errorMsg, { type: 'error' }); throw new Error(errorMsg); } }) .then(data => { const models = (data.data || data) .map(m => m.id) .sort(); console.log("找到 OpenAI 可用模型:", models); modelListCache[cacheKey] = { models: models, timestamp: Date.now() }; resolve(models); }) .catch(error => { if (error.name === 'AbortError') { showNotification('获取 OpenAI 模型列表超时', { type: 'error' }); reject(new Error('获取 OpenAI 模型列表超时')); } else { showNotification('获取 OpenAI 模型列表失败: ' + error.message, { type: 'error' }); reject(error); } }); }); } case 'gemini': { let modelsEndpoint = endpoint.replace(/\/v\d+(beta)?\/models\/.*$/, '').replace(/\/models\/.*$/, '').replace(/\/$/, ''); modelsEndpoint += `/v1beta/models?key=${apiKey}`; console.log("Gemini 模型端点:", modelsEndpoint); return new Promise((resolve, reject) => { fetch(modelsEndpoint, { method: 'GET', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(15000) }) .then(response => { if (response.ok) { return response.json(); } else { const errorMsg = `获取 Gemini 模型列表失败 (${response.status})`; showNotification(errorMsg, { type: 'error' }); throw new Error(errorMsg); } }) .then(data => { const models = (data.models || []) .map(m => m.name.replace('models/', '')) .sort(); console.log("找到 Gemini 可用模型:", models); modelListCache[cacheKey] = { models: models, timestamp: Date.now() }; resolve(models); }) .catch(error => { if (error.name === 'AbortError') { showNotification('获取 Gemini 模型列表超时', { type: 'error' }); reject(new Error('获取 Gemini 模型列表超时')); } else { showNotification('获取 Gemini 模型列表失败: ' + error.message, { type: 'error' }); reject(error); } }); }); } case 'anthropic': { let modelsEndpoint = endpoint.split('?')[0].replace(/\/$/, ''); if (modelsEndpoint.endsWith('/v1/messages')) { modelsEndpoint = modelsEndpoint.replace('/v1/messages', '/v1/models'); } else if (!modelsEndpoint.endsWith('/v1/models')) { if (modelsEndpoint.includes('/v1')) { modelsEndpoint = modelsEndpoint.substring(0, modelsEndpoint.indexOf('/v1')) + '/v1/models'; } else { modelsEndpoint += '/v1/models'; } } console.log("Anthropic 模型端点:", modelsEndpoint); return new Promise((resolve, reject) => { fetch(modelsEndpoint, { method: 'GET', headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(15000) }) .then(response => { if (response.ok) { return response.json(); } else { const errorMsg = `获取 Anthropic 模型列表失败 (${response.status})`; showNotification(errorMsg, { type: 'error' }); throw new Error(errorMsg); } }) .then(data => { const models = (data.data || []) .map(m => m.id) .sort(); console.log("找到 Anthropic 可用模型:", models); modelListCache[cacheKey] = { models: models, timestamp: Date.now() }; resolve(models); }) .catch(error => { if (error.name === 'AbortError') { showNotification('获取 Anthropic 模型列表超时', { type: 'error' }); reject(new Error('获取 Anthropic 模型列表超时')); } else { showNotification('获取 Anthropic 模型列表失败: ' + error.message, { type: 'error' }); reject(error); } }); }); } case 'azure': default: showNotification(`${provider} 提供商暂不支持自动获取模型列表。`, { type: 'warning' }); return Promise.resolve([]); } } catch (error) { console.error(`获取 ${provider} 模型列表时出错:`, error); showNotification(`获取 ${provider} 模型列表失败: ${error.message}`, { type: 'error' }); return Promise.resolve([]); } } async function fetchModelsAndPopulateDropdown(modelInputId) { const provider = inputElements['ai-provider'].value; const endpoint = inputElements['ai-endpoint'].value.trim(); const apiKey = inputElements['ai-key'].value.trim(); const azureApiVersion = inputElements['ai-azure-apiversion']?.value.trim(); const modelSelect = inputElements[`${modelInputId}-select`]; const modelInput = inputElements[modelInputId]; const fetchButton = modelInput.closest('.form-group').querySelector('button[type="button"]'); if (provider === 'default') { showNotification('默认提供商无需获取模型。', { type: 'info' }); return; } if (!endpoint || !apiKey) { showNotification('请先填写 API 地址和 API Key。', { type: 'warning' }); return; } fetchButton.disabled = true; const originalButtonText = fetchButton.innerHTML; fetchButton.innerHTML = ` 获取中...`; if (!document.getElementById('spin-animation-style')) { const style = document.createElement('style'); style.id = 'spin-animation-style'; style.textContent = ` @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } `; document.head.appendChild(style); } try { const models = await fetchAvailableModels(provider, endpoint, apiKey, azureApiVersion); modelSelect.innerHTML = ''; if (models && models.length > 0) { models.forEach(modelId => { const option = document.createElement('option'); option.value = modelId; option.textContent = modelId; modelSelect.appendChild(option); }); modelSelect.style.display = 'block'; inputElements[`${modelInputId}-search`].style.display = 'block'; showNotification(`成功获取 ${models.length} 个模型。`, { type: 'success' }); } else if (models) { modelSelect.style.display = 'none'; inputElements[`${modelInputId}-search`].style.display = 'none'; showNotification(`未能获取到 ${provider} 的模型列表。`, { type: 'warning' }); } else { modelSelect.style.display = 'none'; inputElements[`${modelInputId}-search`].style.display = 'none'; } } catch (error) { console.error("Error in fetchModelsAndPopulateDropdown:", error); modelSelect.style.display = 'none'; inputElements[`${modelInputId}-search`].style.display = 'none'; } finally { fetchButton.disabled = false; fetchButton.innerHTML = originalButtonText; } } const updateSttFieldsForProvider = () => { const sttProvider = inputElements['stt-provider'].value; const endpointInput = inputElements['stt-endpoint']; const modelInput = inputElements['stt-model']; const apiKeyInput = inputElements['stt-api-key']; if (sttProvider === 'gemini') { if (!endpointInput.value) { endpointInput.value = 'https://generativelanguage.googleapis.com/v1beta/models/'; } endpointInput.placeholder = 'Gemini API 地址'; if (!modelInput.value) { modelInput.value = 'gemini-1.5-flash'; } apiKeyInput.placeholder = "请输入你的 Gemini API Key"; } else if (sttProvider === 'openai_compatible') { if (!endpointInput.value) { endpointInput.value = 'https://api.openai.com/v1/audio/transcriptions'; } endpointInput.placeholder = '例如: https://api.openai.com/v1/audio/transcriptions'; if (!modelInput.value) { modelInput.value = 'whisper-1'; } apiKeyInput.placeholder = "可选,可与 LLM Key 不同"; } }; const updateFieldVisibility = () => { const selectedProvider = inputElements['ai-provider'].value; const sttEnabled = inputElements['stt-enabled'].checked; fields.forEach(field => { const group = inputElements[field.id]?.closest('.form-group'); if (!group) return; let shouldBeVisible = true; const dependenciesStr = group.dataset.dependsOn; if (dependenciesStr) { try { const dependencies = JSON.parse(dependenciesStr); if (dependencies.includes('stt-enabled')) { shouldBeVisible = sttEnabled; } else { shouldBeVisible = dependencies.includes(selectedProvider); } } catch (e) { console.error("解析字段依赖项时出错:", field.id, dependenciesStr); shouldBeVisible = false; } } if (shouldBeVisible) { group.classList.remove('hidden'); group.style.maxHeight = '500px'; group.style.opacity = '1'; group.style.marginBottom = '24px'; group.style.display = 'flex'; } else { group.classList.add('hidden'); group.style.maxHeight = '0'; group.style.opacity = '0'; group.style.marginBottom = '0'; setTimeout(() => { if (group.classList.contains('hidden')) { group.style.display = 'none'; } }, 300); } }); const advancedWrapper = advancedDetails.querySelector('.advanced-content-wrapper'); const hasVisibleAdvancedChild = Array.from(advancedWrapper.querySelectorAll('.form-group')).some(group => !group.classList.contains('hidden')); advancedDetails.style.display = hasVisibleAdvancedChild ? 'block' : 'none'; }; const closeModal = () => { modal.style.transform = 'scale(0.95) translateY(15px)'; modal.style.opacity = '0'; overlay.style.opacity = '0'; setTimeout(() => { if (document.body.contains(overlay)) { document.body.removeChild(overlay); } }, 400); }; const updateUrlPreview = () => { const provider = inputElements['ai-provider'].value; const endpoint = inputElements['ai-endpoint'].value.trim(); const apiKey = inputElements['ai-key'].value.trim(); const modelId = inputElements['ai-model'].value.trim(); const azureApiVersion = inputElements['ai-azure-apiversion'].value.trim() || '2024-05-01-preview'; const disableCorrection = inputElements['ai-disable-correction'].checked; let finalUrl = '-'; let status = '-'; let statusClass = ''; let suggestion = ''; if (provider === 'default') { finalUrl = '使用小雅内置 AI,无需配置 URL。'; status = '默认配置'; statusClass = 'valid'; suggestion = '注意: 小雅内置 AI 必须在课程页面内使用。'; } else if (endpoint) { if (disableCorrection) { let baseEndpoint = endpoint.split('?')[0].replace(/\/$/, ''); const urlParams = new URLSearchParams(endpoint.split('?')[1] || ''); if (provider === 'azure' && !urlParams.has('api-version')) { urlParams.set('api-version', azureApiVersion); } finalUrl = `${baseEndpoint}${urlParams.toString() ? '?' + urlParams.toString() : ''}`; status = '已禁用自动修正'; statusClass = 'valid'; suggestion = '将强制使用你输入的地址。请确保格式正确。'; if (provider === 'azure' && !urlParams.has('api-version')) { suggestion += ` (已自动添加 api-version=${azureApiVersion})`; } } else { try { let cleanEndpoint = endpoint.split('?')[0].replace(/\/$/, ''); const originalUrlParams = new URLSearchParams(endpoint.split('?')[1] || ''); switch (provider) { case 'openai': { let corrected = false; if (!cleanEndpoint.endsWith('/v1/chat/completions')) { if (cleanEndpoint.includes('/v1')) { cleanEndpoint = cleanEndpoint.substring(0, cleanEndpoint.indexOf('/v1')) + '/v1/chat/completions'; } else { cleanEndpoint += '/v1/chat/completions'; } corrected = true; } finalUrl = cleanEndpoint; status = corrected ? '格式已自动修正' : '格式有效'; statusClass = corrected ? 'warning' : 'valid'; if (corrected) suggestion = `建议使用标准路径: ${finalUrl}`; break; } case 'gemini': { let cleanBaseEndpoint = endpoint.replace(/\/v\d+(beta)?\/models\/.*$/, '').replace(/\/models\/.*$/, '').replace(/\/$/, ''); const modelToUse = modelId || "gemini-1.5-flash-latest"; const apiVersion = "v1beta"; originalUrlParams.set('key', apiKey ? '***' : '[需要API Key]'); finalUrl = `${cleanBaseEndpoint}/${apiVersion}/models/${modelToUse}:generateContent?${originalUrlParams.toString()}`; if (!apiKey) { status = '缺少 API Key'; statusClass = 'invalid'; suggestion = 'Gemini 请求需要在 URL 中包含 API Key。'; } else { status = '格式有效 (请确认基础地址)'; statusClass = 'valid'; suggestion = `预览显示的是预期请求格式 (Key已隐藏)。请确保基础地址正确。`; } break; } case 'anthropic': { let corrected = false; if (!cleanEndpoint.endsWith('/v1/messages')) { if (cleanEndpoint.includes('/v1')) { cleanEndpoint = cleanEndpoint.substring(0, cleanEndpoint.indexOf('/v1')) + '/v1/messages'; } else { cleanEndpoint += '/v1/messages'; } corrected = true; } finalUrl = cleanEndpoint; status = corrected ? '格式已自动修正' : '格式有效'; statusClass = corrected ? 'warning' : 'valid'; if (corrected) suggestion = `建议使用标准路径: ${finalUrl}`; break; } case 'azure': { if (!originalUrlParams.has('api-version')) { originalUrlParams.set('api-version', azureApiVersion); } const isOpenAIStyleHost = cleanEndpoint.includes('.openai.azure.com'); const isAIServicesStyleHost = cleanEndpoint.includes('.services.ai.azure.com') || cleanEndpoint.includes('.inference.ai.azure.com'); if (isOpenAIStyleHost) { const expectedPathSegment = '/openai/deployments/'; const expectedSuffix = '/chat/completions'; if (!cleanEndpoint.includes(expectedPathSegment)) { status = 'URL 格式不规范 (OpenAI-Style)'; statusClass = 'invalid'; suggestion = `对于 *.openai.azure.com 主机,路径应包含部署名: ...${expectedPathSegment}<部署名>${expectedSuffix}。`; } else if (!cleanEndpoint.endsWith(expectedSuffix)) { if (/\/openai\/deployments\/[^/]+$/.test(cleanEndpoint)) { cleanEndpoint += expectedSuffix; status = '路径已自动补全 (OpenAI-Style)'; statusClass = 'warning'; suggestion = `已自动添加 ${expectedSuffix}。部署名应在路径中。`; } else { status = 'URL 路径不完整 (OpenAI-Style)'; statusClass = 'invalid'; suggestion = `路径应以 ${expectedSuffix} 结尾,并包含部署名。`; } } else { status = '格式有效 (OpenAI-Style Azure)'; statusClass = 'valid'; suggestion = 'URL 格式符合 OpenAI on Azure 部署要求。模型 ID (部署名) 已在路径中。'; } } else if (isAIServicesStyleHost) { const expectedPath = '/models/chat/completions'; const partialPath = '/models/chat'; if (cleanEndpoint.endsWith(expectedPath)) { status = '格式有效 (AI Services-Style Azure)'; statusClass = 'valid'; suggestion = 'URL 格式符合 Azure AI Services 模型部署。模型 ID 在请求体中指定。'; } else if (cleanEndpoint.endsWith(partialPath)) { cleanEndpoint += '/completions'; status = '路径已自动补全 (AI Services-Style)'; statusClass = 'warning'; suggestion = `已自动补全为 ${expectedPath}。`; } else if (!cleanEndpoint.includes('/models/')) { cleanEndpoint += expectedPath; status = '路径已自动添加 (AI Services-Style)'; statusClass = 'warning'; suggestion = `已自动添加标准路径 ${expectedPath}。`; } else { status = 'URL 格式不规范 (AI Services-Style)'; statusClass = 'invalid'; suggestion = `对于 *.services.ai.azure.com*.inference.ai.azure.com 主机, 路径通常是 ${expectedPath}。`; } } else { status = 'Azure URL 主机格式未知'; statusClass = 'invalid'; suggestion = `请确保 Endpoint 指向 *.openai.azure.com, *.services.ai.azure.com, 或 *.inference.ai.azure.com。`; } finalUrl = `${cleanEndpoint}?${originalUrlParams.toString()}`; break; } default: finalUrl = endpoint; status = '未知提供商'; statusClass = 'warning'; } } catch (e) { finalUrl = endpoint; status = 'URL 解析失败'; statusClass = 'invalid'; suggestion = `无法解析输入的 Endpoint: ${e.message}`; } } } else if (provider !== 'default') { status = '请输入 API 地址'; statusClass = 'invalid'; } urlDisplayElement.textContent = finalUrl; statusElement.textContent = status; statusElement.className = `status ${statusClass}`; suggestionElement.innerHTML = suggestion; }; inputElements['ai-provider'].addEventListener('change', updateUrlPreview); inputElements['ai-endpoint'].addEventListener('input', updateUrlPreview); inputElements['ai-key'].addEventListener('input', updateUrlPreview); inputElements['ai-model'].addEventListener('input', updateUrlPreview); inputElements['ai-azure-apiversion'].addEventListener('input', updateUrlPreview); inputElements['ai-disable-correction'].addEventListener('change', updateUrlPreview); inputElements['stt-enabled'].addEventListener('change', updateFieldVisibility); inputElements['stt-provider'].addEventListener('change', updateSttFieldsForProvider); updateFieldVisibility(); updateUrlPreview(); updateSttFieldsForProvider(); } const WavEncoderWorker = ` self.onmessage = function(e) { const { channels, sampleRate, length } = e.data; const numOfChan = channels.length; const bufferLength = length * numOfChan * 2 + 44; const buffer = new ArrayBuffer(bufferLength); const view = new DataView(buffer); let pos = 0; function setUint16(data) { view.setUint16(pos, data, true); pos += 2; } function setUint32(data) { view.setUint32(pos, data, true); pos += 4; } setUint32(0x46464952); setUint32(bufferLength - 8); setUint32(0x45564157); setUint32(0x20746d66); setUint32(16); setUint16(1); setUint16(numOfChan); setUint32(sampleRate); setUint32(sampleRate * 2 * numOfChan); setUint16(numOfChan * 2); setUint16(16); setUint32(0x61746164); setUint32(bufferLength - pos - 4); let offset = 0; while (pos < bufferLength) { for (let i = 0; i < numOfChan; i++) { let sample = Math.max(-1, Math.min(1, channels[i][offset])); sample = (0.5 + sample < 0 ? sample * 32768 : sample * 32767) | 0; view.setInt16(pos, sample, true); pos += 2; } offset++; } self.postMessage(new Blob([view], { type: 'audio/wav' })); }; `; const updateChecker = { API_URL: 'https://api.zygame1314.site/check/scripts', SCRIPT_NAME: '小雅答答答', CURRENT_VERSION: GM_info.script.version, async check() { console.log(`[更新检查] 当前版本: ${this.CURRENT_VERSION},正在请求版本列表...`); try { const response = await fetch(this.API_URL); if (!response.ok) { console.error('[更新检查] 请求版本API失败:', response.statusText); return; } const scriptsData = await response.json(); if (!Array.isArray(scriptsData)) { console.error('[更新检查] API返回数据格式不正确,期望一个数组,但收到了:', scriptsData); return; } const targetScript = scriptsData.find(s => s.name === this.SCRIPT_NAME); if (!targetScript) { console.warn(`[更新检查] 在API列表中未找到脚本: ${this.SCRIPT_NAME}`); return; } console.log(`[更新检查] 最新版本: ${targetScript.version}`); if (this.isNewerVersion(targetScript.version, this.CURRENT_VERSION)) { console.log('[更新检查] 发现新版本!准备推送更新通知。'); this.showUpdateNotification(targetScript); } else { console.log('[更新检查] 当前已是最新版本。'); } } catch (error) { console.error('[更新检查] 发生错误:', error); } }, isNewerVersion(newVersion, oldVersion) { const newParts = newVersion.split('.').map(Number); const oldParts = oldVersion.split('.').map(Number); for (let i = 0; i < Math.max(newParts.length, oldParts.length); i++) { const newPart = newParts[i] || 0; const oldPart = oldParts[i] || 0; if (newPart > oldPart) return true; if (newPart < oldPart) return false; } return false; }, showUpdateNotification(scriptInfo) { showNotification( `发现新版本 v${scriptInfo.version}!点击立即更新。`, { type: 'success', duration: 0, keywords: ['新版本', `v${scriptInfo.version}`, '更新'], animation: 'scale' } ); setTimeout(() => { const container = document.getElementById('notification-container'); if (container && container.lastChild) { const notificationElement = container.lastChild; notificationElement.style.cursor = 'pointer'; notificationElement.onclick = () => { window.open(scriptInfo.downloadUrl, '_blank'); notificationElement.innerHTML = '正在跳转至更新页面...'; setTimeout(() => { if (container.contains(notificationElement)) { container.removeChild(notificationElement); } }, 2000); }; } }, 100); }, init() { setTimeout(() => this.check(), 10000); setInterval(() => this.check(), 4 * 60 * 60 * 1000); } }; updateChecker.init(); })();