mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
feat(webui): scratch jigsaw-block canvas priority sorting (same-value prio, image excluded)
This commit is contained in:
@ -157,30 +157,53 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
|
||||
justify-content:center; overflow:auto; padding:48px 20px; z-index:50; }
|
||||
.twrap { overflow-x:auto; -webkit-overflow-scrolling:touch; }
|
||||
|
||||
/* ---------- scratch-style priority sorting ---------- */
|
||||
.sort-canvas { display:flex; flex-direction:column; gap:26px; }
|
||||
.sort-col { display:flex; flex-direction:column; gap:3px; }
|
||||
.sort-group { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:16px 18px;
|
||||
box-shadow:var(--shadow); }
|
||||
.sort-group > h3 { margin:0 0 12px; font-size:13px; color:var(--muted); font-weight:600;
|
||||
display:flex; align-items:center; gap:8px; }
|
||||
.sort-group > h3 .grow { flex:1; }
|
||||
.sort-chip { display:flex; align-items:center; gap:12px; padding:12px 14px; border-radius:12px; cursor:grab;
|
||||
color:#fff; font-weight:600; font-size:13.5px; box-shadow:0 3px 0 rgba(0,0,0,.14);
|
||||
position:relative; user-select:none; transition:transform .12s,box-shadow .12s,opacity .15s; touch-action:none; }
|
||||
.sort-chip::before { content:''; position:absolute; left:0; top:0; bottom:0; width:10px; border-radius:9px 0 0 9px;
|
||||
background:rgba(0,0,0,.22); }
|
||||
.sort-chip.dragging { opacity:.55; transform:scale(1.02); }
|
||||
.sort-chip .grip { font-size:15px; opacity:.75; }
|
||||
.sort-chip .mid { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.sort-chip .src { font-size:11px; opacity:.85; font-weight:500; padding:2px 9px; border-radius:20px;
|
||||
background:rgba(255,255,255,.18); }
|
||||
.sort-chip .prio { font-size:11px; opacity:.95; font-weight:500; background:rgba(0,0,0,.18);
|
||||
padding:2px 9px; border-radius:20px; white-space:nowrap; }
|
||||
.sort-dropline { height:8px; border-radius:6px; margin:0; background:var(--accent); opacity:.8; }
|
||||
.chip-shell { display:flex; flex-direction:column; }
|
||||
/* ---------- scratch-style canvas & jigsaw blocks ---------- */
|
||||
.sort-canvas { position:relative; min-height:360px; padding:26px 10px 30px; margin:4px 0 10px;
|
||||
background-color:var(--card2);
|
||||
background-image:linear-gradient(var(--line) 1px,transparent 1px),linear-gradient(90deg,var(--line) 1px,transparent 1px);
|
||||
background-size:26px 26px;
|
||||
border:1px dashed var(--line); border-radius:12px; }
|
||||
.scr-block { position:relative; display:flex; align-items:center; gap:12px; margin:10px 24px;
|
||||
padding:16px 20px 15px 26px; color:#fff; font-weight:700; font-size:13.5px; cursor:grab;
|
||||
user-select:none; touch-action:none; border-radius:12px 12px 7px 7px;
|
||||
box-shadow:0 7px 0 rgba(0,0,0,.13), inset 0 2px 0 rgba(255,255,255,.28),
|
||||
inset 0 -6px 0 rgba(0,0,0,.07); }
|
||||
/* 顶部凸榫:像拼图块上缘伸出的插头 */
|
||||
.scr-block::before { content:''; position:absolute; left:50%; transform:translateX(-50%);
|
||||
top:-8px; width:56px; height:14px; border-radius:9px 9px 4px 4px; background:inherit; }
|
||||
/* 底部凹槽:被下一块凸榫咬合 */
|
||||
.scr-block::after { content:''; position:absolute; left:50%; transform:translateX(-50%);
|
||||
bottom:-6px; width:66px; height:11px; background:var(--card2);
|
||||
border-radius:4px 4px 9px 9px; box-shadow:inset 0 1px 0 var(--line); }
|
||||
/* 链首:没有顶部榫头,块顶圆润 + 大扭扣帽 */
|
||||
.scr-block.cap::before { display:none; }
|
||||
.scr-block.cap { border-radius:16px 16px 8px 8px; box-shadow:0 9px 0 rgba(0,0,0,.14),
|
||||
inset 0 2px 0 rgba(255,255,255,.25), inset 0 -6px 0 rgba(0,0,0,.07); }
|
||||
.scr-block .scr-ico { width:28px; height:28px; border-radius:50%; flex:0 0 auto; display:flex;
|
||||
align-items:center; justify-content:center; font-size:12px; font-weight:800; letter-spacing:.5px;
|
||||
background:rgba(0,0,0,.2); box-shadow:inset 0 2px 3px rgba(0,0,0,.15), 0 1px 0 rgba(255,255,255,.2); }
|
||||
.scr-block .scr-src { font-size:11px; opacity:.92; font-weight:600; padding:3px 11px; border-radius:20px;
|
||||
background:rgba(255,255,255,.22); white-space:nowrap; }
|
||||
.scr-block .scr-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
|
||||
font-family:ui-monospace,Menlo,Consolas,monospace; font-size:12.5px; }
|
||||
/* 优先级 = 积木上的参数槽(Scratch 椭圆参数口样式) */
|
||||
.scr-block .scr-prio { width:86px; flex:0 0 auto; text-align:center; color:var(--accent); background:#fff;
|
||||
border:2px solid rgba(255,255,255,.65); border-radius:14px; padding:6px 10px;
|
||||
font:700 13px ui-monospace,Menlo,Consolas,monospace; outline:none; box-shadow:inset 0 1px 4px rgba(0,0,0,.12); }
|
||||
.scr-block .scr-prio:focus { border-color:var(--accent); }
|
||||
.scr-block .scr-grip { flex:0 0 auto; font-size:15px; opacity:.65; letter-spacing:2px; color:rgba(255,255,255,.9);
|
||||
text-shadow:0 1px 2px rgba(0,0,0,.3); }
|
||||
.scr-block.drag-src { opacity:.30; }
|
||||
.scr-ghost { position:fixed; z-index:200; pointer-events:none; margin:0!important; padding-right:0!important;
|
||||
transform:rotate(1.2deg) scale(1.045); filter:drop-shadow(0 16px 20px rgba(0,0,0,.35)); cursor:grabbing; }
|
||||
.scr-ghost .scr-prio, .scr-ghost .scr-grip { visibility:hidden; }
|
||||
.sort-dropline { height:10px; margin:2px 26px; border-radius:7px; background:var(--accent);
|
||||
box-shadow:0 1px 6px var(--accent); }
|
||||
.sortbar { display:flex; align-items:center; gap:12px; margin-bottom:18px; flex-wrap:wrap; }
|
||||
.sortbar .grow { flex:1; }
|
||||
.scr-hint { font-size:12px; color:var(--muted); margin-top:10px; display:flex; gap:12px; flex-wrap:wrap; }
|
||||
.scr-hint span { display:flex; align-items:center; gap:5px; }
|
||||
.scr-hint i { font-style:normal; width:10px; height:10px; border-radius:3px; display:inline-block; }
|
||||
|
||||
/* ---------- responsive / narrow screens ---------- */
|
||||
@media (max-width: 900px) {
|
||||
@ -285,8 +308,8 @@ const STR = {
|
||||
toastDelOk:'已删除', confirmDelSrc:'确定删除源 %s 吗?', confirmDelAdp:'确定删除适配器 %s 吗?',
|
||||
u:'用户', a:'助手', at:'助手[思考]', aEmpty:'(空回复)', aErr:'请求失败: %s', cErr:'错误: %s',
|
||||
chatMeta:'模型=%s · 耗时 %s ms · %d 字符', connPinned:'# 固定到模型: %s (源 %s)', connAuto:'# 模型名 AUTO(自动选择可用源)',
|
||||
sortTitle:'拖拽排序:调整源下各模型的优先级', sortHint:'从高(上)到低(下)拖动各源下的模型方块,松手后自动重算 priority。数值越大 AUTO 越优先使用。',
|
||||
sortSave:'保存排序', sortReset:'重置', sortSaved:'排序已保存并热重载',
|
||||
sortTitle:'积木排序:在画布上拖拽模型积木', sortHint:'拼图积木从上到下排列 = 优先级从高到低。拖动积木把它拼到链上任意位置;每个积木上的数字(priority)可直接编辑——多个积木可设相同值,此时按链上位置(上者先用)作为先后顺序。生图模型(kind=image)不参与链排序。',
|
||||
sortSave:'保存排序', sortReset:'重置', sortSaved:'排序已保存并热重载', sortNoChange:'无变更',
|
||||
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'(该源暂无模型)',
|
||||
},
|
||||
en: {
|
||||
@ -317,8 +340,8 @@ const STR = {
|
||||
toastDelOk:'Deleted', confirmDelSrc:'Delete source %s?', confirmDelAdp:'Delete adapter %s?',
|
||||
u:'You:', a:'Assistant:', at:'Assistant [thinking]', aEmpty:'(empty)', aErr:'Request failed: %s',
|
||||
cErr:'Error: %s', chatMeta:'Model=%s · %s ms · %d chars', connPinned:'# Pinned to model: %s (source %s)', connAuto:'# Model "AUTO" picks healthy source by priority',
|
||||
sortTitle:'Drag to reorder: priority of each source’s models', sortHint:'Drag model blocks from high (top) to low (bottom); priority is recomputed on drop. Larger values are preferred by AUTO.',
|
||||
sortSave:'Save order', sortReset:'Reset', sortSaved:'Order saved & hot-reloaded',
|
||||
sortTitle:'Block sorting: drag model blocks on the canvas', sortHint:'Blocks chain top→bottom = priority high→low. Drag a block to snap it anywhere in the chain; the number on each block (priority) is editable — blocks may share the same value, then chain order (top first) breaks ties. Image models (kind=image) stay out of the chain.',
|
||||
sortSave:'Save order', sortReset:'Reset', sortSaved:'Order saved & hot-reloaded', sortNoChange:'No changes',
|
||||
sortSource:'source', sortPrio:'priority %s', sortEmpty:'(no models in this source)',
|
||||
}
|
||||
};
|
||||
@ -755,178 +778,169 @@ async function delSource(name) {
|
||||
toast(t('toastDelOk')); renderSources();
|
||||
}
|
||||
|
||||
/* ---------- sort tab (scratch-style drag priority) ---------- */
|
||||
/* ---------- sort tab: scratch jigsaw chain on canvas ---------- */
|
||||
const SORT_COLORS = [
|
||||
['#5b8def', '#3f6ef5'], ['#37b273', '#1f9d5a'], ['#f2a541', '#e08a15'],
|
||||
['#a466e8', '#8b4dd4'], ['#e86f9c', '#d74f86'], ['#45c4c0', '#2aa8a4'],
|
||||
['#e8564a', '#d03f34'], ['#8ba3c7', '#6f8ab3'],
|
||||
];
|
||||
const sortState = { cols: [], origin: null, drag: null };
|
||||
function srcColor(name) {
|
||||
let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return SORT_COLORS[h % SORT_COLORS.length];
|
||||
}
|
||||
function srcShort(name) { return (name || '?').slice(0, 2).toUpperCase(); }
|
||||
const sortState = { items: [], origin: null, drag: null };
|
||||
async function renderSort() {
|
||||
const j = await api('/api/sources');
|
||||
sortState.cols = j.sources.map((s, i) => ({
|
||||
name: s.name, colors: SORT_COLORS[i % SORT_COLORS.length],
|
||||
models: s.models.map(m => ({ id: m.id, priority: m.priority || 0 })),
|
||||
sortState.items = [];
|
||||
j.sources.forEach(s => (s.models || []).forEach(m => {
|
||||
if (m.kind === 'image') return; // image models never share the chat chain
|
||||
sortState.items.push({ src: s.name, id: m.id, prio: m.priority || 0 });
|
||||
}));
|
||||
sortState.origin = JSON.stringify(sortState.cols);
|
||||
sortState.origin = JSON.stringify(sortState.items);
|
||||
$('#tab-sort').innerHTML = `
|
||||
<div class="card"><h2><span>${t('sortTitle')}</span><span class="grow"></span>
|
||||
<button class="ghost small" onclick="sortReset()">${t('sortReset')}</button>
|
||||
<button class="small" onclick="saveSort()">${t('sortSave')}</button></h2>
|
||||
<div class="muted" style="margin-bottom:16px">${t('sortHint')}</div>
|
||||
<div class="sort-canvas" id="scr-canvas"></div>
|
||||
<div class="scr-hint">
|
||||
${sortState.items.map((it, i) => { const c = srcColor(it.src);
|
||||
return `<span><i style="background:linear-gradient(135deg,${c[0]},${c[1]})"></i>${esc(it.src)}</span>`; })
|
||||
.filter((v, i, a) => a.indexOf(v) === i).join('') || ''}
|
||||
</div>
|
||||
</div>`;
|
||||
paintSort();
|
||||
}
|
||||
function scrBlockHtml(it, isCap) {
|
||||
const c = srcColor(it.src);
|
||||
return `
|
||||
<div class="scr-block ${isCap ? 'cap' : ''}" data-key="${escAttr(it.src + '|' + it.id)}"
|
||||
style="background:linear-gradient(135deg,${c[0]},${c[1]})">
|
||||
<span class="scr-ico">${esc(srcShort(it.src))}</span>
|
||||
<span class="scr-src">${esc(it.src)}</span>
|
||||
<span class="scr-name">${esc(it.id)}</span>
|
||||
<input class="scr-prio" type="number" value="${it.prio}" min="-9999" max="9999" spellcheck="false">
|
||||
<span class="scr-grip">⠿</span>
|
||||
</div>`;
|
||||
}
|
||||
function paintSort() {
|
||||
const groups = sortState.cols.map((c, ci) => `
|
||||
<div class="sort-group">
|
||||
<h3><span style="width:10px;height:10px;border-radius:3px;background:${c.colors[0]}"></span>
|
||||
<span>${esc(c.name)}</span><span class="muted" style="font-weight:400">${t('sortSource')}</span>
|
||||
<span class="grow"></span></h3>
|
||||
<div class="sort-col" data-col="${ci}">
|
||||
${c.models.length ? c.models.map((m, mi) => sortShell(ci, mi, m)).join('')
|
||||
: `<div class="empty">${t('sortEmpty')}</div>`}
|
||||
</div>
|
||||
</div>`).join('');
|
||||
$('#tab-sort').innerHTML = `
|
||||
<div class="card">
|
||||
<h2><span>${t('sortTitle')}</span><span class="grow"></span>
|
||||
<button class="ghost small" onclick="sortReset()">${t('sortReset')}</button>
|
||||
<button class="small" onclick="saveSort()">${t('sortSave')}</button></h2>
|
||||
<div class="muted" style="margin-bottom:16px">${t('sortHint')}</div>
|
||||
<div class="sort-canvas">${groups}</div>
|
||||
</div>`;
|
||||
bindSortDrag();
|
||||
}
|
||||
function sortShell(ci, mi, m) {
|
||||
const c = sortState.cols[ci];
|
||||
return `<div class="chip-shell" data-col="${ci}" data-id="${escAttr(m.id)}">
|
||||
${mi === 0 ? '' : '<div class="sort-dropline hidden"></div>'}
|
||||
<div class="sort-chip" draggable="true" data-col="${ci}" data-id="${escAttr(m.id)}"
|
||||
style="background:linear-gradient(135deg,${c.colors[0]},${c.colors[1]})">
|
||||
<span class="grip">⋮⋮</span>
|
||||
<span class="mid">${esc(m.id)}</span>
|
||||
<span class="src">${esc(c.name)}</span>
|
||||
<span class="prio">${tFmt('sortPrio', m.priority)}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
function bindSortDrag() {
|
||||
document.querySelectorAll('.sort-chip').forEach(chip => {
|
||||
chip.addEventListener('dragstart', e => {
|
||||
sortState.drag = { col: +chip.dataset.col, id: chip.dataset.id };
|
||||
chip.classList.add('dragging');
|
||||
if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', ''); }
|
||||
});
|
||||
chip.addEventListener('dragend', () => { sortDragCleanup(); });
|
||||
chip.addEventListener('touchstart', e => {
|
||||
sortState.drag = { col: +chip.dataset.col, id: chip.dataset.id };
|
||||
chip.classList.add('dragging');
|
||||
}, { passive: true });
|
||||
chip.addEventListener('touchmove', e => {
|
||||
if (!sortState.drag) return;
|
||||
e.preventDefault();
|
||||
const t = e.touches[0];
|
||||
const el = document.elementFromPoint(t.clientX, t.clientY);
|
||||
if (el && el.classList && el.classList.contains('sort-chip')) {
|
||||
showDropLine(+el.dataset.col, el.dataset.id, t.clientY, el);
|
||||
}
|
||||
}, { passive: false });
|
||||
chip.addEventListener('touchend', () => {
|
||||
const d = sortState.drag;
|
||||
if (d) {
|
||||
const tgt = document.querySelector('#tab-sort .sort-dropline:not(.hidden)');
|
||||
if (tgt) {
|
||||
const tcol = +tgt.closest('[data-col]').dataset.col;
|
||||
const before = tgt.dataset.before === '1';
|
||||
sortDrop(tcol, before ? tgt.dataset.nextId : tgt.dataset.id, before);
|
||||
}
|
||||
}
|
||||
sortDragCleanup();
|
||||
}, { passive: true });
|
||||
});
|
||||
document.querySelectorAll('.sort-col').forEach(col => {
|
||||
col.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
const t = e.clientY;
|
||||
const chips = [...col.querySelectorAll('.sort-chip')];
|
||||
const under = chips.filter(c => { const r = c.getBoundingClientRect(); return t < r.bottom; })
|
||||
.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top)[0];
|
||||
if (under) showDropLine(+col.dataset.col, under.dataset.id, t, under);
|
||||
});
|
||||
col.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
const dl = col.querySelector('.sort-dropline:not(.hidden)');
|
||||
if (dl) sortDrop(+col.dataset.col, dl.dataset.before === '1' ? dl.dataset.nextId : dl.dataset.id, dl.dataset.before === '1');
|
||||
else sortDrop(+col.dataset.col, null, true);
|
||||
sortDragCleanup();
|
||||
});
|
||||
});
|
||||
}
|
||||
function showDropLine(ci, id, y, refChip) {
|
||||
const shells = [...document.querySelectorAll(`#tab-sort .chip-shell[data-col="${ci}"]`)];
|
||||
const r = refChip.getBoundingClientRect();
|
||||
const before = y < r.top + r.height / 2;
|
||||
const idx = shells.findIndex(s => s.dataset.id === id);
|
||||
const target = shells[idx + (before ? 0 : 1)];
|
||||
document.querySelectorAll('#tab-sort .sort-dropline:not(.hidden)').forEach(l => l.classList.add('hidden'));
|
||||
if (!target) return;
|
||||
const line = target.querySelector('.sort-dropline');
|
||||
if (!line) return;
|
||||
line.dataset.before = before ? '1' : '0';
|
||||
line.dataset.nextId = target.dataset.id;
|
||||
line.classList.remove('hidden');
|
||||
}
|
||||
function sortDrop(tci, tid, before) {
|
||||
if (!sortState.drag) return;
|
||||
const dci = sortState.drag.col, did = sortState.drag.id;
|
||||
const dCol = sortState.cols[dci], tCol = sortState.cols[tci];
|
||||
const dIdx = dCol.models.findIndex(m => m.id === did);
|
||||
if (dIdx < 0) return;
|
||||
const [mdl] = dCol.models.splice(dIdx, 1);
|
||||
let insertAt = tCol.models.length;
|
||||
if (tid !== null) {
|
||||
const tIdx = tCol.models.findIndex(m => m.id === tid);
|
||||
if (tIdx >= 0) {
|
||||
// 同列且目标原本在被拖着下方时,移除后目标会前移一位
|
||||
let pos = tIdx;
|
||||
if (tci === dci && dIdx < tIdx) pos -= 1;
|
||||
insertAt = before ? pos : pos + 1;
|
||||
}
|
||||
const cv = $('#scr-canvas');
|
||||
if (!cv) return;
|
||||
const srcIdx = sortState.items.map((it, i) => i);
|
||||
const html = [];
|
||||
for (let i = 0; i <= sortState.items.length; i++) {
|
||||
html.push(`<div class="sort-dropline" data-line="${i}" style="display:none"></div>`);
|
||||
if (i < sortState.items.length) html.push(scrBlockHtml(sortState.items[i], i === 0));
|
||||
}
|
||||
insertAt = Math.max(0, Math.min(insertAt, tCol.models.length));
|
||||
tCol.models.splice(insertAt, 0, mdl);
|
||||
recomputePriorities();
|
||||
cv.innerHTML = html.join('');
|
||||
cv.querySelectorAll('.scr-prio').forEach(inp => {
|
||||
inp.addEventListener('input', () => {
|
||||
const blk = inp.closest('.scr-block');
|
||||
const idx = sortState.items.findIndex(x => x.src + '|' + x.id === blk.dataset.key);
|
||||
if (idx >= 0) { const v = parseInt(inp.value, 10); sortState.items[idx].prio = isFinite(v) ? v : 0; }
|
||||
});
|
||||
inp.addEventListener('pointerdown', e => e.stopPropagation());
|
||||
});
|
||||
cv.querySelectorAll('.scr-block').forEach(block => {
|
||||
block.addEventListener('pointerdown', e => {
|
||||
if (e.target.classList.contains('scr-prio')) return;
|
||||
e.preventDefault();
|
||||
const idx = sortState.items.findIndex(x => x.src + '|' + x.id === block.dataset.key);
|
||||
if (idx < 0) return;
|
||||
sortState.drag = { idx, key: block.dataset.key };
|
||||
block.classList.add('drag-src');
|
||||
makeGhost(block, e.clientX, e.clientY);
|
||||
const move = ev => onSortMove(ev);
|
||||
const up = ev => {
|
||||
block.removeEventListener('pointermove', move);
|
||||
block.removeEventListener('pointerup', up);
|
||||
block.removeEventListener('pointercancel', up);
|
||||
onSortUp(ev);
|
||||
};
|
||||
block.addEventListener('pointermove', move);
|
||||
block.addEventListener('pointerup', up);
|
||||
block.addEventListener('pointercancel', up);
|
||||
try { block.setPointerCapture(e.pointerId); } catch (err) {}
|
||||
});
|
||||
});
|
||||
}
|
||||
function onSortMove(e) {
|
||||
if (!sortState.drag) return;
|
||||
const cv = document.getElementById('scr-canvas');
|
||||
if (!cv) return;
|
||||
let toLine = sortState.items.length;
|
||||
cv.querySelectorAll('.scr-block').forEach(b => {
|
||||
const idx = sortState.items.findIndex(x => x.src + '|' + x.id === b.dataset.key);
|
||||
if (idx < 0 || idx === sortState.drag.idx) return;
|
||||
const r = b.getBoundingClientRect();
|
||||
if (e.clientY < r.top + r.height / 2) { if (idx < toLine) toLine = idx; }
|
||||
else { if (idx + 1 > toLine) toLine = idx + 1; }
|
||||
});
|
||||
cv.querySelectorAll('.sort-dropline').forEach(l => l.style.display = 'none');
|
||||
const line = cv.querySelector(`[data-line="${Math.max(0, Math.min(toLine, sortState.items.length))}"]`);
|
||||
if (line) line.style.display = 'block';
|
||||
if (gEl) { gEl.style.left = (e.clientX + 12) + 'px'; gEl.style.top = (e.clientY - 40) + 'px'; }
|
||||
}
|
||||
function onSortUp(e) {
|
||||
const d = sortState.drag;
|
||||
if (!d) return;
|
||||
const line = document.querySelector('#scr-canvas .sort-dropline[style*="display: block"]');
|
||||
let to = d.idx;
|
||||
if (line) {
|
||||
to = +line.dataset.line;
|
||||
if (d.idx < to) to -= 1;
|
||||
to = Math.max(0, Math.min(to, sortState.items.length - 1));
|
||||
}
|
||||
const [moved] = sortState.items.splice(d.idx, 1);
|
||||
sortState.items.splice(to, 0, moved);
|
||||
removeGhost();
|
||||
sortState.drag = null;
|
||||
paintSort();
|
||||
}
|
||||
function recomputePriorities() {
|
||||
let rank = 0;
|
||||
sortState.cols.forEach(c => c.models.forEach(m => { m.priority = Math.max(100 - rank * 5, 5); rank++; }));
|
||||
}
|
||||
function sortDragCleanup() {
|
||||
sortState.drag = null;
|
||||
document.querySelectorAll('#tab-sort .sort-dropline').forEach(l => l.classList.add('hidden'));
|
||||
document.querySelectorAll('#tab-sort .sort-chip.dragging').forEach(c => c.classList.remove('dragging'));
|
||||
let gEl = null;
|
||||
function makeGhost(block, x, y) {
|
||||
removeGhost();
|
||||
gEl = block.cloneNode(true);
|
||||
gEl.classList.add('scr-ghost');
|
||||
gEl.style.position = 'fixed';
|
||||
gEl.style.left = (x + 12) + 'px';
|
||||
gEl.style.top = (y - 40) + 'px';
|
||||
gEl.style.width = block.offsetWidth + 'px';
|
||||
gEl.style.margin = '0';
|
||||
gEl.style.pointerEvents = 'none';
|
||||
document.body.appendChild(gEl);
|
||||
}
|
||||
function removeGhost() { if (gEl) { gEl.remove(); gEl = null; } }
|
||||
function sortReset() {
|
||||
if (sortState.origin) sortState.cols = JSON.parse(sortState.origin);
|
||||
sortDragCleanup();
|
||||
if (sortState.origin) sortState.items = JSON.parse(sortState.origin);
|
||||
sortState.drag = null; removeGhost();
|
||||
paintSort();
|
||||
}
|
||||
async function saveSort() {
|
||||
const snap = await api('/api/sources');
|
||||
const byName = {}; snap.sources.forEach(s => byName[s.name] = s);
|
||||
const byName = {}; snap.sources.forEach(x => byName[x.name] = x);
|
||||
const groups = {};
|
||||
sortState.items.forEach(it => (groups[it.src] = groups[it.src] || []).push(it));
|
||||
let dirty = 0;
|
||||
for (const c of sortState.cols) {
|
||||
const base = byName[c.name];
|
||||
for (const name of Object.keys(groups)) {
|
||||
const base = byName[name];
|
||||
if (!base) continue;
|
||||
const next = c.models.map(m => {
|
||||
const old = (base.models || []).find(x => x.id === m.id) || { kind: 'chat' };
|
||||
return { id: m.id, priority: m.priority, kind: old.kind || 'chat' };
|
||||
const seen = new Set();
|
||||
const ordered = groups[name].filter(it => { if (seen.has(it.id)) return false; seen.add(it.id); return true; })
|
||||
.map(it => ({ id: it.id, priority: it.prio, kind: 'chat' }));
|
||||
(base.models || []).forEach(x => {
|
||||
if (seen.has(x.id)) return;
|
||||
ordered.push({ id: x.id, priority: x.priority || 0, kind: x.kind === 'image' ? 'image' : 'chat' });
|
||||
});
|
||||
if (JSON.stringify(next) !== JSON.stringify(base.models)) {
|
||||
base.models = next;
|
||||
if (JSON.stringify(ordered) !== JSON.stringify(base.models)) {
|
||||
base.models = ordered;
|
||||
await api('/api/sources', { method: 'POST', body: JSON.stringify(base) });
|
||||
dirty++;
|
||||
}
|
||||
}
|
||||
toast(t('sortSaved'));
|
||||
sortState.origin = JSON.stringify(sortState.cols);
|
||||
renderSort();
|
||||
toast(dirty ? t('sortSaved') : t('sortNoChange'));
|
||||
sortState.origin = JSON.stringify(sortState.items);
|
||||
}
|
||||
|
||||
/* ---------- adapters tab ---------- */
|
||||
|
||||
Reference in New Issue
Block a user