// floating-panel.js // Novel Draw 悬浮面板 - 冷却倒计时优化版(修复版) import { openNovelDrawSettings, generateAndInsertImages, getSettings, saveSettings, isModuleEnabled, findLastAIMessageId, classifyError, ErrorType, } from './novel-draw.js'; // ═══════════════════════════════════════════════════════════════════════════ // 常量 // ═══════════════════════════════════════════════════════════════════════════ const FLOAT_POS_KEY = 'xb_novel_float_pos'; const AUTO_RESET_DELAY = 8000; const FloatState = { IDLE: 'idle', LLM: 'llm', GEN: 'gen', COOLDOWN: 'cooldown', SUCCESS: 'success', PARTIAL: 'partial', ERROR: 'error', }; // ═══════════════════════════════════════════════════════════════════════════ // 状态 // ═══════════════════════════════════════════════════════════════════════════ let floatEl = null; let dragState = null; let currentState = FloatState.IDLE; let currentResult = { success: 0, total: 0, error: null, startTime: 0 }; let autoResetTimer = null; // 冷却倒计时相关 let cooldownTimer = null; let cooldownEndTime = 0; // DOM 缓存 let $cache = {}; function cacheDOM() { if (!floatEl) return; $cache = { capsule: floatEl.querySelector('.nd-capsule'), statusIcon: floatEl.querySelector('#nd-status-icon'), statusText: floatEl.querySelector('#nd-status-text'), detailResult: floatEl.querySelector('#nd-detail-result'), detailErrorRow: floatEl.querySelector('#nd-detail-error-row'), detailError: floatEl.querySelector('#nd-detail-error'), detailTime: floatEl.querySelector('#nd-detail-time'), presetSelect: floatEl.querySelector('#nd-preset-select'), autoDot: floatEl.querySelector('#nd-menu-auto-dot'), }; } // ═══════════════════════════════════════════════════════════════════════════ // 样式 // ═══════════════════════════════════════════════════════════════════════════ const STYLES = ` :root { --nd-w: 74px; --nd-h: 34px; --nd-bg: rgba(28,28,32,0.96); --nd-border: rgba(255,255,255,0.12); --nd-accent: #d4a574; --nd-success: #3ecf8e; --nd-warning: #f0b429; --nd-error: #f87171; --nd-cooldown: #60a5fa; } .nd-float { position: fixed; z-index: 10000; user-select: none; } .nd-capsule { width: var(--nd-w); height: var(--nd-h); background: var(--nd-bg); border: 1px solid var(--nd-border); border-radius: 17px; box-shadow: 0 4px 16px rgba(0,0,0,0.35); position: relative; overflow: hidden; transition: all 0.25s ease; touch-action: none; cursor: grab; } .nd-capsule:active { cursor: grabbing; } .nd-float:hover .nd-capsule { border-color: rgba(255,255,255,0.25); box-shadow: 0 6px 20px rgba(0,0,0,0.45); transform: translateY(-1px); } /* 状态边框 */ .nd-float.working .nd-capsule { border-color: rgba(240,180,41,0.5); } .nd-float.cooldown .nd-capsule { border-color: rgba(96,165,250,0.6); background: rgba(96,165,250,0.08); } .nd-float.success .nd-capsule { border-color: rgba(62,207,142,0.6); background: rgba(62,207,142,0.08); } .nd-float.partial .nd-capsule { border-color: rgba(240,180,41,0.6); background: rgba(240,180,41,0.08); } .nd-float.error .nd-capsule { border-color: rgba(248,113,113,0.6); background: rgba(248,113,113,0.08); } /* 层叠 */ .nd-inner { display: grid; width: 100%; height: 100%; grid-template-areas: "s"; pointer-events: none; } .nd-layer { grid-area: s; display: flex; align-items: center; width: 100%; height: 100%; transition: opacity 0.2s, transform 0.2s; pointer-events: auto; } .nd-layer-idle { opacity: 1; transform: translateY(0); } .nd-float.working .nd-layer-idle, .nd-float.cooldown .nd-layer-idle, .nd-float.success .nd-layer-idle, .nd-float.partial .nd-layer-idle, .nd-float.error .nd-layer-idle { opacity: 0; transform: translateY(-100%); pointer-events: none; } /* 按钮 */ .nd-btn-draw { flex: 1; height: 100%; border: none; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; position: relative; color: rgba(255,255,255,0.9); transition: background 0.15s; font-size: 16px; font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; } .nd-btn-draw:hover { background: rgba(255,255,255,0.08); } .nd-btn-draw:active { background: rgba(255,255,255,0.12); } .nd-auto-dot { position: absolute; top: 7px; right: 6px; width: 6px; height: 6px; background: var(--nd-success); border-radius: 50%; box-shadow: 0 0 4px rgba(62,207,142,0.6); opacity: 0; transform: scale(0); transition: all 0.2s; } .nd-float.auto-on .nd-auto-dot { opacity: 1; transform: scale(1); } .nd-sep { width: 1px; height: 14px; background: rgba(255,255,255,0.1); } .nd-btn-menu { width: 28px; height: 100%; border: none; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; color: rgba(255,255,255,0.4); font-size: 8px; transition: all 0.15s; } .nd-btn-menu:hover { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.8); } .nd-arrow { transition: transform 0.2s; } .nd-float.expanded .nd-arrow { transform: rotate(180deg); } /* 工作层 */ .nd-layer-active { opacity: 0; transform: translateY(100%); justify-content: center; gap: 6px; font-size: 14px; font-weight: 600; color: #fff; cursor: pointer; pointer-events: none; font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; } .nd-float.working .nd-layer-active, .nd-float.cooldown .nd-layer-active, .nd-float.success .nd-layer-active, .nd-float.partial .nd-layer-active, .nd-float.error .nd-layer-active { opacity: 1; transform: translateY(0); pointer-events: auto; } .nd-float.cooldown .nd-layer-active { color: var(--nd-cooldown); } .nd-float.success .nd-layer-active { color: var(--nd-success); } .nd-float.partial .nd-layer-active { color: var(--nd-warning); } .nd-float.error .nd-layer-active { color: var(--nd-error); } /* 🔧 修复1:旋转动画 */ .nd-spin { display: inline-block; animation: nd-spin 1.5s linear infinite; } @keyframes nd-spin { to { transform: rotate(360deg); } } /* 倒计时数字 - 等宽显示 */ .nd-countdown { font-variant-numeric: tabular-nums; min-width: 36px; text-align: center; } /* 详情气泡 */ .nd-detail { position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%) translateY(4px); background: rgba(20,20,24,0.98); border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; padding: 10px 14px; font-size: 11px; color: rgba(255,255,255,0.8); white-space: nowrap; box-shadow: 0 8px 24px rgba(0,0,0,0.5); opacity: 0; visibility: hidden; transition: all 0.15s ease; z-index: 10; } .nd-detail::after { content: ''; position: absolute; bottom: -5px; left: 50%; transform: translateX(-50%); border: 5px solid transparent; border-top-color: rgba(20,20,24,0.98); } .nd-float.show-detail .nd-detail { opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0); } .nd-detail-row { display: flex; align-items: center; gap: 8px; padding: 2px 0; } .nd-detail-row + .nd-detail-row { margin-top: 4px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,0.08); } .nd-detail-icon { opacity: 0.6; } .nd-detail-label { color: rgba(255,255,255,0.5); } .nd-detail-value { margin-left: auto; font-weight: 600; } .nd-detail-value.success { color: var(--nd-success); } .nd-detail-value.warning { color: var(--nd-warning); } .nd-detail-value.error { color: var(--nd-error); } /* 菜单 */ .nd-menu { position: absolute; bottom: calc(100% + 8px); right: 0; width: 180px; background: rgba(28,28,32,0.98); border: 1px solid rgba(255,255,255,0.1); border-radius: 10px; padding: 6px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); opacity: 0; visibility: hidden; transform: translateY(6px) scale(0.98); transform-origin: bottom right; transition: all 0.15s cubic-bezier(0.34,1.56,0.64,1); z-index: 5; } .nd-float.expanded .nd-menu { opacity: 1; visibility: visible; transform: translateY(0) scale(1); } .nd-menu-header { padding: 6px 10px 4px; font-size: 10px; color: rgba(255,255,255,0.35); } .nd-menu-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 6px; cursor: pointer; color: rgba(255,255,255,0.75); font-size: 12px; transition: background 0.1s; } .nd-menu-item:hover { background: rgba(255,255,255,0.08); } .nd-menu-item.active { color: var(--accent); } .nd-item-icon { width: 14px; text-align: center; font-size: 10px; opacity: 0.5; } .nd-menu-item.active .nd-item-icon { opacity: 1; } .nd-menu-divider { height: 1px; background: rgba(255,255,255,0.08); margin: 4px 0; } .nd-menu-dot { width: 6px; height: 6px; border-radius: 50%; background: rgba(255,255,255,0.2); margin-left: auto; transition: all 0.2s; } .nd-menu-dot.active { background: var(--nd-success); box-shadow: 0 0 6px rgba(62,207,142,0.6); } /* 预设下拉框 */ .nd-preset-row { padding: 4px 10px 8px; } .nd-preset-select { width: 100%; padding: 6px 8px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; color: rgba(255,255,255,0.9); font-size: 12px; cursor: pointer; outline: none; transition: border-color 0.15s; } .nd-preset-select:hover { border-color: rgba(255,255,255,0.25); } .nd-preset-select:focus { border-color: var(--nd-accent); } .nd-preset-select option { background: #1a1a1e; color: #fff; } `; function injectStyles() { if (document.getElementById('nd-float-styles')) return; const el = document.createElement('style'); el.id = 'nd-float-styles'; el.textContent = STYLES; document.head.appendChild(el); } // ═══════════════════════════════════════════════════════════════════════════ // 位置管理 // ═══════════════════════════════════════════════════════════════════════════ function getPosition() { try { const raw = localStorage.getItem(FLOAT_POS_KEY); if (raw) return JSON.parse(raw); } catch {} const debug = document.getElementById('xiaobaix-debug-mini'); if (debug) { const r = debug.getBoundingClientRect(); return { left: r.left, top: r.bottom + 8 }; } return { left: window.innerWidth - 110, top: window.innerHeight - 80 }; } function savePosition() { if (!floatEl) return; const r = floatEl.getBoundingClientRect(); try { localStorage.setItem(FLOAT_POS_KEY, JSON.stringify({ left: Math.round(r.left), top: Math.round(r.top) })); } catch {} } function applyPosition() { if (!floatEl) return; const pos = getPosition(); const w = floatEl.offsetWidth || 77; const h = floatEl.offsetHeight || 34; floatEl.style.left = `${Math.max(0, Math.min(pos.left, window.innerWidth - w))}px`; floatEl.style.top = `${Math.max(0, Math.min(pos.top, window.innerHeight - h))}px`; } // ═══════════════════════════════════════════════════════════════════════════ // 冷却倒计时 // ═══════════════════════════════════════════════════════════════════════════ function clearCooldownTimer() { if (cooldownTimer) { clearInterval(cooldownTimer); cooldownTimer = null; } cooldownEndTime = 0; } function startCooldownTimer(duration) { clearCooldownTimer(); cooldownEndTime = Date.now() + duration; // 立即更新一次 updateCooldownDisplay(); // 🔧 修复3:每50ms更新一次,更流畅,且始终更新显示 cooldownTimer = setInterval(() => { updateCooldownDisplay(); // 倒计时结束后清理定时器(但不切换状态,等 novel-draw.js 来切换) if (cooldownEndTime - Date.now() <= -100) { clearCooldownTimer(); } }, 50); } function updateCooldownDisplay() { const { statusIcon, statusText } = $cache; if (!statusIcon || !statusText) return; // 🔧 修复2 & 3:显示小数点后一位,最小显示0.0 const remaining = Math.max(0, cooldownEndTime - Date.now()); const seconds = (remaining / 1000).toFixed(1); statusText.textContent = `${seconds}s`; statusText.className = 'nd-countdown'; } // ═══════════════════════════════════════════════════════════════════════════ // 状态管理 // ═══════════════════════════════════════════════════════════════════════════ // 🔧 修复1:spinning 设为 true const STATE_CONFIG = { [FloatState.IDLE]: { cls: '', icon: '', text: '', spinning: false }, [FloatState.LLM]: { cls: 'working', icon: '⏳', text: '分析', spinning: true }, [FloatState.GEN]: { cls: 'working', icon: '🎨', text: '', spinning: true }, [FloatState.COOLDOWN]: { cls: 'cooldown', icon: '⏳', text: '', spinning: true }, [FloatState.SUCCESS]: { cls: 'success', icon: '✓', text: '', spinning: false }, [FloatState.PARTIAL]: { cls: 'partial', icon: '⚠', text: '', spinning: false }, [FloatState.ERROR]: { cls: 'error', icon: '✗', text: '', spinning: false }, }; function setState(state, data = {}) { if (!floatEl) return; currentState = state; // 清理自动重置定时器 if (autoResetTimer) { clearTimeout(autoResetTimer); autoResetTimer = null; } // 非冷却状态时清理冷却定时器 if (state !== FloatState.COOLDOWN) { clearCooldownTimer(); } // 移除所有状态类 floatEl.classList.remove('working', 'cooldown', 'success', 'partial', 'error', 'show-detail'); const cfg = STATE_CONFIG[state]; if (cfg.cls) floatEl.classList.add(cfg.cls); const { statusIcon, statusText } = $cache; if (!statusIcon || !statusText) return; // 🔧 修复1:根据 spinning 添加旋转类 statusIcon.textContent = cfg.icon; statusIcon.className = cfg.spinning ? 'nd-spin' : ''; statusText.className = ''; switch (state) { case FloatState.IDLE: currentResult = { success: 0, total: 0, error: null, startTime: 0 }; break; case FloatState.LLM: currentResult.startTime = Date.now(); statusText.textContent = cfg.text; break; case FloatState.GEN: statusText.textContent = `${data.current || 0}/${data.total || 0}`; currentResult.total = data.total || 0; break; case FloatState.COOLDOWN: // 启动冷却倒计时 startCooldownTimer(data.duration); break; case FloatState.SUCCESS: case FloatState.PARTIAL: statusText.textContent = `${data.success}/${data.total}`; currentResult.success = data.success; currentResult.total = data.total; autoResetTimer = setTimeout(() => setState(FloatState.IDLE), AUTO_RESET_DELAY); break; case FloatState.ERROR: statusText.textContent = data.error?.label || '错误'; currentResult.error = data.error; autoResetTimer = setTimeout(() => setState(FloatState.IDLE), AUTO_RESET_DELAY); break; } } function updateProgress(current, total) { if (currentState !== FloatState.GEN || !$cache.statusText) return; $cache.statusText.textContent = `${current}/${total}`; } function updateDetailPopup() { const { detailResult, detailErrorRow, detailError, detailTime } = $cache; if (!detailResult) return; const elapsed = currentResult.startTime ? ((Date.now() - currentResult.startTime) / 1000).toFixed(1) : '-'; const isSuccess = currentState === FloatState.SUCCESS; const isPartial = currentState === FloatState.PARTIAL; const isError = currentState === FloatState.ERROR; if (isSuccess || isPartial) { detailResult.textContent = `${currentResult.success}/${currentResult.total} 成功`; detailResult.className = `nd-detail-value ${isSuccess ? 'success' : 'warning'}`; detailErrorRow.style.display = isPartial ? 'flex' : 'none'; if (isPartial) detailError.textContent = `${currentResult.total - currentResult.success} 张失败`; } else if (isError) { detailResult.textContent = '生成失败'; detailResult.className = 'nd-detail-value error'; detailErrorRow.style.display = 'flex'; detailError.textContent = currentResult.error?.desc || '未知错误'; } detailTime.textContent = `${elapsed}s`; } // ═══════════════════════════════════════════════════════════════════════════ // 拖拽与点击 // ═══════════════════════════════════════════════════════════════════════════ function onPointerDown(e) { if (e.button !== 0) return; dragState = { startX: e.clientX, startY: e.clientY, startLeft: floatEl.getBoundingClientRect().left, startTop: floatEl.getBoundingClientRect().top, pointerId: e.pointerId, moved: false, originalTarget: e.target }; try { e.currentTarget.setPointerCapture(e.pointerId); } catch {} e.preventDefault(); } function onPointerMove(e) { if (!dragState || dragState.pointerId !== e.pointerId) return; const dx = e.clientX - dragState.startX; const dy = e.clientY - dragState.startY; if (!dragState.moved && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) { dragState.moved = true; } if (dragState.moved) { const w = floatEl.offsetWidth || 88; const h = floatEl.offsetHeight || 36; floatEl.style.left = `${Math.max(0, Math.min(dragState.startLeft + dx, window.innerWidth - w))}px`; floatEl.style.top = `${Math.max(0, Math.min(dragState.startTop + dy, window.innerHeight - h))}px`; } e.preventDefault(); } function onPointerUp(e) { if (!dragState || dragState.pointerId !== e.pointerId) return; const { moved, originalTarget } = dragState; try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {} dragState = null; if (moved) { savePosition(); } else { routeClick(originalTarget); } } function routeClick(target) { if (target.closest('#nd-btn-draw')) { handleDrawClick(); } else if (target.closest('#nd-btn-menu')) { floatEl.classList.remove('show-detail'); if (!floatEl.classList.contains('expanded')) { refreshPresetSelect(); } floatEl.classList.toggle('expanded'); } else if (target.closest('#nd-layer-active')) { if ([FloatState.SUCCESS, FloatState.PARTIAL, FloatState.ERROR].includes(currentState)) { updateDetailPopup(); floatEl.classList.toggle('show-detail'); } } } // ═══════════════════════════════════════════════════════════════════════════ // 核心操作 // ═══════════════════════════════════════════════════════════════════════════ async function handleDrawClick() { if (currentState !== FloatState.IDLE) return; const messageId = findLastAIMessageId(); if (messageId < 0) { toastr?.warning?.('没有可配图的AI消息'); return; } try { await generateAndInsertImages({ messageId, onStateChange: (state, data) => { switch (state) { case 'llm': setState(FloatState.LLM); break; case 'gen': setState(FloatState.GEN, data); break; case 'progress': setState(FloatState.GEN, data); // 用 GEN 状态显示进度 break; case 'cooldown': setState(FloatState.COOLDOWN, data); break; case 'success': setState(data.success === data.total ? FloatState.SUCCESS : FloatState.PARTIAL, data); break; } } }); } catch (e) { console.error('[NovelDraw]', e); setState(FloatState.ERROR, { error: classifyError(e) }); } } // ═══════════════════════════════════════════════════════════════════════════ // 预设管理 // ═══════════════════════════════════════════════════════════════════════════ function buildPresetOptions() { const settings = getSettings(); const presets = settings.paramsPresets || []; const currentId = settings.selectedParamsPresetId; return presets.map(p => `` ).join(''); } function refreshPresetSelect() { if (!$cache.presetSelect) return; $cache.presetSelect.innerHTML = buildPresetOptions(); } function handlePresetChange(e) { const presetId = e.target.value; if (!presetId) return; const settings = getSettings(); settings.selectedParamsPresetId = presetId; saveSettings(settings); } export function updateAutoModeUI() { if (!floatEl) return; const isAuto = getSettings().mode === 'auto'; floatEl.classList.toggle('auto-on', isAuto); const menuDot = floatEl.querySelector('#nd-menu-auto-dot'); menuDot?.classList.toggle('active', isAuto); } function handleAutoToggle() { const settings = getSettings(); settings.mode = settings.mode === 'auto' ? 'manual' : 'auto'; saveSettings(settings); updateAutoModeUI(); } // ═══════════════════════════════════════════════════════════════════════════ // 创建与销毁 // ═══════════════════════════════════════════════════════════════════════════ export function createFloatingPanel() { if (floatEl) return; injectStyles(); const settings = getSettings(); const isAuto = settings.mode === 'auto'; floatEl = document.createElement('div'); floatEl.className = `nd-float${isAuto ? ' auto-on' : ''}`; floatEl.id = 'nd-floating-panel'; floatEl.innerHTML = `