1104 lines
39 KiB
JavaScript
1104 lines
39 KiB
JavaScript
// floating-panel.js
|
||
|
||
import {
|
||
openNovelDrawSettings,
|
||
generateAndInsertImages,
|
||
getSettings,
|
||
saveSettings,
|
||
findLastAIMessageId,
|
||
classifyError,
|
||
} 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',
|
||
};
|
||
|
||
// 尺寸预设
|
||
const SIZE_OPTIONS = [
|
||
{ value: 'default', label: '跟随预设', width: null, height: null },
|
||
{ value: '832x1216', label: '832 × 1216 竖图', width: 832, height: 1216 },
|
||
{ value: '1216x832', label: '1216 × 832 横图', width: 1216, height: 832 },
|
||
{ value: '1024x1024', label: '1024 × 1024 方图', width: 1024, height: 1024 },
|
||
{ value: '768x1280', label: '768 x 1280 大竖', width: 768, height: 1280 },
|
||
{ value: '1280x768', label: '1280 x 768 大横', width: 1280, height: 768 },
|
||
];
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 状态
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
let floatEl = null;
|
||
let dragState = null;
|
||
let currentState = FloatState.IDLE;
|
||
let currentResult = { success: 0, total: 0, error: null, startTime: 0 };
|
||
let autoResetTimer = null;
|
||
let cooldownRafId = null;
|
||
let cooldownEndTime = 0;
|
||
|
||
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'),
|
||
sizeSelect: floatEl.querySelector('#nd-size-select'),
|
||
autoToggle: floatEl.querySelector('#nd-auto-toggle'),
|
||
};
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 样式 - 精致简约
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
const STYLES = `
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
设计令牌 (Design Tokens)
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
:root {
|
||
/* 胶囊尺寸 */
|
||
--nd-w: 74px;
|
||
--nd-h: 34px;
|
||
|
||
/* 颜色系统 */
|
||
--nd-bg-solid: rgba(24, 24, 28, 0.98);
|
||
--nd-bg-card: rgba(0, 0, 0, 0.35);
|
||
--nd-bg-hover: rgba(255, 255, 255, 0.06);
|
||
--nd-bg-active: rgba(255, 255, 255, 0.1);
|
||
|
||
--nd-border-subtle: rgba(255, 255, 255, 0.08);
|
||
--nd-border-default: rgba(255, 255, 255, 0.12);
|
||
--nd-border-hover: rgba(255, 255, 255, 0.2);
|
||
|
||
--nd-text-primary: rgba(255, 255, 255, 0.92);
|
||
--nd-text-secondary: rgba(255, 255, 255, 0.65);
|
||
--nd-text-muted: rgba(255, 255, 255, 0.5);
|
||
|
||
/* 语义色 */
|
||
--nd-accent: #d4a574;
|
||
--nd-success: #3ecf8e;
|
||
--nd-warning: #f0b429;
|
||
--nd-error: #f87171;
|
||
--nd-info: #60a5fa;
|
||
|
||
/* 阴影 */
|
||
--nd-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||
--nd-shadow-md: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||
--nd-shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||
|
||
/* 圆角 */
|
||
--nd-radius-sm: 6px;
|
||
--nd-radius-md: 10px;
|
||
--nd-radius-lg: 14px;
|
||
--nd-radius-full: 9999px;
|
||
|
||
/* 过渡 */
|
||
--nd-transition-fast: 0.15s ease;
|
||
--nd-transition-normal: 0.25s ease;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
悬浮容器
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
.nd-float {
|
||
position: fixed;
|
||
z-index: 10000;
|
||
user-select: none;
|
||
will-change: transform;
|
||
contain: layout style;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
胶囊主体
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
.nd-capsule {
|
||
width: var(--nd-w);
|
||
height: var(--nd-h);
|
||
background: var(--nd-bg-solid);
|
||
border: 1px solid var(--nd-border-default);
|
||
border-radius: 17px;
|
||
box-shadow: var(--nd-shadow-md);
|
||
position: relative;
|
||
overflow: hidden;
|
||
transition: border-color var(--nd-transition-normal),
|
||
box-shadow var(--nd-transition-normal),
|
||
background var(--nd-transition-normal);
|
||
touch-action: none;
|
||
cursor: grab;
|
||
}
|
||
|
||
.nd-capsule:active { cursor: grabbing; }
|
||
|
||
.nd-float:hover .nd-capsule {
|
||
border-color: var(--nd-border-hover);
|
||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
|
||
}
|
||
|
||
/* 状态边框 */
|
||
.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.06);
|
||
}
|
||
.nd-float.success .nd-capsule {
|
||
border-color: rgba(62, 207, 142, 0.6);
|
||
background: rgba(62, 207, 142, 0.06);
|
||
}
|
||
.nd-float.partial .nd-capsule {
|
||
border-color: rgba(240, 180, 41, 0.6);
|
||
background: rgba(240, 180, 41, 0.06);
|
||
}
|
||
.nd-float.error .nd-capsule {
|
||
border-color: rgba(248, 113, 113, 0.6);
|
||
background: rgba(248, 113, 113, 0.06);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
胶囊内层
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
.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: var(--nd-text-primary);
|
||
transition: background var(--nd-transition-fast);
|
||
font-size: 16px;
|
||
}
|
||
.nd-btn-draw:hover { background: var(--nd-bg-hover); }
|
||
.nd-btn-draw:active { background: var(--nd-bg-active); }
|
||
|
||
/* 自动模式指示点 */
|
||
.nd-auto-dot {
|
||
position: absolute;
|
||
top: 7px;
|
||
right: 6px;
|
||
width: 6px;
|
||
height: 6px;
|
||
background: var(--nd-success);
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 6px 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: var(--nd-border-subtle);
|
||
}
|
||
|
||
/* 菜单按钮 */
|
||
.nd-btn-menu {
|
||
width: 28px;
|
||
height: 100%;
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--nd-text-muted);
|
||
font-size: 8px;
|
||
transition: all var(--nd-transition-fast);
|
||
}
|
||
.nd-btn-menu:hover {
|
||
background: var(--nd-bg-hover);
|
||
color: var(--nd-text-secondary);
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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-info); }
|
||
.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); }
|
||
|
||
.nd-spin {
|
||
display: inline-block;
|
||
animation: nd-spin 1.5s linear infinite;
|
||
will-change: transform;
|
||
}
|
||
@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% + 10px);
|
||
left: 50%;
|
||
transform: translateX(-50%) translateY(4px);
|
||
background: var(--nd-bg-solid);
|
||
border: 1px solid var(--nd-border-default);
|
||
border-radius: var(--nd-radius-md);
|
||
padding: 12px 16px;
|
||
font-size: 12px;
|
||
color: var(--nd-text-secondary);
|
||
white-space: nowrap;
|
||
box-shadow: var(--nd-shadow-lg);
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
transition: opacity var(--nd-transition-fast), transform var(--nd-transition-fast);
|
||
z-index: 10;
|
||
}
|
||
|
||
.nd-detail::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: -6px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
border: 6px solid transparent;
|
||
border-top-color: var(--nd-bg-solid);
|
||
}
|
||
|
||
.nd-float.show-detail .nd-detail {
|
||
opacity: 1;
|
||
visibility: visible;
|
||
transform: translateX(-50%) translateY(0);
|
||
}
|
||
|
||
.nd-detail-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 3px 0;
|
||
}
|
||
.nd-detail-row + .nd-detail-row {
|
||
margin-top: 6px;
|
||
padding-top: 8px;
|
||
border-top: 1px solid var(--nd-border-subtle);
|
||
}
|
||
|
||
.nd-detail-icon { opacity: 0.6; font-size: 13px; }
|
||
.nd-detail-label { color: var(--nd-text-muted); }
|
||
.nd-detail-value { margin-left: auto; font-weight: 600; color: var(--nd-text-primary); }
|
||
.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% + 10px);
|
||
right: 0;
|
||
width: 190px;
|
||
background: var(--nd-bg-solid);
|
||
border: 1px solid var(--nd-border-default);
|
||
border-radius: var(--nd-radius-lg);
|
||
padding: 10px;
|
||
box-shadow: var(--nd-shadow-lg);
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
transform: translateY(6px) scale(0.98);
|
||
transform-origin: bottom right;
|
||
transition: opacity var(--nd-transition-fast),
|
||
transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||
visibility var(--nd-transition-fast);
|
||
z-index: 5;
|
||
}
|
||
|
||
.nd-float.expanded .nd-menu {
|
||
opacity: 1;
|
||
visibility: visible;
|
||
transform: translateY(0) scale(1);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
参数卡片
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
.nd-card {
|
||
background: var(--nd-bg-card);
|
||
border: 1px solid var(--nd-border-subtle);
|
||
border-radius: var(--nd-radius-md);
|
||
overflow: hidden;
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
|
||
}
|
||
|
||
.nd-row {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 2px 0;
|
||
}
|
||
|
||
/* 标签 - 提升可读性 */
|
||
.nd-label {
|
||
width: 36px;
|
||
padding-left: 10px;
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
color: var(--nd-text-muted);
|
||
letter-spacing: 0.2px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 选择框 - 统一风格 */
|
||
.nd-select {
|
||
flex: 1;
|
||
min-width: 0;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--nd-text-primary);
|
||
font-size: 12px;
|
||
padding: 10px 8px;
|
||
outline: none;
|
||
cursor: pointer;
|
||
transition: color var(--nd-transition-fast);
|
||
text-align: center;
|
||
text-align-last: center;
|
||
margin: 0;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.nd-select:hover { color: #fff; }
|
||
.nd-select:focus { color: #fff; }
|
||
|
||
.nd-select option {
|
||
background: #1a1a1e;
|
||
color: #eee;
|
||
padding: 8px;
|
||
text-align: left;
|
||
}
|
||
|
||
/* 尺寸选择框 - 等宽字体,白色文字 */
|
||
.nd-select.size {
|
||
font-family: "SF Mono", "Menlo", "Consolas", "Liberation Mono", monospace;
|
||
font-size: 11px;
|
||
letter-spacing: -0.2px;
|
||
}
|
||
|
||
/* 内部分隔线 */
|
||
.nd-inner-sep {
|
||
height: 1px;
|
||
background: linear-gradient(
|
||
90deg,
|
||
transparent 8px,
|
||
var(--nd-border-subtle) 8px,
|
||
var(--nd-border-subtle) calc(100% - 8px),
|
||
transparent calc(100% - 8px)
|
||
);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
控制栏
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
.nd-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
/* 自动开关 */
|
||
.nd-auto {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 9px 12px;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
border: 1px solid var(--nd-border-subtle);
|
||
border-radius: var(--nd-radius-sm);
|
||
cursor: pointer;
|
||
transition: all var(--nd-transition-fast);
|
||
}
|
||
|
||
.nd-auto:hover {
|
||
background: var(--nd-bg-hover);
|
||
border-color: var(--nd-border-default);
|
||
}
|
||
|
||
.nd-auto.on {
|
||
background: rgba(62, 207, 142, 0.08);
|
||
border-color: rgba(62, 207, 142, 0.3);
|
||
}
|
||
|
||
.nd-dot {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
transition: all 0.2s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.nd-auto.on .nd-dot {
|
||
background: var(--nd-success);
|
||
box-shadow: 0 0 8px rgba(62, 207, 142, 0.5);
|
||
}
|
||
|
||
.nd-auto-text {
|
||
font-size: 12px;
|
||
color: var(--nd-text-muted);
|
||
transition: color var(--nd-transition-fast);
|
||
}
|
||
|
||
.nd-auto:hover .nd-auto-text {
|
||
color: var(--nd-text-secondary);
|
||
}
|
||
|
||
.nd-auto.on .nd-auto-text {
|
||
color: rgba(62, 207, 142, 0.95);
|
||
}
|
||
|
||
/* 设置按钮 */
|
||
.nd-gear {
|
||
width: 36px;
|
||
height: 36px;
|
||
border: 1px solid var(--nd-border-subtle);
|
||
border-radius: var(--nd-radius-sm);
|
||
background: rgba(255, 255, 255, 0.03);
|
||
color: var(--nd-text-muted);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 14px;
|
||
transition: all var(--nd-transition-fast);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.nd-gear:hover {
|
||
background: var(--nd-bg-hover);
|
||
border-color: var(--nd-border-default);
|
||
color: var(--nd-text-secondary);
|
||
}
|
||
|
||
.nd-gear:active {
|
||
background: var(--nd-bg-active);
|
||
}
|
||
`;
|
||
|
||
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 (cooldownRafId) {
|
||
cancelAnimationFrame(cooldownRafId);
|
||
cooldownRafId = null;
|
||
}
|
||
cooldownEndTime = 0;
|
||
}
|
||
|
||
function startCooldownTimer(duration) {
|
||
clearCooldownTimer();
|
||
cooldownEndTime = Date.now() + duration;
|
||
|
||
function tick() {
|
||
if (!cooldownEndTime) return;
|
||
updateCooldownDisplay();
|
||
const remaining = cooldownEndTime - Date.now();
|
||
if (remaining <= -100) {
|
||
clearCooldownTimer();
|
||
return;
|
||
}
|
||
cooldownRafId = requestAnimationFrame(tick);
|
||
}
|
||
|
||
cooldownRafId = requestAnimationFrame(tick);
|
||
}
|
||
|
||
function updateCooldownDisplay() {
|
||
const { statusText } = $cache;
|
||
if (!statusText) return;
|
||
const remaining = Math.max(0, cooldownEndTime - Date.now());
|
||
const seconds = (remaining / 1000).toFixed(1);
|
||
statusText.textContent = `${seconds}s`;
|
||
statusText.className = 'nd-countdown';
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 状态管理
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
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;
|
||
|
||
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();
|
||
refreshSizeSelect();
|
||
}
|
||
floatEl.classList.toggle('expanded');
|
||
} else if (target.closest('#nd-layer-active')) {
|
||
|
||
if ([FloatState.LLM, FloatState.GEN, FloatState.COOLDOWN].includes(currentState)) {
|
||
|
||
handleAbort();
|
||
} else 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); break;
|
||
case 'cooldown': setState(FloatState.COOLDOWN, data); break;
|
||
case 'success':
|
||
// ▼ 修改:中止时也显示结果
|
||
if (data.aborted && data.success === 0) {
|
||
setState(FloatState.IDLE);
|
||
} else if (data.aborted || data.success < data.total) {
|
||
setState(FloatState.PARTIAL, data);
|
||
} else {
|
||
setState(FloatState.SUCCESS, data);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
} catch (e) {
|
||
console.error('[NovelDraw]', e);
|
||
// ▼ 修改:中止不显示错误
|
||
if (e.message === '已取消') {
|
||
setState(FloatState.IDLE);
|
||
} else {
|
||
setState(FloatState.ERROR, { error: classifyError(e) });
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handleAbort() {
|
||
try {
|
||
const { abortGeneration } = await import('./novel-draw.js');
|
||
if (abortGeneration()) {
|
||
setState(FloatState.IDLE);
|
||
toastr?.info?.('已中止');
|
||
}
|
||
} catch (e) {
|
||
console.error('[NovelDraw] 中止失败:', e);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 预设与尺寸管理
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function buildPresetOptions() {
|
||
const settings = getSettings();
|
||
const presets = settings.paramsPresets || [];
|
||
const currentId = settings.selectedParamsPresetId;
|
||
return presets.map(p =>
|
||
`<option value="${p.id}"${p.id === currentId ? ' selected' : ''}>${p.name || '未命名'}</option>`
|
||
).join('');
|
||
}
|
||
|
||
function buildSizeOptions() {
|
||
const settings = getSettings();
|
||
const current = settings.overrideSize || 'default';
|
||
return SIZE_OPTIONS.map(opt =>
|
||
`<option value="${opt.value}"${opt.value === current ? ' selected' : ''}>${opt.label}</option>`
|
||
).join('');
|
||
}
|
||
|
||
function refreshPresetSelect() {
|
||
if (!$cache.presetSelect) return;
|
||
// Template-only UI markup.
|
||
// eslint-disable-next-line no-unsanitized/property
|
||
$cache.presetSelect.innerHTML = buildPresetOptions();
|
||
}
|
||
|
||
function refreshSizeSelect() {
|
||
if (!$cache.sizeSelect) return;
|
||
// Template-only UI markup.
|
||
// eslint-disable-next-line no-unsanitized/property
|
||
$cache.sizeSelect.innerHTML = buildSizeOptions();
|
||
}
|
||
|
||
function handlePresetChange(e) {
|
||
const presetId = e.target.value;
|
||
if (!presetId) return;
|
||
const settings = getSettings();
|
||
settings.selectedParamsPresetId = presetId;
|
||
saveSettings(settings);
|
||
}
|
||
|
||
function handleSizeChange(e) {
|
||
const value = e.target.value;
|
||
const settings = getSettings();
|
||
settings.overrideSize = value;
|
||
saveSettings(settings);
|
||
}
|
||
|
||
export function updateAutoModeUI() {
|
||
if (!floatEl) return;
|
||
const isAuto = getSettings().mode === 'auto';
|
||
floatEl.classList.toggle('auto-on', isAuto);
|
||
$cache.autoToggle?.classList.toggle('on', 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';
|
||
|
||
// Template-only UI markup.
|
||
// eslint-disable-next-line no-unsanitized/property
|
||
floatEl.innerHTML = `
|
||
<!-- 详情气泡 -->
|
||
<div class="nd-detail">
|
||
<div class="nd-detail-row">
|
||
<span class="nd-detail-icon">📊</span>
|
||
<span class="nd-detail-label">结果</span>
|
||
<span class="nd-detail-value" id="nd-detail-result">-</span>
|
||
</div>
|
||
<div class="nd-detail-row" id="nd-detail-error-row" style="display:none">
|
||
<span class="nd-detail-icon">💡</span>
|
||
<span class="nd-detail-label">原因</span>
|
||
<span class="nd-detail-value error" id="nd-detail-error">-</span>
|
||
</div>
|
||
<div class="nd-detail-row">
|
||
<span class="nd-detail-icon">⏱</span>
|
||
<span class="nd-detail-label">耗时</span>
|
||
<span class="nd-detail-value" id="nd-detail-time">-</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 菜单面板 -->
|
||
<div class="nd-menu">
|
||
<!-- 参数卡片 -->
|
||
<div class="nd-card">
|
||
<div class="nd-row">
|
||
<span class="nd-label">预设</span>
|
||
<select class="nd-select" id="nd-preset-select">
|
||
${buildPresetOptions()}
|
||
</select>
|
||
</div>
|
||
<div class="nd-inner-sep"></div>
|
||
<div class="nd-row">
|
||
<span class="nd-label">尺寸</span>
|
||
<select class="nd-select size" id="nd-size-select">
|
||
${buildSizeOptions()}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 控制栏 -->
|
||
<div class="nd-controls">
|
||
<div class="nd-auto${isAuto ? ' on' : ''}" id="nd-auto-toggle">
|
||
<span class="nd-dot"></span>
|
||
<span class="nd-auto-text">自动配图</span>
|
||
</div>
|
||
<button class="nd-gear" id="nd-settings-btn" title="打开设置">⚙</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 胶囊主体 -->
|
||
<div class="nd-capsule">
|
||
<div class="nd-inner">
|
||
<div class="nd-layer nd-layer-idle">
|
||
<button class="nd-btn-draw" id="nd-btn-draw" title="点击生成配图">
|
||
<span>🎨</span>
|
||
<span class="nd-auto-dot"></span>
|
||
</button>
|
||
<div class="nd-sep"></div>
|
||
<button class="nd-btn-menu" id="nd-btn-menu" title="展开菜单">
|
||
<span class="nd-arrow">▲</span>
|
||
</button>
|
||
</div>
|
||
<div class="nd-layer nd-layer-active" id="nd-layer-active">
|
||
<span id="nd-status-icon">⏳</span>
|
||
<span id="nd-status-text">分析</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(floatEl);
|
||
cacheDOM();
|
||
applyPosition();
|
||
bindEvents();
|
||
|
||
window.addEventListener('resize', applyPosition);
|
||
}
|
||
|
||
function bindEvents() {
|
||
const capsule = $cache.capsule;
|
||
if (!capsule) return;
|
||
|
||
capsule.addEventListener('pointerdown', onPointerDown, { passive: false });
|
||
capsule.addEventListener('pointermove', onPointerMove, { passive: false });
|
||
capsule.addEventListener('pointerup', onPointerUp, { passive: false });
|
||
capsule.addEventListener('pointercancel', onPointerUp, { passive: false });
|
||
|
||
$cache.presetSelect?.addEventListener('change', handlePresetChange);
|
||
$cache.sizeSelect?.addEventListener('change', handleSizeChange);
|
||
$cache.autoToggle?.addEventListener('click', handleAutoToggle);
|
||
|
||
floatEl.querySelector('#nd-settings-btn')?.addEventListener('click', () => {
|
||
floatEl.classList.remove('expanded');
|
||
openNovelDrawSettings();
|
||
});
|
||
|
||
document.addEventListener('click', handleOutsideClick, { passive: true });
|
||
}
|
||
|
||
function handleOutsideClick(e) {
|
||
if (floatEl && !floatEl.contains(e.target)) {
|
||
floatEl.classList.remove('expanded', 'show-detail');
|
||
}
|
||
}
|
||
|
||
export function destroyFloatingPanel() {
|
||
clearCooldownTimer();
|
||
|
||
if (autoResetTimer) {
|
||
clearTimeout(autoResetTimer);
|
||
autoResetTimer = null;
|
||
}
|
||
|
||
window.removeEventListener('resize', applyPosition);
|
||
document.removeEventListener('click', handleOutsideClick);
|
||
|
||
floatEl?.remove();
|
||
floatEl = null;
|
||
dragState = null;
|
||
currentState = FloatState.IDLE;
|
||
$cache = {};
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 导出
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
export { FloatState, setState, updateProgress, refreshPresetSelect, SIZE_OPTIONS };
|