525 lines
24 KiB
JavaScript
525 lines
24 KiB
JavaScript
// 软件作业面板:右侧工作区列表、游标分页、活动数与终态未读通知。
|
||
import { api } from "./api.js";
|
||
import { state } from "./state.js";
|
||
import { $, syncConversationSideRail, toggleConversationRailPinned } from "./dom.js";
|
||
import { escapeHtml } from "./format.js";
|
||
import {
|
||
openSoftwareJobResults,
|
||
refreshCurrentTaskAfterSoftwareJob,
|
||
selectTask,
|
||
} from "./chat.js";
|
||
import { dialogConfirm, message } from "./dialog.js";
|
||
import { mqPhone, setMobileView, setRightPanelTab } from "./layout.js";
|
||
import { openSoftwarePreviewGallery } from "./preview.js";
|
||
|
||
const ACTIVE = new Set(["queued", "offered", "dispatched", "running", "disconnected", "cancelling"]);
|
||
const TERMINAL = new Set(["succeeded", "failed", "cancelled"]);
|
||
const PAGE_SIZE = 20;
|
||
const POLL_ACTIVE_MS = 4000;
|
||
const POLL_IDLE_MS = 30000;
|
||
let timer = null;
|
||
let known = new Map();
|
||
let jobs = [];
|
||
let nextCursor = null;
|
||
let loadingMore = false;
|
||
let initialized = false;
|
||
let loadError = "";
|
||
let taskJobs = [];
|
||
let taskJobsTaskId = "";
|
||
let taskJobsLoadSeq = 0;
|
||
const unreadTerminal = new Set();
|
||
|
||
const statusLabel = {
|
||
queued: "等待计算节点", offered: "正在分配节点", dispatched: "节点已接收",
|
||
running: "正在执行", disconnected: "节点连接中断", cancelling: "正在停止",
|
||
succeeded: "已完成", failed: "失败", cancelled: "已取消",
|
||
};
|
||
const stageLabel = {
|
||
accepted: "节点已接收", downloading_inputs: "正在下载输入文件", ready_to_run: "准备软件环境",
|
||
software_running: "专业软件正在执行", uploading_outputs: "正在上传结果",
|
||
cancel_requested: "停止请求已发送", terminal: "任务已结束",
|
||
};
|
||
|
||
const icons = {
|
||
activity: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2-6 4 12 2-6h6"/></svg>',
|
||
analyze: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19V9m6 10V5m6 14v-7m4 7H2"/></svg>',
|
||
cancel: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>',
|
||
cancelled: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6m0-6-6 6"/></svg>',
|
||
clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
|
||
failed: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5m0 3h.01"/></svg>',
|
||
open: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 5h5v5m0-5-8 8"/><path d="M19 13v5a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"/></svg>',
|
||
copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="8" width="11" height="11" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>',
|
||
pending: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
|
||
running: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/></svg>',
|
||
succeeded: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/></svg>',
|
||
};
|
||
|
||
function statusIcon(status) {
|
||
if (status === "succeeded") return icons.succeeded;
|
||
if (status === "failed") return icons.failed;
|
||
if (status === "cancelled") return icons.cancelled;
|
||
if (status === "queued" || status === "dispatched") return icons.pending;
|
||
return icons.running;
|
||
}
|
||
|
||
export function initSoftwareJobs() {
|
||
if (initialized || !$("software-job-list")) return;
|
||
initialized = true;
|
||
$("software-job-refresh").onclick = refreshSoftwareJobs;
|
||
const mobileResults = $("mobile-software-results");
|
||
if (mobileResults) {
|
||
mobileResults.onclick = () => {
|
||
setRightPanelTab("jobs");
|
||
setMobileView("mv-right", "jobs");
|
||
};
|
||
}
|
||
$("software-job-list").addEventListener("scroll", onListScroll);
|
||
document.addEventListener("software-job-submitted", refreshSoftwareJobs);
|
||
document.addEventListener("software-job-submitted", () => {
|
||
if (state.taskId) void refreshTaskSoftwareResults(state.taskId);
|
||
});
|
||
document.addEventListener("task-selection-changed", (event) => {
|
||
void refreshTaskSoftwareResults(event.detail?.taskId || "");
|
||
});
|
||
document.addEventListener("right-panel-changed", (event) => {
|
||
if (event.detail?.tab === "jobs") {
|
||
unreadTerminal.clear();
|
||
render();
|
||
}
|
||
});
|
||
refreshSoftwareJobs();
|
||
void refreshTaskSoftwareResults(state.taskId || "");
|
||
}
|
||
|
||
async function refreshTaskSoftwareResults(taskId) {
|
||
const seq = ++taskJobsLoadSeq;
|
||
taskJobsTaskId = taskId || "";
|
||
taskJobs = [];
|
||
renderTaskResultRail();
|
||
if (!taskJobsTaskId) {
|
||
return;
|
||
}
|
||
try {
|
||
const params = new URLSearchParams({ task_id: taskJobsTaskId, limit: "50" });
|
||
const data = await api("GET", `/v1/software-jobs?${params}`);
|
||
if (seq !== taskJobsLoadSeq || state.taskId !== taskId) return;
|
||
taskJobs = data.results || [];
|
||
} catch (_) {
|
||
if (seq !== taskJobsLoadSeq) return;
|
||
taskJobs = [];
|
||
}
|
||
renderTaskResultRail();
|
||
}
|
||
|
||
function mergeTaskJobs(currentPage) {
|
||
const taskId = state.taskId || "";
|
||
if (!taskId || taskJobsTaskId !== taskId) return;
|
||
const updates = new Map(
|
||
(currentPage || []).filter((job) => job.task_id === taskId).map((job) => [job.job_id, job]),
|
||
);
|
||
if (!updates.size) return;
|
||
const seen = new Set();
|
||
taskJobs = taskJobs.map((job) => {
|
||
const updated = updates.get(job.job_id);
|
||
if (updated) seen.add(job.job_id);
|
||
return updated || job;
|
||
});
|
||
for (const [jobId, job] of updates) {
|
||
if (!seen.has(jobId)) taskJobs.unshift(job);
|
||
}
|
||
taskJobs.sort((a, b) => String(b.created_at || "").localeCompare(String(a.created_at || "")));
|
||
renderTaskResultRail();
|
||
}
|
||
|
||
function taskResultLabel(job) {
|
||
const summary = job.request_summary || {};
|
||
return summary.title || summary.display_name || job.capability || "专业软件任务";
|
||
}
|
||
|
||
function taskResultMeta(job) {
|
||
const previews = Array.isArray(job.preview_manifest) ? job.preview_manifest.length : 0;
|
||
if (job.status === "succeeded") {
|
||
return previews ? `${previews} 个预览 · 点击查看` : "已完成 · 点击查看";
|
||
}
|
||
if (job.status === "failed") return "执行失败 · 点击查看详情";
|
||
if (job.status === "cancelled") return "已取消 · 点击查看详情";
|
||
return `${statusLabel[job.status] || "处理中"}${job.progress ? ` · ${job.progress}%` : ""}`;
|
||
}
|
||
|
||
function renderTaskResultRail() {
|
||
const rail = $("software-result-rail");
|
||
const mobileResults = $("mobile-software-results");
|
||
if (!rail) return;
|
||
if (!state.taskId || taskJobsTaskId !== state.taskId || !taskJobs.length) {
|
||
rail.innerHTML = "";
|
||
rail.hidden = true;
|
||
rail.classList.remove("pinned");
|
||
syncConversationSideRail();
|
||
if (mobileResults) mobileResults.hidden = true;
|
||
return;
|
||
}
|
||
const activeCount = taskJobs.filter((job) => ACTIVE.has(job.status)).length;
|
||
const unreadCount = taskJobs.filter((job) => unreadTerminal.has(job.job_id)).length;
|
||
const attention = activeCount > 0 || unreadCount > 0;
|
||
const attentionText = [
|
||
activeCount ? `${activeCount} 个进行中` : "",
|
||
unreadCount ? `${unreadCount} 个新结果` : "",
|
||
].filter(Boolean).join(",");
|
||
const pinned = rail.classList.contains("pinned");
|
||
rail.innerHTML = `<button type="button" class="conversation-rail-caption"`
|
||
+ ` aria-expanded="${pinned}" title="${taskJobs.length} 个作业${attentionText ? `,${attentionText}` : ""} · 点击固定展开或收起">`
|
||
+ `<span class="rail-caption-label">作业</span>`
|
||
+ `<b class="rail-count${attention ? " attention" : ""}">${taskJobs.length}</b>`
|
||
+ `<span class="rail-chevron" aria-hidden="true">⌄</span></button>`
|
||
+ taskJobs.map((job) => `
|
||
<button type="button" class="sr-item ${escapeHtml(job.status)}"
|
||
data-result-job-id="${escapeHtml(job.job_id)}"
|
||
title="${escapeHtml(taskResultLabel(job))} · ${escapeHtml(taskResultMeta(job))}"
|
||
aria-label="打开软件作业:${escapeHtml(taskResultLabel(job))},${escapeHtml(taskResultMeta(job))}">
|
||
<span class="sr-label"><strong>${escapeHtml(taskResultLabel(job))}</strong>
|
||
<small>${escapeHtml(taskResultMeta(job))}</small></span>
|
||
</button>`).join("");
|
||
rail.hidden = false;
|
||
syncConversationSideRail();
|
||
if (mobileResults) {
|
||
mobileResults.hidden = false;
|
||
mobileResults.textContent = `软件结果 ${taskJobs.length}`;
|
||
}
|
||
const caption = rail.querySelector(".conversation-rail-caption");
|
||
if (caption) {
|
||
caption.onclick = () => {
|
||
const pinned = toggleConversationRailPinned(rail);
|
||
if (!pinned) caption.blur();
|
||
};
|
||
}
|
||
rail.querySelectorAll("[data-result-job-id]").forEach((button) => {
|
||
button.onclick = () => void openTaskResult(button.dataset.resultJobId);
|
||
});
|
||
}
|
||
|
||
async function openTaskResult(jobId) {
|
||
const job = taskJobs.find((item) => item.job_id === jobId)
|
||
|| jobs.find((item) => item.job_id === jobId);
|
||
if (!job) return;
|
||
if (unreadTerminal.delete(jobId)) renderTaskResultRail();
|
||
const previews = Array.isArray(job.preview_manifest) ? job.preview_manifest : [];
|
||
if (job.status === "succeeded" && previews.length) {
|
||
try {
|
||
await openSoftwarePreviewGallery(taskResultLabel(job), previews);
|
||
} catch (error) {
|
||
message(error.message || "预览打开失败", "error");
|
||
}
|
||
return;
|
||
}
|
||
if (job.status === "succeeded" && !job.workspace_id) {
|
||
await openSoftwareJobResults(job.task_id, job.output_dir || "");
|
||
setRightPanelTab("files");
|
||
if (mqPhone.matches) setMobileView("mv-right", "files");
|
||
return;
|
||
}
|
||
setRightPanelTab("jobs");
|
||
if (mqPhone.matches) setMobileView("mv-right", "jobs");
|
||
requestAnimationFrame(() => {
|
||
const card = document.querySelector(
|
||
`#software-job-list [data-job-id="${CSS.escape(jobId)}"]`,
|
||
);
|
||
if (card) card.scrollIntoView({ block: "center", behavior: "smooth" });
|
||
});
|
||
}
|
||
|
||
function pagePath(cursor = null) {
|
||
const params = new URLSearchParams({ limit: String(PAGE_SIZE) });
|
||
if (cursor) {
|
||
params.set("before_created_at", cursor.created_at);
|
||
params.set("before_job_id", cursor.job_id);
|
||
}
|
||
return `/v1/software-jobs?${params}`;
|
||
}
|
||
|
||
export async function refreshSoftwareJobs() {
|
||
if (!state.token) return;
|
||
try {
|
||
const data = await api("GET", pagePath());
|
||
loadError = "";
|
||
const next = data.results || [];
|
||
next.forEach((job) => {
|
||
const previous = known.get(job.job_id);
|
||
if (previous && ACTIVE.has(previous.status) && TERMINAL.has(job.status)) {
|
||
notifyTerminal(job);
|
||
} else if (
|
||
previous
|
||
&& previous.followup_status !== job.followup_status
|
||
&& ["running", "completed", "failed"].includes(job.followup_status)
|
||
) {
|
||
const reloadMessages = job.completion_action === "analyze"
|
||
&& job.followup_status === "completed";
|
||
void refreshCurrentTaskAfterSoftwareJob(job.task_id, reloadMessages);
|
||
}
|
||
if (
|
||
job.followup_status === "running"
|
||
&& state.taskId === job.task_id
|
||
&& !state.liveRuns.has(job.task_id)
|
||
) {
|
||
void refreshCurrentTaskAfterSoftwareJob(job.task_id);
|
||
}
|
||
known.set(job.job_id, {
|
||
status: job.status,
|
||
followup_status: job.followup_status,
|
||
});
|
||
});
|
||
const previousJobs = jobs;
|
||
const hadLoadedMore = previousJobs.length > PAGE_SIZE;
|
||
const firstPageIds = new Set(next.map((job) => job.job_id));
|
||
jobs = [...next, ...previousJobs.filter((job) => !firstPageIds.has(job.job_id))];
|
||
mergeTaskJobs(next);
|
||
if (!hadLoadedMore) nextCursor = data.next_cursor;
|
||
render();
|
||
} catch (_) {
|
||
loadError = "作业列表加载失败,请点击刷新重试";
|
||
render();
|
||
}
|
||
schedule();
|
||
}
|
||
|
||
async function loadMore() {
|
||
if (loadingMore || !nextCursor) return;
|
||
loadingMore = true;
|
||
renderList();
|
||
try {
|
||
const data = await api("GET", pagePath(nextCursor));
|
||
const ids = new Set(jobs.map((job) => job.job_id));
|
||
jobs.push(...(data.results || []).filter((job) => !ids.has(job.job_id)));
|
||
nextCursor = data.next_cursor;
|
||
} catch (error) {
|
||
message(error.message || "加载专业软件任务失败", "error");
|
||
} finally {
|
||
loadingMore = false;
|
||
renderList();
|
||
}
|
||
}
|
||
|
||
function schedule() {
|
||
if (timer) clearTimeout(timer);
|
||
const delay = jobs.some((job) => (
|
||
ACTIVE.has(job.status) || ["pending", "running"].includes(job.followup_status)
|
||
)) ? POLL_ACTIVE_MS : POLL_IDLE_MS;
|
||
timer = setTimeout(refreshSoftwareJobs, document.hidden ? Math.max(delay, 30000) : delay);
|
||
}
|
||
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (!document.hidden && state.token) refreshSoftwareJobs(); else schedule();
|
||
});
|
||
|
||
function render() {
|
||
const active = jobs.filter((job) => ACTIVE.has(job.status));
|
||
const latest = jobs[0];
|
||
const unreadJob = jobs.find((job) => unreadTerminal.has(job.job_id));
|
||
const entryDot = $("software-job-entry-dot");
|
||
entryDot.className = "sj-entry-dot";
|
||
if (unreadJob?.status === "failed") entryDot.classList.add("failed");
|
||
else if (unreadJob) entryDot.classList.add("succeeded");
|
||
for (const id of ["software-job-badge", "software-job-mobile-badge"]) {
|
||
const badge = $(id);
|
||
badge.hidden = active.length === 0;
|
||
badge.textContent = active.length ? String(active.length) : "";
|
||
}
|
||
$("mv-tab-jobs").classList.toggle("has-unread", unreadTerminal.size > 0);
|
||
$("mv-tab-jobs").classList.toggle("has-unread-failed", unreadJob?.status === "failed");
|
||
$("software-job-count").textContent = active.length
|
||
? `${active.length} 个进行中 · ${statusLabel[active[0].status]}`
|
||
: (latest ? `最近:${statusLabel[latest.status] || latest.status}` : "暂无作业");
|
||
renderList();
|
||
renderTaskResultRail();
|
||
}
|
||
|
||
function renderList() {
|
||
const list = $("software-job-list");
|
||
const errorNotice = loadError
|
||
? `<div class="sj-empty">${escapeHtml(loadError)}</div>` : "";
|
||
list.innerHTML = errorNotice + (jobs.length
|
||
? jobs.map(jobCard).join("") + (nextCursor ? `<div class="sj-loading">${loadingMore ? "正在加载…" : "继续向下滚动加载"}</div>` : "")
|
||
: (loadError ? "" : '<div class="sj-empty">暂无专业软件任务</div>'));
|
||
list.querySelectorAll("[data-job-action]").forEach((button) => {
|
||
button.onclick = (event) => handleAction(event, button);
|
||
});
|
||
}
|
||
|
||
function onListScroll(event) {
|
||
const list = event.currentTarget;
|
||
if (list.scrollHeight - list.scrollTop - list.clientHeight < 160) loadMore();
|
||
}
|
||
|
||
function jobRuntimeText(job) {
|
||
const runtime = job.execution_runtime || {};
|
||
const software = runtime.software === "OriginPro"
|
||
|| job.capability?.startsWith("origin.") ? "Origin" : (runtime.software || "软件");
|
||
const parts = [
|
||
runtime.node_version ? `Node ${runtime.node_version}` : "",
|
||
runtime.adapter_version ? `${software} Adapter ${runtime.adapter_version}` : "",
|
||
runtime.software_version ? `${software} ${runtime.software_version}` : "",
|
||
].filter(Boolean);
|
||
if (parts.length) return parts.join(" · ");
|
||
return ["queued", "offered"].includes(job.status)
|
||
? "执行版本待节点确认"
|
||
: "执行版本未记录";
|
||
}
|
||
|
||
function fmtDate(value) {
|
||
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "—";
|
||
}
|
||
|
||
function elapsedText(job) {
|
||
const start = job.started_at || job.created_at;
|
||
const end = job.terminal_at;
|
||
if (!start || !end) return "";
|
||
const seconds = Math.max(0, Math.round((new Date(end) - new Date(start)) / 1000));
|
||
if (seconds < 60) return `${seconds} 秒`;
|
||
const minutes = Math.floor(seconds / 60);
|
||
return seconds % 60 ? `${minutes} 分 ${seconds % 60} 秒` : `${minutes} 分钟`;
|
||
}
|
||
|
||
function followupLabel(job) {
|
||
if (job.completion_action === "analyze") {
|
||
if (job.followup_status === "pending") return "等待自动分析";
|
||
if (job.followup_status === "running") return "正在自动分析";
|
||
if (job.followup_status === "completed") return "已报告并分析";
|
||
if (job.followup_status === "failed") return "自动分析失败";
|
||
return "报告并分析";
|
||
}
|
||
return job.followup_status === "completed" ? "结果已就绪" : "仅展示结果";
|
||
}
|
||
|
||
function jobActions(job, active) {
|
||
const task = escapeHtml(job.task_id);
|
||
if (active) return `
|
||
<button class="small" data-job-action="open" data-task-id="${task}">${icons.open}打开对话</button>
|
||
${job.status !== "cancelling" ? `<button class="small danger" data-job-action="cancel">${icons.cancel}停止</button>` : ""}`;
|
||
if (job.status !== "succeeded") {
|
||
return `<button class="small" data-job-action="open" data-task-id="${task}">${icons.open}${job.status === "failed" ? "查看错误" : "打开对话"}</button>`;
|
||
}
|
||
const view = `<button class="small primary" data-job-action="results" data-task-id="${task}">${icons.open}查看结果</button>`;
|
||
if (["pending", "running"].includes(job.followup_status) && job.completion_action === "analyze") {
|
||
const label = job.followup_status === "pending" ? "等待分析" : "分析中";
|
||
return `${view}<button class="small" disabled>${icons.analyze}${label}</button>`;
|
||
}
|
||
if (job.completion_action === "analyze" && job.followup_status === "completed") {
|
||
return `${view}<button class="small" data-job-action="open" data-task-id="${task}">${icons.analyze}查看分析</button><button class="small" data-job-action="analyze" data-task-id="${task}">重新分析</button>`;
|
||
}
|
||
const label = job.followup_status === "failed" ? "重试分析" : "深入分析";
|
||
return `${view}<button class="small" data-job-action="analyze" data-task-id="${task}">${icons.analyze}${label}</button>`;
|
||
}
|
||
|
||
function jobCard(job) {
|
||
const summary = job.request_summary || {};
|
||
const inputs = Array.isArray(job.input) ? job.input : [];
|
||
const allInputNames = inputs.map((item) => item.filename).filter(Boolean);
|
||
const inputNames = allInputNames.slice(0, 2).join("、")
|
||
+ (allInputNames.length > 2 ? ` 等 ${allInputNames.length} 个文件` : "");
|
||
const active = ACTIVE.has(job.status);
|
||
const progress = Math.max(0, Math.min(100, Number(job.progress || 0)));
|
||
const detail = stageLabel[job.stage] || statusLabel[job.status] || "正在处理";
|
||
const error = job.error && (job.error.detail || job.error.code);
|
||
const artifacts = Array.isArray(job.artifact_manifest)
|
||
? job.artifact_manifest.filter((item) => item && item.artifact_id) : [];
|
||
const previews = Array.isArray(job.preview_manifest) ? job.preview_manifest : [];
|
||
const formats = (summary.formats || []).filter(Boolean).join("、");
|
||
const elapsed = elapsedText(job);
|
||
const shortId = String(job.job_id || "").slice(0, 8);
|
||
const sourceJobId = String(job.source_job_id || "");
|
||
const workspaceRelation = sourceJobId
|
||
? `续接自 Job ${sourceJobId.slice(0, 8)}`
|
||
: (job.workspace_id ? "新建工程" : "独立任务");
|
||
return `<article class="sj-card ${escapeHtml(job.status)}" data-job-id="${escapeHtml(job.job_id)}">
|
||
<div class="sj-title"><div class="sj-title-main"><strong>${escapeHtml(summary.display_name || job.capability)}</strong></div>
|
||
<span class="sj-status">${statusIcon(job.status)}${escapeHtml(statusLabel[job.status] || job.status)}</span></div>
|
||
<div class="sj-context"><span>${escapeHtml(job.task_name || "未命名对话")}</span><code>Job ${escapeHtml(shortId)}</code></div>
|
||
<div class="sj-sub">${icons.activity}<span>${escapeHtml(detail)}${error ? ` · ${escapeHtml(error)}` : ""}</span></div>
|
||
${active ? `<div class="sj-progress"><i style="width:${progress}%"></i></div>` : ""}
|
||
<div class="sj-facts">
|
||
${inputNames ? `<span>输入:${escapeHtml(inputNames)}</span>` : ""}
|
||
<span>工程关系:${escapeHtml(workspaceRelation)}</span>
|
||
${job.status === "succeeded" ? `<span>${job.workspace_id ? `预览:${previews.length} 个 · 工程保存在节点` : `输出:${artifacts.length} 个文件${formats ? ` · ${escapeHtml(formats)}` : ""}`}</span>` : ""}
|
||
<span>完成方式:${escapeHtml(followupLabel(job))}</span>
|
||
<span>${active ? `创建于 ${escapeHtml(fmtDate(job.created_at))}` : `${elapsed ? `耗时 ${escapeHtml(elapsed)} · ` : ""}${job.terminal_at ? `完成于 ${escapeHtml(fmtDate(job.terminal_at))}` : `创建于 ${escapeHtml(fmtDate(job.created_at))}`}`}</span>
|
||
</div>
|
||
<details class="sj-details"><summary>任务详情</summary><dl>
|
||
<div><dt>Job ID</dt><dd><code>${escapeHtml(job.job_id)}</code><button type="button" class="sj-copy" data-job-action="copy">${icons.copy}复制</button></dd></div>
|
||
<div><dt>来源 Job</dt><dd>${sourceJobId ? `<code>${escapeHtml(sourceJobId)}</code>` : "—(新建工程)"}</dd></div>
|
||
<div><dt>Capability</dt><dd><code>${escapeHtml(job.capability)}</code></dd></div>
|
||
<div><dt>${job.workspace_id ? "Workspace ID" : "输出目录"}</dt><dd><code>${escapeHtml(job.workspace_id || job.output_dir || "—")}</code></dd></div>
|
||
<div><dt>执行版本</dt><dd>${escapeHtml(jobRuntimeText(job))}</dd></div>
|
||
<div><dt>时间</dt><dd>创建 ${escapeHtml(fmtDate(job.created_at))}<br>开始 ${escapeHtml(fmtDate(job.started_at))}<br>结束 ${escapeHtml(fmtDate(job.terminal_at))}</dd></div>
|
||
</dl></details>
|
||
<div class="sj-actions">${jobActions(job, active)}</div>
|
||
</article>`;
|
||
}
|
||
|
||
async function handleAction(event, button) {
|
||
event.stopPropagation();
|
||
const card = button.closest("[data-job-id]");
|
||
const jobId = card.dataset.jobId;
|
||
const action = button.dataset.jobAction;
|
||
if (action === "copy") {
|
||
try {
|
||
await navigator.clipboard.writeText(jobId);
|
||
message("Job ID 已复制", "success");
|
||
} catch (_) { message("复制失败,请手动复制", "error"); }
|
||
return;
|
||
}
|
||
if (action === "cancel") {
|
||
if (!await dialogConfirm({
|
||
title: "停止专业软件任务",
|
||
message: "确定停止这个任务?已经产生但尚未发布的中间输出可能不会保留。",
|
||
okText: "停止", danger: true,
|
||
})) return;
|
||
button.disabled = true;
|
||
try { await api("POST", `/v1/software-jobs/${jobId}/cancel`); }
|
||
catch (error) { message(error.message || "停止失败", "error"); }
|
||
refreshSoftwareJobs();
|
||
return;
|
||
}
|
||
const taskId = button.dataset.taskId;
|
||
if (action === "results") {
|
||
const selected = jobs.find((item) => item.job_id === jobId);
|
||
if (selected?.workspace_id) {
|
||
const previews = Array.isArray(selected.preview_manifest) ? selected.preview_manifest : [];
|
||
if (previews.length) {
|
||
try { await openSoftwarePreviewGallery(taskResultLabel(selected), previews); }
|
||
catch (error) { message(error.message || "预览打开失败", "error"); }
|
||
} else {
|
||
await selectTask(taskId);
|
||
}
|
||
} else {
|
||
await openSoftwareJobResults(taskId, selected?.output_dir || "");
|
||
setRightPanelTab("files");
|
||
if (mqPhone.matches) setMobileView("mv-right", "files");
|
||
}
|
||
return;
|
||
}
|
||
if (action === "analyze") {
|
||
button.disabled = true;
|
||
try {
|
||
await api("POST", `/v1/software-jobs/${jobId}/analyze`);
|
||
if (taskId) await selectTask(taskId);
|
||
await refreshCurrentTaskAfterSoftwareJob(taskId);
|
||
message("已排队分析结果", "success");
|
||
refreshSoftwareJobs();
|
||
} catch (error) {
|
||
button.disabled = false;
|
||
message(error.message || "启动分析失败", "error");
|
||
}
|
||
return;
|
||
}
|
||
if (taskId) await selectTask(taskId);
|
||
}
|
||
|
||
function notifyTerminal(job) {
|
||
const ok = job.status === "succeeded";
|
||
const label = ok ? "已完成" : (job.status === "cancelled" ? "已取消" : "失败");
|
||
const summary = job.request_summary || {};
|
||
if (!document.body.classList.contains("right-tab-jobs")) unreadTerminal.add(job.job_id);
|
||
message(`${summary.display_name || "专业软件任务"}${label}`, ok ? "success" : "error", 6000);
|
||
if (state.taskId === job.task_id) void refreshTaskSoftwareResults(job.task_id);
|
||
void refreshCurrentTaskAfterSoftwareJob(job.task_id, false);
|
||
}
|