diff --git a/.changeset/report-chart-measure-label-4020.md b/.changeset/report-chart-measure-label-4020.md
new file mode 100644
index 0000000000..e403cbc559
--- /dev/null
+++ b/.changeset/report-chart-measure-label-4020.md
@@ -0,0 +1,21 @@
+---
+'@object-ui/plugin-report': patch
+---
+
+报表内嵌图表的度量显示名按三级回落解析,不再直接印原始 `name`
+
+数据集绑定的 report chart 此前把度量原样交给图表组件(`series: [{ dataKey }]`,无 label),
+于是图例、标记 tooltip 与单值卡片的说明文字都落回 dataKey——在全中文控制台上打出
+`potential_upside_tons`,而同一张报表下方的汇总表、以及绑定同一数据集的 dashboard 图表
+都能正确解析出授权 `label`。`ReportChartSchema` 自 rc.1 起声明的 `series[].label` 也从未
+被读取,作者因此没有任何可授权的手段控制这个字符串。
+
+现按三级回落解析,与汇总表和 dashboard 的既有口径对齐:
+
+1. `chart.series[]` 中 `name` 命中该度量的条目的 `label`(spec 的 `I18nLabel`,按控制台
+ 语言解析;同名重复以第一条为准);
+2. 绑定数据集的度量 `label`(结果字段的 `label`,即汇总表表头一直在读的同一个值);
+3. 度量 `name` 兜底。
+
+图例与 tooltip 同源同修:`ChartRenderer` 把 series 的 `label` 写进 `config[dataKey].label`,
+三处读的是同一个输入。单值族(`metric`/`kpi`/`gauge`)的说明文字与系列图共用这一次解析。
diff --git a/packages/plugin-report/src/DatasetReportRenderer.tsx b/packages/plugin-report/src/DatasetReportRenderer.tsx
index 99607f0829..ce548e0dfa 100644
--- a/packages/plugin-report/src/DatasetReportRenderer.tsx
+++ b/packages/plugin-report/src/DatasetReportRenderer.tsx
@@ -71,7 +71,7 @@ import {
type DatasetResultField,
type DatasetDrillRange,
} from '@object-ui/core';
-import { useSafeFieldLabel, useSafeTranslate, useDisplayLocale } from '@object-ui/i18n';
+import { useSafeFieldLabel, useSafeTranslate, useDisplayLocale, useObjectTranslation, pickLocalized } from '@object-ui/i18n';
import { mergeFilters } from './mergeFilters';
import { useDatasetDimensionLabels } from './useDatasetDimensionLabels';
@@ -642,6 +642,37 @@ function useRegistryComponent(
| undefined;
}
+/**
+ * The author's per-chart override for ONE measure's display name — the entry of
+ * `chart.series[]` whose `name` IS that measure (objectui#4020, level ①).
+ *
+ * `ReportChartSchema.series[]` has declared `{ name, label? }` since rc.1, but
+ * the renderer never read it: the chart was handed `series: [{ dataKey }]` with
+ * no label at all, so an authored `label` was inert metadata and the measure
+ * printed as its raw `name`. Declared is now enforced.
+ *
+ * The FIRST entry naming the measure wins, the same ruling the dashboard's
+ * `mergeAuthoredPresentation` makes for its own `chartConfig.series` — a later
+ * duplicate cannot silently re-label a series the author already described.
+ *
+ * `label` is the spec's `I18nLabel` (`string | { en, 'zh-CN', … }`), so it goes
+ * through `pickLocalized`, the repo's one resolver for that shape — a
+ * first-string-wins pick would put the English limb of a translated label on a
+ * zh-CN console, which is the very defect class this card closes. An entry that
+ * names the measure but carries no usable `label` returns `undefined` and falls
+ * through to the dataset's own measure label, exactly as an absent entry does.
+ */
+function authoredSeriesLabel(series: unknown, measure: string, language: string | undefined): string | undefined {
+ if (!Array.isArray(series) || !measure) return undefined;
+ for (const entry of series as unknown[]) {
+ if (!entry || typeof entry !== 'object') continue;
+ const { name, label } = entry as { name?: unknown; label?: unknown };
+ if (name !== measure) continue;
+ return pickLocalized(label, language) || undefined;
+ }
+ return undefined;
+}
+
/**
* Render a report's embedded `chart` (ADR-0021) by running its OWN dataset
* query — the `xAxis` dimension grouped, the `yAxis` measure aggregated — and
@@ -696,6 +727,12 @@ function DatasetReportChart({
// and follows the display locale. (The series charts render their own labels
// through the chart component, not through `formatMeasure`.)
const displayLocale = useDisplayLocale();
+ // objectui#4020 — the UI LANGUAGE an authored `series[].label` is picked in.
+ // Deliberately not `displayLocale`: that one prefers the tenant's REGIONAL
+ // locale (ADR-0053) because it feeds `Intl` number formatting, and a workspace
+ // formatting numbers as `de-DE` while its console runs zh must still read the
+ // zh limb of a translated label.
+ const { language } = useObjectTranslation();
// objectui#4330 — the embedded chart plots the SAME dimension the table
// beneath it groups by, so it takes the same label map. Leaving it out would
// put the two spellings of one value on one screen, which is the defect this
@@ -738,8 +775,25 @@ function DatasetReportChart({
// On error or empty, fall back silently to the table beneath.
if (state.status === 'error' || state.rows.length === 0) return null;
+ const { measureField, headerLabel } = buildDatasetFieldHelpers(state.fields, state.object, fieldLabel);
+ // The measure's display name, resolved ONCE for every branch below
+ // (objectui#4020). Three levels, highest first:
+ //
+ // 1. `chart.series[]`'s entry naming this measure — the spec's own per-chart
+ // override (see `authoredSeriesLabel`);
+ // 2. the bound dataset's `measures[].label`, which reaches the client as the
+ // result field's `label` — `headerLabel` reads exactly that (then the i18n
+ // field-label convention), so the chart and the summary table BENEATH IT
+ // print one string for one measure instead of two;
+ // 3. the measure NAME, `headerLabel`'s own last resort.
+ //
+ // Level ② is what the dashboard's `buildChartSeries` already did for its own
+ // chart series (`fields[].label`), and what the table here has always done;
+ // the report chart was the one surface that consulted neither and shipped the
+ // raw `name` to a fully-translated console.
+ const measureLabel = authoredSeriesLabel(chart.series, yAxis, language) ?? headerLabel(yAxis);
+
if (plan.kind === 'single_value') {
- const { measureField, headerLabel } = buildDatasetFieldHelpers(state.fields, state.object, fieldLabel);
const mf = measureField(yAxis);
return (
@@ -748,7 +802,7 @@ function DatasetReportChart({
{formatMeasure(state.rows[0]?.[yAxis], mf?.format, mf?.currency, mf?.percentScale, displayLocale)}
- {headerLabel(yAxis)}
+ {measureLabel}
);
@@ -767,7 +821,12 @@ function DatasetReportChart({
chartType: plan.chartType,
data: relabelDimensions(state.rows, dimensionLabels),
xAxisKey: xAxis,
- series: [{ dataKey: yAxis }],
+ // One series, carrying its display name. `ChartRenderer` turns a
+ // series `label` into `config[dataKey].label`, which is the single
+ // input the legend, the mark's tooltip name and the single-value
+ // caption all read — so legend and tooltip follow this by
+ // construction rather than by a second resolution.
+ series: [{ dataKey: yAxis, label: measureLabel }],
height: typeof chart.height === 'number' ? chart.height : 280,
// Render deterministically (no rAF entrance animation): reports are
// often viewed in a background tab or exported, where an animated
diff --git a/packages/plugin-report/src/__tests__/DatasetReportRenderer.chartMeasureLabel.test.tsx b/packages/plugin-report/src/__tests__/DatasetReportRenderer.chartMeasureLabel.test.tsx
new file mode 100644
index 0000000000..02bc0ce540
--- /dev/null
+++ b/packages/plugin-report/src/__tests__/DatasetReportRenderer.chartMeasureLabel.test.tsx
@@ -0,0 +1,276 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectui#4020 — a report chart printed the raw dataset measure `name` where
+ * the summary table beneath it, and a dashboard chart over the same dataset,
+ * both printed the authored `label`.
+ *
+ * The renderer handed the chart component `series: [{ dataKey: yAxis }]`: no
+ * label at all. `ChartRenderer` then fills `config[dataKey].label` with the
+ * dataKey itself, so the legend, the mark's tooltip name and (on a single-value
+ * family) the caption all read `potential_upside_tons` under a fully-translated
+ * console. The authored `chart.series[].label` — declared by
+ * `ReportChartSchema` since rc.1 — never reached the chart either, which is why
+ * the card's reporter measured it as inert metadata.
+ *
+ * The resolution these cases pin, highest first:
+ * ① `chart.series[]`'s entry naming the measure,
+ * ② the bound dataset's measure label (the result field's `label`),
+ * ③ the measure `name`.
+ *
+ * DIRECTIONS, written before the reverse verification was run. "Revert it and
+ * watch them go red" is ambiguous here, because there are two different ways to
+ * take the resolution away and they do NOT move the same case — so both were
+ * predicted, then run, and both matched:
+ *
+ * - **name直读** (`measureLabel = yAxis`): 9 red, 1 green. The survivor is ③,
+ * and necessarily so — reading the name directly COINCIDES with level ③, so
+ * that case cannot distinguish the two. Every failure printed
+ * `potential_upside_tons`, i.e. the card's reported symptom verbatim.
+ * - **the exact pre-change lines** (`series: [{ dataKey }]`, caption
+ * `headerLabel(yAxis)`): 9 red, 1 green — a DIFFERENT survivor. ③ goes red
+ * here (the forwarded series carried no `label` key at all, so it is
+ * `undefined`, not the name), and the green one is "a kpi caption still
+ * falls back to the dataset measure label" — measured `'120可抢吨位(吨)'`.
+ * That is not a weak case; it is the record that the single-value branch
+ * already resolved ② and ③ before this change, and that ① was the only level
+ * missing there. The series branch resolved none of the three.
+ *
+ * So ③'s job is not "red before": it is the guard that the last resort still
+ * lands on the NAME and did not become `undefined` or `[object Object]` — a
+ * failure ① and ② cannot see, and the one the pre-change code actually had.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { ComponentRegistry } from '@object-ui/core';
+import { I18nProvider } from '@object-ui/i18n';
+import { DatasetReportRenderer } from '../DatasetReportRenderer';
+
+/** The card's own dataset: a measure whose authored label is Chinese. */
+const RESULT = {
+ rows: [
+ { account_name: 'Acme', potential_upside_tons: 120 },
+ { account_name: 'Globex', potential_upside_tons: 80 },
+ ],
+ fields: [
+ { name: 'account_name', type: 'string', label: '客户名称' },
+ { name: 'potential_upside_tons', type: 'number', label: '可抢吨位(吨)' },
+ ],
+};
+
+/** The same result with NO field labels — the level ③ shape. */
+const UNLABELLED = {
+ rows: RESULT.rows,
+ fields: [
+ { name: 'account_name', type: 'string' },
+ { name: 'potential_upside_tons', type: 'number' },
+ ],
+};
+
+const sourceOf = (result: unknown) => ({ queryDataset: vi.fn(async () => result) });
+
+/** Props the registered chart component was handed, or `null` if never called. */
+let captured: { schema: Record } | null = null;
+
+beforeEach(() => {
+ captured = null;
+ ComponentRegistry.register('chart', (props: any) => {
+ captured = props;
+ return null;
+ });
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+/** The label the chart component is asked to paint for the plotted measure. */
+async function chartSeriesLabel(report: Record, result: unknown = RESULT) {
+ render();
+ await waitFor(() => expect(captured?.schema?.series?.[0]?.dataKey).toBe('potential_upside_tons'));
+ return captured!.schema.series[0].label;
+}
+
+const BASE = {
+ name: 'heimao_key_account_attack_list',
+ type: 'tabular',
+ dataset: 'heimao_account_metrics',
+ rows: ['account_name'],
+ values: ['potential_upside_tons'],
+};
+
+describe('report chart measure label — three levels (objectui#4020)', () => {
+ it('① an authored chart.series[].label naming the measure wins', async () => {
+ // The card measured this exact metadata as inert: authored, rebuilt,
+ // restarted, still `potential_upside_tons` on the axis.
+ const label = await chartSeriesLabel({
+ ...BASE,
+ chart: {
+ type: 'horizontal-bar',
+ xAxis: 'account_name',
+ yAxis: 'potential_upside_tons',
+ series: [{ name: 'potential_upside_tons', label: '本季可抢吨位' }],
+ },
+ });
+ // Beats the dataset's own label, which is a DIFFERENT string here on
+ // purpose: a per-chart override that merely agreed with the dataset would
+ // pass whether or not it was read.
+ expect(label).toBe('本季可抢吨位');
+ });
+
+ it('② with no series authored, the dataset measure label is the default', async () => {
+ const label = await chartSeriesLabel({
+ ...BASE,
+ chart: { type: 'horizontal-bar', xAxis: 'account_name', yAxis: 'potential_upside_tons' },
+ });
+ expect(label).toBe('可抢吨位(吨)');
+ });
+
+ it('③ with neither, the measure name is the last resort', async () => {
+ const label = await chartSeriesLabel(
+ { ...BASE, chart: { type: 'horizontal-bar', xAxis: 'account_name', yAxis: 'potential_upside_tons' } },
+ UNLABELLED,
+ );
+ expect(label).toBe('potential_upside_tons');
+ });
+
+ it('a series entry naming ANOTHER measure does not relabel this one', async () => {
+ // Membership belongs to the report's `values`/`yAxis`; an authored entry
+ // that names something else is presentation for a series this chart does
+ // not plot, and must not leak onto the one it does.
+ const label = await chartSeriesLabel({
+ ...BASE,
+ chart: {
+ type: 'horizontal-bar',
+ xAxis: 'account_name',
+ yAxis: 'potential_upside_tons',
+ series: [{ name: 'shipped_volume_tons', label: '发货量(吨)' }],
+ },
+ });
+ expect(label).toBe('可抢吨位(吨)');
+ });
+
+ it('an entry naming the measure with no usable label falls through to ②', async () => {
+ const label = await chartSeriesLabel({
+ ...BASE,
+ chart: {
+ type: 'horizontal-bar',
+ xAxis: 'account_name',
+ yAxis: 'potential_upside_tons',
+ series: [{ name: 'potential_upside_tons' }],
+ },
+ });
+ expect(label).toBe('可抢吨位(吨)');
+ });
+
+ it('the FIRST entry naming the measure wins over a later duplicate', async () => {
+ const label = await chartSeriesLabel({
+ ...BASE,
+ chart: {
+ type: 'horizontal-bar',
+ xAxis: 'account_name',
+ yAxis: 'potential_upside_tons',
+ series: [
+ { name: 'potential_upside_tons', label: '第一次' },
+ { name: 'potential_upside_tons', label: '第二次' },
+ ],
+ },
+ });
+ expect(label).toBe('第一次');
+ });
+});
+
+describe('report chart measure label — one measure, one string, one screen', () => {
+ it('the chart series and the summary-table header read the SAME label', async () => {
+ // The card's diagnosis in one assertion: the two elements sit on one
+ // screen, over one measure, and disagreed.
+ render(
+ ,
+ );
+ await waitFor(() => expect(captured?.schema?.series?.[0]?.label).toBeTruthy());
+ const headers = (await screen.findAllByRole('columnheader')).map((th) => th.textContent);
+ expect(headers).toContain('可抢吨位(吨)');
+ expect(captured!.schema.series[0].label).toBe('可抢吨位(吨)');
+ // And the raw name is nowhere on the screen the chart component paints.
+ expect(headers).not.toContain('potential_upside_tons');
+ });
+});
+
+describe('report chart measure label — an i18n label record picks the console language', () => {
+ it('resolves the zh limb of an authored { en, zh-CN } series label', async () => {
+ // `ReportChartSchema.series[].label` is the spec's I18nLabel, so the record
+ // form is as authorable as the string one. Resolving it "first string wins"
+ // would paint the English limb on a zh console — the defect class this card
+ // closes, reintroduced by the fix.
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(captured?.schema?.series?.[0]?.label).toBeTruthy());
+ expect(captured!.schema.series[0].label).toBe('可抢吨位');
+ });
+});
+
+describe('report chart measure label — the single-value families take the same resolution', () => {
+ it('a kpi caption honours the authored series label over the dataset one', async () => {
+ // `metric`/`kpi`/`gauge` render the measure as a number with its display
+ // name beneath; that caption resolved ② and ③ already and skipped ①. One
+ // resolution now serves both branches, so the two cannot drift.
+ render(
+ ,
+ );
+ const metric = await screen.findByTestId('dataset-report-metric');
+ expect(metric.textContent).toContain('本季可抢吨位');
+ expect(metric.textContent).not.toContain('potential_upside_tons');
+ });
+
+ it('a kpi caption still falls back to the dataset measure label', async () => {
+ render(
+ ,
+ );
+ const metric = await screen.findByTestId('dataset-report-metric');
+ expect(metric.textContent).toContain('可抢吨位(吨)');
+ });
+});