Polish static UI flows

This commit is contained in:
iye
2026-05-28 12:29:12 +08:00
parent 5edfa05369
commit bbe29622c2
45 changed files with 2187 additions and 813 deletions
+296 -14
View File
@@ -16,8 +16,13 @@
flex: 1; min-height: 0;
display: grid;
grid-template-columns: 240px 1fr;
transition: grid-template-columns var(--t-base);
}
.io-app.side-collapsed { grid-template-columns: 0 1fr; }
@media (max-width: 1100px) {
.io-app { grid-template-columns: 200px 1fr; }
.io-app.side-collapsed { grid-template-columns: 0 1fr; }
}
@media (max-width: 1100px) { .io-app { grid-template-columns: 200px 1fr; } }
/* ========== 左 · 会话栏 ========== */
.io-side {
@@ -25,6 +30,12 @@
background: var(--surface);
display: flex; flex-direction: column;
min-height: 0; overflow: hidden;
transition: opacity var(--t-base), transform var(--t-base);
}
.io-app.side-collapsed .io-side {
opacity: 0;
transform: translateX(-8px);
pointer-events: none;
}
.io-side-h {
display: flex; align-items: center; gap: 8px;
@@ -147,6 +158,74 @@
background: var(--surface);
}
.io-toolbar .spacer { flex: 1; }
.io-toolbar .side-restore-btn {
height: 32px;
padding: 0 10px;
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-72);
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
}
.io-toolbar .side-restore-btn:hover { border-color: var(--heat-20); color: var(--heat); }
.io-toolbar .side-restore-btn[hidden] { display: none; }
.io-toolbar .side-restore-btn svg { width: 14px; height: 14px; }
.io-toolbar-search {
position: relative;
width: min(320px, 32vw);
min-width: 220px;
}
.io-toolbar-search[hidden] { display: none; }
.io-toolbar-search svg {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 13px;
height: 13px;
color: var(--black-alpha-48);
pointer-events: none;
}
.io-toolbar-search input {
width: 100%;
height: 32px;
padding: 0 32px 0 30px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--accent-black);
font-family: inherit;
font-size: 12.5px;
outline: none;
}
.io-toolbar-search input:focus { border-color: var(--heat-40); background: var(--surface); }
.io-toolbar-search .clear-search {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--black-alpha-48);
cursor: pointer;
display: grid;
place-items: center;
}
.io-toolbar-search .clear-search:hover { background: var(--black-alpha-4); color: var(--accent-black); }
.io-toolbar-search .clear-search svg {
position: static;
transform: none;
width: 12px;
height: 12px;
}
.io-toolbar .search-btn {
width: 32px; height: 32px;
background: var(--surface);
@@ -157,7 +236,9 @@
display: grid; place-items: center;
}
.io-toolbar .search-btn:hover { border-color: var(--heat-20); color: var(--heat); }
.io-toolbar .search-btn.active { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }
.io-toolbar .search-btn svg { width: 14px; height: 14px; }
.io-tool-filter { position: relative; display: inline-flex; }
.io-toolbar .tb-chip {
display: inline-flex; align-items: center; gap: 6px;
height: 32px; padding: 0 10px;
@@ -169,7 +250,48 @@
transition: border-color var(--t-base), color var(--t-base);
}
.io-toolbar .tb-chip:hover { border-color: var(--heat-20); color: var(--heat); }
.io-toolbar .tb-chip.active { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }
.io-toolbar .tb-chip svg { width: 10px; height: 10px; opacity: .6; }
.io-tool-filter.open .tb-chip svg { transform: rotate(180deg); }
.io-tool-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 156px;
display: none;
padding: 4px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
box-shadow: var(--shadow-floating);
z-index: 30;
}
.io-tool-filter.open .io-tool-menu { display: block; }
.io-tool-menu .mi {
width: 100%;
min-height: 32px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--accent-black);
font-family: inherit;
font-size: 12.5px;
text-align: left;
cursor: pointer;
}
.io-tool-menu .mi:hover { background: var(--black-alpha-4); }
.io-tool-menu .mi.selected { background: var(--heat-12); color: var(--heat); font-weight: 500; }
.io-tool-menu .mi svg {
width: 12px;
height: 12px;
opacity: 0;
color: var(--heat);
}
.io-tool-menu .mi.selected svg { opacity: 1; }
/* 对话流主体 */
.io-stream {
@@ -711,22 +833,33 @@
<section class="io-main">
<div class="io-toolbar">
<button class="side-restore-btn" type="button" id="io-side-restore" hidden title="展开会话栏">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/></svg>
会话
</button>
<span class="spacer"></span>
<button class="search-btn" type="button" title="搜索">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
</button>
<button class="tb-chip" type="button">
时间
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
</button>
<button class="tb-chip" type="button">
生成模式
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
</button>
<button class="tb-chip" type="button">
操作类型
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
</button>
<div class="io-toolbar-search" id="io-toolbar-search" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input id="io-toolbar-search-input" type="text" placeholder="搜索当前对话结果">
<button class="clear-search" type="button" id="io-toolbar-search-clear" title="清空搜索">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="io-tool-filter" data-filter="time">
<button class="tb-chip" type="button"><span data-filter-label>时间</span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></button>
<div class="io-tool-menu" data-filter-menu></div>
</div>
<div class="io-tool-filter" data-filter="mode">
<button class="tb-chip" type="button"><span data-filter-label>生成模式</span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></button>
<div class="io-tool-menu" data-filter-menu></div>
</div>
<div class="io-tool-filter" data-filter="action">
<button class="tb-chip" type="button"><span data-filter-label>操作类型</span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></button>
<div class="io-tool-menu" data-filter-menu></div>
</div>
</div>
<div class="io-stream" id="io-stream">
@@ -893,6 +1026,13 @@ Shell.render({
const convList = $('#io-conv-list');
const newConvBtn = $('#io-new-conv');
const jumpBtn = $('#io-jump-bottom');
const ioApp = $('.io-app');
const foldBtn = $('.io-side-h .fold');
const sideRestore = $('#io-side-restore');
const toolbarSearchBtn = $('.io-toolbar .search-btn');
const toolbarSearch = $('#io-toolbar-search');
const toolbarSearchInput = $('#io-toolbar-search-input');
const toolbarSearchClear = $('#io-toolbar-search-clear');
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function uid() { return 'm-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); }
@@ -913,6 +1053,12 @@ Shell.render({
messages: [], // 当前激活会话的对话流(从 convMessages 镜像出来)
convMessages: { default: [] }, // 所有会话的 messages 按 convId 持久化
refImages: [],
toolbar: {
q: '',
time: 'all',
mode: 'all',
action: 'all',
},
param: {
model: 'studio-v2',
ratio: '1:1',
@@ -921,6 +1067,27 @@ Shell.render({
},
};
const TOOL_FILTERS = {
time: [
{ id: 'all', label: '时间' },
{ id: 'today', label: '今天' },
{ id: 'week', label: '近 7 天' },
],
mode: [
{ id: 'all', label: '生成模式' },
{ id: 'studio-v2', label: 'Airshelf v2' },
{ id: 'studio-v2-pro', label: 'v2 Pro' },
{ id: 'realistic', label: '写实增强' },
{ id: 'anime', label: '国风动漫' },
],
action: [
{ id: 'all', label: '操作类型' },
{ id: 'ok', label: '已完成' },
{ id: 'gen', label: '生成中' },
{ id: 'err', label: '失败' },
],
};
function slimMessages(msgs) {
// dataUrl 体积大,持久化时剥离;只保留元数据
return (msgs || []).map(m => ({
@@ -1074,6 +1241,29 @@ Shell.render({
}
/* ---------- 渲染:中央对话流 ---------- */
function getVisibleMessages() {
const t = state.toolbar;
const q = (t.q || '').trim().toLowerCase();
const now = Date.now();
return state.messages.filter(m => {
if (q) {
const hay = [
m.prompt,
m.ratio,
modelLabel(m.model),
styleLabel(m.style),
...(m.results || []).map(r => r.label || r.status),
].join(' ').toLowerCase();
if (!hay.includes(q)) return false;
}
if (t.time === 'today' && now - (m.createdAt || now) > 86400000) return false;
if (t.time === 'week' && now - (m.createdAt || now) > 86400000 * 7) return false;
if (t.mode !== 'all' && m.model !== t.mode) return false;
if (t.action !== 'all' && !(m.results || []).some(r => r.status === t.action)) return false;
return true;
});
}
function renderStream() {
if (!state.messages.length) {
streamInner.innerHTML = `
@@ -1099,7 +1289,27 @@ Shell.render({
});
return;
}
streamInner.innerHTML = state.messages.map(messageHTML).join('');
const visible = getVisibleMessages();
if (!visible.length) {
streamInner.innerHTML = `
<div class="io-empty">
<div class="ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
</div>
<div class="badge">// NO MATCH</div>
<h2>没有符合筛选的结果</h2>
<p>当前对话里没有匹配的批次,可以清空搜索或切回全部筛选。</p>
<div class="examples"><button class="ex" type="button" id="io-clear-toolbar-filters">清空筛选</button></div>
</div>`;
$('#io-clear-toolbar-filters')?.addEventListener('click', () => {
state.toolbar = { q: '', time: 'all', mode: 'all', action: 'all' };
if (toolbarSearchInput) toolbarSearchInput.value = '';
syncToolbarFilters();
renderStream();
});
return;
}
streamInner.innerHTML = visible.map(messageHTML).join('');
bindMessageEvents();
}
@@ -1608,6 +1818,9 @@ Shell.render({
if (!e.target.closest('.io-input-bottom .param')) {
document.querySelectorAll('.io-input-bottom .param.open').forEach(x => x.classList.remove('open'));
}
if (!e.target.closest('.io-tool-filter')) {
document.querySelectorAll('.io-tool-filter.open').forEach(x => x.classList.remove('open'));
}
if (!e.target.closest('.cell-more-wrap')) {
document.querySelectorAll('.cell-more-wrap.open').forEach(x => x.classList.remove('open'));
}
@@ -1616,6 +1829,73 @@ Shell.render({
}
});
/* ---------- 顶部工具栏:折叠 / 搜索 / 筛选 ---------- */
function setSideCollapsed(collapsed) {
ioApp?.classList.toggle('side-collapsed', collapsed);
if (sideRestore) sideRestore.hidden = !collapsed;
try { localStorage.setItem('fs-io-side-collapsed', collapsed ? '1' : '0'); } catch (e) {}
}
foldBtn?.addEventListener('click', () => setSideCollapsed(true));
sideRestore?.addEventListener('click', () => setSideCollapsed(false));
toolbarSearchBtn?.addEventListener('click', () => {
const open = toolbarSearch?.hidden;
if (toolbarSearch) toolbarSearch.hidden = !open;
toolbarSearchBtn.classList.toggle('active', !!open || !!state.toolbar.q);
if (open) requestAnimationFrame(() => toolbarSearchInput?.focus());
});
toolbarSearchInput?.addEventListener('input', e => {
state.toolbar.q = e.target.value.trim();
toolbarSearchBtn?.classList.toggle('active', !!state.toolbar.q || !toolbarSearch?.hidden);
renderStream();
});
toolbarSearchClear?.addEventListener('click', () => {
state.toolbar.q = '';
if (toolbarSearchInput) toolbarSearchInput.value = '';
toolbarSearchBtn?.classList.toggle('active', !toolbarSearch?.hidden);
renderStream();
toolbarSearchInput?.focus();
});
function syncToolbarFilters() {
document.querySelectorAll('.io-tool-filter').forEach(wrap => {
const key = wrap.dataset.filter;
const value = state.toolbar[key] || 'all';
const items = TOOL_FILTERS[key] || [];
const selected = items.find(x => x.id === value) || items[0];
wrap.querySelector('[data-filter-label]').textContent = selected?.label || '';
wrap.querySelector('.tb-chip')?.classList.toggle('active', value !== 'all');
const menu = wrap.querySelector('[data-filter-menu]');
if (!menu) return;
menu.innerHTML = items.map(it => `
<button class="mi${it.id === value ? ' selected' : ''}" type="button" data-val="${esc(it.id)}">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 8 7 12 13 4"/></svg>
<span>${esc(it.label)}</span>
</button>
`).join('');
});
}
document.querySelectorAll('.io-tool-filter').forEach(wrap => {
const btn = wrap.querySelector('.tb-chip');
const menu = wrap.querySelector('[data-filter-menu]');
btn?.addEventListener('click', e => {
e.stopPropagation();
const open = wrap.classList.contains('open');
document.querySelectorAll('.io-tool-filter.open').forEach(x => x.classList.remove('open'));
if (!open) wrap.classList.add('open');
});
menu?.addEventListener('click', e => {
const item = e.target.closest('.mi');
if (!item) return;
e.stopPropagation();
state.toolbar[wrap.dataset.filter] = item.dataset.val;
wrap.classList.remove('open');
syncToolbarFilters();
renderStream();
});
});
/* ---------- 新对话 ---------- */
newConvBtn.addEventListener('click', () => {
// 若 default 上有内容,把它归档到「最近」(新 id + 转存 messages),然后清空 default
@@ -1671,6 +1951,8 @@ Shell.render({
} catch (e) {}
buildParamMenus();
syncParamLabels();
syncToolbarFilters();
try { setSideCollapsed(localStorage.getItem('fs-io-side-collapsed') === '1'); } catch (e) {}
updateCost();
renderRefs();
syncSendDisabled();