diff --git a/ControlPanel/electron-main.js b/ControlPanel/electron-main.js
index f05884e..d3d9524 100644
--- a/ControlPanel/electron-main.js
+++ b/ControlPanel/electron-main.js
@@ -86,6 +86,7 @@ function startPanelInboxPoller(window) {
filePath: imagePath,
dataUrl: toDataUrl(imagePath),
fileName: pending.fileName,
+ runId: pending.runId || pending.fileName,
mediaType: pending.mediaType,
uploadTime: pending.uploadTime,
deviceId: requestContext.deviceId,
@@ -95,7 +96,7 @@ function startPanelInboxPoller(window) {
pendingReview = {
deviceId: requestContext.deviceId,
fileID: pending.fileID,
- runId: pending.fileName,
+ runId: pending.runId || pending.fileName,
credentials: requestContext
};
} else {
@@ -378,6 +379,7 @@ function registerHandlers() {
filePath: imagePath,
dataUrl: toDataUrl(imagePath),
fileName: download.fileName || request.fileName,
+ runId: download.runId || request.runId || request.fileName,
mediaType,
uploadTime: download.uploadTime || request.uploadTime,
deviceId: request.deviceId
diff --git a/ControlPanel/electron-ui/index.html b/ControlPanel/electron-ui/index.html
index ce61a74..682ef1c 100644
--- a/ControlPanel/electron-ui/index.html
+++ b/ControlPanel/electron-ui/index.html
@@ -111,7 +111,7 @@
diff --git a/ControlPanel/electron-ui/renderer.js b/ControlPanel/electron-ui/renderer.js
index 4245554..5481690 100644
--- a/ControlPanel/electron-ui/renderer.js
+++ b/ControlPanel/electron-ui/renderer.js
@@ -305,6 +305,7 @@ async function refreshIdentificationFiles() {
state.identificationFiles = result.files || result.fileList || [];
elements.identificationFileList.innerHTML = state.identificationFiles.map((file) => `
| ${escapeHtml(file.uploadTime || "-")} | ${escapeHtml(file.fileName)} |
+ ${escapeHtml(file.runId || "-")} |
${file.mediaType === "json" ? "行程 JSON" : "辨识 CSV"} | ${formatSize(file.size)} |
${file.status === "processed" ? "已处理" : "待处理"} |
|
@@ -457,8 +458,9 @@ function showImage(result) {
plot.showImage.disabled = false;
plot.openImage.disabled = false;
if (mediaType === "csv" && result.reviewable) {
- state.review = { runId: result.fileName, deviceId: result.deviceId };
- elements.reviewTarget.textContent = result.fileName;
+ const runId = result.runId || result.fileName;
+ state.review = { runId, deviceId: result.deviceId };
+ elements.reviewTarget.textContent = `${result.fileName} (runId: ${runId})`;
elements.approveReview.disabled = false;
elements.rejectReview.disabled = false;
}
@@ -954,6 +956,12 @@ elements.identificationFileList.addEventListener("click", async (event) => {
if (result) {
showImage(result);
activateTab("plot-panel");
+ if ((file.mediaType !== "json") && file.runId) {
+ state.review = { runId: file.runId, deviceId: elements.deviceId.value };
+ elements.reviewTarget.textContent = `${result.fileName} (runId: ${file.runId})`;
+ elements.approveReview.disabled = false;
+ elements.rejectReview.disabled = false;
+ }
}
} else if (event.target.dataset.identificationDownload) {
const result = await runBusy("正在保存辨识原始数据", () => window.reinloop.downloadIdentificationFile({
@@ -1146,7 +1154,12 @@ function renderNotifications() {
? "查看结果"
: "查看绘图";
const secondaryAction = notification.type === "identification_result_ready" ? "查看辨识数据" : "";
- return `${escapeHtml(notification.title)}${escapeHtml(notification.message)}
${secondaryAction ? `` : ""}
`;
+ const traces = [
+ notification.runId ? `runId: ${notification.runId}` : "",
+ notification.fileID ? `fileID: ${notification.fileID}` : "",
+ notification.requestId ? `requestId: ${notification.requestId}` : ""
+ ].filter(Boolean).join(" | ");
+ return `${escapeHtml(notification.title)}${escapeHtml(notification.message)}${traces ? `${escapeHtml(traces)}` : ""}
${secondaryAction ? `` : ""}
`;
}).join("");
}
diff --git a/ControlPanel/electron-ui/styles.css b/ControlPanel/electron-ui/styles.css
index dfcc0d5..764d78f 100644
--- a/ControlPanel/electron-ui/styles.css
+++ b/ControlPanel/electron-ui/styles.css
@@ -197,6 +197,14 @@ input:focus, select:focus, textarea:focus { border-color: var(--green); box-shad
.notification-copy { display: grid; gap: 3px; min-width: 0; }
.notification-copy strong { font-size: 13px; }
.notification-copy span { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
+.notification-copy .notification-trace {
+ display: block;
+ margin-top: 4px;
+ color: var(--text-muted);
+ font-size: 12px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
+ word-break: break-all;
+}
.notification-actions { display: flex; gap: 8px; }
.editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) 270px; gap: 18px; }
.editor-column, .publish-aside { background: var(--surface); border: 1px solid var(--line); }
diff --git a/server/src/app.js b/server/src/app.js
index 290bfee..c58b88b 100644
--- a/server/src/app.js
+++ b/server/src/app.js
@@ -496,6 +496,39 @@ function createApp({
return notification;
}
+ function findIdentificationFeedbackRecord(database, deviceId, fileName) {
+ if (!deviceId || !fileName) return null;
+ const normalizedFileName = String(fileName);
+ return database.identificationFeedback.find((item) =>
+ item.deviceId === deviceId
+ && (item.fileName === normalizedFileName || item.runId === normalizedFileName)
+ ) || null;
+ }
+
+ function resolveIdentificationRunId(database, record) {
+ if (!record) return null;
+ if (record.runId) return String(record.runId);
+ const feedback = findIdentificationFeedbackRecord(database, record.deviceId, record.fileName);
+ if (feedback) return String(feedback.runId);
+ if (record.fileName) return String(record.fileName);
+ return null;
+ }
+
+ function enrichIdentificationFile(database, record) {
+ const runId = resolveIdentificationRunId(database, record);
+ return runId ? { ...record, runId } : { ...record };
+ }
+
+ function enrichNotification(database, notification) {
+ if (!notification || notification.type !== "identification_result_ready" || !notification.fileID) {
+ return notification;
+ }
+ const related = database.identificationFiles.find((item) => item.fileID === notification.fileID)
+ || database.panelInbox.find((item) => item.fileID === notification.fileID);
+ const runId = resolveIdentificationRunId(database, related);
+ return runId ? { ...notification, runId } : notification;
+ }
+
async function purgeExpiredIdentificationFiles() {
return store.update(async (database) => {
backfillIdentificationFiles(database);
@@ -901,16 +934,16 @@ function createApp({
const message = database.panelInbox.find((item) => {
if (item.deviceId !== deviceId) return false;
if (item.mediaType !== "csv") return true;
- return database.identificationFeedback.some((feedback) =>
- feedback.deviceId === deviceId && feedback.runId === item.fileName
- );
+ return Boolean(findIdentificationFeedbackRecord(database, deviceId, item.fileName));
});
if (!message) return { success: true, pending: false };
+ const runId = resolveIdentificationRunId(database, message);
return {
success: true,
pending: true,
fileID: message.fileID,
fileName: message.fileName,
+ runId,
mediaType: message.mediaType,
uploadTime: message.uploadTime,
url: issueDownloadUrl(req, message.fileID)
@@ -923,7 +956,7 @@ function createApp({
const database = await store.read();
const notification = database.panelNotifications.find((item) => item.deviceId === deviceId);
return notification
- ? { success: true, pending: true, notification }
+ ? { success: true, pending: true, notification: enrichNotification(database, notification) }
: { success: true, pending: false };
}
case "ackPanelNotification": {
@@ -988,7 +1021,7 @@ function createApp({
const offset = (page - 1) * pageSize;
return {
success: true,
- files: files.slice(offset, offset + pageSize),
+ files: files.slice(offset, offset + pageSize).map((record) => enrichIdentificationFile(database, record)),
total: files.length,
page,
pageSize
@@ -1065,10 +1098,12 @@ function createApp({
if (!historyRecord || !fileRecord || !store.resolveStoredFile(fileRecord.fileID)) {
return { success: false, errMsg: "辨识文件不存在" };
}
+ const runId = resolveIdentificationRunId(database, historyRecord);
return {
success: true,
fileID: fileRecord.fileID,
fileName: fileRecord.fileName,
+ runId,
mediaType: historyRecord.mediaType,
url: issueDownloadUrl(req, fileRecord.fileID)
};
diff --git a/server/test/server.test.js b/server/test/server.test.js
index 6d1fb74..c7eaa8f 100644
--- a/server/test/server.test.js
+++ b/server/test/server.test.js
@@ -294,6 +294,7 @@ test("panel consumes uploaded device files from an inbox without scanning folder
});
assert.equal(pending.pending, true);
assert.equal(pending.fileName, "result_20260724_120000.csv");
+ assert.equal(pending.runId, "result_20260724_120000.csv");
assert.equal(await (await fetch(pending.url)).text(), "t,u,p\n0,10,20\n");
assert.equal((await post({
@@ -328,6 +329,7 @@ test("panel consumes uploaded device files from an inbox without scanning folder
});
assert.equal(history.total, 2);
const csvHistory = history.files.find((file) => file.fileID === pending.fileID);
+ assert.equal(csvHistory.runId, "result_20260724_120000.csv");
assert.equal(csvHistory.status, "processed");
assert.ok(csvHistory.processedAt);
assert.ok(csvHistory.expiresAt);
@@ -575,6 +577,7 @@ test("panel notifications and volume configuration stay isolated by device", asy
type: "getPendingPanelNotification", deviceId, adminToken: "test-token"
});
assert.equal(identificationResult.notification.type, "identification_result_ready");
+ assert.equal(identificationResult.notification.runId, "identification_result.json");
});
test("admin manages companies and production lines with a stable device id", async () => {