Files
LittleWhiteBox/modules/novel-draw/cloud-presets.js
2026-01-17 16:34:39 +08:00

713 lines
26 KiB
JavaScript

// cloud-presets.js
// 云端预设管理模块 (保持大尺寸 + 分页搜索)
// ═══════════════════════════════════════════════════════════════════════════
// 常量
// ═══════════════════════════════════════════════════════════════════════════
const CLOUD_PRESETS_API = 'https://draw.velure.top/';
const PLUGIN_KEY = 'xbaix';
const ITEMS_PER_PAGE = 8;
// ═══════════════════════════════════════════════════════════════════════════
// 状态
// ═══════════════════════════════════════════════════════════════════════════
let modalElement = null;
let allPresets = [];
let filteredPresets = [];
let currentPage = 1;
let onImportCallback = null;
// ═══════════════════════════════════════════════════════════════════════════
// API 调用
// ═══════════════════════════════════════════════════════════════════════════
export async function fetchCloudPresets() {
const response = await fetch(CLOUD_PRESETS_API, {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-Plugin-Key': PLUGIN_KEY,
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
},
cache: 'no-store'
});
if (!response.ok) throw new Error(`HTTP错误: ${response.status}`);
const data = await response.json();
return data.items || [];
}
export async function downloadPreset(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`下载失败: ${response.status}`);
const data = await response.json();
if (data.type !== 'novel-draw-preset' || !data.preset) {
throw new Error('无效的预设文件格式');
}
return data;
}
// ═══════════════════════════════════════════════════════════════════════════
// 预设处理
// ═══════════════════════════════════════════════════════════════════════════
export function parsePresetData(data, generateId) {
const DEFAULT_PARAMS = {
model: 'nai-diffusion-4-5-full',
sampler: 'k_euler_ancestral',
scheduler: 'karras',
steps: 28, scale: 6, width: 1216, height: 832, seed: -1,
qualityToggle: true, autoSmea: false, ucPreset: 0, cfg_rescale: 0,
variety_boost: false, sm: false, sm_dyn: false, decrisper: false,
};
return {
id: generateId(),
name: data.name || data.preset.name || '云端预设',
positivePrefix: data.preset.positivePrefix || '',
negativePrefix: data.preset.negativePrefix || '',
params: { ...DEFAULT_PARAMS, ...(data.preset.params || {}) }
};
}
export function exportPreset(preset) {
const author = prompt("请输入你的作者名:", "") || "";
const description = prompt("简介 (画风介绍):", "") || "";
return {
type: 'novel-draw-preset',
version: 1,
exportDate: new Date().toISOString(),
name: preset.name,
author: author,
简介: description,
preset: {
positivePrefix: preset.positivePrefix,
negativePrefix: preset.negativePrefix,
params: { ...preset.params }
}
};
}
// ═══════════════════════════════════════════════════════════════════════════
// 样式 - 保持原始大尺寸
// ═══════════════════════════════════════════════════════════════════════════
function escapeHtml(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function ensureStyles() {
if (document.getElementById('cloud-presets-styles')) return;
const style = document.createElement('style');
style.id = 'cloud-presets-styles';
style.textContent = `
/* ═══════════════════════════════════════════════════════════════════════════
云端预设弹窗 - 保持大尺寸,接近 iframe 的布局
═══════════════════════════════════════════════════════════════════════════ */
.cloud-presets-overlay {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 100001 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
background: rgba(0, 0, 0, 0.85) !important;
touch-action: none;
-webkit-overflow-scrolling: touch;
animation: cloudFadeIn 0.2s ease;
}
@keyframes cloudFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* ═══════════════════════════════════════════════════════════════════════════
弹窗主体 - 桌面端 80% 高度,宽度增加以适应网格
═══════════════════════════════════════════════════════════════════════════ */
.cloud-presets-modal {
background: #161b22;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px;
/* 大尺寸 - 比原来更宽以适应网格 */
width: calc(100vw - 48px);
max-width: 800px;
height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
/* ═══════════════════════════════════════════════════════════════════════════
手机端 - 接近全屏(和 iframe 一样)
═══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 768px) {
.cloud-presets-modal {
width: 100vw;
height: 100vh;
max-width: none;
border-radius: 0;
border: none;
}
}
/* ═══════════════════════════════════════════════════════════════════════════
头部
═══════════════════════════════════════════════════════════════════════════ */
.cp-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid rgba(255,255,255,0.1);
flex-shrink: 0;
background: #0d1117;
}
.cp-title {
font-size: 16px;
font-weight: 600;
color: #e6edf3;
display: flex;
align-items: center;
gap: 10px;
}
.cp-title i { color: #d4a574; }
.cp-close {
width: 40px;
height: 40px;
min-width: 40px;
border: none;
background: rgba(255,255,255,0.1);
color: #e6edf3;
cursor: pointer;
border-radius: 8px;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
-webkit-tap-highlight-color: transparent;
}
.cp-close:hover,
.cp-close:active {
background: rgba(255,255,255,0.2);
}
/* ═══════════════════════════════════════════════════════════════════════════
搜索栏
═══════════════════════════════════════════════════════════════════════════ */
.cp-search {
padding: 12px 20px;
background: #161b22;
border-bottom: 1px solid rgba(255,255,255,0.05);
flex-shrink: 0;
}
.cp-search-input {
width: 100%;
background: #0d1117;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px;
padding: 12px 16px;
color: #e6edf3;
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
.cp-search-input::placeholder { color: #484f58; }
.cp-search-input:focus { border-color: rgba(212,165,116,0.5); }
/* ═══════════════════════════════════════════════════════════════════════════
内容区域 - 填满剩余空间
═══════════════════════════════════════════════════════════════════════════ */
.cp-body {
flex: 1;
overflow-y: auto;
padding: 20px;
-webkit-overflow-scrolling: touch;
background: #0d1117;
}
/* ═══════════════════════════════════════════════════════════════════════════
网格布局
═══════════════════════════════════════════════════════════════════════════ */
.cp-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
@media (max-width: 500px) {
.cp-grid {
grid-template-columns: 1fr;
gap: 12px;
}
}
/* ═══════════════════════════════════════════════════════════════════════════
卡片样式
═══════════════════════════════════════════════════════════════════════════ */
.cp-card {
background: #21262d;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
transition: all 0.2s;
}
.cp-card:hover {
border-color: rgba(212,165,116,0.5);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
}
.cp-card-head {
display: flex;
align-items: center;
gap: 12px;
}
.cp-icon {
width: 44px;
height: 44px;
background: rgba(212,165,116,0.15);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.cp-meta {
flex: 1;
min-width: 0;
overflow: hidden;
}
.cp-name {
font-weight: 600;
font-size: 14px;
color: #e6edf3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.cp-author {
font-size: 12px;
color: #8b949e;
display: flex;
align-items: center;
gap: 5px;
}
.cp-author i { font-size: 10px; opacity: 0.7; }
.cp-desc {
font-size: 12px;
color: #6e7681;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 36px;
}
.cp-btn {
width: 100%;
padding: 10px 14px;
margin-top: auto;
border: 1px solid rgba(212,165,116,0.4);
background: rgba(212,165,116,0.12);
color: #d4a574;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
-webkit-tap-highlight-color: transparent;
}
.cp-btn:hover {
background: #d4a574;
color: #0d1117;
border-color: #d4a574;
}
.cp-btn:active {
transform: scale(0.98);
}
.cp-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cp-btn.success {
background: #238636;
border-color: #238636;
color: #fff;
}
.cp-btn.error {
background: #da3633;
border-color: #da3633;
color: #fff;
}
/* ═══════════════════════════════════════════════════════════════════════════
分页控件
═══════════════════════════════════════════════════════════════════════════ */
.cp-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 16px 20px;
border-top: 1px solid rgba(255,255,255,0.1);
background: #161b22;
flex-shrink: 0;
}
.cp-page-btn {
padding: 10px 18px;
min-height: 40px;
background: #21262d;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
color: #e6edf3;
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 6px;
-webkit-tap-highlight-color: transparent;
}
.cp-page-btn:hover:not(:disabled) {
background: #30363d;
border-color: rgba(255,255,255,0.2);
}
.cp-page-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.cp-page-info {
font-size: 14px;
color: #8b949e;
min-width: 70px;
text-align: center;
font-variant-numeric: tabular-nums;
}
/* ═══════════════════════════════════════════════════════════════════════════
状态提示
═══════════════════════════════════════════════════════════════════════════ */
.cp-loading, .cp-error, .cp-empty {
text-align: center;
padding: 60px 20px;
color: #8b949e;
}
.cp-loading i {
font-size: 36px;
color: #d4a574;
margin-bottom: 16px;
display: block;
}
.cp-empty i {
font-size: 48px;
opacity: 0.4;
margin-bottom: 16px;
display: block;
}
.cp-empty p {
font-size: 12px;
margin-top: 8px;
opacity: 0.6;
}
.cp-error {
color: #f85149;
}
/* ═══════════════════════════════════════════════════════════════════════════
触摸优化
═══════════════════════════════════════════════════════════════════════════ */
@media (hover: none) and (pointer: coarse) {
.cp-close { width: 44px; height: 44px; }
.cp-search-input { min-height: 48px; padding: 14px 16px; }
.cp-btn { min-height: 48px; padding: 12px 16px; }
.cp-page-btn { min-height: 44px; padding: 12px 20px; }
}
`;
document.head.appendChild(style);
}
// ═══════════════════════════════════════════════════════════════════════════
// UI 逻辑
// ═══════════════════════════════════════════════════════════════════════════
function createModal() {
ensureStyles();
const overlay = document.createElement('div');
overlay.className = 'cloud-presets-overlay';
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property
overlay.innerHTML = `
<div class="cloud-presets-modal">
<div class="cp-header">
<div class="cp-title">
<i class="fa-solid fa-cloud-arrow-down"></i>
云端绘图预设
</div>
<button class="cp-close" type="button">✕</button>
</div>
<div class="cp-search">
<input type="text" class="cp-search-input" placeholder="🔍 搜索预设名称、作者或简介...">
</div>
<div class="cp-body">
<div class="cp-loading">
<i class="fa-solid fa-spinner fa-spin"></i>
<div>正在获取云端数据...</div>
</div>
<div class="cp-error" style="display:none"></div>
<div class="cp-empty" style="display:none">
<i class="fa-solid fa-box-open"></i>
<div>没有找到相关预设</div>
<p>试试其他关键词?</p>
</div>
<div class="cp-grid" style="display:none"></div>
</div>
<div class="cp-pagination" style="display:none">
<button class="cp-page-btn" id="cp-prev">
<i class="fa-solid fa-chevron-left"></i> 上一页
</button>
<span class="cp-page-info" id="cp-info">1 / 1</span>
<button class="cp-page-btn" id="cp-next">
下一页 <i class="fa-solid fa-chevron-right"></i>
</button>
</div>
</div>
`;
// 事件绑定
overlay.querySelector('.cp-close').onclick = closeModal;
overlay.onclick = (e) => { if (e.target === overlay) closeModal(); };
overlay.querySelector('.cloud-presets-modal').onclick = (e) => e.stopPropagation();
overlay.querySelector('.cp-search-input').oninput = (e) => handleSearch(e.target.value);
overlay.querySelector('#cp-prev').onclick = () => changePage(-1);
overlay.querySelector('#cp-next').onclick = () => changePage(1);
return overlay;
}
function handleSearch(query) {
const q = query.toLowerCase().trim();
filteredPresets = allPresets.filter(p =>
(p.name || '').toLowerCase().includes(q) ||
(p.author || '').toLowerCase().includes(q) ||
(p.简介 || p.description || '').toLowerCase().includes(q)
);
currentPage = 1;
renderPage();
}
function changePage(delta) {
const maxPage = Math.ceil(filteredPresets.length / ITEMS_PER_PAGE) || 1;
const newPage = currentPage + delta;
if (newPage >= 1 && newPage <= maxPage) {
currentPage = newPage;
renderPage();
}
}
function renderPage() {
const grid = modalElement.querySelector('.cp-grid');
const pagination = modalElement.querySelector('.cp-pagination');
const empty = modalElement.querySelector('.cp-empty');
const loading = modalElement.querySelector('.cp-loading');
loading.style.display = 'none';
if (filteredPresets.length === 0) {
grid.style.display = 'none';
pagination.style.display = 'none';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
grid.style.display = 'grid';
const maxPage = Math.ceil(filteredPresets.length / ITEMS_PER_PAGE);
pagination.style.display = maxPage > 1 ? 'flex' : 'none';
const start = (currentPage - 1) * ITEMS_PER_PAGE;
const pageItems = filteredPresets.slice(start, start + ITEMS_PER_PAGE);
// Escaped fields are used in the template.
// eslint-disable-next-line no-unsanitized/property
grid.innerHTML = pageItems.map(p => `
<div class="cp-card">
<div class="cp-card-head">
<div class="cp-icon">🎨</div>
<div class="cp-meta">
<div class="cp-name" title="${escapeHtml(p.name)}">${escapeHtml(p.name || '未命名')}</div>
<div class="cp-author"><i class="fa-solid fa-user"></i> ${escapeHtml(p.author || '匿名')}</div>
</div>
</div>
<div class="cp-desc">${escapeHtml(p.简介 || p.description || '暂无简介')}</div>
<button class="cp-btn" type="button" data-url="${escapeHtml(p.url)}">
<i class="fa-solid fa-download"></i> 导入预设
</button>
</div>
`).join('');
// 绑定导入按钮
grid.querySelectorAll('.cp-btn').forEach(btn => {
btn.onclick = async (e) => {
e.stopPropagation();
const url = btn.dataset.url;
if (!url || btn.disabled) return;
btn.disabled = true;
const origHtml = btn.innerHTML;
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 导入中';
try {
const data = await downloadPreset(url);
if (onImportCallback) await onImportCallback(data);
btn.classList.add('success');
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property
btn.innerHTML = '<i class="fa-solid fa-check"></i> 成功';
setTimeout(() => {
btn.classList.remove('success');
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property
btn.innerHTML = origHtml;
btn.disabled = false;
}, 2000);
} catch (err) {
console.error('[CloudPresets]', err);
btn.classList.add('error');
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property
btn.innerHTML = '<i class="fa-solid fa-xmark"></i> 失败';
setTimeout(() => {
btn.classList.remove('error');
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property
btn.innerHTML = origHtml;
btn.disabled = false;
}, 2000);
}
};
});
// 更新分页信息
modalElement.querySelector('#cp-info').textContent = `${currentPage} / ${maxPage}`;
modalElement.querySelector('#cp-prev').disabled = currentPage === 1;
modalElement.querySelector('#cp-next').disabled = currentPage === maxPage;
}
// ═══════════════════════════════════════════════════════════════════════════
// 公开接口
// ═══════════════════════════════════════════════════════════════════════════
export async function openCloudPresetsModal(importCallback) {
onImportCallback = importCallback;
if (!modalElement) modalElement = createModal();
document.body.appendChild(modalElement);
// 重置状态
currentPage = 1;
modalElement.querySelector('.cp-loading').style.display = 'block';
modalElement.querySelector('.cp-grid').style.display = 'none';
modalElement.querySelector('.cp-pagination').style.display = 'none';
modalElement.querySelector('.cp-empty').style.display = 'none';
modalElement.querySelector('.cp-error').style.display = 'none';
modalElement.querySelector('.cp-search-input').value = '';
try {
allPresets = await fetchCloudPresets();
filteredPresets = [...allPresets];
renderPage();
} catch (e) {
console.error('[CloudPresets]', e);
modalElement.querySelector('.cp-loading').style.display = 'none';
const errEl = modalElement.querySelector('.cp-error');
errEl.style.display = 'block';
errEl.textContent = '加载失败: ' + e.message;
}
}
export function closeModal() {
modalElement?.remove();
}
export function downloadPresetAsFile(preset) {
const data = exportPreset(preset);
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${preset.name || 'preset'}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function destroyCloudPresets() {
closeModal();
modalElement = null;
allPresets = [];
filteredPresets = [];
document.getElementById('cloud-presets-styles')?.remove();
}