feat: complete image console and add verified REST and MCP workflows

This commit is contained in:
2026-11-04 00:41:54 +08:00
parent 3140cd51d2
commit bef8af53ed
48 changed files with 5167 additions and 1176 deletions
+342
View File
@@ -0,0 +1,342 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>画廊 · ai-image</title>
<link rel="stylesheet" href="styles.css" />
<script src="api.js"></script>
<script src="components.js"></script>
</head>
<body data-page="gallery">
<aside class="app-nav">
<div class="app-brand">
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-text"><b>ai-image</b><small>绘图聚合控制台</small></span>
</div>
<nav class="nav-list">
<p class="nav-group">工作台</p>
<a class="nav-item" data-nav="create" href="create.html"><span class="nav-ico"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12 3-1.9 5.8L4 10.7l6.1 1.9L12 18.4l1.9-5.8 6.1-1.9-6.1-1.9Z"/><path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/></svg></span>出图</a>
<a class="nav-item" data-nav="gallery" href="index.html"><span class="nav-ico"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg></span>画廊</a>
<a class="nav-item" data-nav="records" href="records.html"><span class="nav-ico"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg></span>生成记录</a>
<p class="nav-group">配置</p>
<a class="nav-item" data-nav="providers" href="providers.html"><span class="nav-ico"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v3a6 6 0 0 1-12 0V8Z"/></svg></span>供应商</a>
<a class="nav-item" data-nav="models" href="models.html"><span class="nav-ico"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z"/><path d="m7 16.5-4.74-2.85"/><path d="m7 16.5 5-3"/><path d="M7 16.5v5.17"/><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z"/><path d="m17 16.5-5-3"/><path d="m17 16.5 4.74-2.85"/><path d="M17 16.5v5.17"/><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z"/><path d="M12 8 7.26 5.15"/><path d="m12 8 4.74-2.85"/><path d="M12 13.5V8"/></svg></span>模型</a>
<p class="nav-group">Agent</p>
<a class="nav-item" data-nav="skills" href="skills.html"><span class="nav-ico"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg></span>SKILL.md</a>
</nav>
<div class="nav-foot">
<span class="dot dot-ok"></span>服务在线 <span class="mono">:8765</span>
</div>
</aside>
<main class="app-content">
<header class="top-bar">
<div class="crumb">
<span class="crumb-sec">工作台</span>
<span class="crumb-sep">/</span>
<h1 id="pageTitle">画廊</h1>
</div>
<div class="top-actions">
<span class="tag tag-info mono" id="outDirTag">output/</span>
<a class="btn btn-secondary btn-sm" href="records.html">生成记录</a>
<a class="btn btn-sm" href="providers.html">接入供应商</a>
</div>
</header>
<section class="page-body">
<!-- ============ 视图一:画廊(output/ 下的文件夹)列表 ============ -->
<div id="viewFolders">
<div class="toolbar">
<div class="input-group" style="max-width:280px;flex:1">
<input type="search" id="folderSearch" placeholder="搜索画廊名称(profile_name)" />
</div>
<span class="spacer"></span>
<span class="dim mono" id="folderCount">—</span>
<button class="btn btn-secondary btn-sm" id="refreshFolders"><span class="ico" data-icon="refresh"></span>刷新</button>
</div>
<div class="grid grid-4" style="margin-bottom:16px">
<div class="card stat">
<div class="k">画廊</div>
<div class="v" id="statFolders">—</div>
<div class="sub">output/ 下的子目录</div>
</div>
<div class="card stat">
<div class="k">画廊图片</div>
<div class="v" id="statTotal">—</div>
<div class="sub">全部目录累计</div>
</div>
<div class="card stat">
<div class="k">占用空间</div>
<div class="v" id="statSize">—</div>
<div class="sub">output/ 累计</div>
</div>
<div class="card stat">
<div class="k">最新产出</div>
<div class="v" id="statLatest" style="font-size:18px">—</div>
<div class="sub">最近一次生成时间</div>
</div>
</div>
<div class="card">
<div class="card-head">
<div>
<h3>画廊列表</h3>
<div class="sub">每个文件夹即一个画廊(对应生成时的 profile_name),点击进入查看图片</div>
</div>
<div class="top-actions">
<span class="tag tag-info mono">output/</span>
</div>
</div>
<div class="card-body">
<div class="folder-grid" id="folderGrid"></div>
<div class="empty hidden" id="folderEmpty">
<span class="ico" data-icon="images"></span>
<h4>output/ 下还没有画廊</h4>
<p>出图时传入 <code>profile_name</code>,图片会自动落到 output/&lt;profile_name&gt;/ 并生成一个画廊。</p>
<a class="btn btn-secondary btn-sm" href="providers.html">配置供应商</a>
</div>
</div>
</div>
</div>
<!-- ============ 视图二:单个画廊内的图片 ============ -->
<div id="viewGallery" class="hidden">
<div class="toolbar">
<button class="btn btn-secondary btn-sm" id="backBtn"><span class="ico" data-icon="chevronLeft"></span>返回画廊列表</button>
<span class="mono dim" id="galleryPath">output/</span>
<span class="spacer"></span>
<select id="modelFilter" style="width:220px">
<option value="all">全部模型</option>
</select>
<div class="input-group" style="max-width:240px;flex:1">
<input type="search" id="imageSearch" placeholder="搜索文件名 / 模型" />
</div>
<span class="dim mono" id="galCount">—</span>
</div>
<div class="grid grid-3" style="margin-bottom:16px">
<div class="card stat">
<div class="k">图片数</div>
<div class="v" id="gStatCount">—</div>
<div class="sub">当前画廊</div>
</div>
<div class="card stat">
<div class="k">占用空间</div>
<div class="v" id="gStatSize">—</div>
<div class="sub">当前画廊累计</div>
</div>
<div class="card stat">
<div class="k">最新产出</div>
<div class="v" id="gStatLatest" style="font-size:18px">—</div>
<div class="sub">该画廊最后生成时间</div>
</div>
</div>
<div class="card">
<div class="card-head">
<div>
<h3 id="galleryTitle">—</h3>
<div class="sub">点击缩略图查看大图与元信息</div>
</div>
<div class="top-actions">
<span class="tag tag-info mono" id="galleryPathTag">output/</span>
</div>
</div>
<div class="card-body">
<div class="gal-grid" id="galleryGrid"></div>
<div class="empty hidden" id="galEmpty">
<span class="ico" data-icon="images"></span>
<h4>这个画廊还没有图片</h4>
<p>图片会落到 <code>output/&lt;profile_name&gt;/</code>,出图成功后自动出现。</p>
<a class="btn btn-secondary btn-sm" href="providers.html">配置供应商</a>
</div>
</div>
</div>
</div>
<div class="lightbox" id="lightbox">
<div class="lb-inner">
<div class="lb-stage">
<button class="lb-nav" id="lbPrev"><span class="ico" data-icon="chevronLeft"></span></button>
<img id="lbImg" alt="图片预览" />
<button class="lb-nav" id="lbNext"><span class="ico" data-icon="chevronRight"></span></button>
</div>
<div class="lb-info" id="lbInfo"></div>
<div style="display:flex;justify-content:flex-end">
<button class="btn btn-secondary btn-sm" id="lbDelete">删除图片</button>
<button class="btn btn-secondary btn-sm" id="lbClose">关闭</button>
</div>
</div>
</div>
<script>
(function () {
let folders = [];
let items = [];
let view = [];
let cur = 0;
let current = null;
const $ = (s) => document.querySelector(s);
const folderGrid = $("#folderGrid");
const grid = $("#galleryGrid");
/* ------------------------------------------------ 视图一:画廊列表 */
function renderFolders() {
const q = $("#folderSearch").value.trim().toLowerCase();
const list = folders.filter((f) => !q || f.name.toLowerCase().includes(q));
folderGrid.innerHTML = "";
$("#folderEmpty").classList.toggle("hidden", list.length !== 0);
$("#folderCount").textContent = list.length + " / " + folders.length + " 个画廊";
list.forEach((f) => {
const el = document.createElement("div");
el.className = "folder-card";
el.innerHTML =
'<div class="folder-cover"><img src="' + esc(f.cover) + '" alt="' + esc(f.name) + '" loading="lazy" />' +
'<span class="folder-count">' + f.count + " 张</span></div>" +
'<div class="folder-body">' +
'<div class="folder-name" title="' + esc(f.name) + '">' + esc(f.name) + "</div>" +
'<div class="folder-meta">' + fmtBytes(f.bytes) + " · " + esc(f.updated_at) + "</div>" +
'<div class="folder-models">' +
f.models.map((m) => '<span class="tag">' + esc(m) + "</span>").join("") +
'</div></div>';
el.addEventListener("click", () => openGallery(f.name));
folderGrid.appendChild(el);
});
}
async function loadFolders() {
folders = await window.API.listGalleries();
const bytes = folders.reduce((s, f) => s + (f.bytes || 0), 0);
const total = folders.reduce((s, f) => s + (f.count || 0), 0);
const latest = folders.length
? folders.map((f) => f.updated_at).sort().slice(-1)[0]
: "—";
$("#statFolders").textContent = folders.length;
$("#statTotal").textContent = total;
$("#statSize").textContent = fmtBytes(bytes);
$("#statLatest").textContent = latest;
renderFolders();
}
/* ------------------------------------------- 视图二:画廊内图片 */
function openGallery(name) {
current = name;
$("#viewFolders").classList.add("hidden");
$("#viewGallery").classList.remove("hidden");
$("#pageTitle").textContent = name;
$("#galleryTitle").textContent = name;
$("#galleryPath").textContent = "output/" + name + "/";
$("#galleryPathTag").textContent = "output/" + name + "/";
$("#imageSearch").value = "";
$("#modelFilter").value = "all";
runTask(loadImages);
}
function closeGallery() {
current = null;
$("#viewGallery").classList.add("hidden");
$("#viewFolders").classList.remove("hidden");
$("#pageTitle").textContent = "画廊";
}
function applyImageFilter() {
const m = $("#modelFilter").value;
const q = $("#imageSearch").value.trim().toLowerCase();
view = items.filter((g) => {
if (m !== "all" && g.model !== m) return false;
if (q && !((g.filename + g.model + g.provider_name).toLowerCase().includes(q))) return false;
return true;
});
renderImages();
}
function renderImages() {
grid.innerHTML = "";
$("#galEmpty").classList.toggle("hidden", view.length !== 0);
$("#galCount").textContent = view.length + " / " + items.length + " 张";
view.forEach((g, i) => {
const el = document.createElement("div");
el.className = "gal-item";
el.innerHTML =
'<img src="' + esc(g.url) + '" alt="' + esc(g.filename) + '" loading="lazy" />' +
'<span class="tag gal-tag">' + esc(g.model) + "</span>" +
'<div class="gal-meta"><b>' + esc(g.filename) + "</b><span>" + fmtBytes(g.bytes) + " · " + esc(g.created_at) + "</span></div>";
el.addEventListener("click", () => openLb(i));
grid.appendChild(el);
});
}
async function loadImages() {
items = await window.API.listGallery(current);
const models = Array.from(new Set(items.map((g) => g.model)));
$("#modelFilter").innerHTML =
'<option value="all">全部模型</option>' +
models.map((m) => '<option value="' + esc(m) + '">' + esc(m) + "</option>").join("");
const bytes = items.reduce((s, g) => s + (g.bytes || 0), 0);
const latest = items.length
? items.map((g) => g.created_at).sort().slice(-1)[0]
: "—";
$("#gStatCount").textContent = items.length;
$("#gStatSize").textContent = fmtBytes(bytes);
$("#gStatLatest").textContent = latest;
applyImageFilter();
}
function openLb(i) {
if (!view.length) return;
cur = i;
const g = view[i];
$("#lbImg").src = g.url;
$("#lbImg").alt = g.filename;
$("#lbInfo").innerHTML =
"<span><b>文件</b> " + esc(g.filename) + "</span>" +
"<span><b>画廊</b> " + esc(g.profile_name) + "</span>" +
"<span><b>模型</b> " + esc(g.model) + "</span>" +
"<span><b>供应商</b> " + esc(g.provider_name) + "</span>" +
"<span><b>大小</b> " + fmtBytes(g.bytes) + "</span>" +
"<span><b>时间</b> " + esc(g.created_at) + "</span>";
$("#lightbox").classList.add("open");
}
$("#lbPrev").addEventListener("click", (e) => { e.stopPropagation(); if (view.length) openLb((cur - 1 + view.length) % view.length); });
$("#lbNext").addEventListener("click", (e) => { e.stopPropagation(); if (view.length) openLb((cur + 1) % view.length); });
$("#lbClose").addEventListener("click", () => $("#lightbox").classList.remove("open"));
$("#lbDelete").addEventListener("click", (event) => {
const image = view[cur];
if (!image || !window.confirm("确定删除图片「" + image.filename + "」?")) return;
runTask(async () => {
await window.API.deleteGalleryImage(image.profile_name, image.filename);
$("#lightbox").classList.remove("open");
await loadImages();
await loadFolders();
toast("图片已删除");
}, event.currentTarget);
});
$("#lightbox").addEventListener("click", (e) => { if (e.target === $("#lightbox")) $("#lightbox").classList.remove("open"); });
document.addEventListener("keydown", (e) => {
if (!$("#lightbox").classList.contains("open")) return;
if (e.key === "ArrowLeft") $("#lbPrev").click();
if (e.key === "ArrowRight") $("#lbNext").click();
});
$("#backBtn").addEventListener("click", closeGallery);
$("#folderSearch").addEventListener("input", renderFolders);
$("#modelFilter").addEventListener("change", applyImageFilter);
$("#imageSearch").addEventListener("input", applyImageFilter);
$("#refreshFolders").addEventListener("click", (event) => runTask(loadFolders, event.currentTarget));
paintIcons();
runTask(loadFolders);
})();
</script>
</section>
</main>
</body>
</html>