350 lines
17 KiB
HTML
350 lines
17 KiB
HTML
<!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/<profile_name>/ 并生成一个画廊。</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/<profile_name>/</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 class="lb-prompt hidden" id="lbPrompt">
|
||
<b>提示词</b>
|
||
<div id="lbPromptText"></div>
|
||
</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>";
|
||
const prompt = typeof g.prompt === "string" ? g.prompt : "";
|
||
$("#lbPromptText").textContent = prompt;
|
||
$("#lbPrompt").classList.toggle("hidden", !prompt.trim());
|
||
$("#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>
|