admin-dressgendetail.html
9.43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>人衣图任务详情</title>
<link rel="stylesheet" href="style.css" />
<style>
.admin-wrap { padding: 18px 24px; max-width: 1200px; }
.task-header { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.task-id-box { font-family: monospace; font-size: 15px; background: #f4f4f4; border-radius: 4px; padding: 4px 10px; }
.status-badge { padding: 3px 10px; border-radius: 4px; font-size: 13px; }
.status-queued, .status-generating { background: #fff8e1; color: #b26a00; }
.status-done { background: #eef7ee; color: #2e7d32; }
.status-partial { background: #fff8e1; color: #b26a00; }
.status-failed { background: #fdecea; color: #c62828; }
.hint { color: var(--muted); font-size: 12px; }
.inputs-grid { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 10px; }
.inputs-grid figure { margin: 0; text-align: center; }
.inputs-grid img { width: 84px; height: 118px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); display: block; cursor: zoom-in; }
.inputs-grid figcaption { font-size: 11px; color: var(--muted); margin-top: 2px; }
.view-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 12px; }
.view-card { border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
.view-card h3 { margin: 0 0 8px; font-size: 14px; display: flex; justify-content: space-between; align-items: center; }
.view-output { width: 100%; height: 320px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; cursor: zoom-in; }
.view-output-missing { width: 100%; height: 320px; border: 1px dashed var(--border); border-radius: 6px; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 13px; }
.view-error { color: #c62828; font-size: 12px; margin-top: 8px; white-space: pre-wrap; word-break: break-word; background: #fdecea; border-radius: 4px; padding: 8px; }
.view-card textarea { width: 100%; min-height: 110px; font-size: 12px; margin-top: 8px; }
.view-card button { margin-top: 8px; width: 100%; }
#notFound { padding: 24px; }
#lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.82); display: flex; align-items: center; justify-content: center; cursor: zoom-out; z-index: 1000; }
#lightbox img { max-width: 90vw; max-height: 90vh; border-radius: 6px; }
</style>
</head>
<body>
<header class="topbar">
<h1>人衣图任务详情</h1>
<div class="spacer"></div>
<a href="admin-dressgenlist.html">← 返回人衣图列表</a>
<a href="live.html">← 返回直播频道</a>
</header>
<main class="admin-wrap" id="app" hidden>
<div class="card">
<div class="task-header">
<span class="task-id-box" id="taskId"></span>
<span class="status-badge" id="statusBadge"></span>
<span class="hint" id="providerLabel"></span>
<span class="hint" id="createdAt"></span>
<span class="spacer"></span>
<button id="retryAllBtn">重试所有未完成视角</button>
<button class="danger" id="deleteBtn">删除任务</button>
</div>
<div class="hint" id="pollHint" style="margin-top:6px;"></div>
<div class="field" style="margin-top:14px;"><label>输入:人物参考图 + 衣服图</label></div>
<div class="inputs-grid" id="inputsGrid"></div>
</div>
<div class="card">
<div class="field"><label>生成结果(正 / 侧 / 背,每张可单独重新生成)</label></div>
<div class="view-grid" id="viewGrid"></div>
</div>
</main>
<div id="notFound" hidden>
<p>任务不存在或已被删除。<a href="admin-dressgenlist.html">返回列表</a></p>
</div>
<script>
const _fetch = window.fetch;
window.fetch = async (...args) => {
const res = await _fetch(...args);
if (res.status === 401 && String(args[0]).startsWith("/api/admin/") && !String(args[0]).includes("/login")) {
location.href = "/admin-login.html?next=" + encodeURIComponent(location.pathname);
}
return res;
};
function escapeHtml(s) {
const div = document.createElement("div");
div.textContent = s || "";
return div.innerHTML;
}
function openLightbox(src) {
const box = document.createElement("div");
box.id = "lightbox";
box.innerHTML = `<img src="${src}" />`;
box.addEventListener("click", () => box.remove());
document.body.appendChild(box);
}
const id = new URLSearchParams(location.search).get("id");
const PERSON_SLOTS = ["bodyFront", "bodySide", "bodyBack", "faceFront", "faceSide", "faceBack"];
const PERSON_CAPTION = { bodyFront: "全身正", bodySide: "全身侧", bodyBack: "全身背", faceFront: "脸正", faceSide: "脸侧", faceBack: "脸背" };
const VIEWS = ["front", "side", "back"];
const VIEW_LABEL = { front: "正面", side: "侧面", back: "背面" };
const STATUS_LABEL = { queued: "排队中", generating: "生成中", done: "完成", partial: "部分完成", failed: "失败" };
const PROVIDER_LABEL = { image2: "GPT Image 2", wavespeed: "Seedream v4" };
let record = null;
let pollTimer = null;
if (!id) {
document.getElementById("notFound").hidden = false;
} else {
load();
}
async function load() {
const res = await fetch(`/api/admin/dressgen/${id}`);
if (!res.ok) {
document.getElementById("notFound").hidden = false;
document.getElementById("app").hidden = true;
return;
}
record = await res.json();
document.getElementById("app").hidden = false;
render();
if (record.status === "queued" || record.status === "generating") startPolling();
}
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(async () => {
await load();
if (record.status !== "queued" && record.status !== "generating" && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}, 3000);
}
function render() {
document.getElementById("taskId").textContent = record.id;
document.getElementById("statusBadge").className = `status-badge status-${record.status}`;
document.getElementById("statusBadge").textContent = STATUS_LABEL[record.status] || record.status;
document.getElementById("createdAt").textContent = new Date(record.createdAt).toLocaleString();
document.getElementById("providerLabel").textContent = `默认供应商: ${PROVIDER_LABEL[record.provider] || record.provider || "GPT Image 2"}`;
document.getElementById("pollHint").textContent =
record.status === "queued" || record.status === "generating" ? "任务进行中,页面每 3 秒自动刷新一次…" : "";
document.getElementById("retryAllBtn").disabled = !(record.status === "partial" || record.status === "failed");
document.getElementById("inputsGrid").innerHTML =
PERSON_SLOTS.filter((f) => record[f])
.map((f) => `<figure><img src="${escapeHtml(record[f])}" /><figcaption>${PERSON_CAPTION[f]}</figcaption></figure>`)
.join("") + `<figure><img src="${escapeHtml(record.garmentImage)}" /><figcaption>衣服</figcaption></figure>`;
document.querySelectorAll("#inputsGrid img").forEach((img) => img.addEventListener("click", () => openLightbox(img.src)));
document.getElementById("viewGrid").innerHTML = VIEWS.map((view) => {
const vs = (record.viewStatus && record.viewStatus[view]) || (record.outputImages[view] ? "done" : "failed");
const url = record.outputImages[view];
const viewProvider = record.viewProvider && record.viewProvider[view];
let body;
if (vs === "done" && url) body = `<img class="view-output" src="${escapeHtml(url)}" />`;
else if (vs === "queued") body = `<div class="view-output-missing">排队中</div>`;
else if (vs === "generating") body = `<div class="view-output-missing">生成中…</div>`;
else body = `<div class="view-output-missing">失败</div>`;
const providerHint = viewProvider ? `<div class="hint">实际使用: ${PROVIDER_LABEL[viewProvider] || viewProvider}</div>` : "";
const errorBox = record.errors[view] ? `<div class="view-error">${escapeHtml(record.errors[view])}</div>` : "";
const busy = vs === "queued" || vs === "generating";
return `
<div class="view-card" data-view="${view}">
<h3>${VIEW_LABEL[view]}<span class="status-badge status-${vs}" style="font-size:11px;">${STATUS_LABEL[vs] || vs}</span></h3>
${body}
${providerHint}
${errorBox}
<textarea data-prompt="${view}">${escapeHtml(record.prompts[view])}</textarea>
<button data-regen="${view}" ${busy ? "disabled" : ""}>重新生成这张(可先改上面的 prompt)</button>
</div>`;
}).join("");
document.querySelectorAll(".view-output").forEach((img) => img.addEventListener("click", () => openLightbox(img.src)));
document.querySelectorAll("[data-regen]").forEach((b) =>
b.addEventListener("click", async () => {
const view = b.dataset.regen;
const prompt = document.querySelector(`[data-prompt="${view}"]`).value;
b.disabled = true;
await fetch(`/api/admin/dressgen/${id}/retry/${view}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
await load();
})
);
}
document.getElementById("retryAllBtn").addEventListener("click", async () => {
document.getElementById("retryAllBtn").disabled = true;
await fetch(`/api/admin/dressgen/${id}/retry`, { method: "POST" });
await load();
});
document.getElementById("deleteBtn").addEventListener("click", async () => {
if (!confirm("确认删除这条任务?")) return;
await fetch(`/api/admin/dressgen/${id}`, { method: "DELETE" });
location.href = "admin-dressgenlist.html";
});
</script>
</body>
</html>