538 lines
28 KiB
JavaScript
538 lines
28 KiB
JavaScript
(() => {
|
||
"use strict";
|
||
|
||
const csrfToken = () => document.querySelector('meta[name="csrf-token"]')?.content || "";
|
||
const show = element => element?.classList.remove("hidden");
|
||
const hide = element => element?.classList.add("hidden");
|
||
const maxImages = () => Number(document.querySelector('meta[name="max-images"]')?.content || 8);
|
||
const maxPromptBytes = () => Number(document.querySelector('meta[name="max-prompt-bytes"]')?.content || 8000);
|
||
let toastTimer;
|
||
let selectedImages = [];
|
||
let dragIndex = null;
|
||
let historyLoading = false;
|
||
let historyObserver;
|
||
let legacyTextRetry = false;
|
||
|
||
function toast(message) {
|
||
const element = document.querySelector("#toast");
|
||
const text = document.querySelector("#toast-text");
|
||
if (!element || !text) return;
|
||
window.clearTimeout(toastTimer);
|
||
text.textContent = message;
|
||
show(element);
|
||
toastTimer = window.setTimeout(() => hide(element), 3200);
|
||
}
|
||
|
||
function renderIcons(root = document) {
|
||
window.renderChorusIcons?.(root);
|
||
}
|
||
|
||
function initializeAlpine() {
|
||
window.Alpine.data("composer", () => ({
|
||
mode: "edit",
|
||
generatePressed: "false",
|
||
editPressed: "true",
|
||
imageClass: "",
|
||
setGenerate() {
|
||
this.mode = "generate";
|
||
this.generatePressed = "true";
|
||
this.editPressed = "false";
|
||
this.imageClass = "hidden";
|
||
document.querySelector("#generation-mode").value = "generate";
|
||
handleModeChanged("generate");
|
||
},
|
||
setEdit() {
|
||
this.mode = "edit";
|
||
this.generatePressed = "false";
|
||
this.editPressed = "true";
|
||
this.imageClass = "";
|
||
document.querySelector("#generation-mode").value = "edit";
|
||
handleModeChanged("edit");
|
||
}
|
||
}));
|
||
}
|
||
|
||
function handleModeChanged(mode) {
|
||
legacyTextRetry = false;
|
||
const label = document.querySelector("#generate-button span");
|
||
if (label) label.textContent = mode === "generate" ? "开始文生图" : "开始图片编辑";
|
||
hide(document.querySelector("#upload-error"));
|
||
hide(document.querySelector("#role-rule-error"));
|
||
updatePromptPreview();
|
||
}
|
||
|
||
function setMode(mode) {
|
||
document.querySelector(`[data-mode="${mode}"]`)?.click();
|
||
}
|
||
|
||
function clientID() {
|
||
const value = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
return `img-${value}`.slice(0, 64);
|
||
}
|
||
|
||
function iconButton(name, label, action, imageID, disabled = false) {
|
||
const button = document.createElement("button");
|
||
button.className = "order-button";
|
||
if (action === "remove") button.classList.add("remove-image");
|
||
button.type = "button";
|
||
button.dataset.action = action;
|
||
button.dataset.imageId = imageID;
|
||
button.disabled = disabled;
|
||
button.setAttribute("aria-label", label);
|
||
button.title = label;
|
||
const icon = document.createElement("i");
|
||
icon.className = "icon";
|
||
icon.dataset.lucide = name;
|
||
button.append(icon);
|
||
return button;
|
||
}
|
||
|
||
function roleButton(image, role, label, iconName) {
|
||
const button = document.createElement("button");
|
||
button.className = "role-button";
|
||
button.type = "button";
|
||
button.dataset.action = "role";
|
||
button.dataset.role = role;
|
||
button.dataset.imageId = image.id;
|
||
button.setAttribute("aria-pressed", String(image.role === role));
|
||
const icon = document.createElement("i");
|
||
icon.className = "icon";
|
||
icon.dataset.lucide = iconName;
|
||
button.append(icon, document.createTextNode(label));
|
||
return button;
|
||
}
|
||
|
||
function releaseImage(image) {
|
||
if (image.previewURL) URL.revokeObjectURL(image.previewURL);
|
||
}
|
||
|
||
function renderImages() {
|
||
const list = document.querySelector("#image-list");
|
||
if (!list) return;
|
||
list.replaceChildren();
|
||
selectedImages.forEach((image, index) => {
|
||
const card = document.createElement("article");
|
||
card.className = "image-editor";
|
||
card.draggable = true;
|
||
card.dataset.imageId = image.id;
|
||
card.setAttribute("aria-label", `第 ${index + 1} 张输入图片:${image.file.name}`);
|
||
|
||
const preview = document.createElement("div");
|
||
preview.className = "image-thumb";
|
||
const picture = document.createElement("img");
|
||
picture.src = image.previewURL;
|
||
picture.alt = `第 ${index + 1} 张输入图片预览`;
|
||
picture.width = 96;
|
||
picture.height = 96;
|
||
const position = document.createElement("span");
|
||
position.className = "image-index";
|
||
position.textContent = String(index + 1);
|
||
preview.append(picture, position);
|
||
|
||
const main = document.createElement("div");
|
||
main.className = "image-editor-main";
|
||
const titleRow = document.createElement("div");
|
||
titleRow.className = "image-title-row";
|
||
const title = document.createElement("span");
|
||
title.className = "image-title";
|
||
title.title = image.file.name;
|
||
title.textContent = image.file.name;
|
||
const hint = document.createElement("span");
|
||
hint.className = "drag-hint";
|
||
hint.textContent = "可拖动";
|
||
titleRow.append(title, hint);
|
||
const roles = document.createElement("div");
|
||
roles.className = "role-group";
|
||
roles.setAttribute("role", "group");
|
||
roles.setAttribute("aria-label", `${image.file.name} 的角色`);
|
||
roles.append(roleButton(image, "primary", "主体图", "image"), roleButton(image, "reference", "参考图", "file-text"));
|
||
const noteLabel = document.createElement("label");
|
||
noteLabel.className = "sr-only";
|
||
noteLabel.htmlFor = `note-${image.id}`;
|
||
noteLabel.textContent = `${image.file.name} 的备注`;
|
||
const note = document.createElement("input");
|
||
note.className = "input image-note";
|
||
note.id = `note-${image.id}`;
|
||
note.dataset.imageId = image.id;
|
||
note.maxLength = 500;
|
||
note.value = image.note;
|
||
note.placeholder = "填写图片备注,最多 500 字符";
|
||
main.append(titleRow, roles, noteLabel, note);
|
||
|
||
const controls = document.createElement("div");
|
||
controls.className = "order-controls";
|
||
controls.append(
|
||
iconButton("arrow-up", `上移第 ${index + 1} 张图`, "up", image.id, index === 0),
|
||
iconButton("arrow-down", `下移第 ${index + 1} 张图`, "down", image.id, index === selectedImages.length - 1),
|
||
iconButton("x", `移除第 ${index + 1} 张图`, "remove", image.id)
|
||
);
|
||
card.append(preview, main, controls);
|
||
list.append(card);
|
||
});
|
||
const count = document.querySelector("#image-count");
|
||
if (count) count.textContent = `${selectedImages.length} / ${maxImages()}`;
|
||
renderIcons(list);
|
||
updatePromptPreview();
|
||
}
|
||
|
||
function validateFiles(files) {
|
||
const allowed = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||
if (files.length > maxImages()) return `一次最多添加 ${maxImages()} 张图片。`;
|
||
const invalid = files.find(file => !allowed.has(file.type));
|
||
return invalid ? `${invalid.name}:格式不支持,请改用 JPG、PNG 或 WebP。` : "";
|
||
}
|
||
|
||
function ensurePrimary() {
|
||
if (selectedImages.length && !selectedImages.some(image => image.role === "primary")) selectedImages[0].role = "primary";
|
||
}
|
||
|
||
function updatePromptPreview() {
|
||
const prompt = document.querySelector("#prompt")?.value.trim() || "";
|
||
const rule = document.querySelector("#role-rule")?.value.trim() || "";
|
||
const mode = document.querySelector("#generation-mode")?.value || "edit";
|
||
const ruleSummary = document.querySelector("#rule-summary");
|
||
const imageSummary = document.querySelector("#image-summary");
|
||
const preview = document.querySelector("#prompt-preview-text");
|
||
if (!ruleSummary || !imageSummary || !preview) return;
|
||
if (mode === "generate") {
|
||
ruleSummary.textContent = "文生图不使用图片角色规则。";
|
||
imageSummary.textContent = "文生图不包含输入图片。";
|
||
preview.textContent = `管理端默认模板 → ${prompt || "(尚未填写生成要求)"}。`;
|
||
return;
|
||
}
|
||
ruleSummary.textContent = rule ? "非空,将覆盖默认图片角色规则。" : "留空,当前只使用管理端默认规则。";
|
||
const primaryCount = selectedImages.filter(image => image.role === "primary").length;
|
||
imageSummary.textContent = selectedImages.length ? `按当前顺序附加:${primaryCount} 张主体图,${selectedImages.length - primaryCount} 张参考图。` : "尚未添加输入图片。";
|
||
const images = selectedImages.map((image, index) => `第 ${index + 1} 张为${image.role === "primary" ? "主体图" : "参考图"}${image.note ? `,备注:${image.note}` : ""}`).join(";");
|
||
preview.textContent = `管理端默认规则 → ${rule || "(未填写用户规则)"} → ${images || "尚未添加输入图片"}。`;
|
||
}
|
||
|
||
function moveImage(id, direction) {
|
||
const index = selectedImages.findIndex(image => image.id === id);
|
||
const target = direction === "up" ? index - 1 : index + 1;
|
||
if (index < 0 || target < 0 || target >= selectedImages.length) return;
|
||
[selectedImages[index], selectedImages[target]] = [selectedImages[target], selectedImages[index]];
|
||
renderImages();
|
||
document.querySelector(`[data-image-id="${CSS.escape(id)}"] [data-action="${direction}"]`)?.focus();
|
||
toast(direction === "up" ? "图片已上移" : "图片已下移");
|
||
}
|
||
|
||
function setImageRole(id, role) {
|
||
const image = selectedImages.find(item => item.id === id);
|
||
if (!image) return;
|
||
if (role === "primary") {
|
||
selectedImages.forEach(item => { item.role = item.id === id ? "primary" : "reference"; });
|
||
} else if (image.role === "primary") {
|
||
const replacement = selectedImages.find(item => item.id !== id);
|
||
if (!replacement) { toast("图片编辑必须保留一张主体图"); return; }
|
||
replacement.role = "primary";
|
||
image.role = "reference";
|
||
}
|
||
renderImages();
|
||
document.querySelector(`[data-image-id="${CSS.escape(id)}"] [data-role="${role}"]`)?.focus();
|
||
toast("图片角色已更新");
|
||
}
|
||
|
||
async function submitGeneration(event) {
|
||
event.preventDefault();
|
||
const prompt = document.querySelector("#prompt");
|
||
const promptError = document.querySelector("#prompt-error");
|
||
const uploadError = document.querySelector("#upload-error");
|
||
const ruleError = document.querySelector("#role-rule-error");
|
||
const button = document.querySelector("#generate-button");
|
||
const mode = document.querySelector("#generation-mode").value;
|
||
const promptBytes = new TextEncoder().encode(prompt.value).length;
|
||
hide(promptError); hide(uploadError); hide(ruleError);
|
||
if (!prompt.value.trim() || promptBytes > maxPromptBytes()) {
|
||
promptError.textContent = promptBytes > maxPromptBytes() ? `生成要求不能超过 ${maxPromptBytes()} 字节。` : "请输入生成要求。";
|
||
show(promptError); prompt.focus(); return;
|
||
}
|
||
const roleRule = document.querySelector("#role-rule").value;
|
||
if (mode === "edit") {
|
||
const fileError = selectedImages.length === 0 ? "图片编辑至少需要 1 张图片。" : validateFiles(selectedImages.map(image => image.file));
|
||
if (fileError) { uploadError.textContent = fileError; show(uploadError); document.querySelector("#image-upload").focus(); return; }
|
||
if (selectedImages.filter(image => image.role === "primary").length !== 1) { uploadError.textContent = "图片编辑必须且只能设置 1 张主体图。"; show(uploadError); document.querySelector("#image-upload").focus(); return; }
|
||
if (new TextEncoder().encode(roleRule).length > maxPromptBytes()) { ruleError.textContent = `图片角色规则不能超过 ${maxPromptBytes()} 字节。`; show(ruleError); document.querySelector("#role-rule").focus(); return; }
|
||
}
|
||
button.disabled = true;
|
||
button.querySelector("span").textContent = "正在提交";
|
||
const idempotencyKey = crypto.randomUUID?.() || `web-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
let endpoint = "/api/generations/image";
|
||
let headers = { "X-CSRF-Token": csrfToken() };
|
||
let body;
|
||
if (legacyTextRetry) {
|
||
endpoint = "/api/generations/text";
|
||
headers["Content-Type"] = "application/json";
|
||
body = JSON.stringify({ idempotency_key: idempotencyKey, prompt: prompt.value });
|
||
} else {
|
||
body = new FormData();
|
||
body.set("idempotency_key", idempotencyKey);
|
||
body.set("prompt", prompt.value);
|
||
const editing = mode === "edit";
|
||
body.set("metadata", JSON.stringify({ capability: editing ? "image_edit" : "image_generate", role_rule: editing ? roleRule : "", images: editing ? selectedImages.map((image, position) => ({ client_id: image.id, position, role: image.role, note: image.note })) : [] }));
|
||
if (editing) selectedImages.forEach(image => body.append(`files[${image.id}]`, image.file, image.file.name));
|
||
}
|
||
try {
|
||
const response = await fetch(endpoint, { method: "POST", headers, body });
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (response.status === 401) { showAuthExpired(); return; }
|
||
if (!response.ok) {
|
||
const error = payload.error || {};
|
||
const imageErrorCodes = new Set(["invalid_metadata", "invalid_capability", "invalid_client_id", "invalid_primary_count"]);
|
||
if (error.file || String(error.code || "").includes("image") || imageErrorCodes.has(error.code)) {
|
||
uploadError.textContent = error.file ? `${error.file.name}:图片未通过校验,请检查格式和大小。` : (error.message || "图片信息不符合要求,请检查角色和顺序。");
|
||
show(uploadError); document.querySelector("#image-upload").focus();
|
||
} else if (error.code === "invalid_role_rule") {
|
||
ruleError.textContent = "图片角色规则不符合要求。"; show(ruleError); document.querySelector("#role-rule").focus();
|
||
} else {
|
||
promptError.textContent = error.message || "提交失败,请检查输入后重试。"; show(promptError); prompt.focus();
|
||
}
|
||
return;
|
||
}
|
||
window.location.assign(`/generations/${payload.id}`);
|
||
} catch (_) {
|
||
toast("提交失败,请检查网络后重试");
|
||
} finally {
|
||
button.disabled = false;
|
||
button.querySelector("span").textContent = mode === "generate" ? "开始文生图" : "开始图片编辑";
|
||
}
|
||
}
|
||
|
||
async function retryGeneration(button) {
|
||
const section = button.closest("#result-section");
|
||
const data = section?.querySelector(".retry-data");
|
||
const prompt = document.querySelector("#prompt");
|
||
if (!data || !prompt) return;
|
||
prompt.value = data.dataset.prompt || "";
|
||
prompt.dispatchEvent(new Event("input"));
|
||
selectedImages.forEach(releaseImage);
|
||
selectedImages = [];
|
||
const inputs = Array.from(section.querySelectorAll(".retry-input"));
|
||
if (data.dataset.kind === "text") { setMode("generate"); legacyTextRetry = true; } else { setMode(inputs.length ? "edit" : "generate"); }
|
||
document.querySelector("#role-rule").value = data.dataset.roleRule || "";
|
||
if (inputs.length) {
|
||
button.disabled = true;
|
||
try {
|
||
for (const input of inputs) {
|
||
const response = await fetch(input.dataset.inputUrl);
|
||
if (!response.ok) throw new Error("input fetch failed");
|
||
const blob = await response.blob();
|
||
const file = new File([blob], input.dataset.inputName || "image", { type: input.dataset.inputMime || blob.type });
|
||
selectedImages.push({ id: clientID(), file, role: input.dataset.inputRole || "reference", note: input.dataset.inputNote || "", previewURL: URL.createObjectURL(file) });
|
||
}
|
||
ensurePrimary(); renderImages();
|
||
} catch (_) {
|
||
selectedImages.forEach(releaseImage); selectedImages = []; renderImages(); toast("原图读取失败,请重新选择图片");
|
||
} finally { button.disabled = false; }
|
||
}
|
||
updatePromptPreview();
|
||
document.querySelector("#composer-title")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
prompt.focus();
|
||
}
|
||
|
||
function showAuthExpired() {
|
||
show(document.querySelector("#auth-modal"));
|
||
document.querySelector("#relogin-button")?.focus();
|
||
}
|
||
|
||
function showPollingError(element) {
|
||
if (!element || element.querySelector(".polling-error")) return;
|
||
const notice = document.createElement("div");
|
||
notice.className = "notice polling-error";
|
||
notice.setAttribute("role", "alert");
|
||
notice.textContent = "状态更新失败。当前内容仍然保留,正在等待网络恢复。";
|
||
element.querySelector(".result-body")?.prepend(notice);
|
||
}
|
||
|
||
function statusText(status) {
|
||
return { pending: "排队中", running: "生成中", succeeded: "已完成", failed: "失败" }[status] || "未知";
|
||
}
|
||
|
||
function relativeTime(value) {
|
||
const elapsed = Math.max(0, Date.now() - new Date(value).getTime());
|
||
if (elapsed < 60000) return "刚刚";
|
||
if (elapsed < 3600000) return `${Math.floor(elapsed / 60000)} 分钟前`;
|
||
if (elapsed < 86400000) return `${Math.floor(elapsed / 3600000)} 小时前`;
|
||
return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
|
||
}
|
||
|
||
function historyItem(row) {
|
||
const item = document.createElement("li");
|
||
item.dataset.historySearch = `${row.user_prompt || ""} ${row.kind || ""}`;
|
||
const link = document.createElement("a");
|
||
link.className = "history-item";
|
||
link.href = `/generations/${row.id}`;
|
||
const iconWrap = document.createElement("span");
|
||
iconWrap.className = "history-icon";
|
||
const icon = document.createElement("i");
|
||
icon.className = "icon";
|
||
icon.dataset.lucide = row.kind === "image" ? "image" : "file-text";
|
||
iconWrap.append(icon);
|
||
const copy = document.createElement("span");
|
||
copy.className = "history-copy";
|
||
const title = document.createElement("span");
|
||
title.className = "history-title";
|
||
title.textContent = row.user_prompt || `任务 #${row.id}`;
|
||
title.title = title.textContent;
|
||
const meta = document.createElement("span");
|
||
meta.className = "history-meta";
|
||
const dot = document.createElement("span");
|
||
dot.className = `status-dot ${row.status === "succeeded" ? "success" : row.status || ""}`;
|
||
meta.append(dot, document.createTextNode(`${statusText(row.status)} · ${row.kind === "image" ? "图片" : "文本"} · ${relativeTime(row.created_at)}`));
|
||
copy.append(title, meta);
|
||
link.append(iconWrap, copy);
|
||
item.append(link);
|
||
return item;
|
||
}
|
||
|
||
function applyHistorySearch() {
|
||
const query = document.querySelector("#history-search")?.value.trim().toLowerCase() || "";
|
||
document.querySelectorAll("[data-history-search]").forEach(item => item.classList.toggle("hidden", !item.dataset.historySearch.toLowerCase().includes(query)));
|
||
}
|
||
|
||
async function loadHistory() {
|
||
const controls = document.querySelector("#history-controls");
|
||
const button = document.querySelector("#load-more-history");
|
||
const list = document.querySelector("#history-list");
|
||
if (!controls || !button || !list || historyLoading || controls.dataset.hasMore !== "true") return;
|
||
historyLoading = true;
|
||
hide(document.querySelector("#history-error"));
|
||
button.disabled = true;
|
||
button.querySelector("span").textContent = "正在加载";
|
||
try {
|
||
const response = await fetch(`/api/generations?limit=${encodeURIComponent(controls.dataset.limit || "20")}&cursor=${encodeURIComponent(controls.dataset.cursor || "")}`, { headers: { "Accept": "application/json" } });
|
||
if (response.status === 401) { showAuthExpired(); return; }
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error?.code || "history_failed");
|
||
const sentinel = document.querySelector("#history-sentinel");
|
||
(payload.items || []).forEach(row => list.insertBefore(historyItem(row), sentinel));
|
||
controls.dataset.cursor = payload.next_cursor || "";
|
||
controls.dataset.hasMore = String(Boolean(payload.has_more));
|
||
if (payload.has_more) show(button); else { hide(button); show(document.querySelector("#history-end")); }
|
||
applyHistorySearch(); renderIcons(list);
|
||
} catch (_) {
|
||
show(document.querySelector("#history-error")); show(button);
|
||
} finally {
|
||
historyLoading = false;
|
||
button.disabled = false;
|
||
button.querySelector("span").textContent = "加载更多历史";
|
||
}
|
||
}
|
||
|
||
function initializeHistory() {
|
||
const list = document.querySelector("#history-list");
|
||
const sentinel = document.querySelector("#history-sentinel");
|
||
document.querySelector("#history-search")?.addEventListener("input", applyHistorySearch);
|
||
document.querySelector("#load-more-history")?.addEventListener("click", loadHistory);
|
||
if (!list || !sentinel || typeof IntersectionObserver === "undefined") return;
|
||
historyObserver = new IntersectionObserver(entries => {
|
||
if (entries.some(entry => entry.isIntersecting)) loadHistory();
|
||
}, { root: list, rootMargin: "0px 0px 80px 0px" });
|
||
historyObserver.observe(sentinel);
|
||
}
|
||
|
||
function initializeLogin() {
|
||
const form = document.querySelector("#login-form");
|
||
const toggle = document.querySelector("#toggle-password");
|
||
toggle?.addEventListener("click", () => {
|
||
const input = document.querySelector("#password");
|
||
const showing = input.type === "text";
|
||
input.type = showing ? "password" : "text";
|
||
toggle.setAttribute("aria-label", showing ? "显示密码" : "隐藏密码");
|
||
toggle.setAttribute("title", showing ? "显示密码" : "隐藏密码");
|
||
toggle.querySelector("svg")?.remove(); toggle.querySelector("i")?.remove();
|
||
const icon = document.createElement("i"); icon.className = "icon"; icon.dataset.lucide = showing ? "eye" : "eye-off"; toggle.append(icon); renderIcons(toggle);
|
||
});
|
||
form?.addEventListener("submit", async event => {
|
||
event.preventDefault();
|
||
const email = document.querySelector("#email"); const password = document.querySelector("#password");
|
||
const error = document.querySelector("#login-error"); const emailError = document.querySelector("#email-error");
|
||
hide(error); hide(emailError);
|
||
if (!email.validity.valid) { show(emailError); email.focus(); return; }
|
||
if (!password.value) { show(error); password.focus(); return; }
|
||
const button = document.querySelector("#login-button"); button.disabled = true; button.querySelector("span").textContent = "登录中";
|
||
try {
|
||
const response = await fetch("/api/session/login", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken() }, body: JSON.stringify({ email: email.value, password: password.value }) });
|
||
if (!response.ok) { show(error); password.focus(); return; }
|
||
const requested = document.body.dataset.returnTo || "/";
|
||
window.location.assign(requested.startsWith("/") && !requested.startsWith("//") ? requested : "/");
|
||
} catch (_) { error.textContent = "登录请求失败,请检查网络后重试。"; show(error); }
|
||
finally { button.disabled = false; button.querySelector("span").textContent = "登录"; }
|
||
});
|
||
}
|
||
|
||
function initializeApp() {
|
||
document.querySelector("#prompt")?.addEventListener("input", event => {
|
||
document.querySelector("#prompt-count").textContent = String(new TextEncoder().encode(event.target.value).length);
|
||
hide(document.querySelector("#prompt-error")); updatePromptPreview();
|
||
});
|
||
document.querySelector("#role-rule")?.addEventListener("input", () => { hide(document.querySelector("#role-rule-error")); updatePromptPreview(); });
|
||
document.querySelector("#image-upload")?.addEventListener("change", event => {
|
||
const added = Array.from(event.target.files || []);
|
||
const error = validateFiles(selectedImages.map(image => image.file).concat(added));
|
||
const output = document.querySelector("#upload-error");
|
||
if (error) { output.textContent = error; show(output); event.target.value = ""; event.target.focus(); return; }
|
||
hide(output);
|
||
added.forEach(file => selectedImages.push({ id: clientID(), file, role: selectedImages.length ? "reference" : "primary", note: "", previewURL: URL.createObjectURL(file) }));
|
||
event.target.value = ""; ensurePrimary(); renderImages();
|
||
});
|
||
const imageList = document.querySelector("#image-list");
|
||
imageList?.addEventListener("input", event => {
|
||
const input = event.target.closest(".image-note"); if (!input) return;
|
||
const image = selectedImages.find(item => item.id === input.dataset.imageId); if (image) image.note = input.value;
|
||
updatePromptPreview();
|
||
});
|
||
imageList?.addEventListener("click", event => {
|
||
const button = event.target.closest("button[data-action]"); if (!button) return;
|
||
const id = button.dataset.imageId;
|
||
if (button.dataset.action === "role") { setImageRole(id, button.dataset.role); return; }
|
||
if (button.dataset.action === "up" || button.dataset.action === "down") { moveImage(id, button.dataset.action); return; }
|
||
if (button.dataset.action === "remove") {
|
||
if (selectedImages.length === 1) { toast("图片编辑至少保留一张图片"); return; }
|
||
const index = selectedImages.findIndex(image => image.id === id); if (index < 0) return;
|
||
releaseImage(selectedImages[index]); selectedImages.splice(index, 1); ensurePrimary(); renderImages(); toast("图片已移除");
|
||
}
|
||
});
|
||
imageList?.addEventListener("dragstart", event => {
|
||
const card = event.target.closest(".image-editor"); if (!card) return;
|
||
dragIndex = selectedImages.findIndex(image => image.id === card.dataset.imageId); card.classList.add("dragging"); event.dataTransfer.effectAllowed = "move";
|
||
});
|
||
imageList?.addEventListener("dragover", event => { const card = event.target.closest(".image-editor"); if (card) { event.preventDefault(); card.classList.add("drag-over"); } });
|
||
imageList?.addEventListener("dragleave", event => event.target.closest(".image-editor")?.classList.remove("drag-over"));
|
||
imageList?.addEventListener("drop", event => {
|
||
const card = event.target.closest(".image-editor"); if (!card || dragIndex === null) return;
|
||
event.preventDefault(); const target = selectedImages.findIndex(image => image.id === card.dataset.imageId);
|
||
if (target >= 0 && target !== dragIndex) { const [moved] = selectedImages.splice(dragIndex, 1); selectedImages.splice(target, 0, moved); renderImages(); toast("图片顺序已调整"); }
|
||
dragIndex = null;
|
||
});
|
||
imageList?.addEventListener("dragend", () => { dragIndex = null; imageList.querySelectorAll(".image-editor").forEach(card => card.classList.remove("dragging", "drag-over")); });
|
||
document.querySelector("#composer-form")?.addEventListener("submit", submitGeneration);
|
||
initializeHistory();
|
||
document.querySelector("#logout-button")?.addEventListener("click", async () => { await fetch("/api/session/logout", { method: "POST", headers: { "X-CSRF-Token": csrfToken() } }).catch(() => {}); window.location.assign("/login"); });
|
||
document.addEventListener("click", async event => {
|
||
const retry = event.target.closest(".retry-button"); if (retry) { await retryGeneration(retry); return; }
|
||
const copy = event.target.closest(".copy-button"); if (copy) { const text = copy.closest("#result-section")?.querySelector(".text-result")?.innerText || ""; try { await navigator.clipboard.writeText(text); toast("文本已复制"); } catch (_) { toast("浏览器未允许复制,请手动选择文本"); } }
|
||
});
|
||
document.querySelector("#dismiss-auth")?.addEventListener("click", () => hide(document.querySelector("#auth-modal")));
|
||
document.querySelector("#relogin-button")?.addEventListener("click", () => window.location.assign(`/login?return_to=${encodeURIComponent(window.location.pathname)}`));
|
||
document.addEventListener("keydown", event => { if (event.key === "Escape") { hide(document.querySelector("#auth-modal")); document.querySelector("#main-content")?.focus(); } });
|
||
document.body.addEventListener("htmx:afterSwap", event => renderIcons(event.detail.target));
|
||
const handlePollingError = event => {
|
||
const status = event.detail.xhr?.status || 0; const element = event.detail.elt;
|
||
if (status === 401) { showAuthExpired(); return; }
|
||
showPollingError(element); window.setTimeout(() => window.htmx?.trigger(element, "retryPoll"), 3000);
|
||
};
|
||
document.body.addEventListener("htmx:responseError", handlePollingError);
|
||
document.body.addEventListener("htmx:sendError", handlePollingError);
|
||
updatePromptPreview();
|
||
}
|
||
|
||
document.addEventListener("alpine:init", initializeAlpine);
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
if (document.body.dataset.page === "login") initializeLogin();
|
||
if (document.body.dataset.page === "app") initializeApp();
|
||
renderIcons();
|
||
});
|
||
window.addEventListener("beforeunload", () => { selectedImages.forEach(releaseImage); historyObserver?.disconnect(); });
|
||
})();
|