Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a63926284 |
@@ -17,7 +17,7 @@ describe("answer presentation time zone", () => {
|
|||||||
expect(result.elements.table.props.rows).toEqual([["2026/7/16 08:00:00(UTC+8)"]]);
|
expect(result.elements.table.props.rows).toEqual([["2026/7/16 08:00:00(UTC+8)"]]);
|
||||||
expect(result.elements.evidence.props.observed_at).toBe("2026/7/16 08:00:00(UTC+8)");
|
expect(result.elements.evidence.props.observed_at).toBe("2026/7/16 08:00:00(UTC+8)");
|
||||||
expect(result.elements.evidence.props.evidence_id).toBe("2026-07-16T00:00:00Z");
|
expect(result.elements.evidence.props.evidence_id).toBe("2026-07-16T00:00:00Z");
|
||||||
expect(result.elements.chart.props.x_axis_name).toBe("时间(UTC+8)");
|
expect(result.elements.chart.props.x_axis_name).toBe("时间");
|
||||||
expect(result.elements.chart.props.x_data).toEqual(["08:00"]);
|
expect(result.elements.chart.props.x_data).toEqual(["08:00"]);
|
||||||
expect(JSON.stringify(spec)).toBe(original);
|
expect(JSON.stringify(spec)).toBe(original);
|
||||||
expect(withAnswerDisplayTimes(result)).toEqual(result);
|
expect(withAnswerDisplayTimes(result)).toEqual(result);
|
||||||
@@ -36,4 +36,23 @@ describe("answer presentation time zone", () => {
|
|||||||
expect(result.elements.table.props.rows).toEqual([["2027/1/1 01:00:00(UTC+8)", "00:00"]]);
|
expect(result.elements.table.props.rows).toEqual([["2027/1/1 01:00:00(UTC+8)", "00:00"]]);
|
||||||
expect(withAnswerDisplayTimes(result)).toEqual(result);
|
expect(withAnswerDisplayTimes(result)).toEqual(result);
|
||||||
});
|
});
|
||||||
|
it("removes chart timezone wording while preserving the window, data and sample instants", () => {
|
||||||
|
const samples = ["2026-07-15T23:30:00Z", "2026-07-15T23:45:00Z"];
|
||||||
|
const spec: Spec = { root: "chart", elements: {
|
||||||
|
chart: { type: "Chart", props: {
|
||||||
|
title: "回放流量:487434(m³/h,上海时间 07-16 04:45–07:45)",
|
||||||
|
x_axis_name: "上海时间", x_data: ["07:30", "07:45"],
|
||||||
|
series: [{ name: "当前回放(2026-07-16,UTC+8)", data: [420, 380], sample_times: samples }]
|
||||||
|
}, children: [] }
|
||||||
|
} };
|
||||||
|
const result = withAnswerDisplayTimes(spec);
|
||||||
|
expect(result.elements.chart.props.title).toBe("回放流量:487434(m³/h,07-16 04:45–07:45)");
|
||||||
|
expect(result.elements.chart.props.x_axis_name).toBe("时间");
|
||||||
|
expect(result.elements.chart.props.x_data).toEqual(["07:30", "07:45"]);
|
||||||
|
expect(result.elements.chart.props.series).toEqual([
|
||||||
|
{ name: "当前回放(2026-07-16)", data: [420, 380], sample_times: samples }
|
||||||
|
]);
|
||||||
|
expect(withAnswerDisplayTimes(result)).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Spec } from "@json-render/core";
|
import type { Spec } from "@json-render/core";
|
||||||
import { formatDisplayText } from "@/shared/utils/date-time-text";
|
import { formatDisplayText, withChartDisplayTimes } from "@/shared/utils/date-time-text";
|
||||||
|
|
||||||
const textFields = new Set(["title", "description", "summary", "text", "label", "value", "source", "observed_at"]);
|
const textFields = new Set(["title", "description", "summary", "text", "label", "value", "source", "observed_at"]);
|
||||||
const utcLabel = /UTC(?:\s*\+?0(?::00)?)?(?![\w+:-])/;
|
const utcLabel = /UTC(?:\s*\+?0(?::00)?)?(?![\w+:-])/;
|
||||||
@@ -23,14 +23,7 @@ export function withAnswerDisplayTimes(spec: Spec): Spec {
|
|||||||
props.columns = columns.map(timeAxis);
|
props.columns = columns.map(timeAxis);
|
||||||
}
|
}
|
||||||
if (node.type === "Chart") {
|
if (node.type === "Chart") {
|
||||||
const axis = typeof props.x_axis_name === "string" ? props.x_axis_name : "";
|
Object.assign(props, withChartDisplayTimes(props as Parameters<typeof withChartDisplayTimes>[0]));
|
||||||
props.x_data = (props.x_data as string[]).map((label) => {
|
|
||||||
const displayed = displayCell(label, axis);
|
|
||||||
return timeAxis(axis).includes("UTC+8") ? displayed.replace(/(UTC\+8)$/, "") : displayed;
|
|
||||||
});
|
|
||||||
if (typeof props.x_axis_name === "string") props.x_axis_name = timeAxis(props.x_axis_name);
|
|
||||||
props.series = (props.series as Array<Record<string, unknown>>).map((series) => ({ ...series,
|
|
||||||
name: typeof series.name === "string" ? formatDisplayText(series.name) : series.name }));
|
|
||||||
}
|
}
|
||||||
return [id, { ...node, props }];
|
return [id, { ...node, props }];
|
||||||
})) };
|
})) };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
|
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
|
||||||
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
||||||
import { memo, type ReactNode } from "react";
|
import { memo, type ReactNode } from "react";
|
||||||
|
import { formatChartDisplayText, withChartDisplayTimes } from "@/shared/utils/date-time-text";
|
||||||
import { StatusIcon } from "@/shared/ui/status";
|
import { StatusIcon } from "@/shared/ui/status";
|
||||||
import { StandaloneInteractiveChart } from "../answer-ui/interactive-chart";
|
import { StandaloneInteractiveChart } from "../answer-ui/interactive-chart";
|
||||||
import { normalizeSafeChartData, parseChartSpec } from "../chart-data";
|
import { normalizeSafeChartData, parseChartSpec } from "../chart-data";
|
||||||
@@ -78,8 +79,12 @@ export const AgentChartView = memo(function AgentChartView({ chart, embedded = f
|
|||||||
});
|
});
|
||||||
|
|
||||||
function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: boolean }) {
|
function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: boolean }) {
|
||||||
const spec = parseChartSpec(envelope.spec);
|
const parsed = parseChartSpec(envelope.spec);
|
||||||
const data = normalizeSafeChartData(envelope.data, 100);
|
const raw = normalizeSafeChartData(envelope.data, 100);
|
||||||
|
const displayed = withChartDisplayTimes({ title: parsed.title ?? "Agent 图表", x_axis_name: parsed.xAxisName,
|
||||||
|
x_data: raw?.xData ?? [], series: raw?.series ?? [] });
|
||||||
|
const spec = { ...parsed, title: displayed.title, xAxisName: displayed.x_axis_name };
|
||||||
|
const data = raw ? { ...raw, xData: displayed.x_data, series: displayed.series } : null;
|
||||||
|
|
||||||
if (!data || data.series.length === 0 || data.xData.length === 0) {
|
if (!data || data.series.length === 0 || data.xData.length === 0) {
|
||||||
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
|
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
|
||||||
@@ -206,7 +211,7 @@ function getEnvelopeIcon(envelope: UIEnvelope) {
|
|||||||
function getEnvelopeTitle(envelope: UIEnvelope) {
|
function getEnvelopeTitle(envelope: UIEnvelope) {
|
||||||
if (envelope.kind === "chart") {
|
if (envelope.kind === "chart") {
|
||||||
const spec = parseChartSpec(envelope.spec);
|
const spec = parseChartSpec(envelope.spec);
|
||||||
return spec.title ?? "Agent 图表";
|
return formatChartDisplayText(spec.title ?? "Agent 图表");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (envelope.kind === "registered_component") {
|
if (envelope.kind === "registered_component") {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useId, useState } from "react";
|
|||||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause, X } from "lucide-react";
|
import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause, X } from "lucide-react";
|
||||||
import { AgentChartView, type AnswerUiBlock } from "@/features/agent";
|
import { AgentChartView, type AnswerUiBlock } from "@/features/agent";
|
||||||
|
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
|
||||||
import { Badge } from "@/shared/ui/badge";
|
import { Badge } from "@/shared/ui/badge";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
||||||
@@ -295,13 +296,13 @@ export function InvestigationPresentationPanel({
|
|||||||
<Card
|
<Card
|
||||||
data-investigation-chart={item.chart_id}
|
data-investigation-chart={item.chart_id}
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={item.chart.title}
|
aria-label={formatChartDisplayText(item.chart.title)}
|
||||||
className="border-border shadow-none"
|
className="border-border shadow-none"
|
||||||
>
|
>
|
||||||
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0 p-3 pb-2">
|
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0 p-3 pb-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
|
<h3 className="break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
|
||||||
{item.chart.title}
|
{formatChartDisplayText(item.chart.title)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||||
{item.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
|
{item.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
|
||||||
@@ -310,7 +311,7 @@ export function InvestigationPresentationPanel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<FloatingWindow title={item.chart.title} fillContent
|
<FloatingWindow title={formatChartDisplayText(item.chart.title)} fillContent
|
||||||
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, item])}>
|
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, item])}>
|
||||||
{() => <div className="flex h-full min-h-0 min-w-0 flex-col gap-2">
|
{() => <div className="flex h-full min-h-0 min-w-0 flex-col gap-2">
|
||||||
<AgentChartView chart={item.chart} embedded expanded />
|
<AgentChartView chart={item.chart} embedded expanded />
|
||||||
@@ -323,7 +324,7 @@ export function InvestigationPresentationPanel({
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
disabled={reviewing}
|
disabled={reviewing}
|
||||||
aria-label={`关闭图表 ${item.chart.title}`}
|
aria-label={`关闭图表 ${formatChartDisplayText(item.chart.title)}`}
|
||||||
onClick={() => onCloseChart(item.chart_id)}
|
onClick={() => onCloseChart(item.chart_id)}
|
||||||
>
|
>
|
||||||
<X size={14} aria-hidden />
|
<X size={14} aria-hidden />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
|
||||||
import type { InvestigationPresentationCommand } from "../api/investigation-presentation";
|
import type { InvestigationPresentationCommand } from "../api/investigation-presentation";
|
||||||
import { investigationActionLabels } from "./investigation-dev-preview";
|
import { investigationActionLabels } from "./investigation-dev-preview";
|
||||||
import type { PresentationView } from "./presentation-view-history";
|
import type { PresentationView } from "./presentation-view-history";
|
||||||
@@ -8,11 +9,11 @@ export function presentationCommandLabel(
|
|||||||
) {
|
) {
|
||||||
const detail =
|
const detail =
|
||||||
command.action === "open_chart"
|
command.action === "open_chart"
|
||||||
? command.chart.title
|
? formatChartDisplayText(command.chart.title)
|
||||||
: command.action === "set_overlay"
|
: command.action === "set_overlay"
|
||||||
? command.title
|
? command.title
|
||||||
: command.action === "close_chart"
|
: command.action === "close_chart"
|
||||||
? view.charts.find((chart) => chart.chart_id === command.chart_id)?.chart.title
|
? formatChartDisplayText(view.charts.find((chart) => chart.chart_id === command.chart_id)?.chart.title ?? "")
|
||||||
: "feature_ids" in command
|
: "feature_ids" in command
|
||||||
? command.feature_ids.slice(0, 3).join("、")
|
? command.feature_ids.slice(0, 3).join("、")
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -41,3 +41,32 @@ export function formatDisplayText(text: string | null | undefined): string {
|
|||||||
});
|
});
|
||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Chart labels use the display clock without repeating its zone to readers. */
|
||||||
|
export function formatChartDisplayText(value: string): string {
|
||||||
|
return formatDisplayText(value)
|
||||||
|
.replace(/[((]\s*(?:上海时间|Asia\/Shanghai|UTC\s*\+\s*8(?::00)?)\s*[))]/g, "")
|
||||||
|
.replace(/(?:上海时间|Asia\/Shanghai|UTC\s*\+\s*8(?::00)?)(?![\w:+-])\s*/g, "")
|
||||||
|
.replace(/[,,]\s*([))])/g, "$1")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartDisplayFields = {
|
||||||
|
title: string;
|
||||||
|
x_axis_name?: string;
|
||||||
|
y_axis_name?: string;
|
||||||
|
x_data: string[];
|
||||||
|
series: Array<{ name: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Preserve values and original sample timestamps; convert only visible labels. */
|
||||||
|
export function withChartDisplayTimes<T extends ChartDisplayFields>(chart: T): T {
|
||||||
|
const utcAxis = /UTC(?:\s*\+?0(?::00)?)?(?![\w+:-])/.test(chart.x_axis_name ?? "");
|
||||||
|
const bareTime = /^(?:\d{4}-\d{2}-\d{2}[T ])?\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?$/;
|
||||||
|
return { ...chart,
|
||||||
|
title: formatChartDisplayText(chart.title),
|
||||||
|
...(chart.x_axis_name ? { x_axis_name: formatChartDisplayText(chart.x_axis_name.replace(/UTC(?:\s*\+?0(?::00)?)?(?![\w+:-])/g, "UTC+8")) || "时间" } : {}),
|
||||||
|
x_data: chart.x_data.map((label) => formatChartDisplayText(utcAxis && bareTime.test(label) ? `${label} UTC` : label)),
|
||||||
|
series: chart.series.map((series) => ({ ...series, name: formatChartDisplayText(series.name) }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ test("SCADA chart axes and legends fit every nested container", async ({ page },
|
|||||||
series: [{ name: "测点 123748(实测回放)", data: [26.2, 25.7, 23.5] },
|
series: [{ name: "测点 123748(实测回放)", data: [26.2, 25.7, 23.5] },
|
||||||
{ name: "测点 159673(实测回放)", data: [24.9, 24.87, 21.6] }]
|
{ name: "测点 159673(实测回放)", data: [24.9, 24.87, 21.6] }]
|
||||||
} });
|
} });
|
||||||
const popup = page.getByRole("dialog", { name: "SCADA 回放压力:异常前后(2026-07-16,UTC+8)", exact: true });
|
const popup = page.getByRole("dialog", { name: "SCADA 回放压力:异常前后(2026-07-16)", exact: true });
|
||||||
await expect(popup).toBeVisible();
|
await expect(popup).toBeVisible();
|
||||||
await expect.poll(() => popup.evaluate((node) => Array.from(node.querySelectorAll<HTMLElement>("*"))
|
await expect.poll(() => popup.evaluate((node) => Array.from(node.querySelectorAll<HTMLElement>("*"))
|
||||||
.filter((element) => {
|
.filter((element) => {
|
||||||
@@ -173,3 +173,31 @@ test("SCADA chart axes and legends fit every nested container", async ({ page },
|
|||||||
body: await popup.screenshot({ path: testInfo.outputPath("scada-chart-layout.png") }), contentType: "image/png"
|
body: await popup.screenshot({ path: testInfo.outputPath("scada-chart-layout.png") }), contentType: "image/png"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("SCADA pressure and flow keep full comparison data without timezone wording", async ({ page }, testInfo) => {
|
||||||
|
const task = investigationFixture(1, "SCADA 三小时对照核查");
|
||||||
|
const mock = await mockInvestigationPresentation(page, [task]);
|
||||||
|
await selectInvestigation(page, task.title);
|
||||||
|
const labels = Array.from({ length: 13 }, (_, i) => `${String(4 + Math.floor((45 + i * 15) / 60)).padStart(2, "0")}:${String((45 + i * 15) % 60).padStart(2, "0")}`);
|
||||||
|
for (const [metric, device, unit] of [["压力", "123748", "m"], ["流量", "487434", "m³/h"]]) {
|
||||||
|
await mock.emit(task, { action: "open_chart", chart_id: `scada-${device}`, evidence_refs: ["synthetic:pressure"], chart: {
|
||||||
|
title: `回放${metric}:${device}(${unit},上海时间 07-16 04:45–07:45)`, chart_type: "line",
|
||||||
|
x_axis_name: "上海时间", y_axis_name: `${metric}(${unit})`, x_data: labels,
|
||||||
|
series: [{ name: "当前回放(2026-07-16)", type: "line", data: labels.map((_, i) => 30 + i) },
|
||||||
|
...Array.from({ length: 7 }, (_, day) => ({ name: `参考 07-${String(9 + day).padStart(2, "0")}`, type: "scatter",
|
||||||
|
data: labels.map((_, i) => i < 10 ? null : 28 + day) }))]
|
||||||
|
} });
|
||||||
|
const popup = page.getByRole("dialog", { name: `回放${metric}:${device}(${unit},07-16 04:45–07:45)`, exact: true });
|
||||||
|
await expect(popup).toBeVisible();
|
||||||
|
await expect(popup.getByText(/上海时间|UTC\+8|Asia\/Shanghai/)).toHaveCount(0);
|
||||||
|
await expect(popup.getByRole("img", { name: `回放${metric}:${device}(${unit},07-16 04:45–07:45)`, exact: true })).toBeVisible();
|
||||||
|
await testInfo.attach(`scada-${device}-comparison`, { body: await popup.screenshot(), contentType: "image/png" });
|
||||||
|
await popup.getByRole("tab", { name: "数据", exact: true }).click();
|
||||||
|
const table = popup.getByRole("table");
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
await expect(table.getByRole("row")).toHaveCount(14);
|
||||||
|
await expect(table.getByRole("columnheader")).toHaveCount(9);
|
||||||
|
await popup.getByRole("button", { name: "关闭窗口", exact: true }).click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user