fix: make codex queue storage pg authoritative
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1767,8 +1767,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 318px);
|
||||
}
|
||||
.codex-task-pagination,
|
||||
.codex-lazy-detail-callout {
|
||||
.codex-task-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
@@ -1782,17 +1781,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.codex-lazy-detail-callout {
|
||||
margin: 8px 0;
|
||||
}
|
||||
.codex-lazy-detail-callout > div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: min(100%, 280px);
|
||||
}
|
||||
.codex-lazy-detail-callout strong {
|
||||
color: var(--text);
|
||||
}
|
||||
.codex-task-pagination code {
|
||||
color: var(--accent-2);
|
||||
}
|
||||
@@ -1862,17 +1850,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
}
|
||||
.codex-task-card.unread-terminal {
|
||||
padding-right: 23px;
|
||||
border-color: rgba(215, 161, 58, 0.32);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(78, 183, 168, 0.09), #0a1015 42%),
|
||||
#0a1015;
|
||||
}
|
||||
.codex-task-card.unread-terminal.selected,
|
||||
.codex-task-card.unread-terminal:hover {
|
||||
border-color: rgba(215, 161, 58, 0.62);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(78, 183, 168, 0.18), #0c181c 42%),
|
||||
#0c181c;
|
||||
}
|
||||
.codex-task-card:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -2561,30 +2538,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
.codex-detail-grid {
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr);
|
||||
}
|
||||
.codex-prompt-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.codex-prompt-detail {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
.codex-prompt-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.codex-prompt-meta span:not(.status-badge) {
|
||||
max-width: 100%;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--line-soft);
|
||||
color: var(--muted);
|
||||
background: rgba(255,255,255,0.025);
|
||||
font-size: 11px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.codex-prompt-full {
|
||||
max-height: 360px;
|
||||
margin: 0;
|
||||
@@ -2629,14 +2582,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
flex: 0 0 auto;
|
||||
color: var(--muted);
|
||||
}
|
||||
.codex-prompt-reference-full {
|
||||
max-height: 460px;
|
||||
border-color: rgba(78, 183, 168, 0.28);
|
||||
border-left-color: rgba(78, 183, 168, 0.62);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(78, 183, 168, 0.08), transparent 34%),
|
||||
rgba(6, 10, 13, 0.86);
|
||||
}
|
||||
.codex-prompt-final-full {
|
||||
max-height: 560px;
|
||||
border-color: rgba(215, 161, 58, 0.34);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { BEIJING_TIME_LABEL, fmtClock, fmtDate } from "./time";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ClaudeQqPage } from "./claudeqq";
|
||||
import { CodexQueuePage } from "./codex-queue";
|
||||
@@ -73,16 +74,6 @@ function taskRawButtonData(task: any): any {
|
||||
return task?._summaryOnly ? { ...task, _loadRaw: () => loadTaskRawData(task) } : task;
|
||||
}
|
||||
|
||||
function fmtDate(value: any): string {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function fmtClock(value: Date): string {
|
||||
return value.toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function fmtDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds)) return "--";
|
||||
@@ -191,6 +182,12 @@ function summarizeValue(value: any): string {
|
||||
}
|
||||
|
||||
function summarizeFieldValue(key: string, value: any): string {
|
||||
const normalizedKey = key.replace(/[-_\s]/g, "").toLowerCase();
|
||||
const looksLikeTimeKey = normalizedKey === "ts" || normalizedKey.endsWith("at") || normalizedKey.endsWith("timestamp") || normalizedKey.endsWith("heartbeat");
|
||||
if ((typeof value === "string" || typeof value === "number") && looksLikeTimeKey) {
|
||||
const formatted = fmtDate(value);
|
||||
if (formatted !== "--") return formatted;
|
||||
}
|
||||
if (key === "bodyText" && typeof value === "string") {
|
||||
const kind = /^\s*[{[]/.test(value) ? "JSON" : "HTTP";
|
||||
return `${kind} body ${value.length} chars`;
|
||||
@@ -504,7 +501,7 @@ function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock,
|
||||
{ key: "core", label: "核心", value: connection.text, tone: connection.ok ? "ok" : "fail", testId: "conn-text" },
|
||||
...(Array.isArray(activeStatusItems) ? activeStatusItems : []),
|
||||
{ key: "refresh", label: "刷新", value: lastRefresh ? fmtClock(lastRefresh) : "未刷新" },
|
||||
{ key: "clock", label: "时间", value: fmtClock(clock) },
|
||||
{ key: "clock", label: BEIJING_TIME_LABEL, value: fmtClock(clock) },
|
||||
{ key: "user", label: "用户", value: session?.user?.username || "--", tone: "user" },
|
||||
];
|
||||
return h("header", { className: "topbar" },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { fmtClock, fmtDate } from "./time";
|
||||
import { errorMessage, requestJson as requestUniDeskJson } from "./unidesk-error";
|
||||
import { UniDeskErrorBanner } from "./unidesk-error-banner";
|
||||
|
||||
@@ -9,16 +10,6 @@ const { useEffect } = React;
|
||||
const useState: any = React.useState;
|
||||
const PRIMARY_PRIVATE_CHAT = { label: "主用户私聊账号", userId: 645275593 };
|
||||
|
||||
function fmtDate(value: any): string {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function fmtClock(value: Date): string {
|
||||
return value.toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function numberText(value: any): string {
|
||||
const number = Number(value);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { fmtClock, fmtDate } from "./time";
|
||||
import { TraceView, codexTracePort } from "./trace";
|
||||
import { errorMessage, requestJson as requestUniDeskJson } from "./unidesk-error";
|
||||
import { UniDeskErrorBanner } from "./unidesk-error-banner";
|
||||
@@ -11,7 +12,6 @@ const useState: any = React.useState;
|
||||
const codexTranscriptChunkLimit = 120;
|
||||
const codexInitialTaskLimit = 24;
|
||||
const codexMoreTaskLimit = 48;
|
||||
const codexLocalReadRetention = 300;
|
||||
const queueErrorPreviewLength = 1200;
|
||||
|
||||
function isDocumentVisible(): boolean {
|
||||
@@ -22,16 +22,6 @@ function errorText(error: unknown, fallback = "操作失败"): string {
|
||||
return errorMessage(error, fallback);
|
||||
}
|
||||
|
||||
function fmtDate(value: any): string {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function fmtClock(value: Date): string {
|
||||
return value.toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function fmtDuration(ms: any): string {
|
||||
const value = Number(ms);
|
||||
@@ -45,6 +35,44 @@ function fmtDuration(ms: any): string {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function timestampMs(value: any): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const ms = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
function fmtRelativeAge(value: any, nowMs = Date.now()): string {
|
||||
const atMs = timestampMs(value);
|
||||
if (atMs === null) return "--";
|
||||
const totalSeconds = Math.max(0, Math.floor((nowMs - atMs) / 1000));
|
||||
if (totalSeconds < 1) return "刚刚";
|
||||
const days = Math.floor(totalSeconds / 86400);
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (days > 0) return `${days}天${hours > 0 ? `${hours}小时` : ""}前`;
|
||||
if (hours > 0) return `${hours}小时${minutes > 0 ? `${minutes}分钟` : ""}前`;
|
||||
if (minutes > 0) return `${minutes}分钟${seconds}秒前`;
|
||||
return `${seconds}秒前`;
|
||||
}
|
||||
|
||||
function latestTimestampValue(...values: any[]): string {
|
||||
let best = "";
|
||||
let bestMs = -Infinity;
|
||||
for (const value of values) {
|
||||
const text = String(value || "");
|
||||
if (text.length === 0) continue;
|
||||
const ms = timestampMs(value);
|
||||
if (ms !== null && ms >= bestMs) {
|
||||
best = text;
|
||||
bestMs = ms;
|
||||
} else if (best.length === 0) {
|
||||
best = text;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function fmtPreciseMs(ms: any): string {
|
||||
const value = Number(ms);
|
||||
if (!Number.isFinite(value) || value < 0) return "--";
|
||||
@@ -174,7 +202,6 @@ function activeTaskIds(queue: any): string[] {
|
||||
const allQueuesId = "__all__";
|
||||
const queueMobileMediaQuery = "(max-width: 760px)";
|
||||
const queueDesktopMediaQuery = "(min-width: 761px)";
|
||||
const codexReadAtStorageKey = "unidesk:codex-queue:read-at:v1";
|
||||
|
||||
function isAllQueues(queueId: string): boolean {
|
||||
return !queueId || queueId === allQueuesId;
|
||||
@@ -571,6 +598,25 @@ function attemptExecutionSummary(task: any, attempt: any): AnyRecord {
|
||||
return objectRecord(attempt?.execution) || taskExecutionSummary(task);
|
||||
}
|
||||
|
||||
function attemptExecutionUpdatedAt(task: any, attempt: any, attemptIndex: any, execution: AnyRecord): string {
|
||||
const summary = taskTraceSummary(task);
|
||||
const currentAttempt = Number(summary?.currentAttempt || task?.currentAttempt || 0);
|
||||
const numericAttempt = Number(attemptIndex);
|
||||
const isCurrentAttempt = Number.isFinite(numericAttempt) && numericAttempt > 0 && numericAttempt === currentAttempt;
|
||||
const activeTaskUpdatedAt = latestTimestampValue(task?.updatedAt, summary?.updatedAt);
|
||||
if (isCurrentAttempt && !attempt?.finishedAt && activeTaskUpdatedAt.length > 0) return activeTaskUpdatedAt;
|
||||
return String(
|
||||
attempt?.updatedAt
|
||||
|| attempt?.finishedAt
|
||||
|| execution.effectiveEndAt
|
||||
|| (isCurrentAttempt ? activeTaskUpdatedAt : "")
|
||||
|| activeTaskUpdatedAt
|
||||
|| task?.finishedAt
|
||||
|| task?.startedAt
|
||||
|| "",
|
||||
);
|
||||
}
|
||||
|
||||
function attemptFinalResponseText(task: any, attempt: any): string {
|
||||
const text = String(attempt?.finalResponse || attempt?.finalResponsePreview || "");
|
||||
if (Object.prototype.hasOwnProperty.call(attempt || {}, "finalResponse") || Object.prototype.hasOwnProperty.call(attempt || {}, "finalResponsePreview")) return text.trimEnd();
|
||||
@@ -632,6 +678,104 @@ function taskTraceSteps(task: any, attemptIndex: any = null): any[] {
|
||||
return Array.isArray(task?._traceSteps) ? task._traceSteps : [];
|
||||
}
|
||||
|
||||
function traceStepSummaryLines(step: any): string[] {
|
||||
return (Array.isArray(step?.summaryLines) ? step.summaryLines : []).map((line: any) => String(line || ""));
|
||||
}
|
||||
|
||||
function traceStepFileChangeMethod(step: any): string {
|
||||
const status = String(step?.status || "").trim();
|
||||
if (status.length > 0) return status;
|
||||
const text = traceStepSummaryLines(step).join("\n");
|
||||
return /^(item\/[A-Za-z]+(?:\/[A-Za-z]+)?):/u.exec(text)?.[1] || "";
|
||||
}
|
||||
|
||||
function isFileChangeLifecycleSummaryLine(line: string): boolean {
|
||||
return /^item\/(?:started|completed): file changes status=/u.test(String(line || "").trim());
|
||||
}
|
||||
|
||||
function fileChangeStatusFromStep(step: any): string {
|
||||
const lines = traceStepSummaryLines(step);
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const status = /file changes status=([A-Za-z0-9_-]+)/u.exec(lines[index] || "")?.[1];
|
||||
if (status) return status;
|
||||
}
|
||||
const method = traceStepFileChangeMethod(step);
|
||||
if (method === "item/fileChange/outputDelta") return "updated";
|
||||
if (method === "item/started") return "started";
|
||||
if (method === "item/completed") return "completed";
|
||||
return method.replace(/^item\//u, "") || String(step?.status || "changed");
|
||||
}
|
||||
|
||||
function isFileChangeTraceStep(step: any): boolean {
|
||||
if (String(step?.kind || "") !== "edited") return false;
|
||||
const title = String(step?.title || "");
|
||||
const status = String(step?.status || "");
|
||||
const text = traceStepSummaryLines(step).join("\n");
|
||||
if (title === "Edited files") return true;
|
||||
if (/^item\/fileChange\//u.test(status)) return true;
|
||||
if ((status === "item/started" || status === "item/completed") && /file changes status=/u.test(text)) return true;
|
||||
if (/^Success\. Updated the following files:/mu.test(text)) return true;
|
||||
if (/^diff --git /mu.test(text)) return true;
|
||||
return /^([AMDRCU?]{1,2})\s+\S+/mu.test(text);
|
||||
}
|
||||
|
||||
function mergeFileChangeTraceStepGroup(group: any[]): any {
|
||||
if (group.length <= 1) return group[0];
|
||||
const outputStep = group.find((step) => traceStepFileChangeMethod(step) === "item/fileChange/outputDelta") || group.find((step) => traceStepSummaryLines(step).some((line) => !isFileChangeLifecycleSummaryLine(line))) || group.at(-1) || group[0];
|
||||
const rawSeqs = group.flatMap((step) => Array.isArray(step?.rawSeqs) ? step.rawSeqs : [step?.seq]).filter((seq) => seq !== undefined);
|
||||
const summaryLines = group
|
||||
.flatMap(traceStepSummaryLines)
|
||||
.filter((line) => line.trim().length > 0 && !isFileChangeLifecycleSummaryLine(line));
|
||||
const finalStep = group[group.length - 1] || outputStep;
|
||||
return {
|
||||
...outputStep,
|
||||
at: outputStep?.at || finalStep?.at,
|
||||
title: String(outputStep?.title || "Edited files"),
|
||||
status: fileChangeStatusFromStep(finalStep),
|
||||
summaryLines: summaryLines.length > 0 ? summaryLines : traceStepSummaryLines(outputStep),
|
||||
rawSeqs,
|
||||
};
|
||||
}
|
||||
|
||||
function coalesceFileChangeTraceSteps(steps: any[]): any[] {
|
||||
const rows = Array.isArray(steps) ? steps : [];
|
||||
const merged: any[] = [];
|
||||
let group: any[] = [];
|
||||
const flush = () => {
|
||||
if (group.length > 0) merged.push(mergeFileChangeTraceStepGroup(group));
|
||||
group = [];
|
||||
};
|
||||
for (const step of rows) {
|
||||
if (isFileChangeTraceStep(step)) {
|
||||
if (traceStepFileChangeMethod(step) === "item/started" && group.length > 0) flush();
|
||||
group.push(step);
|
||||
if (traceStepFileChangeMethod(step) === "item/completed") flush();
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
merged.push(step);
|
||||
}
|
||||
flush();
|
||||
return merged;
|
||||
}
|
||||
|
||||
function executionSummaryWithDisplayedSteps(execution: AnyRecord, steps: any[]): AnyRecord {
|
||||
if (steps.length === 0) return execution;
|
||||
const counts = steps.reduce((memo: AnyRecord, step: any) => {
|
||||
const kind = String(step?.kind || "");
|
||||
if (kind === "explored") memo.readCount += 1;
|
||||
else if (kind === "edited") memo.editCount += 1;
|
||||
else if (kind === "ran") memo.runCount += 1;
|
||||
return memo;
|
||||
}, { readCount: 0, editCount: 0, runCount: 0 });
|
||||
return {
|
||||
...execution,
|
||||
...counts,
|
||||
toolCallCount: counts.readCount + counts.editCount + counts.runCount,
|
||||
stepCount: steps.length,
|
||||
};
|
||||
}
|
||||
|
||||
function taskTraceStepsLoaded(task: any, attemptIndex: any = null): boolean {
|
||||
if (attemptIndex !== null && attemptIndex !== undefined) {
|
||||
const byAttempt = objectRecord(task?._traceStepsLoadedByAttempt) || {};
|
||||
@@ -644,6 +788,20 @@ function taskTraceStepDetails(task: any): AnyRecord {
|
||||
return task?._traceStepDetails && typeof task._traceStepDetails === "object" && !Array.isArray(task._traceStepDetails) ? task._traceStepDetails : {};
|
||||
}
|
||||
|
||||
function attemptSegmentIndex(attempt: any, position: number): number {
|
||||
const raw = Number(attempt?.index);
|
||||
return Number.isFinite(raw) ? raw : position + 1;
|
||||
}
|
||||
|
||||
function isSyntheticAttemptSegment(attempt: any, attemptIndex: any): boolean {
|
||||
return Boolean(attempt?.synthetic) || Number(attemptIndex) <= 0;
|
||||
}
|
||||
|
||||
function attemptDataIndex(attemptIndex: any): string | undefined {
|
||||
const value = Number(attemptIndex);
|
||||
return Number.isFinite(value) ? String(value) : undefined;
|
||||
}
|
||||
|
||||
function taskDurationLabel(task: any): string {
|
||||
const timing = task?.timing && typeof task.timing === "object" ? task.timing : {};
|
||||
const status = String(task?.status || "");
|
||||
@@ -685,46 +843,6 @@ function taskIsUnreadTerminal(task: any): boolean {
|
||||
return !task?.readAt;
|
||||
}
|
||||
|
||||
function loadLocalReadAt(): AnyRecord {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const parsed = JSON.parse(window.localStorage.getItem(codexReadAtStorageKey) || "{}");
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? trimLocalReadAt(parsed) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function trimLocalReadAt(readAtByTask: AnyRecord): AnyRecord {
|
||||
const entries = Object.entries(readAtByTask || {})
|
||||
.filter(([, value]) => typeof value === "string" && value.length > 0)
|
||||
.sort((left, right) => {
|
||||
const leftTime = Date.parse(String(left[1] || ""));
|
||||
const rightTime = Date.parse(String(right[1] || ""));
|
||||
return (Number.isFinite(rightTime) ? rightTime : 0) - (Number.isFinite(leftTime) ? leftTime : 0);
|
||||
})
|
||||
.slice(0, codexLocalReadRetention);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function saveLocalReadAt(readAtByTask: AnyRecord): AnyRecord {
|
||||
const retained = trimLocalReadAt(readAtByTask);
|
||||
if (typeof window === "undefined") return retained;
|
||||
try {
|
||||
window.localStorage.setItem(codexReadAtStorageKey, JSON.stringify(retained));
|
||||
} catch {
|
||||
// Best-effort fallback only; backend readAt remains authoritative when deployed.
|
||||
}
|
||||
return retained;
|
||||
}
|
||||
|
||||
function applyLocalReadState(task: any, readAtByTask: AnyRecord): any {
|
||||
const taskId = String(task?.id || "");
|
||||
const localReadAt = String(readAtByTask?.[taskId] || "");
|
||||
if (!taskIsTerminal(task) || localReadAt.length === 0) return task;
|
||||
return { ...task, readAt: task?.readAt || localReadAt, terminalUnread: false };
|
||||
}
|
||||
|
||||
function countNumber(value: any): number {
|
||||
const number = Number(value || 0);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
@@ -1035,18 +1153,21 @@ function ProgressivePromptBlock({ task, loading, onLoadPromptPart, testId = "cod
|
||||
}
|
||||
|
||||
function ProgressiveExecutionSummary({ task, attempt, attemptIndex, loading, onLoadSteps, onLoadStep, testId = "codex-execution-summary" }: AnyRecord) {
|
||||
const execution = attemptExecutionSummary(task, attempt);
|
||||
const steps = taskTraceSteps(task, attemptIndex);
|
||||
const steps = coalesceFileChangeTraceSteps(taskTraceSteps(task, attemptIndex));
|
||||
const execution = executionSummaryWithDisplayedSteps(attemptExecutionSummary(task, attempt), steps);
|
||||
const stepDetails = taskTraceStepDetails(task);
|
||||
const stepsLoaded = taskTraceStepsLoaded(task, attemptIndex);
|
||||
const toolCount = Number(execution.toolCallCount || 0);
|
||||
const editedFiles = Array.isArray(execution.editedFiles) ? execution.editedFiles : [];
|
||||
const commands = Array.isArray(execution.commands) ? execution.commands : [];
|
||||
const labelSuffix = attemptIndex ? ` #${attemptIndex}` : "";
|
||||
const synthetic = isSyntheticAttemptSegment(attempt, attemptIndex);
|
||||
const labelSuffix = synthetic ? ` · ${String(attempt?.label || "recovered thread execution")}` : attemptIndex ? ` #${attemptIndex}` : "";
|
||||
const updatedAt = attemptExecutionUpdatedAt(task, attempt, attemptIndex, execution);
|
||||
const recentUpdateLabel = `最近更新: ${fmtRelativeAge(updatedAt)}`;
|
||||
return h("details", {
|
||||
className: "codex-progressive-card codex-execution-summary",
|
||||
"data-testid": testId,
|
||||
"data-attempt-index": attemptIndex ? String(attemptIndex) : undefined,
|
||||
"data-attempt-index": attemptDataIndex(attemptIndex),
|
||||
onToggle: (event: any) => {
|
||||
if (event.currentTarget?.open && !stepsLoaded) onLoadSteps?.(attemptIndex);
|
||||
},
|
||||
@@ -1055,7 +1176,8 @@ function ProgressiveExecutionSummary({ task, attempt, attemptIndex, loading, onL
|
||||
h("div", { className: "codex-progressive-card-head" },
|
||||
h("span", { className: "codex-output-channel" }, "Summary"),
|
||||
h("strong", null, `执行过程摘要${labelSuffix}`),
|
||||
h("code", null, `${fmtDuration(execution.durationMs ?? execution.totalElapsedMs)} / ${toolCount} tools`),
|
||||
h("code", { title: updatedAt ? `最近更新: ${fmtDate(updatedAt)}` : recentUpdateLabel },
|
||||
`${fmtDuration(execution.durationMs ?? execution.totalElapsedMs)} / ${toolCount} tools / ${recentUpdateLabel}`),
|
||||
),
|
||||
h("div", { className: "codex-execution-digest" },
|
||||
h("span", null, `read ${Number(execution.readCount || 0)}`),
|
||||
@@ -1122,7 +1244,7 @@ function ProgressiveFinalResponse({ task, attempt, attemptIndex, testId = "codex
|
||||
const text = attemptFinalResponseText(task, attempt);
|
||||
const chars = Number(attempt?.finalResponseChars || text.length);
|
||||
const labelSuffix = attemptIndex ? ` #${attemptIndex}` : "";
|
||||
return h("section", { className: "codex-progressive-card codex-final-response", "data-testid": testId, "data-attempt-index": attemptIndex ? String(attemptIndex) : undefined },
|
||||
return h("section", { className: "codex-progressive-card codex-final-response", "data-testid": testId, "data-attempt-index": attemptDataIndex(attemptIndex) },
|
||||
h("div", { className: "codex-progressive-card-head" },
|
||||
h("span", { className: "codex-output-channel" }, "Final"),
|
||||
h("strong", null, `最终 response${labelSuffix}`),
|
||||
@@ -1135,7 +1257,7 @@ function ProgressiveFinalResponse({ task, attempt, attemptIndex, testId = "codex
|
||||
function ProgressiveJudge({ task, attempt, attemptIndex, testId = "codex-progressive-judge" }: AnyRecord) {
|
||||
const judge = attemptJudge(task, attempt);
|
||||
const labelSuffix = attemptIndex ? ` #${attemptIndex}` : "";
|
||||
return h("section", { className: "codex-progressive-card codex-progressive-judge", "data-testid": testId, "data-attempt-index": attemptIndex ? String(attemptIndex) : undefined },
|
||||
return h("section", { className: "codex-progressive-card codex-progressive-judge", "data-testid": testId, "data-attempt-index": attemptDataIndex(attemptIndex) },
|
||||
h("div", { className: "codex-progressive-card-head" },
|
||||
h("span", { className: "codex-output-channel" }, "Judge"),
|
||||
h("strong", null, `完成判定${labelSuffix}`),
|
||||
@@ -1165,7 +1287,7 @@ function ProgressiveJudgeFeedbackPrompt({ task, attempt, attemptIndex, loading,
|
||||
return h("details", {
|
||||
className: "codex-progressive-card codex-judge-feedback-prompt",
|
||||
"data-testid": testId,
|
||||
"data-attempt-index": attemptIndex ? String(attemptIndex) : undefined,
|
||||
"data-attempt-index": attemptDataIndex(attemptIndex),
|
||||
onToggle: (event: any) => {
|
||||
if (event.currentTarget?.open && !detailText) onLoadPromptPart?.("feedback", attemptIndex);
|
||||
},
|
||||
@@ -1184,11 +1306,13 @@ function ProgressiveJudgeFeedbackPrompt({ task, attempt, attemptIndex, loading,
|
||||
}
|
||||
|
||||
function ProgressiveAttemptCycle({ task, attempt, position, loading, onLoadPromptPart, onLoadSteps, onLoadStep }: AnyRecord) {
|
||||
const attemptIndex = Number(attempt?.index || position + 1);
|
||||
const attemptIndex = attemptSegmentIndex(attempt, position);
|
||||
const first = position === 0;
|
||||
const synthetic = isSyntheticAttemptSegment(attempt, attemptIndex);
|
||||
const title = synthetic ? String(attempt?.label || "Recovered thread execution") : `Attempt ${attemptIndex}`;
|
||||
return h("section", { className: "codex-attempt-cycle", "data-testid": `codex-attempt-cycle-${attemptIndex}` },
|
||||
h("div", { className: "codex-attempt-cycle-head" },
|
||||
h("span", { className: "codex-output-channel" }, `Attempt ${attemptIndex}`),
|
||||
h("span", { className: "codex-output-channel" }, title),
|
||||
h("strong", null, String(attempt?.mode || (attemptIndex <= 1 ? "initial" : "retry"))),
|
||||
attempt?.terminalStatus ? h(StatusBadge, { status: attempt.terminalStatus }, attempt.terminalStatus) : null,
|
||||
h("code", null, `${fmtDate(attempt?.startedAt)} -> ${fmtDate(attempt?.finishedAt)}`),
|
||||
@@ -1202,19 +1326,19 @@ function ProgressiveAttemptCycle({ task, attempt, position, loading, onLoadPromp
|
||||
onLoadStep,
|
||||
testId: first ? "codex-execution-summary" : `codex-execution-summary-attempt-${attemptIndex}`,
|
||||
}),
|
||||
h(ProgressiveFinalResponse, {
|
||||
synthetic ? null : h(ProgressiveFinalResponse, {
|
||||
task,
|
||||
attempt,
|
||||
attemptIndex,
|
||||
testId: first ? "codex-final-response" : `codex-final-response-attempt-${attemptIndex}`,
|
||||
}),
|
||||
h(ProgressiveJudge, {
|
||||
synthetic ? null : h(ProgressiveJudge, {
|
||||
task,
|
||||
attempt,
|
||||
attemptIndex,
|
||||
testId: first ? "codex-progressive-judge" : `codex-progressive-judge-attempt-${attemptIndex}`,
|
||||
}),
|
||||
h(ProgressiveJudgeFeedbackPrompt, {
|
||||
synthetic ? null : h(ProgressiveJudgeFeedbackPrompt, {
|
||||
task,
|
||||
attempt,
|
||||
attemptIndex,
|
||||
@@ -1242,63 +1366,6 @@ function ProgressiveTrace({ task, loading, onLoadPromptPart, onLoadSteps, onLoad
|
||||
}
|
||||
|
||||
|
||||
function PromptDetail({ task, loading, onLoadPromptPart }: AnyRecord) {
|
||||
if (!task) return h(EmptyState, { title: "未选择任务", text: "选择队列或历史 session 后,这里显示完整 prompt、模型和工作目录。" });
|
||||
const promptSummary = taskPromptSummary(task);
|
||||
const promptDetails = taskPromptDetails(task);
|
||||
const visiblePrompt = taskBasePromptText(task).trimEnd();
|
||||
const promptText = String(promptDetails.full?.text || "");
|
||||
const hasReference = taskHasReferencePrompt(task);
|
||||
const visibleLines = Number(promptSummary.basePromptLines || promptLineCount(visiblePrompt));
|
||||
const totalLines = Number(promptSummary.promptLines || promptLineCount(promptText));
|
||||
const referenceLines = Number(promptSummary.referencePromptLines || 0);
|
||||
const fullChars = Number(promptSummary.promptChars || task?.promptChars || promptText.length);
|
||||
return h("div", { className: "codex-prompt-detail", "data-testid": "codex-task-prompt-detail" },
|
||||
h("div", { className: "codex-prompt-meta" },
|
||||
h(StatusBadge, { status: task?.status }, task?.status || "unknown"),
|
||||
h("span", null, `model=${task?.model || "--"}`),
|
||||
h("span", null, `cwd=${task?.cwd || "--"}`),
|
||||
h("span", null, `created=${fmtDate(task?.createdAt)}`),
|
||||
h("span", null, hasReference
|
||||
? `task ${visibleLines} lines / total ${Number.isFinite(totalLines) && totalLines > 0 ? totalLines : "--"} lines`
|
||||
: `${visibleLines} lines / ${visiblePrompt.length} chars`),
|
||||
),
|
||||
h("div", { className: "codex-lazy-detail-callout", "data-testid": "codex-task-summary-callout" },
|
||||
h("div", null,
|
||||
h("strong", null, "渐进式 Trace"),
|
||||
h("span", null, "首屏使用后端 Summary;展开 prompt / 步骤时只按需拉取对应片段,不一次性拉取完整 transcript。"),
|
||||
),
|
||||
),
|
||||
hasReference ? h("details", {
|
||||
className: "codex-reference-injection codex-final-prompt-injection",
|
||||
"data-testid": "codex-final-prompt-full",
|
||||
onToggle: (event: any) => {
|
||||
if (event.currentTarget?.open && !promptText) onLoadPromptPart?.("full");
|
||||
},
|
||||
},
|
||||
h("summary", null,
|
||||
h("span", null, "最终传入 Codex 的真实完整 prompt"),
|
||||
h("code", null, promptText ? `${totalLines || promptLineCount(promptText)} lines / ${promptText.length} chars` : `${Number.isFinite(fullChars) && fullChars > 0 ? fullChars : "--"} chars`),
|
||||
),
|
||||
h("pre", { className: "codex-prompt-full codex-prompt-final-full", "data-testid": "codex-task-final-prompt-full" }, promptText || (loading ? "正在按需拉取完整 prompt..." : "展开后将只请求完整 prompt。")),
|
||||
) : null,
|
||||
hasReference ? h("details", {
|
||||
className: "codex-reference-injection",
|
||||
"data-testid": "codex-reference-injection",
|
||||
onToggle: (event: any) => {
|
||||
if (event.currentTarget?.open && !promptDetails.reference?.text) onLoadPromptPart?.("reference");
|
||||
},
|
||||
},
|
||||
h("summary", null,
|
||||
h("span", null, "引用注入已折叠"),
|
||||
h("code", null, promptDetails.reference?.text ? `${promptLineCount(String(promptDetails.reference.text || ""))} lines / ${String(promptDetails.reference.text || "").length} chars` : `${referenceLines || "--"} lines`),
|
||||
),
|
||||
h("pre", { className: "codex-prompt-full codex-prompt-reference-full", "data-testid": "codex-task-reference-full" }, String(promptDetails.reference?.text || "") || (loading ? "正在按需拉取引用注入..." : "展开后将只请求引用注入片段。")),
|
||||
) : null,
|
||||
h("pre", { className: "codex-prompt-full", "data-testid": "codex-task-prompt-full" }, visiblePrompt || "空 prompt"),
|
||||
);
|
||||
}
|
||||
|
||||
function RawTranscript({ task }: AnyRecord) {
|
||||
const output = taskOutput(task);
|
||||
if (!task || output.length === 0) return h(EmptyState, { title: "暂无原始消息", text: "原始 Codex app-server 消息会保留在任务 JSON 中。" });
|
||||
@@ -1395,7 +1462,6 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api", init
|
||||
const [copiedTaskId, setCopiedTaskId] = useState("");
|
||||
const [markingReadTaskId, setMarkingReadTaskId] = useState("");
|
||||
const [markingAllRead, setMarkingAllRead] = useState(false);
|
||||
const [localReadAt, setLocalReadAt] = useState(loadLocalReadAt);
|
||||
const [loadStats, setLoadStats] = useState(initialTasksData ? {
|
||||
phase: "complete",
|
||||
taskId: initialSelectedId,
|
||||
@@ -1410,7 +1476,7 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api", init
|
||||
const [refreshedAt, setRefreshedAt] = useState(initialTasksData ? new Date() : null);
|
||||
const [loadingMoreTasks, setLoadingMoreTasks] = useState(false);
|
||||
|
||||
const tasks = taskRows(tasksData).map((task: any) => applyLocalReadState(task, localReadAt));
|
||||
const tasks = taskRows(tasksData);
|
||||
const unreadTerminalTasks = tasks.filter(taskIsUnreadTerminal);
|
||||
const queuedTasks = tasks.filter((task: any) => !taskIsTerminal(task));
|
||||
const historyTasks = tasks.filter((task: any) => taskIsTerminal(task) && !taskIsUnreadTerminal(task));
|
||||
@@ -1429,7 +1495,7 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api", init
|
||||
const globalCounts = queueCounts(queue);
|
||||
const overallQueuedCount = queueWaitingCount(globalCounts);
|
||||
const overallRunningCount = Math.max(queueRunningCount(globalCounts), globalActiveIds.length);
|
||||
const overallUnreadTerminalCount = countNumber(queue?.unreadTerminal ?? unreadTerminalTasks.length);
|
||||
const overallUnreadTerminalCount = countNumber((isAllQueues(selectedQueueId) ? queue?.unreadTerminal : viewQueueRow?.unreadTerminal) ?? unreadTerminalTasks.length);
|
||||
const selectedQueueName = isAllQueues(selectedQueueId) ? "All queues" : selectedQueueId;
|
||||
const runtime = service ? microserviceRuntime(service) : {};
|
||||
const repository = service ? microserviceRepository(service) : {};
|
||||
@@ -1478,16 +1544,6 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api", init
|
||||
}
|
||||
}
|
||||
|
||||
function rememberLocalRead(taskIds: string[], readAt: string): void {
|
||||
const ids = taskIds.map((id) => String(id || "")).filter(Boolean);
|
||||
if (ids.length === 0) return;
|
||||
setLocalReadAt((previous: AnyRecord) => {
|
||||
const next = { ...(previous || {}) };
|
||||
for (const id of ids) next[id] = readAt;
|
||||
return saveLocalReadAt(next);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setBatchConfirmed(false);
|
||||
}, [prompt, repeatCount, referenceTaskId]);
|
||||
@@ -1867,6 +1923,11 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api", init
|
||||
}
|
||||
setRefreshedAt(new Date());
|
||||
if (trackLoad) trackedLoadInFlightRef.current = false;
|
||||
// Overview is intentionally small and may only contain the latest attempt.
|
||||
// Load the canonical Trace Summary as a follow-up so retry/judge feedback
|
||||
// cards are shown for historical multi-attempt sessions too.
|
||||
void ensureTraceSummary(nextId, false, trackLoad ? startedAt : undefined, trackLoad ? queueMs : undefined)
|
||||
.catch((err) => setError(errorText(err, "加载 Codex Trace Summary 失败")));
|
||||
return;
|
||||
}
|
||||
if (trackLoad) {
|
||||
@@ -1993,18 +2054,11 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api", init
|
||||
if (!service || !taskId) return;
|
||||
setMarkingReadTaskId(taskId);
|
||||
await guarded(async () => {
|
||||
let result: any = null;
|
||||
let localOnly = false;
|
||||
try {
|
||||
result = await markTaskReadRequest(apiBaseUrl, taskId);
|
||||
} catch {
|
||||
localOnly = true;
|
||||
}
|
||||
const result = await markTaskReadRequest(apiBaseUrl, taskId);
|
||||
const task = result?.task || { id: taskId, readAt: new Date().toISOString(), terminalUnread: false };
|
||||
const readAt = String(task?.readAt || new Date().toISOString());
|
||||
rememberLocalRead([taskId], readAt);
|
||||
patchLoadedReadState([taskId], readAt, result?.queue || null, task);
|
||||
setNotice(localOnly ? `已在本浏览器将任务 ${taskId} 标为已读;后端升级后会同步持久化` : `已将任务 ${taskId} 标为已读`);
|
||||
setNotice(`已将任务 ${taskId} 标为已读`);
|
||||
}, "标记 Codex task 已读失败");
|
||||
setMarkingReadTaskId((value: string) => value === taskId ? "" : value);
|
||||
}
|
||||
@@ -2013,27 +2067,19 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api", init
|
||||
if (!service || markingAllRead) return;
|
||||
setMarkingAllRead(true);
|
||||
await guarded(async () => {
|
||||
let result: any = null;
|
||||
let localOnly = false;
|
||||
try {
|
||||
result = await markAllTerminalReadRequest(apiBaseUrl);
|
||||
} catch {
|
||||
localOnly = true;
|
||||
}
|
||||
const result = await markAllTerminalReadRequest(apiBaseUrl);
|
||||
const readAt = String(result?.readAt || new Date().toISOString());
|
||||
const loadedUnreadIds = taskRows(tasksDataRef.current)
|
||||
.map((task: any) => applyLocalReadState(task, localReadAt))
|
||||
.filter(taskIsUnreadTerminal)
|
||||
.map((task: any) => String(task?.id || ""))
|
||||
.filter(Boolean);
|
||||
const cachedUnreadIds = Array.from(sessionCacheRef.current.entries())
|
||||
.filter(([, value]) => taskIsUnreadTerminal(applyLocalReadState(value?.task, localReadAt)))
|
||||
.filter(([, value]) => taskIsUnreadTerminal(value?.task))
|
||||
.map(([id]) => id);
|
||||
const readIds = Array.from(new Set([...loadedUnreadIds, ...cachedUnreadIds]));
|
||||
rememberLocalRead(readIds, readAt);
|
||||
patchLoadedReadState(readIds, readAt, result?.queue || null);
|
||||
const markedCount = localOnly ? readIds.length : Number(result?.count || readIds.length);
|
||||
setNotice(localOnly ? `已在本浏览器将 ${markedCount} 个已结束未读任务标为已读;后端升级后会同步持久化` : `已将 ${markedCount} 个已结束未读任务标为已读`);
|
||||
const markedCount = Number(result?.count || readIds.length);
|
||||
setNotice(`已将 ${markedCount} 个已结束未读任务标为已读`);
|
||||
}, "全部标为已读失败");
|
||||
setMarkingAllRead(false);
|
||||
}
|
||||
@@ -2446,9 +2492,6 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api", init
|
||||
),
|
||||
h("div", { className: "codex-main-stage" },
|
||||
h("div", { className: "codex-detail-grid" },
|
||||
h(Panel, { title: "Prompt 全量", eyebrow: selectedTask ? String(selectedTask.id) : "selected task", className: "codex-prompt-panel" },
|
||||
h(PromptDetail, { task: selectedTask, loading: selectedDetailLoading, onLoadPromptPart: ensurePromptPart }),
|
||||
),
|
||||
h(Panel, { title: "运行控制", eyebrow: selectedCanSteer ? "Active turn steer" : "Steer when running" },
|
||||
h("div", { className: "codex-run-control-stack" },
|
||||
h(TaskQueueMoveControl, { task: selectedTask, queueRows, busy, onMove: moveSelectedTaskQueue }),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { fmtClock, fmtDate } from "./time";
|
||||
import { errorMessage, requestJson } from "./unidesk-error";
|
||||
import { UniDeskErrorBanner } from "./unidesk-error-banner";
|
||||
|
||||
@@ -8,16 +9,6 @@ const h = React.createElement;
|
||||
const { useEffect } = React;
|
||||
const useState: any = React.useState;
|
||||
|
||||
function fmtDate(value: any): string {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function fmtClock(value: Date): string {
|
||||
return value.toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function StatusBadge({ status, children }: AnyRecord) {
|
||||
const normalized = String(status || "unknown").toLowerCase();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { fmtClock, fmtDate } from "./time";
|
||||
import { errorMessage, requestJson } from "./unidesk-error";
|
||||
import { UniDeskErrorBanner } from "./unidesk-error-banner";
|
||||
|
||||
@@ -8,16 +9,6 @@ const h = React.createElement;
|
||||
const { useEffect } = React;
|
||||
const useState: any = React.useState;
|
||||
|
||||
function fmtDate(value: any): string {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function fmtClock(value: Date): string {
|
||||
return value.toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function fmtPercent(value: any): string {
|
||||
const number = Number(value);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { fmtClock, fmtDate } from "./time";
|
||||
import { Background, BaseEdge, Controls, Handle, MarkerType, Position, ReactFlow, type Edge, type Node } from "@xyflow/react";
|
||||
import { TraceView, opencodeTracePort } from "./trace";
|
||||
import { errorMessage, requestJson as requestUniDeskJson } from "./unidesk-error";
|
||||
@@ -192,16 +193,6 @@ function PipelineCurveEdge({ id, sourceX, sourceY, targetX, targetY, targetPosit
|
||||
const pipelineEdgeTypes: any = { pipelineCurve: PipelineCurveEdge };
|
||||
const pipelineNodeTypes: any = { pipelineNode: PipelineFlowNode };
|
||||
|
||||
function fmtDate(value: any): string {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function fmtClock(value: Date): string {
|
||||
return value.toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function fmtClockValue(value: any): string {
|
||||
if (!value) return "--";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { beijingDateStamp, fmtClock, fmtDate } from "./time";
|
||||
import { errorMessage, requestBlob, requestJson } from "./unidesk-error";
|
||||
import { UniDeskErrorBanner } from "./unidesk-error-banner";
|
||||
|
||||
@@ -19,16 +20,6 @@ const EMPTY_FORM = {
|
||||
notes: "",
|
||||
};
|
||||
|
||||
function fmtDate(value: any): string {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function fmtClock(value: Date): string {
|
||||
return value.toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function StatusBadge({ status, children }: AnyRecord) {
|
||||
const normalized = String(status || "unknown").toLowerCase();
|
||||
@@ -230,7 +221,7 @@ export function ProjectManagerPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
const href = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = href;
|
||||
anchor.download = `project-manager-${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
anchor.download = `project-manager-${beijingDateStamp()}.xlsx`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
export const BEIJING_TIME_ZONE = "Asia/Shanghai";
|
||||
export const BEIJING_TIME_LABEL = "北京时间";
|
||||
|
||||
const BEIJING_UTC_OFFSET_HOURS = 8;
|
||||
const DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
timeZone: BEIJING_TIME_ZONE,
|
||||
hour12: false,
|
||||
};
|
||||
const CLOCK_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
timeZone: BEIJING_TIME_ZONE,
|
||||
hour12: false,
|
||||
};
|
||||
const INPUT_PARTS_FORMATTER = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: BEIJING_TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
|
||||
function coerceDate(value: any): Date | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function beijingInputParts(value: any): Record<string, string> | null {
|
||||
const date = coerceDate(value);
|
||||
if (!date) return null;
|
||||
return INPUT_PARTS_FORMATTER.formatToParts(date).reduce((parts: Record<string, string>, part) => {
|
||||
if (part.type !== "literal") parts[part.type] = part.value;
|
||||
return parts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function fmtDate(value: any): string {
|
||||
const date = coerceDate(value);
|
||||
return date ? date.toLocaleString("zh-CN", DATE_TIME_OPTIONS) : "--";
|
||||
}
|
||||
|
||||
export function fmtClock(value: any): string {
|
||||
const date = coerceDate(value);
|
||||
return date ? date.toLocaleTimeString("zh-CN", CLOCK_OPTIONS) : "--";
|
||||
}
|
||||
|
||||
export function fmtDateTimeLocalInput(value: any): string {
|
||||
const parts = beijingInputParts(value);
|
||||
if (!parts) return "";
|
||||
const hour = parts.hour === "24" ? "00" : parts.hour;
|
||||
return `${parts.year}-${parts.month}-${parts.day}T${hour}:${parts.minute}`;
|
||||
}
|
||||
|
||||
export function beijingDateStamp(value: any = new Date()): string {
|
||||
const parts = beijingInputParts(value);
|
||||
if (!parts) return "";
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
}
|
||||
|
||||
export function localDateTimeInputToBeijingIso(value: string): string | null {
|
||||
if (!value) return null;
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value);
|
||||
if (!match) return null;
|
||||
const [, year, month, day, hour, minute, second = "00"] = match;
|
||||
const utcMs = Date.UTC(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hour) - BEIJING_UTC_OFFSET_HOURS,
|
||||
Number(minute),
|
||||
Number(second),
|
||||
);
|
||||
const date = new Date(utcMs);
|
||||
const normalized = fmtDateTimeLocalInput(date);
|
||||
return Number.isNaN(date.getTime()) || normalized !== `${year}-${month}-${day}T${hour}:${minute}` ? null : date.toISOString();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { fmtClock, fmtDate, fmtDateTimeLocalInput, localDateTimeInputToBeijingIso } from "./time";
|
||||
import { errorMessage, requestJson } from "./unidesk-error";
|
||||
import { UniDeskErrorBanner } from "./unidesk-error-banner";
|
||||
|
||||
@@ -9,17 +10,6 @@ const h = React.createElement;
|
||||
const { useEffect } = React;
|
||||
const useState: any = React.useState;
|
||||
|
||||
function fmtDate(value: any): string {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function fmtClock(value: Date): string {
|
||||
return value.toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function StatusBadge({ status, children }: AnyRecord) {
|
||||
const normalized = String(status || "unknown").toLowerCase();
|
||||
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
|
||||
@@ -102,20 +92,6 @@ function todoVisible(todo: any, filter: string): boolean {
|
||||
return selfVisible || children.some((child: any) => todoVisible(child, filter));
|
||||
}
|
||||
|
||||
function todoReminderLocal(value: any): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function localReminderIso(value: string): string | null {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
function todoRegistryRows(registry: any): any[] {
|
||||
return Array.isArray(registry?.instances) ? registry.instances : [];
|
||||
}
|
||||
@@ -430,7 +406,7 @@ function TodoRow(props: AnyRecord): ReactNode {
|
||||
todo.reminderAt ? h("span", { className: "todo-reminder" }, `提醒 ${fmtDate(todo.reminderAt)}`) : h("span", null, "无提醒"),
|
||||
),
|
||||
),
|
||||
h("input", { className: "todo-reminder-input", type: "datetime-local", value: todoReminderLocal(todo.reminderAt), onChange: (event: any) => applyTodoAction({ type: "setTodoReminder", todoId: todo.id, reminderAt: localReminderIso(event.target.value) }) }),
|
||||
h("input", { className: "todo-reminder-input", type: "datetime-local", value: fmtDateTimeLocalInput(todo.reminderAt), onChange: (event: any) => applyTodoAction({ type: "setTodoReminder", todoId: todo.id, reminderAt: localDateTimeInputToBeijingIso(event.target.value) }) }),
|
||||
h("div", { className: "todo-row-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => beginEdit(todo) }, "编辑"),
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => addChild(todo.id) }, "子项"),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { fmtDate } from "./time";
|
||||
|
||||
type AnyRecord = Record<string, any>;
|
||||
|
||||
@@ -73,13 +74,6 @@ export function traceFromPort<Input>(port: TracePort<Input>, input: Input): Trac
|
||||
return normalizeTraceItems(port.toTrace(input));
|
||||
}
|
||||
|
||||
function fmtDate(value: any): string {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function fmtDuration(ms: any): string {
|
||||
const value = Number(ms);
|
||||
if (!Number.isFinite(value) || value < 0) return "--";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { BEIJING_TIME_LABEL, fmtDate } from "./time";
|
||||
|
||||
type AnyRecord = Record<string, any>;
|
||||
|
||||
export type UniDeskFailureField = string | false;
|
||||
@@ -231,7 +233,7 @@ function fmtDateTime(value: string | undefined): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return `${date.toLocaleString("zh-CN", { hour12: false })} / ${date.toISOString()}`;
|
||||
return `${fmtDate(date)} ${BEIJING_TIME_LABEL}`;
|
||||
}
|
||||
|
||||
export function describeUniDeskError(error: unknown, fallback = "操作失败"): UniDeskErrorView {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user