// report.jsx  v6  —  自动生成运动表现评估报告（PDF 导出模态框）
// 职责：读取当前 athlete + squad 数据，渲染报告 HTML，触发 window.print()
// 依赖：data.js（metricScore01）· components.jsx

const { useState: rUseS, useMemo: rUseM, useEffect: rUseE, useRef: rUseRef } = React;

function formatAge(age) {
  if (age == null || (typeof age === 'string' && age.trim() === '')) return '—';
  const value = Number(age);
  return Number.isFinite(value) ? value.toFixed(1) : '—';
}

function formatAgeWithUnit(age) {
  const formatted = formatAge(age);
  return formatted === '—' ? formatted : `${formatted}y`;
}

function reportAthleteIdentityParts(athlete) {
  const source = athlete || {};
  const demographics = window.AthleteProfile?.resolveDemographics
    ? window.AthleteProfile.resolveDemographics(source)
    : { age: source.age, gender: source.gender || source.sex || '', sport: source.sport || '' };
  const parts = [];
  if (source.position) parts.push(source.position);
  if (source.jersey != null && String(source.jersey).trim()) {
    parts.push(`#${String(source.jersey).padStart(2, '0')}`);
  }
  if (demographics.age != null) parts.push(`年龄 ${formatAge(demographics.age)} 岁`);
  if (demographics.gender) parts.push(`性别 ${demographics.gender}`);
  if (demographics.sport) parts.push(`项目 ${demographics.sport}`);
  if (source.country) parts.push(source.country);
  return parts;
}

function reportNumber(value) {
  if (value == null || value === '' || (typeof value === 'string' && value.trim() === '')) return null;
  const number = Number(value);
  return Number.isFinite(number) ? number : null;
}

function reportFormatNumber(value, fallback = '—') {
  const number = reportNumber(value);
  if (number == null) return fallback;
  const magnitude = Math.abs(number);
  if (magnitude >= 100) return String(Math.round(number));
  if (magnitude >= 10) return number.toFixed(1);
  return number.toFixed(2);
}

function reportFormatDelta(value) {
  const number = reportNumber(value);
  if (number == null) return '—';
  return Math.abs(number) >= 10 ? number.toFixed(1) : number.toFixed(2);
}

// Fit a cloned report's .paper onto a single A4 page when it is only mildly over.
// Measures content aspect vs A4 (297/210) and zooms down; but if fitting would
// require shrinking past ~0.78 (report is genuinely > ~1.3 pages) we leave it for
// the smart page-break rules to split cleanly across multiple pages instead of
// cramming everything tiny onto one page.
const ONE_PAGE_ZOOM_FLOOR = 0.78;
function fitReportToOnePage(clone) {
  const papers = clone.querySelectorAll('.paper');
  papers.forEach(paper => {
    const w = paper.offsetWidth, h = paper.scrollHeight;
    if (!w || !h) return;
    const pageRatio = 297 / 210;           // full A4 page (full-bleed: paper now fills the page)
    const ratio = h / w;
    if (ratio <= pageRatio + 0.01) return; // already fits one page
    const z = (pageRatio / ratio) * 0.985; // small safety margin
    if (z >= ONE_PAGE_ZOOM_FLOOR) paper.style.zoom = z.toFixed(3);
  });
}

// ── RD-1: report bundle exporter ──────────────────────────────────────────
// Assembles several already-rendered report print bodies into ONE multi-page
// print/PDF job. Pure DOM: clones each source `#*-report-body` node into a
// page-break-wrapped section inside `#bundle-print-root`, reuses the SAME
// one-page fit + `printing-report` kernel as the single-report doPrint flows,
// then fires a single window.print(). Does NOT re-render or modify any report
// body — it only clones the nodes the report components already produced.
function doPrintBundle(bodyNodes, { title } = {}) {
  const nodes = (bodyNodes || []).filter(Boolean);
  if (!nodes.length) return;
  const prevTitle = document.title;
  if (title) document.title = title;

  document.getElementById('bundle-print-root')?.remove();
  const root = document.createElement('div');
  root.id = 'bundle-print-root';
  root.style.cssText = 'display:block;';
  nodes.forEach((node, i) => {
    const page = document.createElement('div');
    page.className = 'bundle-page';
    if (i < nodes.length - 1) {
      page.style.cssText = 'break-after:page;page-break-after:always;';
    }
    const clone = node.cloneNode(true);
    clone.removeAttribute('id'); // avoid duplicate #report-body / #print-clone ids
    clone.style.cssText = 'display:block;';
    page.appendChild(clone);
    root.appendChild(page);
  });
  document.body.appendChild(root);

  fitReportToOnePage(root); // scales EACH .paper page independently

  document.body.classList.add('printing-report');
  const pageStyle = document.createElement('style');
  pageStyle.id = 'rpt-page-override';
  pageStyle.textContent = '@media print { @page { size: A4; margin: 0; } }';
  document.head.appendChild(pageStyle);
  window.addEventListener('afterprint', () => {
    document.body.classList.remove('printing-report');
    document.title = prevTitle;
    document.getElementById('bundle-print-root')?.remove();
    document.getElementById('rpt-page-override')?.remove();
  }, { once: true });
  setTimeout(() => window.print(), 80);
}
Object.assign(window, { doPrintBundle });

// Per-athlete + per-season coach-notes draft persistence.
const coachNotesKey = (athleteId, season) => `coach_notes_${athleteId}_${season}`;
const coachNotesLoad = (athleteId, season) => {
  try { return localStorage.getItem(coachNotesKey(athleteId, season)) || ''; } catch { return ''; }
};

// ── Compact SWC row for the report ─────────────────────────────────────────
function ReportSWCRow({ row: r }) {
  const fmt = v => reportFormatDelta(v);
  return (
    <div style={{
      display: 'flex', alignItems: 'center', justifyContent: 'space-between',
      padding: '5px 8px', marginBottom: 4,
      background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 5,
    }}>
      <div style={{ minWidth: 0 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
          <span style={{ width: 3, height: 12, borderRadius: 1, background: r.groupAccent, flexShrink: 0 }}/>
          <span style={{ fontSize: 12, color: 'var(--text-2)' }}>{r.label}</span>
        </div>
        <div className="mono" style={{ fontSize: 10, color: 'var(--muted)', marginTop: 1, paddingLeft: 9 }}>
          {fmt(r.prev)} → {fmt(r.curr)} {r.unit}
        </div>
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0, marginLeft: 12 }}>
        <span className="mono" style={{ fontSize: 13, fontWeight: 600, color: r.improved ? 'var(--pos)' : 'var(--neg)' }}>
          {r.deltaRaw > 0 ? '+' : ''}{fmt(r.deltaRaw)}<span style={{ fontSize: 10, marginLeft: 2, fontWeight: 400 }}>{r.unit}</span>
        </span>
        <span style={{
          padding: '2px 7px', borderRadius: 999, fontSize: 10, fontWeight: 600,
          background: `${r.band.color}20`, color: r.band.color,
          border: `1px solid ${r.band.color}44`,
          textTransform: 'uppercase', letterSpacing: '.05em', whiteSpace: 'nowrap',
        }}>{r.band.label}</span>
        <span className="mono" style={{ fontSize: 10, color: 'var(--muted)', minWidth: 38, textAlign: 'right' }}>
          {reportFormatDelta(r.absES)}σ
        </span>
      </div>
    </div>
  );
}

// ── P4-3: 变化森林图 Forest Plot ──────────────────────────────────────────
function ReportForestPlot({ rows }) {
  if (!rows || rows.length === 0) return null;
  const D = window.DASHBOARD_DATA;
  const W = 680, ML = 152, MR = 68, MT = 24, MB = 20;
  const PW = W - ML - MR;
  const ROW_H = 22;
  const H = MT + rows.length * ROW_H + MB;

  const maxES = Math.min(3, Math.max(0.4, ...rows.map(r => Math.abs(r.es))) * 1.3);
  const xS = v => ML + PW / 2 + (Math.max(-maxES, Math.min(maxES, v)) / maxES) * (PW / 2);
  const yS = i => MT + i * ROW_H + ROW_H / 2;

  const fmt = v => reportFormatDelta(v);
  const gridES = [0.2, 0.6, 1.2, 2.0].filter(v => v < maxES);

  return (
    <svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', display: 'block', marginTop: 12 }}>
      <rect x={xS(-0.2)} y={MT} width={xS(0.2) - xS(-0.2)} height={rows.length * ROW_H}
        fill="rgba(148,163,184,.13)" />
      {gridES.map(v => [v, -v]).flat().map((v, i) => (
        <line key={i} x1={xS(v)} y1={MT} x2={xS(v)} y2={MT + rows.length * ROW_H}
          stroke="rgba(148,163,184,.2)" strokeDasharray="3 2"/>
      ))}
      <line x1={xS(0)} y1={MT - 8} x2={xS(0)} y2={MT + rows.length * ROW_H}
        stroke="rgba(15,23,42,.25)" strokeWidth="1.5"/>
      {[-Math.round(maxES), -1, 0, 1, Math.round(maxES)].map(v => (
        <text key={v} x={xS(v)} y={MT + rows.length * ROW_H + 14} textAnchor="middle" fontSize="9.5" fill="var(--muted)">{v}σ</text>
      ))}
      {rows.map((r, i) => {
        const cy = yS(i);
        const x0 = xS(0), xE = xS(r.es);
        const icc = D.ICC_USER?.[r.metricId] ?? D.ICC_DEFAULTS?.[r.metricId];
        const mdc = icc != null && r.sd > 0 ? D.computeMDC(r.sd, icc) : null;
        const mdcES = mdc != null && r.sd > 0 ? (mdc / 2) / r.sd : null;
        const isImp = r.es > 0;
        const barColor = r.band.id === 'trivial' ? 'rgba(148,163,184,.5)' : (isImp ? 'var(--pos)' : 'var(--neg)');
        return (
          <g key={r.metricId}>
            {i % 2 === 0 && <rect x={0} y={cy - ROW_H / 2} width={W} height={ROW_H} fill="rgba(15,23,42,.025)"/>}
            <rect x={2} y={cy - 5} width={3} height={10} fill={r.groupAccent} rx={1}/>
            <text x={ML - 8} y={cy + 4} textAnchor="end" fontSize="10.5" fill="var(--text-2)">{r.label}</text>
            <line x1={x0} y1={cy} x2={xE} y2={cy} stroke={barColor} strokeWidth="2.5" strokeOpacity=".7"/>
            {mdcES != null && (
              <g stroke="rgba(148,163,184,.8)" strokeWidth="1.2">
                <line x1={xS(r.es - mdcES)} y1={cy - 4} x2={xS(r.es - mdcES)} y2={cy + 4}/>
                <line x1={xS(r.es + mdcES)} y1={cy - 4} x2={xS(r.es + mdcES)} y2={cy + 4}/>
                <line x1={xS(r.es - mdcES)} y1={cy} x2={xS(r.es + mdcES)} y2={cy} strokeDasharray="2 1"/>
              </g>
            )}
            <path d={`M${xE},${cy - 5} L${xE + 5},${cy} L${xE},${cy + 5} L${xE - 5},${cy} Z`}
              fill={barColor} fillOpacity={r.band.id === 'trivial' ? 0.5 : 0.9}/>
            <text x={W - MR + 6} y={cy + 4} fontSize="10.5" fill={r.improved ? 'var(--pos)' : 'var(--neg)'} fontFamily="var(--font-mono)" fontWeight="600">
              {r.deltaRaw > 0 ? '+' : ''}{fmt(r.deltaRaw)}{r.unit}
            </text>
          </g>
        );
      })}
      <text x={ML} y={MT - 6} fontSize="9.5" fill="var(--muted)">Decline ←</text>
      <text x={ML + PW} y={MT - 6} fontSize="9.5" fill="var(--muted)" textAnchor="end">→ Improvement</text>
      <text x={xS(0) + 4} y={MT - 6} fontSize="9" fill="var(--muted-2)" fontStyle="italic">SWC ±0.2σ</text>
    </svg>
  );
}

function formatReportEvidenceDate(value) {
  if (!value) return '/';
  return String(value).slice(0, 10) || '/';
}

const INDIVIDUAL_REPORT_CMJ_METRICS = [
  { key: 'jumpHeight', label: 'Jump Height', unit: 'cm' },
  { key: 'rsiMod', label: 'RSI-mod', unit: 'm/s' },
  { key: 'peakPower', label: 'Peak Power', unit: 'W/kg' },
  { key: 'peakForce', label: 'Peak Force', unit: 'N' },
  { key: 'brakingRFD', label: 'Braking RFD', unit: 'N/s' },
  { key: 'netImpulse', label: 'Net Impulse', unit: 'N·s' },
  { key: 'timeToTakeoff', label: 'Time to Takeoff', unit: 'ms' },
  { key: 'cmDepth', label: 'CM Depth', unit: 'cm' },
  { key: 'lr', label: 'L/R Asymmetry', unit: '%' },
];
const INDIVIDUAL_REPORT_CMJ_METRIC_PRESETS = [
  { id: 'default', label: '默认组合', keys: ['jumpHeight', 'rsiMod', 'peakPower', 'peakForce'] },
  { id: 'power', label: '爆发输出', keys: ['jumpHeight', 'rsiMod', 'peakPower'] },
  { id: 'force', label: '力量能力', keys: ['peakForce', 'brakingRFD', 'netImpulse'] },
  { id: 'timing', label: '时程 / 技术', keys: ['timeToTakeoff', 'cmDepth', 'lr'] },
];
// RD-4 (2026-07-09, ③b): the four CMJ charts were removed from the individual
// report — they now live ONLY in the source report (cmj.jsx components). The individual
// report's CMJ module keeps 结论 + 关键指标 + 「打开测力台报告」链出. The old
// chart-option table + curve wrapper that used to embed them here were retired.
// FORCE-4 (2026-07-10): the CMJ source report was retired; the link-out now opens
// the unified 测力台报告 (ForceReportWorkbench, force-report.jsx) at the CMJ tab.

function getReportCmjSession(evidence) {
  return evidence?.sourceSummary?.session || evidence?.session || null;
}

function formatReportMetricValue(value, unit) {
  const n = reportNumber(value);
  if (n == null) return '/';
  if (unit === 'ms' || Math.abs(n) >= 1000) return Math.round(n).toLocaleString();
  if (Math.abs(n) >= 100) return n.toFixed(0);
  if (Math.abs(n) >= 10) return n.toFixed(1);
  return n.toFixed(2);
}

function getReportCmjMetricRows(evidence, selectedKeys) {
  const best = getReportCmjSession(evidence)?.best || {};
  const metricByKey = new Map(INDIVIDUAL_REPORT_CMJ_METRICS.map(metric => [metric.key, metric]));
  const selected = selectedKeys instanceof Set ? [...selectedKeys] : INDIVIDUAL_REPORT_CMJ_METRICS.slice(0, 4).map(m => m.key);
  return selected
    .map(key => metricByKey.get(key))
    .filter(Boolean)
    .map(metric => ({
      ...metric,
      value: formatReportMetricValue(best[metric.key], metric.unit),
    }));
}

// RP-T1: p-* 纸面形态 (复刻 mockup .p-concl + .p-metrics)。
// RD-4 (2026-07-09, ③b): 四类图已移出个人报告，只保留 结论 + 关键指标 + 「打开测力台
// 报告」链出；四类图现仅存于源报告 (cmj.jsx 图表组件)。旧的曲线开关 props 与会话派生已移除。
// FORCE-4 (2026-07-10): 链出统一改为「打开测力台报告」(ForceReportWorkbench)。
// 保留意图: 数据来源(evidence/metrics)与真值状态不变; ref 行含审核引用。
function IndividualReportReviewedEvidenceCallout({ evidence, metrics = [], interactive = false, onOpenReview, onOpenReport }) {
  if (!evidence) return null;
  const reviewRef = evidence.reviewRef || evidence.sourceRef || evidence.reviewId || null;
  const reviewer = evidence.reviewer || evidence.reviewedBy || null;
  const reviewerLabel = typeof reviewer === 'string' ? reviewer : reviewer?.displayName || '';
  return (
    <div className="individual-reviewed-evidence">
      {metrics.length > 0 && (
        <div className="p-metrics individual-report-cmj-metrics">
          {metrics.map(metric => (
            <div key={metric.key} className="cell">
              <div className="l">{metric.label}</div>
              <div className="v">{metric.value}<small>{metric.value === '/' ? '' : metric.unit}</small></div>
            </div>
          ))}
        </div>
      )}
      <div className="p-concl">
        <div className="txt">{evidence.acceptedClassification || '/'}</div>
        {evidence.reviewerTrainingFocus && (
          <div className="grid"><div className="full"><strong>训练关注点</strong> {evidence.reviewerTrainingFocus}</div></div>
        )}
        {evidence.reviewerNote && <div className="grid"><div className="full"><strong>审核备注</strong> {evidence.reviewerNote}</div></div>}
        <div className="ref">
          <b>✓ 已确认</b> · CMJ review {formatReportEvidenceDate(evidence.sessionDate)}
          {reviewRef ? ` · reviewRef ${reviewRef}` : ''}
          {evidence.reviewRevision ? ` · revision ${evidence.reviewRevision}` : ''}
          {reviewerLabel ? ` · 审核人 ${reviewerLabel}` : ''}
        </div>
      </div>
      {interactive && (
        <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', paddingTop: 2 }}>
          <button type="button" className="preset" disabled={!evidence.sourceAvailable} onClick={() => (onOpenReport || onOpenReview)?.(evidence.sourceSummary)}>打开测力台报告</button>
        </div>
      )}
    </div>
  );
}

// Generalized confirmed-conclusion callout (SJ / IMTP / manual). Reuses the exact
// paper form of the CMJ reviewed-result callout — same border, background, radius
// and padding — so preview and print stay visually aligned with the CMJ block.
// RP-T1: p-concl 纸面形态 (复刻 mockup .p-concl + ref 行)。保留意图: 数据来源与
// 已确认真值状态不变; ref 行含审核引用与审核人(有则显示, 无不虚构)。
function IndividualReportConfirmedResultCallout({ evidence, title, subtitle }) {
  if (!evidence) return null;
  const reviewRef = evidence.reviewRef || evidence.sourceRef || evidence.reviewId || null;
  const reviewer = evidence.reviewer || evidence.reviewedBy || null;
  const reviewerLabel = typeof reviewer === 'string' ? reviewer : reviewer?.displayName || '';
  return (
    <div className="individual-confirmed-evidence p-concl">
      <div className="txt">{evidence.acceptedClassification || '/'}</div>
      {evidence.reviewerTrainingFocus && (
        <div className="grid"><div className="full"><strong>训练关注点</strong> {evidence.reviewerTrainingFocus}</div></div>
      )}
      {evidence.reviewerNote && <div className="grid"><div className="full"><strong>审核备注</strong> {evidence.reviewerNote}</div></div>}
      <div className="ref">
        <b>✓ 已确认</b>
        {evidence.sessionDate ? ` · ${formatReportEvidenceDate(evidence.sessionDate)}` : ''}
        {reviewRef ? ` · reviewRef ${reviewRef}` : ''}
        {evidence.reviewRevision ? ` · revision ${evidence.reviewRevision}` : ''}
        {reviewerLabel ? ` · 审核人 ${reviewerLabel}` : ''}
      </div>
    </div>
  );
}

// ── RP-R1': Training report modules — paper render variants ─────────────────
// Static paper forms复刻 report-workbench-mockup.html「训练周·负荷与执行」区块:
// p-heat 7-cell load heat + 量/强度 SVG side-by-side + Sessions/Completed/Load
// footnote. Data comes pre-built from window.ReportModuleRegistry.buildData (pure,
// deterministic). All classes live in the .rpt paper CSS段 using --paper族 tokens.

// Load-heat intensity band from a day's load value (null = 无记录, blank cell).
function reportHeatBand(load, maxLoad) {
  if (load == null || !(maxLoad > 0)) return '';
  const r = load / maxLoad;
  if (r > 0.75) return 'd4';
  if (r > 0.5) return 'd3';
  if (r > 0.25) return 'd2';
  return 'd1';
}
const REPORT_HEAT_WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'];

// training-week paper block: 负荷热力 + 量/强度 combo, footnote stats.
function ReportTrainingWeekPaper({ data }) {
  const summary = data?.summary || null;
  const heat = Array.isArray(data?.heat) ? data.heat : [];
  const loads = heat.map(c => c && typeof c.load === 'number' ? c.load : null);
  const maxLoad = Math.max(0, ...loads.filter(v => v != null));

  // 量/强度 mini chart: bars = daily load, smoothed line = daily load trend.
  const W = 260, H = 64, PT = 6, PB = 4;
  const cells = heat.length ? heat : REPORT_HEAT_WEEKDAYS.map(() => ({ load: null }));
  const n = cells.length;
  const slot = W / n;
  const barW = Math.max(4, slot * 0.55);
  const yOf = v => (maxLoad > 0 ? H - PB - (v / maxLoad) * (H - PT - PB) : H - PB);
  const linePoints = cells
    .map((c, i) => (c && typeof c.load === 'number' ? { x: slot * i + slot / 2, y: yOf(c.load) } : null))
    .filter(Boolean);
  const linePath = typeof trSmoothPath === 'function' ? trSmoothPath(linePoints) : '';

  const fmtInt = v => (typeof v === 'number' && isFinite(v) ? Math.round(v).toLocaleString() : '/');

  return (
    <div className="rpt-tr-week">
      <div className="rpt-tr-2col">
        <div className="rpt-tr-chart">
          <div className="t">周负荷热力 <small>hover 查看明细</small></div>
          <div className="rpt-tr-heat">
            {cells.map((c, i) => {
              const load = c && typeof c.load === 'number' ? c.load : null;
              const band = reportHeatBand(load, maxLoad);
              const label = REPORT_HEAT_WEEKDAYS[i] || '';
              return (
                <div key={i} className={band}
                  title={load == null ? `${label} · 无记录` : `${label} · Load ${fmtInt(load)} AU`}>
                  <span>{label}</span>
                </div>
              );
            })}
          </div>
        </div>
        <div className="rpt-tr-chart">
          <div className="t">量 / 强度</div>
          <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-label="每日负荷 量/强度">
            <g fill="rgba(37,99,235,.5)">
              {cells.map((c, i) => {
                const load = c && typeof c.load === 'number' ? c.load : 0;
                const y = yOf(load);
                return <rect key={i} x={slot * i + slot / 2 - barW / 2} y={y} width={barW} height={Math.max(0, H - PB - y)} rx="3" />;
              })}
            </g>
            {linePath && <path d={linePath} fill="none" stroke="var(--warm)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />}
          </svg>
        </div>
      </div>
      <div className="rpt-tr-note">
        Sessions {summary?.sessions ?? '/'} · Completed {summary?.completed ?? '/'} · Session Load {summary?.sessionLoad == null ? '/' : `${fmtInt(summary.sessionLoad)} AU`} · 数据源 sessionFeedback
      </div>
    </div>
  );
}

// training-period paper block: KPI row (增量 chip形态; 无对比数据则不显示 chip).
function ReportTrainingPeriodPaper({ data }) {
  const s = data?.stats || null;
  const kpis = [
    { l: 'Total Volume', v: s?.totalVolume == null ? '/' : Math.round(s.totalVolume).toLocaleString(), u: s?.totalVolume == null ? '' : 'kg' },
    { l: 'Avg Intensity', v: s?.avgIntensity == null ? '/' : s.avgIntensity, u: s?.avgIntensity == null ? '' : 'kg' },
    { l: 'Training Days', v: s?.trainingDays == null ? '/' : s.trainingDays, u: '' },
    { l: 'Total Sets', v: s?.totalSets == null ? '/' : s.totalSets, u: '' },
  ];
  return (
    <div className="rpt-tr-kpis">
      {kpis.map(k => (
        <div key={k.l} className="rpt-tr-kpi">
          <div className="l">{k.l}</div>
          <div className="v">{k.v}{k.u ? <small> {k.u}</small> : null}</div>
        </div>
      ))}
    </div>
  );
}

// training-volume-chart paper block: bars(volume) + smoothed line(avgWeight).
// 复用 trSmoothPath 算法（training.jsx global），纸样静态、无动画。
function ReportTrainingVolumeChartPaper({ data }) {
  const daily = Array.isArray(data?.daily) ? data.daily : [];
  if (!daily.length) {
    return <div className="rpt-tr-empty">/</div>;
  }
  const w = 560, h = 150, padL = 34, padR = 12, padT = 10, padB = 20;
  const innerW = w - padL - padR, innerH = h - padT - padB;
  const maxVolume = Math.max(1, ...daily.map(d => d.volume || 0));
  const weights = daily.map(d => d.avgWeight).filter(v => typeof v === 'number' && isFinite(v));
  const maxWeight = weights.length ? Math.max(...weights) : 1;
  const n = daily.length;
  const slot = innerW / n;
  const barW = Math.max(4, slot * 0.55);
  const xOf = i => padL + i * slot + slot / 2;
  const yVol = v => padT + innerH - (v / maxVolume) * innerH;
  const yWeight = v => padT + innerH - (v / maxWeight) * innerH;
  const linePoints = daily
    .map((d, i) => (typeof d.avgWeight === 'number' && isFinite(d.avgWeight) ? { x: xOf(i), y: yWeight(d.avgWeight) } : null))
    .filter(Boolean);
  const linePath = typeof trSmoothPath === 'function' ? trSmoothPath(linePoints) : '';

  return (
    <div className="rpt-tr-volchart">
      <svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" aria-label="日量 / 强度序列">
        <line x1={padL} y1={padT + innerH} x2={w - padR} y2={padT + innerH} stroke="var(--line-warm)" strokeWidth="1" />
        {daily.map((d, i) => (
          <rect key={d.date} x={xOf(i) - barW / 2} y={yVol(d.volume || 0)} width={barW}
            height={Math.max(0, padT + innerH - yVol(d.volume || 0))} rx="3" fill="rgba(37,99,235,.5)">
            <title>{`${d.date} · ${d.volume || 0} kg`}</title>
          </rect>
        ))}
        {linePath && <path d={linePath} fill="none" stroke="var(--warm)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />}
        {daily.map((d, i) => (
          typeof d.avgWeight === 'number' && isFinite(d.avgWeight)
            ? <circle key={'pt-' + d.date} cx={xOf(i)} cy={yWeight(d.avgWeight)} r="2.5" fill="var(--warm)"><title>{`${d.date} · avg ${d.avgWeight} kg`}</title></circle>
            : null
        ))}
        {daily.map((d, i) => (
          <text key={'lbl-' + d.date} x={xOf(i)} y={h - 5} textAnchor="middle" fontSize="8" fill="var(--muted)">{d.date.slice(5)}</text>
        ))}
      </svg>
    </div>
  );
}

// ── Report print body — matches 报告模板.html Tab 2 ───────────────────────
// variant: 'A' = 教练季度诊断  'B' = 运动员进步故事
const DEFAULT_MODULE_ORDER = ['narrative', 'cmj', 'sjResult', 'imtpResult', 'fieldResult', 'manualResult', 'trainingWeek', 'trainingPeriod', 'trainingVolumeChart', 'dims', 'metrics', 'recs', 'load', 'body', 'attend', 'notes'];
// RP-R1': training report modules — each carries its own report title + subtitle;
// all render through the paper components above from ReportModuleRegistry data.
const TRAINING_REPORT_MODULES = {
  trainingWeek: { label: '训练周 · 负荷与执行', subtitle: 'L1 · trainingLogs' },
  trainingPeriod: { label: '周期统计 · 7天', subtitle: 'L1 · actuals' },
  trainingVolumeChart: { label: '日量 / 强度序列', subtitle: 'L1 · 每日执行' },
};
// Confirmed-conclusion modules generalized from the CMJ reviewed-result callout
// (EV-AUDIT REV-R2). Each carries its own report title + subtitle; all reuse the
// paper-form IndividualReportConfirmedResultCallout below.
const INDIVIDUAL_CONFIRMED_RESULT_MODULES = {
  sjResult: { label: 'SJ 已确认结论', subtitle: '深蹲跳已审核结论' },
  imtpResult: { label: 'IMTP 已确认结论', subtitle: '等长中位拉已审核结论' },
  fieldResult: { label: '现场测试已确认结论', subtitle: '现场测试已审核结论' },
  manualResult: { label: '手动指标结论', subtitle: '手动指标已审核结论' },
};
function normalizeReportModuleOrder(order) {
  const input = Array.isArray(order) ? order : [];
  const kept = input.filter(key => DEFAULT_MODULE_ORDER.includes(key));
  return [...kept, ...DEFAULT_MODULE_ORDER.filter(key => !kept.includes(key))];
}
const TRAINING_RECOMMENDATION_PRESETS = [
  '维持当前训练结构，继续观察下一次测试变化。',
  '本周期优先控制训练总量，保留关键质量课。',
  '下周期聚焦技术执行质量，避免同时增加多个训练刺激。',
  '结合训练计划与主观反馈，再确认是否调整专项重点。',
];
// RD-2: 关键指标 · 原始实测 —— 核心可视化去复合分。每格 = 标签 · 值+单位 ·
// 相邻两次差值 (Δ) · 迷你趋势 sparkline。数据来自 MB-1 统一目录 (dimTiles，已在
// modal 里用 getCurrent/getSeries 预算好)，零派生零加权。Δ 好坏方向严格读
// entry.dir (dir==='lower' ? delta<0 : delta>0)，与 Status Board 同款；neutral /
// null / 0 → 灰色不判定。绝不按指标名硬编码方向。
function ReportRawMetricSparkline({ series }) {
  const pts = (Array.isArray(series) ? series : []).filter(p => p && isFinite(p.value));
  if (pts.length < 2) return null;
  const W = 90, H = 24, pad = 2;
  const vals = pts.map(p => p.value);
  const min = Math.min(...vals), max = Math.max(...vals);
  const span = max - min || 1;
  const coords = pts.map((p, i) => {
    const x = pad + (i / (pts.length - 1)) * (W - 2 * pad);
    const y = H - pad - ((p.value - min) / span) * (H - 2 * pad);
    return `${x.toFixed(1)},${y.toFixed(1)}`;
  }).join(' ');
  const last = coords.split(' ').pop().split(',');
  return (
    <svg className="spark" width={W} height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
      <polyline points={coords} fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" strokeLinecap="round" />
      <circle cx={last[0]} cy={last[1]} r="1.6" fill="currentColor" />
    </svg>
  );
}
function ReportRawMetricTiles({ tiles }) {
  const items = Array.isArray(tiles) ? tiles : [];
  if (items.length === 0) {
    return <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--muted)', padding: '10px 0', fontStyle: 'italic' }}>暂无可绘制指标</div>;
  }
  const fmtV = v => reportFormatNumber(v);
  return (
    <div className="trend-grid">
      {items.map(t => {
        const delta = t.delta;
        const judged = delta != null && delta !== 0 && t.dir !== 'neutral';
        const good = judged ? (t.dir === 'lower' ? delta < 0 : delta > 0) : null;
        const deltaClass = !judged ? 'zero' : good ? 'pos' : 'neg';
        const deltaLabel = delta == null ? '— 首次测试' : `${delta > 0 ? '↗ +' : delta < 0 ? '↘ ' : '→ '}${fmtV(delta)}${t.unit || ''} vs 上次`;
        return (
          <div key={t.id} className="trend raw">
            <div className="name">
              {t.label}
              {t.forceLabel && <span className="force-tag">{t.forceLabel}</span>}
            </div>
            <div className="v">{fmtV(t.value)}{t.unit ? <span className="u">{t.unit}</span> : null}</div>
            <div className={`d ${deltaClass}`}>{deltaLabel}</div>
            <div className={`sparkwrap ${deltaClass}`}><ReportRawMetricSparkline series={t.series} /></div>
          </div>
        );
      })}
    </div>
  );
}
function ReportPrintBody({ athlete, season, prevSeason,
  dimTiles = [], confirmedCount = 0, latestTestDate = null,
  sortedGroups, swcRows, metricRows = [], meaningful, improvements, declines, trivialCount, narrative, coachNotes,
  coachMode, variant = 'A', titles = {}, cols = null, order = DEFAULT_MODULE_ORDER, paperBg = 'warm',
  reviewedEvidence = null, showHeader = true, showAthleteStrip = true, cmjMetrics = [],
  sjEvidence = null, imtpEvidence = null, fieldEvidence = null, manualEvidence = null,
  trainingReportData = {},
  trainingRecommendationText = '', addSections = {}, preview = false, bodyId = null,
  branding = null }) {

  const fmt = v => reportFormatDelta(v);
  const showCol = k => !cols || cols.has(k);
  const initials = athlete.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
  const athleteIdentityParts = reportAthleteIdentityParts(athlete);

  // Build narrative as a single serif block for the insight card
  const insightText = narrative.length > 0 ? narrative[0] : '';
  const insightRest = narrative.slice(1);

  const seasonValues = athlete.seasons?.[season] || {};
  const metricDefById = new Map(sortedGroups.flatMap(group => (group.metrics || []).map(metric => [metric.id, metric])));
  const formatMetricValue = (value, unit = '') => {
    if (value == null || value === '' || !Number.isFinite(Number(value))) return '/';
    const numeric = Number(value);
    const formatted = Math.abs(numeric) >= 100 ? String(Math.round(numeric)) : Math.abs(numeric) >= 10 ? numeric.toFixed(1) : numeric.toFixed(2);
    return unit ? `${formatted} ${unit}` : formatted;
  };
  const metricRow = (id, fallbackLabel) => {
    const def = metricDefById.get(id) || {};
    return {
      label: def.label || fallbackLabel || id,
      value: formatMetricValue(seasonValues[id], def.unit),
    };
  };
  const optionalSectionRows = {
    load: [
      metricRow('sRPE', 'sRPE'),
      metricRow('duration_min', 'Duration'),
      metricRow('session_load', 'Session Load'),
      metricRow('acwr', 'ACWR'),
    ],
    body: [
      metricRow('weight', 'Body Weight'),
      metricRow('bodyfat', 'Body Fat'),
      metricRow('vo2max', 'VO2 Max'),
    ],
    attend: [
      { label: 'Recorded metric fields', value: Object.keys(seasonValues).length ? String(Object.keys(seasonValues).length) : '/' },
      { label: 'Training date', value: season || '/' },
      { label: 'Attendance record', value: '/' },
    ],
  };

  // RP-T1: p-kpis 四格数据 —— 全部来自既有真值, 无对比数据则不显示 delta chip, 无值则跳过该格。
  const kpiCells = (() => {
    const cells = [];
    // RD-2: 去复合分 —— 页眉首格由加权复合分换为客观标识 (最近测试日期 / 已确认项数)。
    cells.push({ l: '最近测试日期', v: latestTestDate ? window.formatDate(latestTestDate, 'short') : '/', u: '' });
    cells.push({ l: '已确认项数', v: String(confirmedCount), u: '项' });
    const cmjHeight = Array.isArray(cmjMetrics) ? cmjMetrics.find(m => m.key === 'jumpHeight' || m.key === 'height') : null;
    if (cmjHeight && cmjHeight.value !== '/') cells.push({ l: cmjHeight.label || 'CMJ Height', v: cmjHeight.value, u: cmjHeight.unit });
    const period = trainingReportData?.trainingPeriod?.stats || null;
    if (period && period.totalVolume != null) cells.push({ l: 'Total Volume', v: Math.round(period.totalVolume).toLocaleString(), u: 'kg' });
    const week = trainingReportData?.trainingWeek?.summary || null;
    if (week && week.sessions != null) cells.push({ l: 'Sessions', v: String(week.sessions), u: week.completed != null ? `/ ${week.completed}` : '' });
    return cells.slice(0, 4);
  })();

  return (
    <div id={preview ? undefined : (bodyId || 'report-body')} className="rpt" data-report-athlete-id={athlete.id}>
      <article className={`paper${(!coachMode && paperBg !== 'white') ? ' warm' : ''}`}>

        {/* ── RP-T1: p-band 头带 (复刻 mockup .p-band) —— 标题 + 右侧 meta(日期范围/机构) ── */}
        {showHeader && <div className="p-band">
          <div className="p-brand">
            {branding?.showLogo && branding?.logoDataUrl ? <img className="p-brand-logo" src={branding.logoDataUrl} alt="机构 Logo" /> : null}
            <h2>{window.t('Athlete Performance Report')}</h2>
          </div>
          <div className="meta">
            {window.formatDate(season, 'short')}{prevSeason ? ` · vs ${window.formatDate(prevSeason, 'compact')}` : ''}<br/>
            Axis Performance
          </div>
        </div>}

        {/* ── RP-T1: p-athlete 运动员条 (复刻 mockup .p-athlete) —— 方形 av + 姓名 + 位置/号码/年龄 ── */}
        {showAthleteStrip && <div className="p-athlete">
          <div className="av">{initials}</div>
          <div>
            <b>{athlete.name}</b><br/>
            {athleteIdentityParts.length > 0
              ? <span>{athleteIdentityParts.join(' · ')}</span>
              : null}
          </div>
        </div>}

        {/* ── RP-T1: p-kpis 四格 KPI 行 + delta chip (复刻 mockup .p-kpis) ──
             增量 chip 仅在有真实对比数据时显示; 无则省略, 不虚构。 */}
        {!coachMode && kpiCells.length > 0 && (
          <div className="p-kpis">
            {kpiCells.map(k => (
              <div key={k.l} className="p-kpi">
                <div className="l">{k.l}</div>
                <div className="v">{k.v}{k.u ? <small> {k.u}</small> : null}</div>
                {k.delta != null && <span className={`d${k.deltaDown ? ' dn' : ''}`}>{k.delta}</span>}
              </div>
            ))}
          </div>
        )}
        {/* ── Coach mode: KPI traffic lights ── */}
        {coachMode && (
          <div>
            {/* RD-2: coach 模式同走原始实测瓦片 —— 复合分/交通灯判断标签移除 (产品红线: 无派生评分)。 */}
            <ReportRawMetricTiles tiles={dimTiles} />
            {swcRows.length > 0 && (() => {
              const top = swcRows.find(r => r.band.id !== 'trivial');
              if (!top) return null;
              return (
                <div className="insight" style={{ marginTop: 16 }}>
                  <div className="body">
                    <em>{top.label}</em> {top.improved ? '↑ 提升' : '↓ 下降'}{' '}
                    {top.improved ? '+' : ''}{fmt(top.deltaRaw)}{top.unit}（ES {reportFormatDelta(top.absES)}σ · {top.band.label}）
                  </div>
                  <div className="by">最显著变化 · SWC 分析</div>
                </div>
              );
            })()}
          </div>
        )}

        {/* ── Ordered content modules (顺序由 modal 的「模块顺序」决定) ── */}
        {!coachMode && (() => {
          const numbered = order.filter(k => k !== 'narrative');
          const numOf = k => String(numbered.indexOf(k) + 1).padStart(2, '0');
          // RP-T1: p-sec 节头 (复刻 mockup .p-sec) —— 左标题 + 分隔线 + 右侧 L级/来源角标。
          const secHead = (key, label, note) => (
            <div className="p-sec"><b>{label}</b><div className="ln"></div><span className="n">{note || `#${numOf(key)}`}</span></div>
          );
          const optionalFacts = (key, title) => (
            <div key={key}>
              {secHead(key, title)}
              <div className="mtable">
                {optionalSectionRows[key].map(row => (
                  <div key={row.label} className="mrow" style={{ gridTemplateColumns: '1fr 90px' }}>
                    <div className="nm">{row.label}</div>
                    <div className="val">{row.value}</div>
                  </div>
                ))}
              </div>
            </div>
          );
          const renderers = {
            narrative: () => (
              <div className="p-concl warm" key="narrative">
                <div className="txt">{insightText.replace(/\*\*(.+?)\*\*/g, '$1')}</div>
                <div className="ref">编辑摘要 · SWC 变化分析</div>
              </div>
            ),
            cmj: () => (
              <div key="cmj">
                {/* RD-4: 四图移出后节头不再提「曲线」，对齐 MODULE_LABELS 的 CMJ 力量-时间结果。 */}
                {secHead('cmj', 'CMJ 力量-时间结果 · 最新确认会话', reviewedEvidence ? `L2 · ${formatReportEvidenceDate(reviewedEvidence.sessionDate)}` : 'L2 · Reviewed')}
                <IndividualReportReviewedEvidenceCallout evidence={reviewedEvidence} metrics={cmjMetrics} />
              </div>
            ),
            sjResult: () => (
              <div key="sjResult">
                {secHead('sjResult', INDIVIDUAL_CONFIRMED_RESULT_MODULES.sjResult.label, 'L2 · Reviewed')}
                <IndividualReportConfirmedResultCallout evidence={sjEvidence} title={INDIVIDUAL_CONFIRMED_RESULT_MODULES.sjResult.label} subtitle={INDIVIDUAL_CONFIRMED_RESULT_MODULES.sjResult.subtitle} />
              </div>
            ),
            imtpResult: () => (
              <div key="imtpResult">
                {secHead('imtpResult', INDIVIDUAL_CONFIRMED_RESULT_MODULES.imtpResult.label, 'L2 · Reviewed')}
                <IndividualReportConfirmedResultCallout evidence={imtpEvidence} title={INDIVIDUAL_CONFIRMED_RESULT_MODULES.imtpResult.label} subtitle={INDIVIDUAL_CONFIRMED_RESULT_MODULES.imtpResult.subtitle} />
              </div>
            ),
            fieldResult: () => (
              <div key="fieldResult">
                {secHead('fieldResult', INDIVIDUAL_CONFIRMED_RESULT_MODULES.fieldResult.label, 'L2 · Reviewed')}
                <IndividualReportConfirmedResultCallout evidence={fieldEvidence} title={INDIVIDUAL_CONFIRMED_RESULT_MODULES.fieldResult.label} subtitle={INDIVIDUAL_CONFIRMED_RESULT_MODULES.fieldResult.subtitle} />
              </div>
            ),
            manualResult: () => (
              <div key="manualResult">
                {secHead('manualResult', INDIVIDUAL_CONFIRMED_RESULT_MODULES.manualResult.label, 'L2 · Reviewed')}
                <IndividualReportConfirmedResultCallout evidence={manualEvidence} title={INDIVIDUAL_CONFIRMED_RESULT_MODULES.manualResult.label} subtitle={INDIVIDUAL_CONFIRMED_RESULT_MODULES.manualResult.subtitle} />
              </div>
            ),
            trainingWeek: () => (
              <div key="trainingWeek">
                {secHead('trainingWeek', TRAINING_REPORT_MODULES.trainingWeek.label, TRAINING_REPORT_MODULES.trainingWeek.subtitle)}
                <ReportTrainingWeekPaper data={trainingReportData.trainingWeek} />
              </div>
            ),
            trainingPeriod: () => (
              <div key="trainingPeriod">
                {secHead('trainingPeriod', TRAINING_REPORT_MODULES.trainingPeriod.label, TRAINING_REPORT_MODULES.trainingPeriod.subtitle)}
                <ReportTrainingPeriodPaper data={trainingReportData.trainingPeriod} />
              </div>
            ),
            trainingVolumeChart: () => (
              <div key="trainingVolumeChart">
                {secHead('trainingVolumeChart', TRAINING_REPORT_MODULES.trainingVolumeChart.label, TRAINING_REPORT_MODULES.trainingVolumeChart.subtitle)}
                <ReportTrainingVolumeChartPaper data={trainingReportData.trainingVolumeChart} />
              </div>
            ),
            dims: () => (
              <div key="dims">
                {secHead('dims', titles.profile || '关键指标 · 原始实测')}
                <ReportRawMetricTiles tiles={dimTiles} />
              </div>
            ),
            metrics: () => (
              <div key="metrics">
                {secHead('metrics', titles.swc || 'SWC 变化分析')}
                {swcRows.length === 0 && metricRows.length === 0 ? (
                  <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--muted)', padding: '10px 0', fontStyle: 'italic' }}>
                    /
                  </div>
                ) : (() => {
                  const showDelta = showCol('delta'), showES = showCol('es'), showDir = showCol('dir');
                  const gtc = ['1fr', '90px', showDelta && '90px', showES && '70px', showDir && '80px'].filter(Boolean).join(' ');
                  return (
                  <>
                  {swcRows.length > 0 && (
                    <div className="mtable">
                      <div className="mrow head" style={{ gridTemplateColumns: gtc }}>
                        <div>指标</div>
                        <div>本次值</div>
                        {showDelta && <div style={{ textAlign: 'center' }}>变化量</div>}
                        {showES && <div>ES</div>}
                        {showDir && <div>等级</div>}
                      </div>
                      {swcRows.map(r => {
                        const deltaClass = r.band.id === 'trivial' ? 'zero' : r.improved ? 'pos' : 'neg';
                        return (
                          <div key={r.metricId} className="mrow" style={{ gridTemplateColumns: gtc }}>
                            <div className="nm">
                              <span style={{ width: 3, height: 10, borderRadius: 1, background: r.groupAccent, display: 'inline-block', marginRight: 6, flexShrink: 0 }}></span>
                              {r.label}
                            </div>
                            <div className="val">{fmt(r.curr)}<span className="u">{r.unit}</span></div>
                            {showDelta && (
                              <div className={`d ${deltaClass}`} style={{ textAlign: 'center' }}>
                                {r.deltaRaw > 0 ? '+' : ''}{fmt(r.deltaRaw)}
                              </div>
                            )}
                            {showES && (
                              <div className="d" style={{ color: 'var(--muted)', textAlign: 'right', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
                                {reportFormatDelta(r.absES)}σ
                              </div>
                            )}
                            {showDir && (
                              <div className="band" style={{ color: r.band.color }}>
                                {r.band.label}
                              </div>
                            )}
                          </div>
                        );
                      })}
                    </div>
                  )}
                  {metricRows.length > 0 && (
                    <div className="mtable" style={{ marginTop: swcRows.length ? 10 : 0 }}>
                      <div className="mrow head" style={{ gridTemplateColumns: '1fr 96px 90px' }}>
                        <div>本次指标</div>
                        <div>数值</div>
                        <div>类别</div>
                      </div>
                      {metricRows.map(row => (
                        <div key={row.id} className="mrow" style={{ gridTemplateColumns: '1fr 96px 90px' }}>
                          <div className="nm">
                            <span style={{ width: 3, height: 10, borderRadius: 1, background: row.accent, display: 'inline-block', marginRight: 6, flexShrink: 0 }}></span>
                            {row.label}
                          </div>
                          <div className="val">{formatMetricValue(row.curr, row.unit)}</div>
                          <div className="band" style={{ color: 'var(--muted)' }}>{row.groupLabel || '/'}</div>
                        </div>
                      ))}
                    </div>
                  )}
                  </>
                  );
                })()}
                {trivialCount > 0 && (
                  <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--muted-2)', marginTop: 6, fontStyle: 'italic' }}>
                    {trivialCount} 项在 SWC 范围内（|ΔES| &lt; 0.2σ）
                  </div>
                )}
              </div>
            ),
            recs: () => (
              <div key="recs">
                {secHead('recs', titles.forestPlot || '训练建议')}
                {(trainingRecommendationText || '').trim() ? (
                  <ol className="recs">
                    {trainingRecommendationText.split('\n').map(line => line.trim()).filter(Boolean).map((line, i) => (
                      <li key={i}><div className="what">{line}</div></li>
                    ))}
                  </ol>
                ) : (
                  <div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--muted)', padding: '8px 0 4px' }}>/</div>
                )}
              </div>
            ),
            load: () => optionalFacts('load', titles.load || '训练负荷'),
            body: () => optionalFacts('body', titles.body || '身体形态'),
            attend: () => optionalFacts('attend', titles.attend || '出勤 / 缺测'),
            notes: () => (variant !== 'B' && (
              <div key="notes">
                {secHead('notes', titles.coachNotes || '科学家解读', 'O · 署名')}
                {coachNotes ? (
                  <div className="p-concl warm">
                    <div className="txt" style={{ whiteSpace: 'pre-wrap' }}>{coachNotes}</div>
                    <div className="ref">刘旭 · Sports Scientist · {window.formatDate(season, 'short')}</div>
                  </div>
                ) : (
                  <div className="ruled">
                    <div className="lab">训练备注 / 下周目标 / 观察记录</div>
                    <div className="ln"></div><div className="ln"></div>
                  </div>
                )}
              </div>
            )) || null,
          };
          return <div key={order.join('|')} className="rpt-modules">{order.map(k => renderers[k] ? renderers[k]() : null)}</div>;
        })()}

        {/* ── Coach mode notes ── */}
        {coachMode && (
          <div>
            <div className="sec-h"><span className="num">02</span><span className="lbl">教练手写注释</span><span className="ln"></span></div>
            <div className="ruled">
              <div className="ln"></div><div className="ln"></div>
            </div>
          </div>
        )}

        {/* ── RP-T1: p-foot 页脚 (复刻 mockup .p-foot) —— Generated 日期 · 产品名 · 署名 ── */}
        <div className="p-foot">
          <span>Generated {window.formatDate(season, 'short')} · Sports Science OS</span>
          <span style={{ marginLeft: 'auto' }}>Reviewed &amp; signed · 刘旭</span>
        </div>

      </article>
    </div>
  );
}

// ── Variant B wrapper: 运动员进步故事 ─────────────────────────────────────────
function ReportPrintBodyB({ athlete, season, prevSeason,
  dimTiles = [], confirmedCount = 0, latestTestDate = null,
  sortedGroups, swcRows, meaningful, improvements, declines, narrative, coachNotes, paperBg = 'warm',
  titles = {}, order = DEFAULT_MODULE_ORDER, reviewedEvidence = null, cmjMetrics = [],
  sjEvidence = null, imtpEvidence = null, fieldEvidence = null, manualEvidence = null,
  trainingRecommendationText = '', addSections = {}, preview = false, bodyId = null,
  branding = null }) {

  const initials = athlete.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
  const insightText = narrative.length > 0 ? narrative.join(' ').replace(/\*\*(.+?)\*\*/g, '$1') : '';
  const quarterLabel = window.formatDate ? window.formatDate(season, 'compact') : season;
  const prevQuarterLabel = prevSeason ? (window.formatDate ? window.formatDate(prevSeason, 'compact') : prevSeason) : null;

  // Top 3 improved metrics for heroes
  const topImprovements = improvements.slice(0, 3);
  const fmt = v => reportFormatDelta(v);
  const firstName = athlete.name.split(' ')[0];
  const athleteIdentityParts = reportAthleteIdentityParts(athlete);

  const ordered = Array.isArray(order) ? order : DEFAULT_MODULE_ORDER;
  const numbered = ordered.filter(key => key !== 'narrative');
  const numOf = key => String(numbered.indexOf(key) + 1).padStart(2, '0');
  const trainingRecommendationLines = (trainingRecommendationText || '').split('\n').map(line => line.trim()).filter(Boolean);
  const seasonValues = athlete.seasons?.[season] || {};
  const metricDefById = new Map(sortedGroups.flatMap(group => (group.metrics || []).map(metric => [metric.id, metric])));
  const metricRow = (id, fallbackLabel) => {
    const def = metricDefById.get(id) || {};
    const value = formatReportMetricValue(seasonValues[id], def.unit);
    return {
      label: def.label || fallbackLabel || id,
      value: value === '/' ? '/' : `${value}${def.unit ? ` ${def.unit}` : ''}`,
    };
  };
  const optionalSectionRows = {
    load: [
      metricRow('sRPE', 'sRPE'),
      metricRow('duration_min', 'Duration'),
      metricRow('session_load', 'Session Load'),
      metricRow('acwr', 'ACWR'),
    ],
    body: [
      metricRow('weight', 'Body Weight'),
      metricRow('bodyfat', 'Body Fat'),
      metricRow('vo2max', 'VO2 Max'),
    ],
    attend: [
      { label: 'Recorded metric fields', value: Object.keys(seasonValues).length ? String(Object.keys(seasonValues).length) : '/' },
      { label: 'Training date', value: season || '/' },
      { label: 'Attendance record', value: '/' },
    ],
  };
  const optionalFacts = (key, title) => addSections?.[key] ? (
    <div key={key} style={{ marginTop: 24 }}>
      <div className="sec-h"><span className="num">{numOf(key)}</span><span className="lbl">{title}</span><span className="ln"></span></div>
      <div className="mtable">
        {optionalSectionRows[key].map(row => (
          <div key={row.label} className="mrow" style={{ gridTemplateColumns: '1fr 90px' }}>
            <div className="nm">{row.label}</div>
            <div className="val">{row.value}</div>
          </div>
        ))}
      </div>
    </div>
  ) : null;

  return (
    <div id={preview ? undefined : (bodyId || 'report-body')} className="rpt" data-report-athlete-id={athlete.id}>
      <article className={`paper${paperBg !== 'white' ? ' warm' : ''}`}>

        {/* ── Greeting ── */}
        <div className="greet" style={{ borderBottom: '1px solid var(--line-warm)', paddingBottom: 18 }}>
          {branding?.showLogo && branding?.logoDataUrl ? <img className="p-brand-logo p-brand-logo-story" src={branding.logoDataUrl} alt="机构 Logo" /> : null}
          <h2>你的{prevQuarterLabel ? `${prevQuarterLabel} → ` : ''}{quarterLabel}，{firstName}。</h2>
          {athleteIdentityParts.length > 0 && (
            <div style={{ color: 'var(--muted)', fontSize: 11, marginTop: -2 }}>
              {athleteIdentityParts.join(' · ')}
            </div>
          )}
          <p className="lead">
            {/* RD-5 (2026-07-09): 旧复合分句去除 — 改为 SWC 有意义原始指标的诚实计数。 */}
            {(improvements.length > 0 || declines.length > 0) && (
              <>这是过去这段时间的回顾。<em>本期 {improvements.length} 项指标实测提升、{declines.length} 项下降</em>。<br/><br/></>
            )}
            {improvements.length > 0 && <>你在 <em>{improvements.slice(0, 2).map(r => r.label).join('和')}</em> 上的进步看得见。</>}
            {declines.length > 0 && <> 不过 <em>{declines.slice(0, 1).map(r => r.label).join('和')}</em> 需要继续关注。</>}
          </p>
        </div>
        <div className="rpt-modules">
          {ordered.map(key => {
            if (key === 'narrative') return null;
            if (key === 'cmj') return reviewedEvidence ? (
              <div key="cmj" style={{ marginTop: 24 }}>
                <div className="sec-h"><span className="num">{numOf('cmj')}</span><span className="lbl">CMJ 力量-时间结果</span><span className="ln"></span></div>
                <IndividualReportReviewedEvidenceCallout evidence={reviewedEvidence} metrics={cmjMetrics} />
              </div>
            ) : null;
            if (key === 'sjResult' || key === 'imtpResult' || key === 'fieldResult' || key === 'manualResult') {
              const evidenceByKey = { sjResult: sjEvidence, imtpResult: imtpEvidence, fieldResult: fieldEvidence, manualResult: manualEvidence };
              const evidence = evidenceByKey[key];
              const meta = INDIVIDUAL_CONFIRMED_RESULT_MODULES[key];
              return evidence ? (
                <div key={key} style={{ marginTop: 24 }}>
                  <div className="sec-h"><span className="num">{numOf(key)}</span><span className="lbl">{meta.label}</span><span className="ln"></span></div>
                  <IndividualReportConfirmedResultCallout evidence={evidence} title={meta.label} subtitle={meta.subtitle} />
                </div>
              ) : null;
            }
            if (key === 'metrics') return topImprovements.length > 0 ? (
              <div key="metrics" style={{ marginTop: 26 }}>
                <div className="sec-h"><span className="num">{numOf('metrics')}</span><span className="lbl">{titles.swc || '关键变化指标'}</span><span className="ln"></span></div>
                <div className="heroes" style={{ gridTemplateColumns: `repeat(${Math.min(topImprovements.length, 3)}, 1fr)` }}>
                  {topImprovements.map(r => (
                    <div key={r.metricId} className="hero">
                      <div className="lbl">{r.label}</div>
                      <div className="v">{fmt(r.curr)}<span className="u">{r.unit}</span></div>
                      <div className="d pos">↗ {r.deltaRaw > 0 ? '+' : ''}{fmt(r.deltaRaw)} {r.unit} · {r.band.label}</div>
                    </div>
                  ))}
                </div>
              </div>
            ) : null;
            if (key === 'dims') return (
              <div key="dims" style={{ marginTop: 28 }}>
                <div className="sec-h"><span className="num">{numOf('dims')}</span><span className="lbl">{titles.profile || '关键指标 · 原始实测'}</span><span className="ln"></span></div>
                <ReportRawMetricTiles tiles={dimTiles} />
              </div>
            );
            if (key === 'recs') return (
              <div key="recs" className="next-goal" style={{ marginTop: 24 }}>
                <div className="k">{titles.forestPlot || '训练建议'}</div>
                {trainingRecommendationLines.length ? (
                  <ul style={{ margin: '8px 0 0 18px', padding: 0, fontFamily: 'var(--font-serif)', fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.8 }}>
                    {trainingRecommendationLines.map((line, index) => <li key={index}>{line}</li>)}
                  </ul>
                ) : (
                  <div className="sub">/</div>
                )}
              </div>
            );
            if (key === 'load') return optionalFacts('load', titles.load || '训练负荷');
            if (key === 'body') return optionalFacts('body', titles.body || '身体形态');
            if (key === 'attend') return optionalFacts('attend', titles.attend || '出勤 / 缺测');
            if (key === 'notes') return (
              <div key="notes" style={{ marginTop: 24 }}>
                <div className="sec-h"><span className="num">{numOf('notes')}</span><span className="lbl">{titles.coachNotes || '教练的话'}</span><span className="ln"></span></div>
                {coachNotes ? (
                  <div style={{ fontFamily: 'var(--font-serif)', fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.8, whiteSpace: 'pre-wrap' }}>
                    {coachNotes}
                  </div>
                ) : (
                  <div className="ruled">
                    <div className="ln"></div><div className="ln"></div><div className="ln"></div>
                    <div className="ln"></div><div className="ln"></div><div className="ln"></div>
                  </div>
                )}
              </div>
            );
            return null;
          })}
        </div>

        {/* ── Signature ── */}
        <div className="sig-block">
          <div className="lead-in">继续保持节奏。<br/>教练看到你这期间的进步了。</div>
          <div className="sig">
            <div className="sig-line"></div>
            <div className="sig-lab">教练签名 · {window.formatDate ? window.formatDate(season, 'short') : season}</div>
          </div>
        </div>

        <div className="pfoot">
          <span>Performance Dashboard · v3.4</span>
          <span>给 {firstName} · {quarterLabel} · 运动员进步故事</span>
        </div>

      </article>
    </div>
  );
}

// ── Mini preview right column ──────────────────────────────────────────────
function ReportPreview({ children, paperBg = 'warm', collapsed = false, toggleControl = null }) {
  const [zoomPct, setZoomPct] = rUseS(null);
  const adjustZoom = delta => setZoomPct(current => Math.max(40, Math.min(110, (current == null ? 62 : current) + delta)));
  return (
    <div className={`m-right individual-report-preview report-pane${collapsed ? ' pane-collapsed' : ''}`}>
      <div className="pane-bar">
        <span className="pane-title">报告预览</span>
        <div className="report-preview-actions">
          <div className="report-preview-zoom" role="group" aria-label="A4 预览缩放">
            <button type="button" onClick={() => adjustZoom(-10)} aria-label="缩小报告预览">−</button>
            <button type="button" className="zoom-value" onClick={() => setZoomPct(null)} aria-label="适配报告预览">{zoomPct == null ? '适配' : `${zoomPct}%`}</button>
            <button type="button" onClick={() => adjustZoom(10)} aria-label="放大报告预览">+</button>
          </div>
          {toggleControl}
        </div>
      </div>
      <div className="pane-content">
        <div
          className={`workbench-canvas-surface individual-report-preview-paper${paperBg !== 'white' ? ' warm' : ''}`}
          style={zoomPct == null ? undefined : { '--report-preview-scale': String(zoomPct / 100) }}
        >
          <span className="canvas-hint">画布即预览 · 纸上直接编辑 · 拖动 ⋮⋮ 排序 · 点击模块在右侧调参</span>
          {children}
          <div className="canvas-pagebreak">分页符 · 打印分页在画布中真实可见</div>
        </div>
      </div>
    </div>
  );
}

function ReportSettingsSection({ id, title, note, defaultOpen, children }) {
  return (
    <details className="insp-settings-section" open={defaultOpen} data-section={id}>
      <summary className="insp-g insp-settings-summary">{title}{note && <span className="insp-note-inline">{note}</span>}</summary>
      <div className="insp-settings-body">{children}</div>
    </details>
  );
}

// ── Inline-editable section title (✎ pencil affordance) ──────────────────────
function NmEdit({ value, onChange, size = 8 }) {
  const [live, setLive] = rUseS(false);
  return (
    <span className="nm-edit">
      <input
        className="ed" value={value} size={Math.max(size, (value || '').length || 4)}
        onChange={e => onChange(e.target.value)}
        onFocus={() => setLive(true)} onBlur={() => setLive(false)}
        onClick={e => e.stopPropagation()}
        spellCheck={false}
      />
      <span className={`pencil${live ? ' live' : ''}`}>✎</span>
    </span>
  );
}

// ── Narrative templates (model: 模板 + {{占位符}} 自动填充) ───────────────────
// SEC-1 (report narrative XSS): system templates may use only the fixed <strong> /
// <span class="var"> emphasis shells. Every dynamic value is escaped before it is
// concatenated into that template source, then the editor renderer below constructs the
// two allowed element types with DOM APIs. User-authored narrative is always restored as
// plain text and never interpreted as markup.
function escapeNarrativeHtml(value) {
  return String(value == null ? '' : value)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}
function decodeNarrativeTemplateText(value) {
  return String(value == null ? '' : value)
    .replace(/&#39;/g, "'")
    .replace(/&quot;/g, '"')
    .replace(/&gt;/g, '>')
    .replace(/&lt;/g, '<')
    .replace(/&amp;/g, '&');
}
function renderNarrativeTemplateDom(node, templateHtml) {
  node.replaceChildren();
  const stack = [{ node, tag: null }];
  const tokens = String(templateHtml || '').split(/(<strong>|<\/strong>|<span class="var">|<\/span>)/g);
  tokens.forEach(token => {
    if (!token) return;
    const current = stack[stack.length - 1].node;
    if (token === '<strong>' || token === '<span class="var">') {
      const tag = token === '<strong>' ? 'strong' : 'span';
      const child = document.createElement(tag);
      if (tag === 'span') child.className = 'var';
      current.appendChild(child);
      stack.push({ node: child, tag });
      return;
    }
    if (token === '</strong>' || token === '</span>') {
      const tag = token === '</strong>' ? 'strong' : 'span';
      if (stack.length > 1 && stack[stack.length - 1].tag === tag) stack.pop();
      else current.appendChild(document.createTextNode(token));
      return;
    }
    current.appendChild(document.createTextNode(decodeNarrativeTemplateText(token)));
  });
}
// The {{…}} label is untrusted text; only the surrounding span shell is trusted markup.
const _vspan = (t) => `<span class="var">{{${escapeNarrativeHtml(t)}}}</span>`;
// RD-5 (2026-07-09): deferral executed — narrative层去复合分。ctx 只从 RAW 来源构建
// (SWC 有意义原始指标变化 label+deltaRaw+unit + RD-2 客观标识 testDate/confirmedCount)，
// 不含维度分 / 概览 Δ / 任何派生加权综合分。product red line: individual report shows
// raw measured values + human-confirmed facts only.
function _narrMetricLabel(row) {
  if (!row) return null;
  const sign = row.deltaRaw > 0 ? '+' : '';
  return `${row.label} ${sign}${reportFormatDelta(row.deltaRaw)}${row.unit || ''}`;
}
function buildNarrCtx({ athlete, periodLabel, meaningful = [], improvements = [], declines = [], testDate, confirmedCount = 0 }) {
  // meaningful/improvements/declines are SWC-meaningful raw rows, pre-sorted by |ES| desc.
  const topImp = improvements[0];
  const topDec = declines[0];
  return {
    name: athlete.name,
    period: periodLabel,
    meaningfulCount: meaningful.length,
    improvedCount: improvements.length,
    declinedCount: declines.length,
    topImprovement: _narrMetricLabel(topImp) || '—',
    topDecline: _narrMetricLabel(topDec) || '—',
    testDate: testDate || '—',
    confirmedCount,
  };
}
const NARR_TEMPLATES = [
  { id: 'gap', label: '最大降幅指标', build: c =>
    c.declinedCount > 0
      ? `${escapeNarrativeHtml(c.name)} 在 ${_vspan('时段 ' + c.period)} 共 ${_vspan('有意义变化项数 ' + c.meaningfulCount)} 项指标有超过 SWC 阈值的实测变化，其中 <strong>${_vspan('最大降幅指标 ' + c.topDecline)} 降幅最大</strong>，可与教练确认下周期侧重。`
      : `${escapeNarrativeHtml(c.name)} 在 ${_vspan('时段 ' + c.period)} 无超过 SWC 阈值的下降指标。` },
  { id: 'attr', label: '进步归因', build: c =>
    c.improvedCount > 0
      ? `本期提升主要来自 <strong>${_vspan('最大升幅指标 ' + c.topImprovement)}</strong> 等 ${_vspan('提升项数 ' + c.improvedCount)} 项指标的实测上行，可与教练确认如何巩固。`
      : `本期无超过 SWC 阈值的指标提升。` },
  { id: 'recover', label: '恢复关注', build: c =>
    c.declinedCount > 0
      ? `<strong>恢复相关指标</strong>：${_vspan('最大降幅指标 ' + c.topDecline)}，可结合 ${_vspan('HRV Δ')} 与睡眠效率一并观察，必要时与教练确认本周强度。`
      : `本期无超过 SWC 阈值的下降指标；可结合 ${_vspan('HRV Δ')} 与睡眠效率持续观察。` },
  { id: 'summary', label: '数据综述', build: c =>
    `本期共 ${_vspan('有意义变化项数 ' + c.meaningfulCount)} 项指标有意义变化：提升 ${_vspan('提升项数 ' + c.improvedCount)} 项、下降 ${_vspan('下降项数 ' + c.declinedCount)} 项；数据截至 ${_vspan('测试日期 ' + c.testDate)}，已确认 ${_vspan('已确认项数 ' + c.confirmedCount)} 项。` },
  { id: 'custom', label: '✎ 自定义', custom: true, build: () => '' },
];
const NARR_VARS = ['运动员', '时段', '最大升幅指标', '最大降幅指标', '提升项数', '下降项数', '有意义变化项数', 'HRV Δ', '测试日期', '已确认项数'];

// ── Main modal ──────────────────────────────────────────────────────────────
function ReportModal({ open, onClose, athlete, season, seasons, groups, scores, squadStatsAll, reviewedEvidence = null, sjReviewedEvidence = null, imtpReviewedEvidence = null, fieldReviewedEvidence = null, manualReviewedEvidence = null, selectedEvidenceType = '', trainingReportData = {}, cmjStore = {}, sjStore = {}, imtpStore = {}, draftAdapter = null, historySettling = false, onOpenReviewedEvidence, onOpenReviewedEvidenceReport, mode = 'modal', batchContexts = [], batchConfig = null, batchBodyId = null, onBatchReady = null }) {
  const isBatchStage = mode === 'batch-stage';
  const batchPolicy = window.ReportBatchPolicy;
  const brandingRepository = window.ReportBrandingRepository?.create?.() || null;
  const [branding, setBranding] = rUseS(() => brandingRepository?.load?.() || null);
  const [brandingError, setBrandingError] = rUseS('');
  const [brandingStatus, setBrandingStatus] = rUseS('idle');
  const [coachNotes, setCoachNotes] = rUseS('');
  const [reportSaveState, setReportSaveState] = rUseS('saved');
  const updateCoachNotes = setCoachNotes;

  const [variant, setVariant] = rUseS('A'); // 'A' 教练季度诊断 | 'B' 运动员进步故事
  const [fmt, setFmt] = rUseS('pdf');       // 'pdf' | 'png' | 'print'
  const [preset, setPreset] = rUseS('recommend');

  const sortedSeasons = rUseM(() => [...seasons].sort((a, b) => new Date(b) - new Date(a)), [seasons]);
  const currIdx = sortedSeasons.indexOf(season);
  const defaultPrev = currIdx < sortedSeasons.length - 1 ? sortedSeasons[currIdx + 1] : null;

  // ── 时段 + 对比基线 ──
  const [baselineSeason, setBaselineSeason] = rUseS(defaultPrev);
  rUseE(() => { setBaselineSeason(defaultPrev); }, [season, seasons]); // eslint-disable-line
  const [baselineType, setBaselineType] = rUseS('last'); // last | yoy | squad | best | none
  const [periodOpts, setPeriodOpts] = rUseS({ swc: true, trend: true, games: false });
  const togglePeriodOpt = k => setPeriodOpts(o => ({ ...o, [k]: !o[k] }));
  const prevSeason = baselineType === 'none' ? null : baselineSeason;

  const prevSquadStats = rUseM(() => prevSeason ? (squadStatsAll[prevSeason] || {}) : null, [prevSeason, squadStatsAll]);
  const prevScores = rUseM(() => {
    if (!prevSeason || !prevSquadStats) return null;
    const D = window.DASHBOARD_DATA;
    const prevVals = athlete.seasons?.[prevSeason] || {};
    const out = {};
    groups.forEach(g => { out[g.id] = D.computeGroupScore(prevVals, g, prevSquadStats, 'range'); });
    return out;
  }, [prevSeason, prevSquadStats, athlete, groups]);

  // RD-5 (2026-07-09): overallScore/prevOverall/overallDiff derivations removed —
  // their last consumer (the narrative层 buildNarrCtx + variant B intro) is now raw-only.
  // RD-5b (2026-07-09, 用户裁决): 孤儿的复合分平均 helper (原 L39) 一并删除。

  const { rows: swcRows } = rUseM(() => buildSWCRows(athlete, groups, seasons, squadStatsAll), [athlete, groups, seasons, squadStatsAll]);
  const meaningful = swcRows.filter(r => r.band.id !== 'trivial');
  const improvements = meaningful.filter(r => r.improved);
  const declines = meaningful.filter(r => !r.improved);
  const trivialCount = swcRows.length - meaningful.length;

  const sortedGroups = rUseM(() =>
    [...groups].map(g => ({ ...g, score: scores[g.id] || 0, prevScore: prevScores ? prevScores[g.id] : null }))
      .sort((a, b) => b.score - a.score),
    [groups, scores, prevScores]);

  // ── 关键指标 · 原始实测 picker (RD-2: 去复合分, 走 MB-1 统一目录) ──
  // 核心可视化改为原始实测关键指标 (值 + 相邻两次差值 + 迷你趋势), 数据独家来自
  // window.AthleteMetricCatalog (getCurrent/getSeries), 零派生零加权。
  const coreMetricInputs = rUseM(() => ({ athlete, cmjStore, sjStore, imtpStore }), [athlete, cmjStore, sjStore, imtpStore]);
  // 目录条目, 仅保留对该运动员有 ≥1 可绘制值的 (镜像 Status Board buildCell 的 cur.value != null 门槛)。
  const coreCatalog = rUseM(() => {
    const cat = (typeof window !== 'undefined' && window.AthleteMetricCatalog) || null;
    if (!cat) return [];
    return cat.listCatalog(coreMetricInputs)
      .map(entry => {
        const cur = cat.getCurrent(coreMetricInputs, entry.id);
        return { ...entry, value: cur.value, delta: cur.delta };
      })
      .filter(e => e.value != null);
  }, [coreMetricInputs]);
  // 默认选择镜像 Status Board 无 pin 时的自动挑选 (季节来源, 按 |Δ| 降序, 上限 6)。
  const coreDefaultIds = rUseM(() => {
    const ranked = coreCatalog
      .filter(e => e.origin === 'seasons')
      .map(e => ({ id: e.id, absDelta: e.delta == null ? null : Math.abs(e.delta) }));
    ranked.sort((a, b) => {
      if ((a.absDelta == null) !== (b.absDelta == null)) return a.absDelta == null ? 1 : -1;
      return (b.absDelta || 0) - (a.absDelta || 0);
    });
    return ranked.slice(0, 6).map(r => r.id);
  }, [coreCatalog]);
  const [dimsOn, setDimsOn] = rUseS(() => new Set());
  rUseE(() => { setDimsOn(new Set(coreDefaultIds)); }, [coreDefaultIds.join('|')]); // eslint-disable-line
  const toggleDim = id => setDimsOn(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
  const [dimsTitle, setDimsTitle] = rUseS('关键指标 · 原始实测');
  // 目录来源徽标语言 (镜像 Status Board dashboard-views.jsx ORIGIN_META)。
  const coreOriginLabel = origin => origin === 'force-cmj' ? '力板 · CMJ' : origin === 'force-sj' ? '力板 · SJ' : origin === 'force-imtp' ? '力板 · IMTP' : '';
  // 已选条目 → tile 数据 (含升序序列供迷你趋势)。
  const dimTiles = rUseM(() => {
    const cat = (typeof window !== 'undefined' && window.AthleteMetricCatalog) || null;
    if (!cat) return [];
    return [...dimsOn]
      .map(id => coreCatalog.find(e => e.id === id))
      .filter(Boolean)
      .map(e => ({ ...e, series: cat.getSeries(coreMetricInputs, e.id), forceLabel: coreOriginLabel(e.origin) }));
  }, [dimsOn, coreCatalog, coreMetricInputs]);

  // ── 关键变化指标 picker ──
  const metricCatalog = rUseM(() => {
    const cur = athlete.seasons?.[season] || {};
    const base = prevSeason ? (athlete.seasons?.[prevSeason] || {}) : {};
    return groups.flatMap(g => g.metrics.map(m => {
      const curr = cur[m.id], prev = base[m.id];
      const currNumber = reportNumber(curr), prevNumber = reportNumber(prev);
      const delta = (currNumber != null && prevNumber != null) ? currNumber - prevNumber : null;
      let dcls = 'zero';
      if (delta != null && Math.abs(delta) > 1e-9 && m.dir !== 'neutral') { const imp = m.dir === 'lower' ? delta < 0 : delta > 0; dcls = imp ? 'pos' : 'neg'; }
      return { id: m.id, label: m.label, unit: m.unit, dir: m.dir, groupId: g.id, groupLabel: g.label, accent: g.accent, curr, prev, delta, dcls };
    }));
  }, [groups, athlete, season, prevSeason]);
  const fmtV = v => reportFormatNumber(v);

  // RD-6 (2026-07-09): metrics (SWC 变化分析) is a depth module — default OFF at fresh
  // open; operator adds rows manually via the picker. moduleEnabled.metrics gates on
  // selMetrics.size > 0 (see below), so an empty Set cleanly hides the module.
  const [selMetrics, setSelMetrics] = rUseS(() => new Set());
  const toggleMetric = id => setSelMetrics(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
  const [mpTab, setMpTab] = rUseS(groups[0]?.id);
  const [mpSearch, setMpSearch] = rUseS('');
  const [metricsTitle, setMetricsTitle] = rUseS('关键变化 · SWC 显著');
  const [cols, setCols] = rUseS(() => new Set(['curr', 'delta', 'es', 'dir']));
  const toggleCol = k => setCols(s => { const n = new Set(s); n.has(k) ? n.delete(k) : n.add(k); return n; });

  // ── 叙事 · 评估摘要 (模板 + {{占位符}}) ──
  // RD-5 (2026-07-09): ctx 只吃 RAW — SWC 有意义原始指标变化 + 客观标识 (测试日期 /
  // 已确认项数)，不再吃 overallDiff / sortedGroups 维度分。
  const periodLabel = window.formatDate ? window.formatDate(season, 'compact') : season;
  const narrTestDate = window.formatDate ? window.formatDate(season, 'short') : season;
  const narrConfirmedCount = [reviewedEvidence, sjReviewedEvidence, imtpReviewedEvidence, fieldReviewedEvidence, manualReviewedEvidence].filter(Boolean).length;
  const narrCtx = rUseM(() => buildNarrCtx({ athlete, periodLabel, meaningful, improvements, declines, testDate: narrTestDate, confirmedCount: narrConfirmedCount }), [athlete, periodLabel, meaningful, improvements, declines, narrTestDate, narrConfirmedCount]);
  const [narrOn, setNarrOn] = rUseS(true);
  const [narrTpl, setNarrTpl] = rUseS('gap');
  const [narrativeMode, setNarrativeMode] = rUseS('system');
  const [narrTitle, setNarrTitle] = rUseS('评估摘要');
  const narrRef = rUseRef(null);
  const narrativeModeRef = rUseRef(narrativeMode);
  const stripHtml = (h) => { const d = document.createElement('div'); d.innerHTML = h; return (d.innerText || '').replace(/[{}]/g, ''); };
  const narrHTML = rUseM(() => { const t = NARR_TEMPLATES.find(x => x.id === narrTpl); return t ? t.build(narrCtx) : ''; }, [narrTpl, narrCtx]);
  const [narrText, setNarrText] = rUseS(() => stripHtml(narrHTML));
  const narrTextRef = rUseRef(narrText);
  const updateNarrativeText = value => {
    const nextText = String(value == null ? '' : value);
    narrTextRef.current = nextText;
    setNarrText(nextText);
  };
  rUseE(() => {
    const nextGenerated = stripHtml(narrHTML);
    if (narrativeMode !== 'system') return;
    updateNarrativeText(nextGenerated);
    if (narrRef.current) renderNarrativeTemplateDom(narrRef.current, narrHTML);
  }, [narrHTML, narrativeMode]); // Only explicit system provenance follows source-data changes.
  const selectNarrativeTemplate = templateId => {
    const template = NARR_TEMPLATES.find(item => item.id === templateId);
    const nextHtml = template ? template.build(narrCtx) : '';
    const nextText = stripHtml(nextHtml);
    const nextMode = template?.custom ? 'custom' : 'system';
    narrativeModeRef.current = nextMode;
    setNarrativeMode(nextMode);
    setNarrTpl(templateId);
    updateNarrativeText(nextText);
    if (narrRef.current) renderNarrativeTemplateDom(narrRef.current, nextHtml);
  };
  const insertVar = (label) => {
    if (!narrRef.current) return;
    // SEC-1: insertHTML is a markup sink — only the fixed NARR_VARS literals may reach it,
    // so this can never become an arbitrary-HTML entry point. Escaped as defence in depth.
    if (!NARR_VARS.includes(label)) return;
    narrativeModeRef.current = 'custom';
    setNarrativeMode('custom');
    const safeLabel = escapeNarrativeHtml(label);
    narrRef.current.focus();
    try { document.execCommand('insertHTML', false, `<span class="var">{{${safeLabel}}}</span>&nbsp;`); }
    catch { narrRef.current.innerHTML += `<span class="var">{{${safeLabel}}}</span>&nbsp;`; }
    updateNarrativeText((narrRef.current.innerText || '').replace(/[{}]/g, ''));
  };
  // SEC-1-AUDIT: the narrative body is contentEditable, so a paste would otherwise drop the
  // clipboard's text/html straight into the editor as live markup — creating elements and event
  // handlers that never passed the template escaping boundary. Force a plain-text boundary:
  // cancel the default paste, read ONLY text/plain, and insert it via createTextNode so the
  // string is never parsed as HTML.
  const handleNarrPaste = (e) => {
    e.preventDefault();
    narrativeModeRef.current = 'custom';
    setNarrativeMode('custom');
    const clip = e.clipboardData || (typeof window !== 'undefined' && window.clipboardData);
    const text = clip ? (clip.getData('text/plain') || '') : '';
    const sel = typeof window !== 'undefined' ? window.getSelection() : null;
    if (text && sel && sel.rangeCount) {
      const range = sel.getRangeAt(0);
      range.deleteContents();
      const node = document.createTextNode(text);
      range.insertNode(node);
      range.setStartAfter(node);
      range.setEndAfter(node);
      sel.removeAllRanges();
      sel.addRange(range);
    }
    if (narrRef.current) updateNarrativeText((narrRef.current.innerText || '').replace(/[{}]/g, ''));
  };

  const [recsOn, setRecsOn] = rUseS(true);
  const [recsTitle, setRecsTitle] = rUseS('训练建议');
  const [trainingRecommendationText, setTrainingRecommendationText] = rUseS('');
  const [headerOn, setHeaderOn] = rUseS(true);
  const [headerLogoOn, setHeaderLogoOn] = rUseS(true);
  const [athleteStripOn, setAthleteStripOn] = rUseS(true);
  const [cmjResultOn, setCmjResultOn] = rUseS(true);
  const [cmjMetricsOn, setCmjMetricsOn] = rUseS(true);
  const [cmjMetricKeys, setCmjMetricKeys] = rUseS(() => new Set(['jumpHeight', 'rsiMod', 'peakPower', 'peakForce']));
  const [cmjMetricSearch, setCmjMetricSearch] = rUseS('');
  // Confirmed-conclusion modules (REV-R2) — default OFF so existing reports are unchanged.
  const [sjResultOn, setSjResultOn] = rUseS(selectedEvidenceType === 'sj');
  const [imtpResultOn, setImtpResultOn] = rUseS(selectedEvidenceType === 'imtp');
  const [fieldResultOn, setFieldResultOn] = rUseS(selectedEvidenceType === 'field');
  const [manualResultOn, setManualResultOn] = rUseS(selectedEvidenceType === 'manual');
  rUseE(() => {
    if (selectedEvidenceType === 'sj') setSjResultOn(true);
    if (selectedEvidenceType === 'imtp') setImtpResultOn(true);
    if (selectedEvidenceType === 'field') setFieldResultOn(true);
    if (selectedEvidenceType === 'manual') setManualResultOn(true);
  }, [selectedEvidenceType]);
  // RP-R1': training report modules.
  // RD-6 (2026-07-09): trainingWeek (训练周概览) joins the default communication set —
  // default ON, still gated on data availability via trainingWeekAvailable/hasTrainingWeek.
  // trainingPeriod/trainingVolumeChart stay depth modules — default OFF, manual add.
  const [trainingWeekOn, setTrainingWeekOn] = rUseS(true);
  const [trainingPeriodOn, setTrainingPeriodOn] = rUseS(false);
  const [trainingVolumeChartOn, setTrainingVolumeChartOn] = rUseS(false);

  // ── 附加章节 (默认关) ──
  const [addSecs, setAddSecs] = rUseS({ load: false, body: false, attend: false });
  const toggleAddSec = k => setAddSecs(s => ({ ...s, [k]: !s[k] }));

  // ── 手写区 ──
  const [notesOn, setNotesOn] = rUseS(true);
  const [notesTitle, setNotesTitle] = rUseS('教练注释 · 横线区');

  // ── 模块顺序（拖拽排序 · 记忆到本地）──
  const [moduleOrder, setModuleOrder] = rUseS(() => {
    try {
      const saved = JSON.parse(localStorage.getItem('report_module_order_v1'));
      if (Array.isArray(saved)) return normalizeReportModuleOrder(saved);
    } catch {}
    return DEFAULT_MODULE_ORDER;
  });
  rUseE(() => {
    if (isBatchStage) return;
    try { localStorage.setItem('report_module_order_v1', JSON.stringify(moduleOrder)); } catch {}
  }, [moduleOrder, isBatchStage]);
  const [dragKey, setDragKey] = rUseS(null);
  const moveModule = (from, to) => setModuleOrder(o => {
    if (to < 0 || to >= o.length) return o;
    const n = [...o]; const [m] = n.splice(from, 1); n.splice(to, 0, m); return n;
  });
  const MODULE_LABELS = { narrative: '评估摘要 / 导语', cmj: 'CMJ 力量-时间结果', sjResult: 'SJ 已确认结论', imtpResult: 'IMTP 已确认结论', fieldResult: '现场测试已确认结论', manualResult: '手动指标结论', trainingWeek: '训练周 · 负荷与执行', trainingPeriod: '周期统计 · 7天', trainingVolumeChart: '日量 / 强度序列', dims: '关键指标 · 原始实测', metrics: '关键变化指标', recs: '训练建议', load: '训练负荷叠加', body: '身体形态变化', attend: '出勤 / 缺测记录', notes: '教练注释 · 手写区' };
  const MODULE_CONTENT_NOTES = {
    narrative: '编辑者叙事：评估摘要、背景和本次沟通重点。',
    cmj: '可选 CMJ 模块：指标多选、顺序可调，曲线默认隐藏。',
    sjResult: '可选：SJ 已确认结论（科学家审核后的表现结论与训练关注点）。',
    imtpResult: '可选：IMTP 已确认结论（科学家审核后的表现结论与训练关注点）。',
    fieldResult: '可选：现场测试已确认结论。',
    manualResult: '可选：手动指标已确认结论。',
    trainingWeek: '可选：周训练摘要与负荷热力（来自 trainingLogs）。',
    trainingPeriod: '可选：周期统计 KPI（总量 / 强度 / 训练天 / 组数）。',
    trainingVolumeChart: '可选：每日量 / 强度序列（柱 + 平滑线）。',
    dims: '关键指标：从统一目录选择原始实测指标（值 · Δ · 迷你趋势），零复合分。',
    metrics: '关键指标：从全部指标目录自由选择显示列。',
    recs: '训练建议：编辑者手写或插入常用表述。',
    load: '附加：训练负荷、ACWR 或周累计信息。',
    body: '附加：体重、体脂或身体形态指标。',
    attend: '附加：出勤、缺测或缺训记录。',
    notes: '手写区：教练观察、计划背景或补充说明。',
  };
  // RP-I1 · 检查器选中态头部一句话说明（模块是什么 / 数据来自哪，每模块一条）。
  const MODULE_INSPECTOR_NOTES = {
    header: '报告头部 · 标题、日期区间与机构名，来自当前报告设置。',
    athleteStrip: '运动员档案条 · 姓名、号码、年龄与位置，来自运动员档案。',
    cmj: '已确认 CMJ 结论 · 来自审核库该运动员最新已确认会话（Reviewed Evidence）。',
    sjResult: '已确认 SJ 结论 · 来自审核库该运动员最新已确认会话。',
    imtpResult: '已确认 IMTP 结论 · 来自审核库该运动员最新已确认会话。',
    fieldResult: '已确认现场测试结论 · 来自审核库当前已确认记录。',
    manualResult: '已确认手动指标结论 · 来自审核库该运动员最新已确认会话。',
    trainingWeek: '训练周摘要 · 来自 trainingLogs 的本周负荷与执行数据。',
    trainingPeriod: '周期统计 · 来自 trainingLogs 的 7/14/30 天滚动汇总。',
    trainingVolumeChart: '日量序列 · 来自 trainingLogs 的逐日训练量与强度。',
    narrative: '编辑者叙事 · 手写文本，可插入运动员/赛季变量，不来自自动计算。',
    recs: '训练建议 · 编辑者手写或选用常用表述，不来自自动计算。',
    dims: '关键指标 · 来自统一目录的原始实测值（getCurrent/getSeries），值 + 相邻两次 Δ + 迷你趋势，零派生零复合分。',
    metrics: '关键指标 · 来自完整指标目录，按维度分组挑选。',
    notes: '教练注释 · 编辑者手写，按运动员 + 日期草稿隔离保存。',
    load: '训练负荷叠加 · 来自 sRPE 周累积与 ACWR 计算，附加章节。',
    body: '身体形态变化 · 来自体重/瘦体重/体脂记录，附加章节。',
    attend: '出勤与缺训 · 来自缺测与缺训时间线记录，附加章节。',
  };
  const MODULE_CHECKLIST = [
    ['基础', '报告头部、运动员档案条、报告版本和日期。'],
    ['CMJ', 'CMJ 结果可作为单人报告可选模块；指标可多选，曲线可按需打开。'],
    ['关键指标', '核心可视化与关键变化指标都由编辑者选择原始实测项，不固定输出。'],
    ['训练建议', '训练建议由编辑者手写或插入常用表述，未来可与 Training 独立联动。'],
    ['附加 / 手写', '负荷、身体形态、出勤和教练注释默认按需启用。'],
  ];
  const [reportPanes, setReportPanes] = rUseS({ context: false, sections: false, preview: false });
  const toggleReportPane = key => setReportPanes(s => ({ ...s, [key]: !s[key] }));
  const isReportPaneCollapsed = key => !!reportPanes[key];
  const renderReportPaneToggle = (key, label) => (
    <button
      type="button"
      className="pane-toggle"
      onClick={e => { e.stopPropagation(); toggleReportPane(key); }}
      aria-label={`${isReportPaneCollapsed(key) ? '展开' : '收起'}${label}`}
      title={`${isReportPaneCollapsed(key) ? '展开' : '收起'}${label}`}
    >
      {isReportPaneCollapsed(key) ? '展开' : '收起'}
    </button>
  );

  // ── footer options ──
  const [footOpts, setFootOpts] = rUseS({ copy: true, archive: false });
  const toggleFoot = k => setFootOpts(s => ({ ...s, [k]: !s[k] }));
  const [paperBg, setPaperBg] = rUseS('warm'); // 'warm' 暖纸底 | 'white' 纯白底（黑白打印）

  // ── RP-R2' 三栏工作台 · 选中模块 + 库/画布/检查器联动 ──
  // 选中模块（null = 检查器显示报告级设置）。moduleControl 把每个模块键映射到
  // 现有的 on 状态读写器上——库点击 / 画布移除都走同一入口，不新增真值来源。
  const [selectedModule, setSelectedModule] = rUseS(null);
  const moduleControl = {
    header: { get: () => headerOn, set: setHeaderOn, available: true },
    athleteStrip: { get: () => athleteStripOn, set: setAthleteStripOn, available: true },
    narrative: { get: () => narrOn, set: setNarrOn, available: true },
    cmj: { get: () => cmjResultOn, set: setCmjResultOn, available: !!reviewedEvidence },
    sjResult: { get: () => sjResultOn, set: setSjResultOn, available: !!sjReviewedEvidence },
    imtpResult: { get: () => imtpResultOn, set: setImtpResultOn, available: !!imtpReviewedEvidence },
    fieldResult: { get: () => fieldResultOn, set: setFieldResultOn, available: !!fieldReviewedEvidence },
    manualResult: { get: () => manualResultOn, set: setManualResultOn, available: !!manualReviewedEvidence },
    trainingWeek: { get: () => trainingWeekOn, set: setTrainingWeekOn, available: () => trainingWeekAvailable },
    trainingPeriod: { get: () => trainingPeriodOn, set: setTrainingPeriodOn, available: () => trainingPeriodAvailable },
    trainingVolumeChart: { get: () => trainingVolumeChartOn, set: setTrainingVolumeChartOn, available: () => trainingVolumeChartAvailable },
    dims: { get: () => dimsOn.size > 0, set: v => setDimsOn(v ? new Set(coreDefaultIds) : new Set()), available: true },
    metrics: { get: () => selMetrics.size > 0, set: v => setSelMetrics(v ? new Set(meaningful.slice(0, 6).map(r => r.metricId)) : new Set()), available: true },
    recs: { get: () => recsOn, set: setRecsOn, available: true },
    load: { get: () => !!addSecs.load, set: v => setAddSecs(s => ({ ...s, load: v })), available: true },
    body: { get: () => !!addSecs.body, set: v => setAddSecs(s => ({ ...s, body: v })), available: true },
    attend: { get: () => !!addSecs.attend, set: v => setAddSecs(s => ({ ...s, attend: v })), available: true },
    notes: { get: () => notesOn, set: setNotesOn, available: true },
  };
  // 库点击：启用未用模块并插入 order 末尾（若不在 order 中），同时选中它。
  const enableAndSelectModule = key => {
    const ctl = moduleControl[key];
    if (!ctl) return;
    if (!ctl.get()) ctl.set(true);
    setModuleOrder(o => (o.includes(key) ? o : [...o, key]));
    setSelectedModule(key);
  };
  // 画布移除：停用模块（保留在 order 中，与既有拖拽排序语义一致），清空选中。
  const disableModule = key => {
    const ctl = moduleControl[key];
    if (ctl) ctl.set(false);
    setSelectedModule(sel => (sel === key ? null : sel));
  };

  // ── presets ──
  const applyPreset = (p) => {
    setPreset(p);
    const allDims = new Set(coreDefaultIds);
    if (p === 'min') { setNarrOn(true); setRecsOn(false); setDimsOn(allDims); setSelMetrics(new Set(meaningful.slice(0, 3).map(r => r.metricId))); setAddSecs({ load: false, body: false, attend: false }); setNotesOn(true); }
    else if (p === 'full') { setNarrOn(true); setRecsOn(true); setDimsOn(allDims); setSelMetrics(new Set(swcRows.map(r => r.metricId))); setAddSecs({ load: true, body: true, attend: true }); setNotesOn(true); }
    else if (p === 'load') { setNarrOn(true); setRecsOn(true); setDimsOn(allDims); setSelMetrics(new Set(meaningful.slice(0, 6).map(r => r.metricId))); setAddSecs({ load: true, body: false, attend: false }); setNotesOn(true); }
    else { setNarrOn(true); setRecsOn(true); setDimsOn(allDims); setSelMetrics(new Set(meaningful.slice(0, 6).map(r => r.metricId))); setAddSecs({ load: false, body: false, attend: false }); setNotesOn(true); }
  };

  // ── 命名模板（另存 / 切换 / 删除）· 保存全部排版决策 ──
  const TPL_KEY = 'report_templates_individual_v1';
  const [templates, setTemplates] = rUseS(() => { try { return JSON.parse(localStorage.getItem(TPL_KEY)) || []; } catch { return []; } });
  const [activeTpl, setActiveTpl] = rUseS(null);
  rUseE(() => {
    if (isBatchStage) return;
    try { localStorage.setItem(TPL_KEY, JSON.stringify(templates)); } catch {}
  }, [templates, isBatchStage]);
  const captureConfig = () => ({
    variant, baselineType, periodOpts: { ...periodOpts },
    dimsOn: [...dimsOn], dimsTitle,
    selMetrics: [...selMetrics], cols: [...cols], metricsTitle,
    narrOn, narrTpl, narrativeMode, narrTitle, narrText,
    recsOn, recsTitle, trainingRecommendationText, headerOn, headerLogoOn, athleteStripOn, cmjResultOn, cmjMetricsOn, cmjMetricKeys: [...cmjMetricKeys], sjResultOn, imtpResultOn, fieldResultOn, manualResultOn, trainingWeekOn, trainingPeriodOn, trainingVolumeChartOn, addSecs: { ...addSecs }, notesOn, notesTitle,
    moduleOrder: [...moduleOrder], footOpts: { ...footOpts }, paperBg, coachNotes,
  });
  const reportDraftDefaultRef = rUseRef(null);
  if (!reportDraftDefaultRef.current) reportDraftDefaultRef.current = captureConfig();
  const reportDraftEditableKeysRef = rUseRef(null);
  if (!reportDraftEditableKeysRef.current) {
    reportDraftEditableKeysRef.current = new Set(Object.keys(reportDraftDefaultRef.current));
  }
  const editableDraftProjection = config => {
    const source = config && typeof config === 'object' ? config : {};
    const projected = {};
    reportDraftEditableKeysRef.current.forEach(key => {
      if (Object.prototype.hasOwnProperty.call(source, key)) projected[key] = source[key];
    });
    return projected;
  };
  const draftPassthroughMetadata = config => Object.fromEntries(
    Object.entries(config && typeof config === 'object' ? config : {})
      .filter(([key]) => !reportDraftEditableKeysRef.current.has(key)),
  );
  const defaultReportDraftConfig = () => {
    const base = reportDraftDefaultRef.current;
    const defaultNarrative = NARR_TEMPLATES.find(item => item.id === base.narrTpl);
    return {
      ...base,
      dimsOn: [...coreDefaultIds],
      narrText: stripHtml(defaultNarrative ? defaultNarrative.build(narrCtx) : ''),
      sjResultOn: selectedEvidenceType === 'sj',
      imtpResultOn: selectedEvidenceType === 'imtp',
      fieldResultOn: selectedEvidenceType === 'field',
      manualResultOn: selectedEvidenceType === 'manual',
    };
  };
  const materializeDraftConfig = (savedConfig) => {
    const defaults = defaultReportDraftConfig();
    const saved = savedConfig && typeof savedConfig === 'object' ? savedConfig : {};
    const hasSavedNarrative = Object.prototype.hasOwnProperty.call(saved, 'narrText');
    const effectiveNarrativeTemplateId = saved.narrTpl || defaults.narrTpl;
    const effectiveNarrativeTemplate = NARR_TEMPLATES.find(item => item.id === effectiveNarrativeTemplateId);
    const generatedNarrative = stripHtml(effectiveNarrativeTemplate ? effectiveNarrativeTemplate.build(narrCtx) : '');
    const effectiveNarrativeMode = saved.narrativeMode === 'system' || saved.narrativeMode === 'custom'
      ? saved.narrativeMode
      : hasSavedNarrative ? 'custom' : 'system';
    return {
      ...defaults,
      ...saved,
      narrativeMode: effectiveNarrativeMode,
      narrText: effectiveNarrativeMode === 'system'
        ? generatedNarrative
        : hasSavedNarrative ? String(saved.narrText || '') : '',
      periodOpts: { ...defaults.periodOpts, ...(saved.periodOpts || {}) },
      addSecs: { ...defaults.addSecs, ...(saved.addSecs || {}) },
      footOpts: { ...defaults.footOpts, ...(saved.footOpts || {}) },
      coachNotes: Object.prototype.hasOwnProperty.call(saved, 'coachNotes')
        ? String(saved.coachNotes || '')
        : coachNotesLoad(athlete.id, season),
    };
  };
  const applyConfig = (c) => {
    if (!c) return;
    if (c.variant) setVariant(c.variant);
    if (c.baselineType) setBaselineType(c.baselineType);
    if (c.periodOpts) setPeriodOpts(c.periodOpts);
    if (c.dimsOn) setDimsOn(new Set(c.dimsOn));
    if (c.dimsTitle != null) setDimsTitle(c.dimsTitle);
    if (c.selMetrics) setSelMetrics(new Set(c.selMetrics));
    if (c.cols) setCols(new Set(c.cols));
    if (c.metricsTitle != null) setMetricsTitle(c.metricsTitle);
    if (c.narrOn != null) setNarrOn(c.narrOn);
    const nextNarrativeMode = c.narrativeMode === 'system' ? 'system' : 'custom';
    narrativeModeRef.current = nextNarrativeMode;
    setNarrativeMode(nextNarrativeMode);
    if (c.narrTpl) setNarrTpl(c.narrTpl);
    if (c.narrTitle != null) setNarrTitle(c.narrTitle);
    if (c.narrText != null) {
      const nextNarrative = String(c.narrText);
      updateNarrativeText(nextNarrative);
      if (narrRef.current) narrRef.current.textContent = nextNarrative;
    }
    if (c.recsOn != null) setRecsOn(c.recsOn);
    if (c.recsTitle != null) setRecsTitle(c.recsTitle);
    if (c.trainingRecommendationText != null) setTrainingRecommendationText(c.trainingRecommendationText);
    if (c.headerOn != null) setHeaderOn(c.headerOn);
    if (c.headerLogoOn != null) setHeaderLogoOn(c.headerLogoOn);
    if (c.athleteStripOn != null) setAthleteStripOn(c.athleteStripOn);
    if (c.cmjResultOn != null) setCmjResultOn(c.cmjResultOn);
    if (c.cmjMetricsOn != null) setCmjMetricsOn(c.cmjMetricsOn);
    if (Array.isArray(c.cmjMetricKeys)) setCmjMetricKeys(new Set(c.cmjMetricKeys));
    // RD-4 (2026-07-09, ③b): stale saved templates may still carry the old CMJ curve
    // toggle / chart-key set — silently ignored (no loader, not re-persisted on next save).
    if (c.sjResultOn != null) setSjResultOn(c.sjResultOn);
    if (c.imtpResultOn != null) setImtpResultOn(c.imtpResultOn);
    if (c.fieldResultOn != null) setFieldResultOn(c.fieldResultOn);
    if (c.manualResultOn != null) setManualResultOn(c.manualResultOn);
    if (c.trainingWeekOn != null) setTrainingWeekOn(c.trainingWeekOn);
    if (c.trainingPeriodOn != null) setTrainingPeriodOn(c.trainingPeriodOn);
    if (c.trainingVolumeChartOn != null) setTrainingVolumeChartOn(c.trainingVolumeChartOn);
    if (c.addSecs) setAddSecs(c.addSecs);
    if (c.notesOn != null) setNotesOn(c.notesOn);
    if (c.notesTitle != null) setNotesTitle(c.notesTitle);
    if (c.coachNotes != null) setCoachNotes(String(c.coachNotes));
    if (Array.isArray(c.moduleOrder)) setModuleOrder(normalizeReportModuleOrder(c.moduleOrder));
    if (c.footOpts) setFootOpts(c.footOpts);
    if (c.paperBg) setPaperBg(c.paperBg);
  };
  const reportDraftScope = { athleteId: athlete.id, season };
  const reportDraftScopeKey = draftAdapter?.scopeKey
    ? draftAdapter.scopeKey(reportDraftScope)
    : `${encodeURIComponent(athlete.id)}|${encodeURIComponent(season)}`;
  const [reportDraftReadyScopeKey, setReportDraftReadyScopeKey] = rUseS(null);
  const [reportDraftError, setReportDraftError] = rUseS(null);
  const [reportDraftErrorPhase, setReportDraftErrorPhase] = rUseS(null);
  const reportDraftRevisionRef = rUseRef('');
  const reportDraftCleanSignatureRef = rUseRef('');
  const reportDraftPassthroughRef = rUseRef({});
  const reportDraftPendingRef = rUseRef(null);
  const reportDraftTimerRef = rUseRef(null);
  const flushPendingReportDraft = () => {
    const pending = reportDraftPendingRef.current;
    if (!pending) return { ok: true };
    if (reportDraftTimerRef.current) clearTimeout(reportDraftTimerRef.current);
    reportDraftTimerRef.current = null;
    const result = pending.adapter.save(pending.scope, pending.config, pending.expectedRevision);
    if (result.ok) {
      reportDraftPendingRef.current = null;
      reportDraftRevisionRef.current = String(result.revision || result.record?.updatedAt || '');
      reportDraftCleanSignatureRef.current = pending.editableSignature;
      reportDraftPassthroughRef.current = draftPassthroughMetadata(result.record?.config || pending.config);
      setReportDraftError(null);
      setReportDraftErrorPhase(null);
      try { localStorage.removeItem(coachNotesKey(pending.scope.athleteId, pending.scope.season)); } catch {}
      return result;
    }
    setReportDraftError(result.error || new Error('Individual Report draft save failed.'));
    setReportDraftErrorPhase('save');
    setReportSaveState('error');
    return result;
  };
  const flushPendingReportEdits = () => {
    const draftResult = flushPendingReportDraft();
    setReportSaveState(draftResult.ok ? 'saved' : 'error');
    return draftResult;
  };
  const discardPendingReportDraft = () => {
    if (reportDraftTimerRef.current) clearTimeout(reportDraftTimerRef.current);
    reportDraftTimerRef.current = null;
    const result = draftAdapter?.load ? draftAdapter.load(reportDraftScope) : { ok: true, record: null };
    if (!result.ok) {
      setReportDraftError(result.error || new Error('Individual Report draft load failed.'));
      setReportDraftErrorPhase('load');
      setReportSaveState('error');
      return result;
    }
    reportDraftPendingRef.current = null;
    const savedConfig = result.record?.config || null;
    const config = materializeDraftConfig(savedConfig);
    reportDraftRevisionRef.current = String(result.record?.updatedAt || '');
    reportDraftPassthroughRef.current = draftPassthroughMetadata(savedConfig);
    reportDraftCleanSignatureRef.current = JSON.stringify(editableDraftProjection(config));
    applyConfig(config);
    setReportDraftError(null);
    setReportDraftErrorPhase(null);
    setReportSaveState('saved');
    return { ok: true };
  };
  const retryCurrentReportDraft = () => flushPendingReportEdits();
  const loadCurrentReportDraft = () => {
    const result = draftAdapter?.load ? draftAdapter.load(reportDraftScope) : { ok: true, record: null };
    if (!result.ok) {
      setReportDraftError(result.error || new Error('Individual Report draft load failed.'));
      setReportDraftErrorPhase('load');
      setReportSaveState('error');
      return result;
    }
    const savedConfig = result.record?.config || null;
    const config = materializeDraftConfig(savedConfig);
    if (reportDraftTimerRef.current) clearTimeout(reportDraftTimerRef.current);
    reportDraftTimerRef.current = null;
    reportDraftPendingRef.current = null;
    reportDraftRevisionRef.current = String(result.record?.updatedAt || '');
    reportDraftPassthroughRef.current = draftPassthroughMetadata(savedConfig);
    reportDraftCleanSignatureRef.current = JSON.stringify(editableDraftProjection(config));
    applyConfig(config);
    setReportDraftReadyScopeKey(reportDraftScopeKey);
    setReportDraftError(null);
    setReportDraftErrorPhase(null);
    setReportSaveState('saved');
    return { ok: true };
  };
  const reportDraftFlushRef = rUseRef(flushPendingReportEdits);
  reportDraftFlushRef.current = flushPendingReportEdits;
  rUseE(() => {
    if (isBatchStage || !draftAdapter?.bind) return undefined;
    const binding = draftAdapter.bind(mode === 'page' ? 'page' : 'modal', {
      target: reportDraftScope,
      isDirty: () => !!reportDraftPendingRef.current,
      flush: () => reportDraftFlushRef.current(),
      discard: discardPendingReportDraft,
    });
    if (!binding.ok) {
      setReportDraftError(binding.error || new Error('Report draft controller ownership conflict.'));
      setReportDraftErrorPhase('owner');
      setReportSaveState('error');
      return undefined;
    }
    return binding.release;
  }, [draftAdapter, isBatchStage, mode, reportDraftScopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
  rUseE(() => {
    if (isBatchStage) { setReportDraftReadyScopeKey(reportDraftScopeKey); return undefined; }
    setReportDraftReadyScopeKey(null);
    setReportSaveState('pending');
    const expectedOwner = mode === 'page' ? 'page' : 'modal';
    if (draftAdapter?.activeOwner?.() && draftAdapter.activeOwner() !== expectedOwner) {
      setReportDraftError(new Error(`Report draft controller is already owned by ${draftAdapter.activeOwner()}.`));
      setReportDraftErrorPhase('owner');
      setReportSaveState('error');
      return undefined;
    }
    loadCurrentReportDraft();
    return flushPendingReportDraft;
  }, [draftAdapter, reportDraftScopeKey, isBatchStage]); // eslint-disable-line react-hooks/exhaustive-deps
  const reportDraftSignature = JSON.stringify(captureConfig());
  rUseE(() => {
    if (isBatchStage || reportDraftReadyScopeKey !== reportDraftScopeKey || !draftAdapter) return undefined;
    if (reportDraftSignature === reportDraftCleanSignatureRef.current) {
      setReportSaveState('saved');
      return undefined;
    }
    setReportSaveState('pending');
    const editableConfig = JSON.parse(reportDraftSignature);
    const pending = {
      adapter: draftAdapter,
      scope: reportDraftScope,
      config: { ...reportDraftPassthroughRef.current, ...editableConfig },
      editableSignature: reportDraftSignature,
      expectedRevision: reportDraftRevisionRef.current,
    };
    reportDraftPendingRef.current = pending;
    const timer = setTimeout(() => {
      if (reportDraftPendingRef.current === pending) flushPendingReportEdits();
    }, 450);
    reportDraftTimerRef.current = timer;
    return () => {
      if (reportDraftTimerRef.current === timer) {
        clearTimeout(timer);
        reportDraftTimerRef.current = null;
      }
    };
  }, [draftAdapter, reportDraftScopeKey, reportDraftReadyScopeKey, reportDraftSignature, isBatchStage]);
  const reportDraftScopeReady = isBatchStage || (
    reportDraftReadyScopeKey === reportDraftScopeKey
    && reportDraftErrorPhase !== 'load'
    && reportDraftErrorPhase !== 'owner'
  );
  const bindNarrativeEditor = React.useCallback(node => {
    narrRef.current = node;
    if (!node) return;
    if (narrativeModeRef.current === 'system') renderNarrativeTemplateDom(node, narrHTML);
    else node.textContent = narrText;
  }, [reportDraftScopeKey, reportDraftReadyScopeKey, narrTpl]); // eslint-disable-line react-hooks/exhaustive-deps
  const saveAsTemplate = () => {
    if (!reportDraftScopeReady) return;
    const name = (window.prompt('模板名称（如：教练版 / 家长版）', `模板 ${templates.length + 1}`) || '').trim();
    if (!name) return;
    const id = 'tpl_' + Date.now();
    const config = batchPolicy?.toBatchTemplateConfig ? batchPolicy.toBatchTemplateConfig(captureConfig()) : captureConfig();
    setTemplates(ts => [...ts.filter(t => t.name !== name), { id, name, config }]);
    setActiveTpl(id);
  };
  const applyTemplate = (t) => { setActiveTpl(t.id); applyConfig(t.config); };
  const deleteTemplate = (id, e) => {
    e.stopPropagation();
    setTemplates(ts => ts.filter(t => t.id !== id));
    if (activeTpl === id) setActiveTpl(null);
  };
  const activeTemplate = templates.find(t => t.id === activeTpl) || null;
  const templateAssessment = batchPolicy?.inspectTemplate
    ? batchPolicy.inspectTemplate({ activeTemplate, currentConfig: captureConfig() })
    : { ready: false, reason: 'no-policy', snapshot: null };

  const readLogoDataUrl = file => new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(String(reader.result || ''));
    reader.onerror = () => reject(new Error('Logo 文件读取失败'));
    reader.readAsDataURL(file);
  });
  const verifyLogoImage = dataUrl => new Promise((resolve, reject) => {
    const image = new Image();
    image.onload = () => resolve(true);
    image.onerror = () => reject(new Error('Logo 图像无法解码'));
    image.src = dataUrl;
  });
  const handleLogoFile = async event => {
    const input = event.currentTarget;
    const file = input.files?.[0];
    if (!file || !batchPolicy || !brandingRepository) {
      input.value = '';
      return;
    }
    setBrandingError('');
    setBrandingStatus('processing');
    try {
      const bytes = new Uint8Array(await file.arrayBuffer());
      const checked = batchPolicy.validateLogo({ name: file.name, type: file.type, size: file.size, bytes });
      if (!checked.ok) {
        const messages = {
          'unsupported-type': '仅支持 PNG、JPEG 或 WebP，不接受 SVG / GIF。',
          'too-large': 'Logo 文件不得超过 1 MB。',
          'mime-mismatch': '文件内容与扩展类型不一致，请重新导出图片。',
          empty: 'Logo 文件为空。',
        };
        throw new Error(messages[checked.reason] || 'Logo 文件无效。');
      }
      const logoDataUrl = await readLogoDataUrl(file);
      await verifyLogoImage(logoDataUrl);
      const next = brandingRepository.save({
        logoDataUrl,
        fileName: checked.name,
        mimeType: checked.mimeType,
        byteSize: checked.byteSize,
      });
      setBranding(next);
      setHeaderLogoOn(true);
      setBrandingStatus('saved');
    } catch (error) {
      setBrandingError(error?.message || 'Logo 上传失败。');
      setBrandingStatus('error');
    } finally {
      input.value = '';
    }
  };
  const removeLogo = () => {
    try {
      brandingRepository?.remove?.();
      setBranding(null);
      setBrandingError('');
      setBrandingStatus('removed');
    } catch (error) {
      setBrandingError(error?.message || 'Logo 删除失败。');
    }
  };

  const [batchOpen, setBatchOpen] = rUseS(false);
  const [batchSearch, setBatchSearch] = rUseS('');
  const [batchSelected, setBatchSelected] = rUseS(() => new Set());
  const [batchSnapshot, setBatchSnapshot] = rUseS(null);
  const [batchReadyIds, setBatchReadyIds] = rUseS({});
  rUseE(() => {
    setBatchOpen(false);
    setBatchSnapshot(null);
    setBatchReadyIds({});
  }, [reportDraftScopeKey]);
  const openBatchExport = () => {
    if (!reportDraftScopeReady) return;
    const checked = batchPolicy?.inspectTemplate?.({ activeTemplate, currentConfig: captureConfig() });
    if (!checked?.ready) return;
    setBatchSnapshot(checked);
    setBatchSelected(new Set(athlete?.id ? [athlete.id] : []));
    setBatchReadyIds({});
    setBatchSearch('');
    setBatchOpen(true);
  };
  const closeBatchExport = () => {
    setBatchOpen(false);
    setBatchSnapshot(null);
    setBatchReadyIds({});
  };
  const visibleBatchAthletes = batchPolicy?.filterAthletes
    ? batchPolicy.filterAthletes(batchContexts.map(ctx => ctx.athlete), batchSearch)
    : batchContexts.map(ctx => ctx.athlete);
  const selectedBatchContexts = batchPolicy?.selectAthletes
    ? batchPolicy.selectAthletes(batchContexts.map(ctx => ctx.athlete), batchSelected)
        .map(selectedAthlete => batchContexts.find(ctx => ctx.athlete.id === selectedAthlete.id))
        .filter(Boolean)
    : [];
  const noteBatchReady = rUseM(() => (athleteId, ready) => {
    setBatchReadyIds(current => {
      if (!!current[athleteId] === !!ready) return current;
      return { ...current, [athleteId]: !!ready };
    });
  }, []);
  const batchAllReady = selectedBatchContexts.length > 0
    && selectedBatchContexts.every(ctx => batchReadyIds[ctx.athlete.id]);
  const toggleBatchAthlete = athleteId => setBatchSelected(current => {
    const next = new Set(current);
    next.has(athleteId) ? next.delete(athleteId) : next.add(athleteId);
    return next;
  });
  const selectVisibleBatchAthletes = enabled => setBatchSelected(current => {
    const next = new Set(current);
    visibleBatchAthletes.forEach(item => enabled ? next.add(item.id) : next.delete(item.id));
    return next;
  });
  const exportAthleteBatch = () => {
    if (!batchSnapshot?.snapshot || !batchAllReady) return;
    const nodes = selectedBatchContexts
      .map(ctx => document.getElementById(`batch-report-${ctx.athlete.id}`))
      .filter(Boolean);
    if (nodes.length !== selectedBatchContexts.length) return;
    window.doPrintBundle?.(nodes, {
      title: `${batchSnapshot.templateName} · ${window.formatDate ? window.formatDate(season, 'short') : season} · ${nodes.length} athletes`,
    });
  };

  const batchTargetSignature = isBatchStage && batchConfig && batchPolicy
    ? batchPolicy.stableConfig(batchPolicy.toBatchTemplateConfig(batchConfig)) : '';
  const batchCurrentSignature = isBatchStage && batchPolicy
    ? batchPolicy.stableConfig(batchPolicy.toBatchTemplateConfig(captureConfig())) : '';
  const batchStageReady = !!batchTargetSignature && batchTargetSignature === batchCurrentSignature;
  rUseE(() => {
    if (isBatchStage && batchConfig) applyConfig(batchConfig);
  }, [isBatchStage, batchTargetSignature]); // eslint-disable-line react-hooks/exhaustive-deps
  rUseE(() => {
    if (!isBatchStage || !onBatchReady) return undefined;
    onBatchReady(athlete.id, batchStageReady);
    return () => onBatchReady(athlete.id, false);
  }, [isBatchStage, onBatchReady, athlete.id, batchStageReady]);

  // ── RP-R2' 模块库分组（注册表联邦形态）· tag 标注 L2/L1/O ──
  const LIBRARY_GROUPS = [
    { label: '基础模块', hint: '可选 · 面向不同收件人', items: [
      { key: 'header', label: '报告头部 + 元数据', tag: 'O' }, { key: 'athleteStrip', label: '运动员档案条', tag: 'O' },
    ] },
    { label: '已确认结论 L2', items: [
      { key: 'cmj', tag: 'L2' }, { key: 'sjResult', tag: 'L2' }, { key: 'imtpResult', tag: 'L2' }, { key: 'fieldResult', tag: 'L2' }, { key: 'manualResult', tag: 'L2' },
    ] },
    { label: '训练', items: [
      { key: 'trainingWeek', tag: 'L1' }, { key: 'trainingPeriod', tag: 'L1' }, { key: 'trainingVolumeChart', tag: 'L1' },
    ] },
    { label: '档案与指标', items: [
      { key: 'dims', tag: 'L1' }, { key: 'metrics', tag: 'L1' },
    ] },
    { label: '文本', items: [
      { key: 'narrative', tag: 'O' }, { key: 'recs', tag: 'O' }, { key: 'notes', tag: 'O' },
    ] },
    { label: '附加章节', items: [
      { key: 'load', tag: 'O' }, { key: 'body', tag: 'O' }, { key: 'attend', tag: 'O' },
    ] },
  ];
  const moduleAvailable = key => {
    const a = moduleControl[key]?.available;
    return typeof a === 'function' ? a() : !!a;
  };

  // ── RP-R2' 画布模块选中层：DOM 注解（不触碰 renderReportBody 渲染路径）──
  // 预览 DOM 中 .rpt-modules 的直接子元素与 canvasSeqRef（按 variant 推导的模块键
  // 序列）一一对应；effect 只加 data-rpt-mod 注解与选中态 class，并测量选中块位置
  // 供浮动把手定位。打印走隐藏 print body（独立 DOM），不受此层影响。
  const canvasRef = rUseRef(null);
  const canvasSeqRef = rUseRef([]);
  const [canvasSelRect, setCanvasSelRect] = rUseS(null);
  rUseE(() => {
    const host = canvasRef.current;
    if (!host) { if (canvasSelRect) setCanvasSelRect(null); return; }
    const seq = canvasSeqRef.current || [];
    const blocks = host.querySelectorAll('.rpt-modules > *');
    blocks.forEach((el, i) => {
      const k = seq[i] || '';
      el.setAttribute('data-rpt-mod', k);
      el.classList.toggle('rpt-mod-selected', !!k && k === selectedModule);
    });
    const ph = host.querySelector('.rpt .ph');
    if (ph) { ph.setAttribute('data-rpt-mod', 'header'); ph.classList.toggle('rpt-mod-selected', selectedModule === 'header'); }
    const strip = host.querySelector('.rpt .athlete-strip');
    if (strip) { strip.setAttribute('data-rpt-mod', 'athleteStrip'); strip.classList.toggle('rpt-mod-selected', selectedModule === 'athleteStrip'); }
    const sel = selectedModule ? host.querySelector(`[data-rpt-mod="${selectedModule}"]`) : null;
    if (sel) {
      const hostRect = host.getBoundingClientRect();
      const r = sel.getBoundingClientRect();
      const next = { top: Math.round(r.top - hostRect.top), height: Math.round(r.height) };
      if (!canvasSelRect || canvasSelRect.top !== next.top || canvasSelRect.height !== next.height) setCanvasSelRect(next);
    } else if (canvasSelRect) {
      setCanvasSelRect(null);
    }
  });

  if (!open) return null;

  // ── derived output ──
  // RD-2: sortedGroups prop feeds metric-def lookups / coach-mode / variant-B
  // strengths lists — it stays the full ordered group set. The core-viz module
  // no longer selects groups (it selects raw catalog metrics via dimsOn → dimTiles).
  const outGroups = sortedGroups;
  const outSwc = swcRows.filter(r => selMetrics.has(r.metricId));
  const outSwcIds = new Set(outSwc.map(r => r.metricId));
  const outMetricRows = metricCatalog.filter(m => selMetrics.has(m.id) && !outSwcIds.has(m.id));
  const outImpr = outSwc.filter(r => r.band.id !== 'trivial' && r.improved);
  const outDecl = outSwc.filter(r => r.band.id !== 'trivial' && !r.improved);
  const narrative = narrOn && narrText ? [narrText] : [];
  // RD-2: header objective identifiers (replace the removed 综合 composite cell).
  // 已确认项数 = present-count of the athlete's human-confirmed evidence items (no
  // derivation — just counting what审核库 returned). 最近测试日期 = latest of the
  // report season and any confirmed evidence sessionDate (honest max, no compute).
  const confirmedCount = [reviewedEvidence, sjReviewedEvidence, imtpReviewedEvidence, fieldReviewedEvidence, manualReviewedEvidence].filter(Boolean).length;
  const latestTestDate = rUseM(() => {
    const dates = [season,
      reviewedEvidence?.sessionDate, sjReviewedEvidence?.sessionDate,
      imtpReviewedEvidence?.sessionDate, fieldReviewedEvidence?.sessionDate, manualReviewedEvidence?.sessionDate,
    ].filter(Boolean).map(String);
    return dates.length ? dates.sort((a, b) => b.localeCompare(a))[0] : season;
  }, [season, reviewedEvidence, sjReviewedEvidence, imtpReviewedEvidence, fieldReviewedEvidence, manualReviewedEvidence]);
  const sectionTitles = { profile: dimsTitle, swc: metricsTitle, forestPlot: recsTitle, coachNotes: notesTitle, summary: narrTitle, load: '训练负荷', body: '身体形态', attend: '出勤 / 缺测' };
  const reportCmjEvidence = cmjResultOn ? reviewedEvidence : null;
  const reportCmjMetrics = reportCmjEvidence && cmjMetricsOn ? getReportCmjMetricRows(reportCmjEvidence, cmjMetricKeys) : [];
  const reportSjEvidence = sjResultOn ? sjReviewedEvidence : null;
  const reportImtpEvidence = imtpResultOn ? imtpReviewedEvidence : null;
  const reportFieldEvidence = fieldResultOn ? fieldReviewedEvidence : null;
  const reportManualEvidence = manualResultOn ? manualReviewedEvidence : null;
  // RP-R1': training modules gate on their toggle AND built data availability.
  const trainingWeekAvailable = !!(trainingReportData.trainingWeek && (trainingReportData.trainingWeek.summary || (trainingReportData.trainingWeek.heat || []).length));
  const trainingPeriodAvailable = !!(trainingReportData.trainingPeriod && trainingReportData.trainingPeriod.stats);
  const trainingVolumeChartAvailable = !!(trainingReportData.trainingVolumeChart && (trainingReportData.trainingVolumeChart.daily || []).length);
  const hasTrainingWeek = trainingWeekOn && trainingWeekAvailable;
  const hasTrainingPeriod = trainingPeriodOn && trainingPeriodAvailable;
  const hasTrainingVolumeChart = trainingVolumeChartOn && trainingVolumeChartAvailable;
  const moduleEnabled = { narrative: narrOn, cmj: !!reportCmjEvidence, sjResult: !!reportSjEvidence, imtpResult: !!reportImtpEvidence, fieldResult: !!reportFieldEvidence, manualResult: !!reportManualEvidence, trainingWeek: hasTrainingWeek, trainingPeriod: hasTrainingPeriod, trainingVolumeChart: hasTrainingVolumeChart, dims: dimsOn.size > 0, metrics: selMetrics.size > 0, recs: recsOn, load: !!addSecs.load, body: !!addSecs.body, attend: !!addSecs.attend, notes: notesOn };
  const outOrder = moduleOrder.filter(k => moduleEnabled[k]);
  // ── RP-R2' 画布序列：与两个 print body 的实际渲染分支保持一致 ──
  // 变体 A：outOrder 中每个键都渲染为 .rpt-modules 的直接子元素；
  // 变体 B：narrative/训练/结论键不渲染，metrics 仅在有提升项时渲染。
  const canvasModuleSeq = variant === 'A'
    ? outOrder
    : outOrder.filter(k => ['cmj', 'sjResult', 'imtpResult', 'fieldResult', 'manualResult'].includes(k)
        || (k === 'metrics' ? outImpr.length > 0 : ['dims', 'recs', 'load', 'body', 'attend', 'notes'].includes(k)));
  canvasSeqRef.current = canvasModuleSeq;
  // 页数估算（画布页码角标 1/N）：按已启用正文模块数粗估打印分页，供角标与分页符显示。
  const canvasPageEstimate = Math.max(1, Math.ceil(canvasModuleSeq.length / 5));
  const workbenchLabel = k => k === 'header' ? '报告头部 + 元数据' : k === 'athleteStrip' ? '运动员档案条' : (MODULE_LABELS[k] || k);
  // 画布上移/下移：只在"已启用模块"序列内交换位置（禁用模块保持原地）。
  const moveCanvasModule = (key, dir) => {
    const enabled = moduleOrder.filter(k => moduleEnabled[k]);
    const idx = enabled.indexOf(key);
    const target = enabled[idx + dir];
    if (idx < 0 || !target) return;
    setModuleOrder(o => {
      const n = o.filter(k => k !== key);
      const at = n.indexOf(target);
      n.splice(dir < 0 ? at : at + 1, 0, key);
      return n;
    });
  };
  const onCanvasClick = e => {
    const t = e.target;
    const el = t && typeof t.closest === 'function' ? t.closest('[data-rpt-mod]') : null;
    const k = el && el.getAttribute('data-rpt-mod');
    if (k) setSelectedModule(k);
  };
  const tabMetrics = metricCatalog.filter(m => m.groupId === mpTab && (!mpSearch || (m.label + m.id).toLowerCase().includes(mpSearch.toLowerCase())));
  const tabSelCount = metricCatalog.filter(m => m.groupId === mpTab && selMetrics.has(m.id)).length;
  const selByGroup = new Set([...selMetrics].map(id => metricCatalog.find(m => m.id === id)?.groupId).filter(Boolean));

  const doPrint = () => {
    if (!reportDraftScopeReady) return;
    const prev = document.title;
    document.title = `${athlete.name} · ${season} · Assessment Report`;
    const reportBody = document.getElementById('report-body');
    if (!reportBody) { window.print(); return; }
    const clone = reportBody.cloneNode(true);
    clone.id = 'print-clone';
    clone.style.cssText = 'display:block;';
    document.body.appendChild(clone);
    fitReportToOnePage(clone);
    document.body.classList.add('printing-report');
    const pageStyle = document.createElement('style');
    pageStyle.id = 'rpt-page-override';
    pageStyle.textContent = '@media print { @page { size: A4; margin: 0; } }';
    document.head.appendChild(pageStyle);
    window.addEventListener('afterprint', () => {
      document.body.classList.remove('printing-report');
      document.title = prev;
      document.getElementById('print-clone')?.remove();
      document.getElementById('rpt-page-override')?.remove();
    }, { once: true });
    setTimeout(() => window.print(), 80);
  };

  const baseMeta = prevSeason ? `${window.formatDate(prevSeason, 'compact')} 为基线` : '无基线对比';
  const periodSessions = Object.keys(athlete.seasons?.[season] || {}).length;

  const hexA = (h, a) => {
    if (typeof h !== 'string' || h[0] !== '#') return h;
    let s = h.slice(1);
    if (s.length === 3) s = s.split('').map(c => c + c).join('');
    const n = parseInt(s, 16);
    return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
  };
  const baselineChips = [
    { id: 'last', label: '上季度' }, { id: 'yoy', label: '上一年同期' },
    { id: 'squad', label: `全队${athlete.position || ''}均值` }, { id: 'best', label: '个人历史最佳' },
    { id: 'none', label: '无' },
  ];
  const colChips = [
    { id: 'curr', label: '本季' }, { id: 'trend', label: '近 8 次趋势' }, { id: 'delta', label: 'Δ 基线' },
    { id: 'dir', label: '方向 (SWC)' }, { id: 'es', label: 'ES (效应量)' }, { id: 'mdc', label: 'MDC 阈值' },
    { id: 'pos', label: '同位置均值' },
  ];
  const toggleCmjMetricKey = key => setCmjMetricKeys(s => { const n = new Set(s); n.has(key) ? n.delete(key) : n.add(key); return n; });
  const moveCmjMetricKey = (key, direction) => setCmjMetricKeys(current => {
    const keys = [...current];
    const index = keys.indexOf(key);
    const nextIndex = index + direction;
    if (index < 0 || nextIndex < 0 || nextIndex >= keys.length) return current;
    [keys[index], keys[nextIndex]] = [keys[nextIndex], keys[index]];
    return new Set(keys);
  });
  const setCmjMetricPreset = keys => {
    const available = new Set(INDIVIDUAL_REPORT_CMJ_METRICS.map(metric => metric.key));
    setCmjMetricKeys(new Set(keys.filter(key => available.has(key))));
  };
  const isCmjMetricPresetActive = keys => keys.length === cmjMetricKeys.size && keys.every(key => cmjMetricKeys.has(key));
  const insertTrainingRecommendationPreset = text => {
    setTrainingRecommendationText(current => {
      const base = (current || '').trim();
      return base ? `${base}\n${text}` : text;
    });
  };
  const reportCmjSession = getReportCmjSession(reviewedEvidence);
  const reportCmjSessionLabel = reportCmjSession?.date
    ? window.formatDate(reportCmjSession.date, 'short')
    : (reviewedEvidence?.sessionDate ? formatReportEvidenceDate(reviewedEvidence.sessionDate) : '/');
  const filteredReportCmjMetrics = INDIVIDUAL_REPORT_CMJ_METRICS.filter(metric => {
    const q = cmjMetricSearch.trim().toLowerCase();
    return !q || `${metric.label} ${metric.key}`.toLowerCase().includes(q);
  });
  const reportBranding = branding ? { ...branding, showLogo: headerLogoOn } : null;
  const renderReportBody = (preview = false, bodyId = null) => variant === 'A' ? (
    <ReportPrintBody
      athlete={athlete}
      season={season}
      prevSeason={prevSeason}
      dimTiles={dimTiles}
      confirmedCount={confirmedCount}
      latestTestDate={latestTestDate}
      sortedGroups={outGroups}
      swcRows={outSwc}
      metricRows={outMetricRows}
      meaningful={selMetrics.size ? outSwc.filter(r => r.band.id !== 'trivial') : []}
      improvements={outImpr}
      declines={outDecl}
      trivialCount={0}
      narrative={narrative}
      coachNotes={notesOn ? coachNotes : ''}
      titles={sectionTitles}
      cols={cols}
      order={outOrder}
      paperBg={paperBg}
      variant="A"
      reviewedEvidence={reportCmjEvidence}
      showHeader={headerOn}
      showAthleteStrip={athleteStripOn}
      cmjMetrics={reportCmjMetrics}
      sjEvidence={reportSjEvidence}
      imtpEvidence={reportImtpEvidence}
      fieldEvidence={reportFieldEvidence}
      manualEvidence={reportManualEvidence}
      trainingReportData={trainingReportData}
      trainingRecommendationText={trainingRecommendationText}
      addSections={addSecs}
      preview={preview}
      bodyId={bodyId}
      branding={reportBranding}
    />
  ) : (
    <ReportPrintBodyB
      athlete={athlete}
      season={season}
      prevSeason={prevSeason}
      dimTiles={dimTiles}
      confirmedCount={confirmedCount}
      latestTestDate={latestTestDate}
      sortedGroups={outGroups}
      swcRows={outSwc}
      metricRows={outMetricRows}
      meaningful={outSwc.filter(r => r.band.id !== 'trivial')}
      improvements={outImpr}
      declines={outDecl}
      narrative={narrative}
      coachNotes={notesOn ? coachNotes : ''}
      paperBg={paperBg}
      reviewedEvidence={reportCmjEvidence}
      cmjMetrics={reportCmjMetrics}
      sjEvidence={reportSjEvidence}
      imtpEvidence={reportImtpEvidence}
      fieldEvidence={reportFieldEvidence}
      manualEvidence={reportManualEvidence}
      titles={sectionTitles}
      order={outOrder}
      trainingRecommendationText={trainingRecommendationText}
      addSections={addSecs}
      preview={preview}
      bodyId={bodyId}
      branding={reportBranding}
    />
  );

  if (isBatchStage) {
    return batchStageReady ? renderReportBody(false, batchBodyId) : null;
  }

  const isPageMode = mode === 'page';
  const composerGridClass = [
    'm-body individual-report-composer-grid workbench-grid',
    isReportPaneCollapsed('context') ? 'context-collapsed' : '',
    isReportPaneCollapsed('sections') ? 'sections-collapsed' : '',
    isReportPaneCollapsed('preview') ? 'preview-collapsed' : '',
  ].filter(Boolean).join(' ');

  const renderBrandingControls = () => (
    <div className="report-branding-control" data-report-branding-control data-branding-status={brandingStatus}>
      <div className="report-branding-preview">
        {branding?.logoDataUrl ? <img src={branding.logoDataUrl} alt="当前机构 Logo" /> : <span>暂无 Logo</span>}
        <div>
          <strong>{branding?.fileName || '机构页眉品牌'}</strong>
          <small>{branding ? `${Math.ceil(branding.byteSize / 1024)} KB · ${branding.mimeType}` : 'PNG / JPEG / WebP · 最大 1 MB'}</small>
        </div>
      </div>
      <div className="report-branding-actions">
        <label className="ghost report-logo-upload">
          {branding ? '替换 Logo' : '上传 Logo'}
          <input
            type="file"
            accept="image/png,image/jpeg,image/webp"
            onChange={handleLogoFile}
            aria-label="上传报告页眉 Logo"
          />
        </label>
        {branding && <button type="button" className="ghost" onClick={removeLogo}>删除 Logo</button>}
        <button
          type="button"
          className={`chip${headerLogoOn && branding ? ' on' : ''}`}
          disabled={!branding}
          onClick={() => branding && setHeaderLogoOn(value => !value)}
        >
          {headerLogoOn && branding ? '页眉显示' : '页眉隐藏'}
        </button>
      </div>
      {brandingError && <div className="report-branding-error" role="alert">{brandingError}</div>}
    </div>
  );

  // ── RP-R2' 检查器 · 每个模块的既有配置 JSX 原样迁入（能力零丢失）──
  const renderModuleInspector = (modKey) => {
    switch (modKey) {
      case 'cmj':
        return reviewedEvidence ? (
          <div className="insp-block">
            <div className="insp-g">数据</div>
            <div className="insp-field"><span>CMJ 力量-时间结果<span className="hint">表现结论 / 训练关注点 / 日期</span></span></div>
            <IndividualReportReviewedEvidenceCallout
              evidence={reviewedEvidence}
              metrics={cmjMetricsOn ? getReportCmjMetricRows(reviewedEvidence, cmjMetricKeys) : []}
              interactive
              onOpenReview={onOpenReviewedEvidence}
              onOpenReport={onOpenReviewedEvidenceReport}
            />
            <div className="nest" style={{ marginTop: 8 }}>
              <div className="nest-h">
                <span className="k">CMJ 指标 · 多选</span>
                <span className="all">{cmjMetricKeys.size} / {INDIVIDUAL_REPORT_CMJ_METRICS.length}</span>
              </div>
              <div className="chips flush">
                <button className={`chip${cmjMetricsOn ? ' on' : ''}`} onClick={() => setCmjMetricsOn(v => !v)}>显示指标</button>
                {INDIVIDUAL_REPORT_CMJ_METRIC_PRESETS.map(preset => (
                  <button key={preset.id} className={`chip${isCmjMetricPresetActive(preset.keys) ? ' on' : ''}`} onClick={() => setCmjMetricPreset(preset.keys)}>{preset.label}</button>
                ))}
                <button className="chip" onClick={() => setCmjMetricKeys(new Set(INDIVIDUAL_REPORT_CMJ_METRICS.map(m => m.key)))}>全选</button>
                <button className="chip" onClick={() => setCmjMetricKeys(new Set())}>清空</button>
              </div>
              {cmjMetricsOn && (
                <>
                  {cmjMetricKeys.size > 0 && (
                    <div className="mp-pills" style={{ marginTop: 7 }}>
                      {[...cmjMetricKeys].map((key, index) => {
                        const metric = INDIVIDUAL_REPORT_CMJ_METRICS.find(item => item.key === key);
                        if (!metric) return null;
                        return (
                          <span key={key} className="m-pill">
                            {metric.label}
                            <button className="x" disabled={index === 0} onClick={() => moveCmjMetricKey(key, -1)} aria-label={`上移 ${metric.label}`}>↑</button>
                            <button className="x" disabled={index === cmjMetricKeys.size - 1} onClick={() => moveCmjMetricKey(key, 1)} aria-label={`下移 ${metric.label}`}>↓</button>
                          </span>
                        );
                      })}
                    </div>
                  )}
                  <input
                    value={cmjMetricSearch}
                    onChange={e => setCmjMetricSearch(e.target.value)}
                    placeholder="搜索 CMJ 指标"
                    style={{ width: '100%', marginTop: 7, background: 'var(--paper)', border: '1px solid var(--line)', borderRadius: 6, padding: '6px 8px', font: '12px/1.4 var(--font-sans)', color: 'var(--ink)', outline: 'none' }}
                  />
                  <div className="mp-grid" style={{ marginTop: 7 }}>
                    {filteredReportCmjMetrics.map(metric => (
                      <div key={metric.key} className={`mp-row${cmjMetricKeys.has(metric.key) ? ' on' : ''}`} onClick={() => toggleCmjMetricKey(metric.key)}>
                        <span className="cbx"></span>
                        <span className="nm2">{metric.label}{metric.unit && <span className="u">{metric.unit}</span>}</span>
                        <span className="v-now">{getReportCmjMetricRows(reviewedEvidence, new Set([metric.key]))[0]?.value || '/'}</span>
                      </div>
                    ))}
                  </div>
                </>
              )}
            </div>
            {/* RD-4 (2026-07-09, ③b): CMJ 四类图已移出个人报告 → 只在测力台报告呈现。
                个人报告 CMJ 模块 = 结论 + 关键指标 + 「打开测力台报告」链出。
                FORCE-4 (2026-07-10): 源报告统一为「测力台报告」(ForceReportWorkbench)。 */}
            <div className="insp-hint" style={{ marginTop: 8, fontSize: 11, color: 'var(--muted)' }}>CMJ 四类图现仅在测力台报告中呈现，可从上方「打开测力台报告」进入。</div>
          </div>
        ) : <div className="insp-empty">无 CMJ 审核数据。</div>;
      case 'sjResult':
        return sjReviewedEvidence
          ? <div className="insp-block"><div className="insp-g">数据</div><IndividualReportConfirmedResultCallout evidence={sjReviewedEvidence} title={INDIVIDUAL_CONFIRMED_RESULT_MODULES.sjResult.label} subtitle={INDIVIDUAL_CONFIRMED_RESULT_MODULES.sjResult.subtitle} /></div>
          : <div className="insp-empty">无 SJ 审核数据。</div>;
      case 'imtpResult':
        return imtpReviewedEvidence
          ? <div className="insp-block"><div className="insp-g">数据</div><IndividualReportConfirmedResultCallout evidence={imtpReviewedEvidence} title={INDIVIDUAL_CONFIRMED_RESULT_MODULES.imtpResult.label} subtitle={INDIVIDUAL_CONFIRMED_RESULT_MODULES.imtpResult.subtitle} /></div>
          : <div className="insp-empty">无 IMTP 审核数据。</div>;
      case 'fieldResult':
        return fieldReviewedEvidence
          ? <div className="insp-block"><div className="insp-g">数据</div><IndividualReportConfirmedResultCallout evidence={fieldReviewedEvidence} title={INDIVIDUAL_CONFIRMED_RESULT_MODULES.fieldResult.label} subtitle={INDIVIDUAL_CONFIRMED_RESULT_MODULES.fieldResult.subtitle} /></div>
          : <div className="insp-empty">无现场测试审核数据。</div>;
      case 'manualResult':
        return manualReviewedEvidence
          ? <div className="insp-block"><div className="insp-g">数据</div><IndividualReportConfirmedResultCallout evidence={manualReviewedEvidence} title={INDIVIDUAL_CONFIRMED_RESULT_MODULES.manualResult.label} subtitle={INDIVIDUAL_CONFIRMED_RESULT_MODULES.manualResult.subtitle} /></div>
          : <div className="insp-empty">无手动指标审核数据。</div>;
      case 'trainingWeek':
        return trainingWeekAvailable ? <div className="insp-block"><div className="insp-g">数据</div><ReportTrainingWeekPaper data={trainingReportData.trainingWeek} /></div> : <div className="insp-empty">无训练周数据。</div>;
      case 'trainingPeriod':
        return trainingPeriodAvailable ? <div className="insp-block"><div className="insp-g">数据</div><ReportTrainingPeriodPaper data={trainingReportData.trainingPeriod} /></div> : <div className="insp-empty">无周期统计数据。</div>;
      case 'trainingVolumeChart':
        return trainingVolumeChartAvailable ? <div className="insp-block"><div className="insp-g">数据</div><ReportTrainingVolumeChartPaper data={trainingReportData.trainingVolumeChart} /></div> : <div className="insp-empty">无日量序列数据。</div>;
      case 'narrative':
        return (
          <div className="insp-block">
            <div className="insp-field">
              <span className="insp-k">标题</span>
              <NmEdit value={narrTitle} onChange={setNarrTitle} size={6} />
            </div>
            <div className="insp-g">呈现</div>
            <div className="tmpl-block">
              <div className="tmpl-row">
                <span className="k">模板</span>
                {NARR_TEMPLATES.map(t => (
                  <button key={t.id} className={`tmpl${t.custom ? ' custom' : ''}${narrTpl === t.id ? ' on' : ''}`} onClick={() => selectNarrativeTemplate(t.id)}>{t.label}</button>
                ))}
              </div>
              <div key={narrTpl} className="tmpl-body" contentEditable suppressContentEditableWarning ref={bindNarrativeEditor}
                onInput={e => {
                  narrativeModeRef.current = 'custom';
                  setNarrativeMode('custom');
                  updateNarrativeText((e.currentTarget.innerText || '').replace(/[{}]/g, ''));
                }}
                onPaste={handleNarrPaste} />
              <div className="tmpl-vars">
                <span className="k">可插入：</span>
                {NARR_VARS.map(v => <span key={v} className="v-chip" onClick={() => insertVar(v)}>{`{{${v}}}`}</span>)}
              </div>
            </div>
          </div>
        );
      case 'recs':
        return (
          <div className="insp-block">
            <div className="insp-field">
              <span className="insp-k">标题</span>
              <NmEdit value={recsTitle} onChange={setRecsTitle} size={7} />
              <span className="hint">编辑者手写 / 常用表述</span>
            </div>
            <div className="insp-g">呈现</div>
            <div className="tmpl-block">
              <textarea
                value={trainingRecommendationText}
                onChange={e => setTrainingRecommendationText(e.target.value)}
                placeholder="手写训练建议；每行会成为一条报告项目。"
                style={{
                  width: '100%', minHeight: 70, resize: 'vertical',
                  background: 'var(--paper)', border: '1px solid var(--line)',
                  borderRadius: 6, padding: '8px 10px',
                  color: 'var(--ink)', font: '12.5px/1.6 var(--font-sans)',
                  outline: 'none', fontFamily: 'var(--font-sans)',
                }}
              />
              <div className="tmpl-vars" style={{ marginTop: 6 }}>
                <span className="k">常用表述：</span>
                {TRAINING_RECOMMENDATION_PRESETS.map(text => (
                  <span key={text} className="v-chip" onClick={() => insertTrainingRecommendationPreset(text)}>{text}</span>
                ))}
              </div>
            </div>
          </div>
        );
      case 'dims':
        return (
          <div className="insp-block">
            <div className="insp-field">
              <span className="insp-k">标题</span>
              <NmEdit value={dimsTitle} onChange={setDimsTitle} size={9} />
            </div>
            <div className="insp-g">关键指标 · 原始实测</div>
            <div className="nest">
              <div className="nest-h">
                <span className="k">指标 · 多选（已选 {dimsOn.size} / {coreCatalog.length}）</span>
                <button className="all" onClick={() => setDimsOn(new Set(coreDefaultIds))}>↺ 默认</button>
              </div>
              <div className="dim-grid">
                {coreCatalog.map(e => {
                  const good = e.delta == null || e.delta === 0 || e.dir === 'neutral' ? '' : (e.dir === 'lower' ? e.delta < 0 : e.delta > 0) ? 'pos' : 'neg';
                  const arrow = e.delta == null || e.delta === 0 ? '·' : e.delta > 0 ? '↗' : '↘';
                  return (
                    <button key={e.id} className={`dim${dimsOn.has(e.id) ? ' on' : ''}`} onClick={() => toggleDim(e.id)} title={e.forceLabel ? coreOriginLabel(e.origin) : ''}>
                      <span className="cbx"></span>
                      <span>{e.label}{coreOriginLabel(e.origin) && <span style={{ fontSize: 9, color: 'var(--force-fg, #4f46c9)', marginLeft: 5 }}>{coreOriginLabel(e.origin)}</span>}</span>
                      <span className={`v-now ${good}`}>{reportFormatNumber(e.value)} {arrow}</span>
                    </button>
                  );
                })}
                {coreCatalog.length === 0 && <div style={{ gridColumn: '1 / -1', fontSize: 11, color: 'var(--muted-2)', padding: '6px 4px', fontStyle: 'italic' }}>暂无可绘制指标 · 请先在 Data Entry 录入</div>}
              </div>
            </div>
          </div>
        );
      case 'metrics':
        return (
          <div className="insp-block">
            <div className="insp-field">
              <span className="insp-k">标题</span>
              <NmEdit value={metricsTitle} onChange={setMetricsTitle} size={9} />
            </div>
            <div className="insp-g">数据</div>
            <div className="mp">
              <div className="mp-h">
                <span>已选指标</span>
                <span className="ct">{selMetrics.size} / {metricCatalog.length} 个</span>
                <span style={{ color: 'var(--muted-2)', letterSpacing: 0, textTransform: 'none', fontFamily: 'var(--font-sans)', fontSize: 10.5 }}>来自 {selByGroup.size} 个维度</span>
              </div>
              <div className="mp-pills">
                {[...selMetrics].map(id => {
                  const m = metricCatalog.find(x => x.id === id);
                  if (!m) return null;
                  return (
                    <span key={id} className="m-pill">
                      <span className="src" style={{ color: m.accent, background: hexA(m.accent, 0.12) }}>{m.groupLabel.slice(0, 4)}</span>
                      {m.label}
                      <button className="x" onClick={() => toggleMetric(id)}>✕</button>
                    </span>
                  );
                })}
                {selMetrics.size === 0 && <span style={{ fontSize: 11, color: 'var(--muted-2)', alignSelf: 'center' }}>未选指标 · 从下方目录勾选</span>}
              </div>
              <div className="mp-cat">
                <div className="mp-tabs">
                  {groups.map(g => {
                    const n = metricCatalog.filter(m => m.groupId === g.id).length;
                    return <button key={g.id} className={`mp-tab${mpTab === g.id ? ' on' : ''}`} onClick={() => setMpTab(g.id)}>{g.label} <span className="n">{n}</span></button>;
                  })}
                </div>
                <div className="mp-search"><span className="ic">⌕</span><input placeholder="搜索指标 · 中英文" value={mpSearch} onChange={e => setMpSearch(e.target.value)} /></div>
                <div className="mp-grid">
                  {tabMetrics.map(m => (
                    <div key={m.id} className={`mp-row${selMetrics.has(m.id) ? ' on' : ''}`} onClick={() => toggleMetric(m.id)}>
                      <span className="cbx"></span>
                      <span className="nm2">{m.label}{m.unit && <span className="u">{m.unit}</span>}</span>
                      <span className={`v-now ${m.delta != null ? m.dcls : ''}`}>{fmtV(m.curr)}{m.delta != null && ` · ${m.delta > 0 ? '+' : ''}${fmtV(m.delta)}`}</span>
                    </div>
                  ))}
                  {!tabMetrics.length && <div style={{ gridColumn: '1 / -1', fontSize: 11, color: 'var(--muted-2)', padding: '6px 4px', fontStyle: 'italic' }}>本维度无匹配指标</div>}
                </div>
                <div className="mp-cat-foot">
                  <span>{groups.find(g => g.id === mpTab)?.label} · {tabSelCount} / {tabMetrics.length} 已选 · 总 {selMetrics.size} / {metricCatalog.length}</span>
                  <span className="acts">
                    <button onClick={() => setSelMetrics(s => { const n = new Set(s); tabMetrics.forEach(m => n.add(m.id)); return n; })}>本组全选</button>
                    <button onClick={() => setSelMetrics(s => { const n = new Set(s); tabMetrics.forEach(m => n.delete(m.id)); return n; })}>清除本组</button>
                  </span>
                </div>
              </div>
            </div>
            <div className="insp-g">呈现</div>
            <div className="nest">
              <div className="nest-h"><span className="k">显示列</span><button className="all" onClick={() => setCols(new Set(['curr', 'delta', 'es', 'dir']))}>↺ 默认</button></div>
              <div className="chips flush">
                {colChips.map(c => <button key={c.id} className={`chip${cols.has(c.id) ? ' on' : ''}`} onClick={() => toggleCol(c.id)}>{c.label}</button>)}
              </div>
            </div>
          </div>
        );
      case 'notes':
        return (
          <div className="insp-block">
            <div className="insp-field">
              <span className="insp-k">标题</span>
              <NmEdit value={notesTitle} onChange={setNotesTitle} size={10} />
            </div>
            <div className="insp-g">呈现</div>
            <textarea
              value={coachNotes}
              onChange={e => updateCoachNotes(e.target.value)}
              placeholder="添加教练观察、训练目标或本次评估背景…"
              style={{
                width: '100%', minHeight: 72, resize: 'vertical',
                background: 'var(--paper)', border: '1px solid var(--line)',
                borderRadius: 6, padding: '8px 10px',
                color: 'var(--ink)', font: '12.5px/1.6 var(--font-sans)',
                outline: 'none', transition: 'border-color .15s', fontFamily: 'var(--font-sans)',
              }}
              onFocus={e => e.target.style.borderColor = 'var(--warm)'}
              onBlur={e => e.target.style.borderColor = 'var(--line)'}
            />
            <div style={{ fontSize: 10, color: 'var(--muted-2)', marginTop: 3, fontFamily: 'var(--font-mono)', fontStyle: 'italic' }}>
              {reportSaveState === 'error'
                ? '备注尚未保存 · 请先处理上方保存错误'
                : reportSaveState === 'pending'
                  ? '备注待保存 · 与当前报告草稿一并提交'
                  : !draftAdapter
                    ? '备注仅保留在当前打开的报告中'
                    : coachNotes
                      ? '✓ 备注已保存到当前报告草稿'
                      : '输入后与当前报告草稿一并保存'}
            </div>
          </div>
        );
      // 无自身数据/呈现控件的模块：说明已在头部一句话呈现，此处不重复（RP-I1 §1 无则省略）。
      case 'header':
        return renderBrandingControls();
      case 'athleteStrip':
      case 'load':
      case 'body':
      case 'attend':
        return null;
      default:
        return <div className="insp-empty">选中画布上的模块可在此调参。</div>;
    }
  };

  // 选中模块时检查器底部的「报告设置」组（mockup insp-g 报告设置：受众 / 模板 / 页边距）。
  // 复用已有的报告级真值源（variant=受众版本 / paperBg=版式），不新增状态。
  // RP-I1 · 未选中模块检查器：五个明确分区标题的折叠组（默认展开前两区）。

  // RPT-BUG修复：已保存CMJ会话的protocol是对象({bodyMass,sampleRate,...})，
  // 直接作React子节点会整树崩溃（保存测力数据后进report白屏的根因）。任何来源统一格式化为字符串。
  function formatReportSessionProtocol(proto) {
    if (proto == null) return '/';
    if (typeof proto === 'string') return proto || '/';
    if (typeof proto === 'object') {
      const parts = [];
      if (Number.isFinite(proto.bodyMass)) parts.push(proto.bodyMass + ' kg');
      if (Number.isFinite(proto.sampleRate)) parts.push(proto.sampleRate + ' Hz');
      return parts.length ? parts.join(' · ') : '/';
    }
    return String(proto);
  }

  // 报告级设置（未选中模块时的检查器）· 五分区：报告版本 / 预设与模板 / 时段与基线 / 模块顺序 / 纸底与页脚。
  // 模块顺序区的清单本体渲染在调用处（拖拽排序 JSX 原位不动）；此函数覆盖前三区 + 第五区。
  const renderReportSettings = () => (
    <div className="insp-block insp-settings">
      <ReportSettingsSection id="version" title="报告版本" defaultOpen>
        <button type="button" className={`report-type-card${variant === 'A' ? ' on' : ''}`} onClick={() => setVariant('A')}>
          <strong>单人沟通版</strong>
          <span>面向沟通对象的简洁报告</span>
        </button>
        <button type="button" className={`report-type-card${variant === 'B' ? ' on' : ''}`} onClick={() => setVariant('B')}>
          <strong>进步叙事版</strong>
          <span>更适合运动员阅读的叙事版本</span>
        </button>
      </ReportSettingsSection>

      <ReportSettingsSection id="preset-template" title="预设与模板" defaultOpen>
        <div className="chips flush">
          {[['recommend', '常规'], ['min', '精简'], ['full', '完整'], ['load', '负荷版']].map(([p, lbl]) => (
            <button key={p} className={`chip${preset === p ? ' on' : ''}`} onClick={() => applyPreset(p)}>{lbl}</button>
          ))}
        </div>
        <div className="insp-note-inline" style={{ marginTop: 8, display: 'block' }}>CMJ 模块 · 来源</div>
        <div className={`context-card source-card${cmjResultOn && reviewedEvidence ? ' included' : ''}`}>
          <div className="context-row">
            <strong>CMJ · {reportCmjSessionLabel}</strong>
            <span>{cmjResultOn && reviewedEvidence ? '已加入' : '可选'}</span>
          </div>
          <div className="context-detail">场次</div>
          <div className="context-value">{reportCmjSession?.label || reportCmjSession?.name || reviewedEvidence?.sessionId || '/'}</div>
          <div className="context-detail">协议</div>
          <div className="context-value">{formatReportSessionProtocol(reportCmjSession?.protocol ?? reviewedEvidence?.protocol)}</div>
          <button type="button" className="context-link" disabled={!reviewedEvidence?.sourceAvailable} onClick={() => (onOpenReviewedEvidenceReport || onOpenReviewedEvidence)?.(reviewedEvidence?.sourceSummary)}>
            打开测力台报告
          </button>
        </div>
      </ReportSettingsSection>

      <ReportSettingsSection id="report-branding" title="页眉品牌" note="机构级资产">
        {renderBrandingControls()}
      </ReportSettingsSection>

      {/* 时段 + 基线 */}
      <ReportSettingsSection id="period-baseline" title="时段与基线" note="季度报告特有">
        <div className="tmpl-block full">
          <div className="period-grid">
            <div className="p-cell">
              <div className="p-k">当前时段</div>
              <div className="p-select"><span className="v">{periodLabel}</span><span className="chev">▾</span></div>
              <div className="p-meta">{window.formatDate(season, 'short')} · {periodSessions} 项指标</div>
            </div>
            <div className="p-cell">
              <div className="p-k">对比基线</div>
              <div className="p-select">
                <select value={baselineSeason || ''} disabled={baselineType === 'none'}
                  onChange={e => setBaselineSeason(e.target.value)}>
                  {sortedSeasons.filter(s => s !== season).map(s => (
                    <option key={s} value={s}>{window.formatDate(s, 'compact')}</option>
                  ))}
                  {!sortedSeasons.filter(s => s !== season).length && <option value="">无更早数据</option>}
                </select>
                <span className="chev">▾</span>
              </div>
              <div className="p-meta">{prevSeason ? `${window.formatDate(prevSeason, 'short')} 为基线` : '不做对比'}</div>
            </div>
          </div>
          <div className="p-baseline">
            <span className="p-k" style={{ marginRight: 6 }}>基线类型</span>
            {baselineChips.map(c => (
              <button key={c.id} className={`chip${baselineType === c.id ? ' on' : ''}`} onClick={() => setBaselineType(c.id)}>{c.label}</button>
            ))}
          </div>
          <div className="p-opts">
            <button className={`opt-inline${periodOpts.swc ? ' on' : ''}`} onClick={() => togglePeriodOpt('swc')}><span className="dot"></span>含 SWC 阈值线</button>
            <button className={`opt-inline${periodOpts.trend ? ' on' : ''}`} onClick={() => togglePeriodOpt('trend')}><span className="dot"></span>含赛季趋势</button>
            <button className={`opt-inline${periodOpts.games ? ' on' : ''}`} onClick={() => togglePeriodOpt('games')}><span className="dot"></span>含比赛日标注</button>
          </div>
        </div>
      </ReportSettingsSection>
    </div>
  );

  // 【纸底与页脚】区：版式(暖纸/纯白) + 页边距(占位) + 页脚选项。渲染于模块顺序区之后（保持既有 DOM 顺序）。
  const renderReportFootSettings = () => (
    <ReportSettingsSection id="paper-footer" title="纸底与页脚">
      <div className="chips flush">
        <button className={`chip${paperBg === 'warm' ? ' on' : ''}`} onClick={() => setPaperBg('warm')}>暖纸背景</button>
        <button className={`chip${paperBg === 'white' ? ' on' : ''}`} onClick={() => setPaperBg('white')}>纯白背景</button>
      </div>
      <label className="insp-select-row" style={{ marginTop: 6 }}>
        <span className="insp-k">页边距</span>
        <select defaultValue="a4"><option value="a4">标准 A4</option></select>
      </label>
      <div className="chips flush" style={{ marginTop: 6 }}>
        <button className={`chip${footOpts.copy ? ' on' : ''}`} onClick={() => toggleFoot('copy')}>含运动员副本</button>
        <button className={`chip${footOpts.archive ? ' on' : ''}`} onClick={() => toggleFoot('archive')}>归档到运动员档案</button>
      </div>
    </ReportSettingsSection>
  );
  const composer = (
      <div id="report-modal-root" className={`individual-report-composer${isPageMode ? ' report-page-composer' : ''}`} onClick={e => e.stopPropagation()}>

        {/* ── m-head ── */}
        <div className="m-head">
          <div className="ttl">
            <span className="report-command-title">{isPageMode ? `${athlete.name} · 个人沟通报告` : '导出报告'}</span>
            <span className="who">{reportDraftScopeReady
              ? `${variant === 'A' ? '教练季度诊断' : '运动员进步故事'} · ${periodLabel} · ${periodSessions} 项可用指标`
              : reportDraftErrorPhase === 'load'
                ? '草稿读取失败 · 编辑与导出已暂停'
                : '正在载入当前运动员草稿…'}</span>
          </div>
          <div className="report-command-actions">
            <div style={{ display: 'flex', background: 'var(--bg)', border: '1px solid var(--line)', borderRadius: 7, overflow: 'hidden' }}>
              {['A', 'B'].map(v => (
                <button key={v} disabled={!reportDraftScopeReady} onClick={() => setVariant(v)}
                  style={{
                    padding: '4px 12px', border: 0, cursor: 'pointer', fontSize: 11, fontFamily: 'var(--font-mono)',
                    background: variant === v ? 'var(--ink)' : 'transparent',
                    color: variant === v ? 'white' : 'var(--muted)',
                  }}>
                  {v === 'A' ? '变体 A · 教练版' : '变体 B · 运动员版'}
                </button>
              ))}
            </div>
            <div className="report-command-presets" aria-label="报告预设">
              {[['recommend', '常规'], ['min', '精简'], ['full', '完整'], ['load', '负荷版']].map(([p, lbl]) => (
                <button key={p} disabled={!reportDraftScopeReady} className={`preset${preset === p ? ' on' : ''}`} onClick={() => applyPreset(p)}>{lbl}</button>
              ))}
            </div>
            <button className={`m-x${isPageMode ? ' page-return' : ''}`} onClick={onClose} aria-label={isPageMode ? '返回个人页' : '关闭'}>
              {isPageMode ? '返回个人页' : '✕'}
            </button>
          </div>
        </div>

        {/* ── m-sub ── */}
        <div className="m-sub">
          <span className="l">
            <strong>{athlete.name} · #{String(athlete.jersey).padStart(2, '0')}</strong>
            {periodLabel} · {periodSessions} 项指标{prevSeason && ` · ${baseMeta}`}
          </span>
          <span className={`r report-save-state ${reportSaveState}`}>
            {reportSaveState === 'error'
              ? draftAdapter?.hasBlocked?.()
                ? '保存失败 · 当前上下文未切换'
                : reportDraftErrorPhase === 'load'
                  ? '读取失败 · 当前草稿未载入'
                  : reportDraftErrorPhase === 'owner'
                    ? '草稿控制器正被另一编辑器占用'
                    : '保存失败 · 当前修改未写入'
              : reportSaveState === 'pending'
                ? '正在保存…'
                : draftAdapter
                  ? '已自动保存 · 画布与导出一致'
                  : '当前会话 · 未启用草稿持久化'}
          </span>
        </div>
        {reportDraftError && (() => {
          const transitionBlocked = !!draftAdapter?.hasBlocked?.();
          const phase = transitionBlocked ? 'transition' : reportDraftErrorPhase;
          const stationarySaveFailure = phase === 'save' || phase === 'stationary-save';
          return (
          <div role="alert" className="report-draft-error">
            <span>
              {reportDraftError?.code === 'revision_conflict' && phase === 'transition'
                ? '草稿保存失败：其他窗口已更新此报告。当前运动员与日期未切换，请核对版本。'
                : reportDraftError?.code === 'revision_conflict'
                  ? '草稿保存失败：其他窗口已更新此报告，本次修改未写入。'
                : phase === 'transition'
                  ? '草稿保存失败，当前运动员与日期未切换。'
                  : phase === 'load'
                    ? '报告草稿读取失败，当前编辑器未载入任何其他运动员的配置。'
                    : phase === 'owner'
                      ? '另一处报告编辑器正在占用草稿控制器。请先关闭另一处编辑器。'
                      : '草稿保存失败，当前修改尚未写入。'}
            </span>
            <span className="report-draft-decisions">
              {stationarySaveFailure && <React.Fragment>
                <button type="button" onClick={retryCurrentReportDraft}>重试保存</button>
                <button type="button" onClick={discardPendingReportDraft}>放弃修改</button>
              </React.Fragment>}
              {phase === 'load' && (
                <button type="button" onClick={loadCurrentReportDraft}>重新加载草稿</button>
              )}
              {phase === 'transition' && <React.Fragment>
                <button type="button" disabled={historySettling} onClick={() => draftAdapter?.retryBlocked?.()}>重试保存</button>
                <button type="button" disabled={historySettling} onClick={() => draftAdapter?.discardBlocked?.()}>放弃修改</button>
                <button type="button" disabled={historySettling} onClick={() => {
                  draftAdapter?.cancelBlocked?.();
                  setReportDraftErrorPhase('stationary-save');
                  setReportSaveState('error');
                }}>取消切换</button>
              </React.Fragment>}
            </span>
          </div>
          );
        })()}
        {/* ── 命名模板条 ── */}
        <div className="m-tpls">
          <span className="k">编辑模板</span>
          <span className="tpl-help">保存板块、指标、顺序和版式；不改变报告版本。</span>
          {templates.map(t => (
            <button key={t.id} disabled={!reportDraftScopeReady} className={`tpl-chip${activeTpl === t.id ? ' on' : ''}`} onClick={() => applyTemplate(t)} title="点击套用此模板">
              {t.name}
              <span className="x" onClick={(e) => deleteTemplate(t.id, e)} title="删除模板">✕</span>
            </button>
          ))}
          {!templates.length && <span className="tpl-empty">尚无自定义模板 · 调好排版后「另存为」可复用</span>}
          <button className="tpl-save" disabled={!reportDraftScopeReady} onClick={saveAsTemplate}><span className="plus">+</span>另存为模板</button>
          <button
            type="button"
            className="tpl-save report-batch-open"
            data-report-batch-open
            data-template-status={templateAssessment.ready ? 'ready' : templateAssessment.reason}
            data-template-changed-keys={(templateAssessment.changedKeys || []).join(',')}
            disabled={!reportDraftScopeReady || !templateAssessment.ready || !batchContexts.length}
            onClick={openBatchExport}
            title={templateAssessment.reason === 'dirty'
              ? `当前修改尚未保存为模板${templateAssessment.changedKeys?.length ? `：${templateAssessment.changedKeys.join('、')}` : ''}`
              : templateAssessment.reason === 'no-template'
                ? '请先套用一个已保存模板'
                : '使用当前模板批量导出运动员报告'}
          >
            使用此模板批量导出
          </button>
          {activeTemplate && !templateAssessment.ready && (
            <span className="tpl-dirty">当前修改尚未保存为模板</span>
          )}
        </div>

        {/* ── m-body ── */}
        {reportDraftScopeReady ? (
        <div className={composerGridClass}>

          <div className={`individual-report-context report-pane${isReportPaneCollapsed('context') ? ' pane-collapsed' : ''}`}>
            <div className="pane-bar">
              <span className="pane-title">模块库</span>
              {renderReportPaneToggle('context', '模块库')}
            </div>
            <div className="pane-content">
            <div className="context-title">编辑对象</div>
            <div className="context-card athlete-card">
              <div className="avatar">{athlete.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}</div>
              <div className="context-main">
                <div className="context-name">{athlete.name}</div>
                {reportAthleteIdentityParts(athlete).length > 0
                  ? <div className="context-meta">{reportAthleteIdentityParts(athlete).join(' · ')}</div>
                  : null}
              </div>
            </div>

            <div className="context-title">模块库 · 点击加入画布</div>
            {LIBRARY_GROUPS.map(g => {
              const items = g.items.filter(it => moduleAvailable(it.key));
              if (!items.length) return null;
              return (
                <div key={g.label} className="lib-group">
                  <div className="lib-g">{g.label}{g.hint && <span className="lib-hint">{g.hint}</span>}</div>
                  {items.map(it => {
                    const used = !!moduleControl[it.key].get();
                    return (
                      <button
                        key={it.key}
                        type="button"
                        className={`lib-i${used ? ' used' : ''}${selectedModule === it.key ? ' sel' : ''}`}
                        onClick={() => (used ? setSelectedModule(it.key) : enableAndSelectModule(it.key))}
                        title={used ? '已在画布中 · 点击选中' : '点击启用并插入报告末尾'}
                      >
                        <span className="lib-nm">{it.label || MODULE_LABELS[it.key]}</span>
                        <span className={`lib-tag${it.tag === 'L2' ? ' l2' : ''}`}>{it.tag}</span>
                        <span className="lib-state">{used ? '已用' : '＋'}</span>
                      </button>
                    );
                  })}
                </div>
              );
            })}

            <div className="context-note">
              每个板块都可开关、改标题或调整顺序；最终输出只包含编辑者选择的内容。
            </div>
            </div>
          </div>

          {/* Left: report-creation controls → RP-R2' 检查器（选中模块调参 / 报告级设置） */}
          <div className={`m-left individual-report-sections report-pane${isReportPaneCollapsed('sections') ? ' pane-collapsed' : ''}`}>
            <div className="pane-bar">
              <span className="pane-title">{selectedModule ? '检查器 · 模块' : '检查器 · 报告设置'}</span>
              {renderReportPaneToggle('sections', '检查器')}
            </div>
            <div className="pane-content">
            {selectedModule ? (
              <>
                <div className="individual-report-sections-head">
                  <div>
                    <h3>检查器 · {workbenchLabel(selectedModule)}</h3>
                    <p>{MODULE_INSPECTOR_NOTES[selectedModule] || MODULE_CONTENT_NOTES[selectedModule] || '基础模块：报告头部与运动员档案条。'}</p>
                  </div>
                  <div className="sections-head-actions">
                    <button type="button" className="ghost" onClick={() => setSelectedModule(null)}>报告设置</button>
                  </div>
                </div>
                {renderModuleInspector(selectedModule)}
                <div className="insp-g danger">危险区</div>
                <div className="insp-danger">
                  <button type="button" className="insp-remove" onClick={() => disableModule(selectedModule)}>移除此模块</button>
                </div>
              </>
            ) : (
              <>
                <div className="individual-report-sections-head">
                  <div>
                    <h3>报告设置</h3>
                    <p>选择、排序并配置本次报告内容；最终只输出已启用的板块。按分区展开调整报告板块，点击画布模块可单独调参。</p>
                  </div>
                  <div className="sections-head-actions">
                    <button type="button" className="ghost" onClick={saveAsTemplate}>保存当前模板</button>
                  </div>
                </div>
                <div className="module-checklist" aria-label="Report module checklist">
                  <div className="module-checklist-title">板块核对 / Module checklist</div>
                  <ul>
                    {MODULE_CHECKLIST.map(([title, detail]) => (
                      <li key={title}><strong>{title}</strong><span>{detail}</span></li>
                    ))}
                  </ul>
                </div>
                {renderReportSettings()}

            <ReportSettingsSection id="module-order" title="模块顺序" note="拖动 ⋮⋮ 调整报告正文顺序">
            <div className="tmpl-block full">
              {(() => {
                const numberedEnabled = moduleOrder.filter(m => m !== 'narrative' && moduleEnabled[m]);
                return (
                  <>
                    <div className="order-summary">
                      <span>最终输出顺序</span>
                      <strong>{numberedEnabled.length} 个正文板块</strong>
                      <em>未启用模块保留在列表中，但不会出现在报告里。</em>
                    </div>
                    <div className="order-list">
                      {moduleOrder.map((k, i) => {
                    const on = moduleEnabled[k];
                    const ordLabel = k === 'narrative' ? '·' : (on ? String(numberedEnabled.indexOf(k) + 1).padStart(2, '0') : '—');
                    return (
                      <div
                        key={k}
                        className={`order-row${dragKey === k ? ' dragging' : ''}${on ? '' : ' off'}`}
                        draggable
                        onDragStart={() => setDragKey(k)}
                        onDragEnd={() => setDragKey(null)}
                        onDragOver={e => { e.preventDefault(); }}
                        onDrop={e => { e.preventDefault(); const from = moduleOrder.indexOf(dragKey); if (from > -1 && from !== i) moveModule(from, i); setDragKey(null); }}
                      >
                        <span className="drag">⋮⋮</span>
                        <span className="ord-n">{ordLabel}</span>
                        <span className="ord-nm"><span>{MODULE_LABELS[k]}</span><small>{MODULE_CONTENT_NOTES[k]}</small></span>
                        <span className={`ord-state${on ? ' on' : ''}`}>{on ? '进入报告' : '不输出'}</span>
                        <span className="ord-moves">
                          <button type="button" disabled={i === 0} onClick={() => moveModule(i, i - 1)} aria-label="上移">↑</button>
                          <button type="button" disabled={i === moduleOrder.length - 1} onClick={() => moveModule(i, i + 1)} aria-label="下移">↓</button>
                        </span>
                      </div>
                    );
                      })}
                    </div>
                  </>
                );
              })()}
            </div>
            </ReportSettingsSection>
                {renderReportFootSettings()}
              </>
            )}
            </div>
          </div>

          {/* Right: live print preview */}
          {/* RP-R2' 画布：画布即预览 · 编辑本体（包裹层，不改 renderReportBody） */}
          <ReportPreview
            paperBg={paperBg}
            collapsed={isReportPaneCollapsed('preview')}
            toggleControl={renderReportPaneToggle('preview', '报告预览')}
          >
            <div className="workbench-canvas" ref={canvasRef} onClick={onCanvasClick}>
              {renderReportBody(true)}
              <span className="canvas-pageno">1 / {canvasPageEstimate}</span>
              {selectedModule && canvasSelRect && (
                <div className="canvas-mod-tools" style={{ top: canvasSelRect.top }}>
                  <span className="grip" title="模块把手">⋮⋮</span>
                  {moduleOrder.includes(selectedModule) && (
                    <>
                      <button type="button" className="mv" onClick={() => moveCanvasModule(selectedModule, -1)} aria-label="上移模块" title="上移模块">↑</button>
                      <button type="button" className="mv" onClick={() => moveCanvasModule(selectedModule, 1)} aria-label="下移模块" title="下移模块">↓</button>
                    </>
                  )}
                  <button type="button" className="rm" onClick={() => disableModule(selectedModule)} aria-label="移除模块" title="移除模块">✕</button>
                </div>
              )}
            </div>
          </ReportPreview>

        </div>
        ) : (
          <div className="report-draft-scope-guard" data-report-draft-scope-state={reportDraftErrorPhase || 'loading'}>
            <strong>{reportDraftErrorPhase === 'load' ? '当前草稿读取失败' : '正在载入当前运动员草稿'}</strong>
            <span>{reportDraftErrorPhase === 'load'
              ? '为防止混用上一位运动员的内容，编辑、预览与导出保持暂停。恢复存储后请重新加载。'
              : '编辑器会在当前运动员与日期的草稿完整载入后开放。'}</span>
          </div>
        )}

        {/* ── m-foot ── */}
        <div className="m-foot">
          <div className="fmt">
            <button disabled={!reportDraftScopeReady} className={fmt === 'pdf' ? 'on' : ''} onClick={() => setFmt('pdf')}><span className="ic">⎙</span> PDF</button>
            <button disabled={!reportDraftScopeReady} className={fmt === 'png' ? 'on' : ''} onClick={() => setFmt('png')}><span className="ic">⤓</span> PNG</button>
            <button disabled={!reportDraftScopeReady} className={fmt === 'print' ? 'on' : ''} onClick={() => setFmt('print')}><span className="ic">⊞</span> 打印</button>
          </div>
          <div className="opts">
            <button disabled={!reportDraftScopeReady} className={`opt${paperBg === 'warm' ? ' on' : ''}`} onClick={() => setPaperBg(p => p === 'warm' ? 'white' : 'warm')} title="黑白打印可关掉背景色"><span className="dot"></span>{paperBg === 'warm' ? '暖纸背景' : '纯白背景'}</button>
            <button disabled={!reportDraftScopeReady} className={`opt${footOpts.copy ? ' on' : ''}`} onClick={() => toggleFoot('copy')}><span className="dot"></span>含运动员副本</button>
            <button disabled={!reportDraftScopeReady} className={`opt${footOpts.archive ? ' on' : ''}`} onClick={() => toggleFoot('archive')}><span className="dot"></span>归档到运动员档案</button>
          </div>
          <span className="grow"></span>
          <button className="ghost" onClick={onClose}>{isPageMode ? '返回' : '取消'}</button>
          <button className="pri" disabled={!reportDraftScopeReady} onClick={doPrint}>导出报告 <span className="pgs">· 1 页</span></button>
        </div>

        {/* Hidden print body — outside modal chrome, cloned by doPrint() */}
        {reportDraftScopeReady && <div style={{ display: 'none' }}>
          {renderReportBody(false)}
        </div>}

        {reportDraftScopeReady && batchOpen && batchSnapshot && (
          <div className="report-batch-backdrop no-print" onClick={closeBatchExport}>
            <section className="report-batch-dialog" role="dialog" aria-modal="true" aria-label="批量导出运动员报告" onClick={event => event.stopPropagation()}>
              <header>
                <div>
                  <span className="report-batch-kicker">模板锁定批量导出</span>
                  <h3>{batchSnapshot.templateName}</h3>
                  <p>{window.formatDate ? window.formatDate(season, 'short') : season} · 当前模板快照不会修改任何运动员草稿</p>
                </div>
                <button type="button" className="ghost" onClick={closeBatchExport} aria-label="关闭批量导出">✕</button>
              </header>
              <div className="report-batch-tools">
                <input
                  type="search"
                  value={batchSearch}
                  onChange={event => setBatchSearch(event.target.value)}
                  placeholder="搜索运动员、号码或位置"
                  aria-label="搜索批量导出运动员"
                />
                <button type="button" className="ghost" onClick={() => selectVisibleBatchAthletes(true)}>全选当前结果</button>
                <button type="button" className="ghost" onClick={() => selectVisibleBatchAthletes(false)}>取消当前结果</button>
              </div>
              <div className="report-batch-list">
                {visibleBatchAthletes.map(item => {
                  const context = batchContexts.find(ctx => ctx.athlete.id === item.id);
                  const selected = batchSelected.has(item.id);
                  return (
                    <label key={item.id} className={`report-batch-row${selected ? ' selected' : ''}`}>
                      <input type="checkbox" checked={selected} onChange={() => toggleBatchAthlete(item.id)} />
                      <span className="report-batch-avatar">{item.name.split(' ').map(part => part[0]).join('').slice(0, 2).toUpperCase()}</span>
                      <span className="report-batch-person">
                        <strong>{item.name}</strong>
                        <small>#{String(item.jersey || '—').padStart(2, '0')} · {item.position || 'Athlete'}</small>
                      </span>
                      <span className={`report-batch-data${context?.missingSummary ? ' warning' : ''}`}>
                        {context?.missingSummary || '数据可用'}
                      </span>
                    </label>
                  );
                })}
                {!visibleBatchAthletes.length && <div className="report-batch-empty">没有匹配的运动员</div>}
              </div>
              <footer>
                <span>已选 {selectedBatchContexts.length} 人 · 按花名册顺序输出</span>
                <button type="button" className="ghost" onClick={closeBatchExport}>取消</button>
                <button
                  type="button"
                  className="pri"
                  disabled={!batchAllReady}
                  onClick={exportAthleteBatch}
                  data-report-batch-export
                >
                  {selectedBatchContexts.length && !batchAllReady ? '正在准备报告…' : `批量导出 ${selectedBatchContexts.length} 份`}
                </button>
              </footer>
            </section>
          </div>
        )}

        {reportDraftScopeReady && batchOpen && batchSnapshot?.snapshot && selectedBatchContexts.length > 0 && (
          <div className="report-batch-stage" aria-hidden="true">
            {selectedBatchContexts.map(context => (
              <ReportModal
                key={context.athlete.id}
                open
                onClose={() => {}}
                athlete={context.athlete}
                season={season}
                seasons={seasons}
                groups={groups}
                scores={context.scores}
                squadStatsAll={squadStatsAll}
                reviewedEvidence={context.reviewedEvidence}
                sjReviewedEvidence={context.sjReviewedEvidence}
                imtpReviewedEvidence={context.imtpReviewedEvidence}
                fieldReviewedEvidence={context.fieldReviewedEvidence}
                manualReviewedEvidence={context.manualReviewedEvidence}
                trainingReportData={context.trainingReportData}
                cmjStore={cmjStore}
                sjStore={sjStore}
                imtpStore={imtpStore}
                draftAdapter={null}
                mode="batch-stage"
                batchConfig={batchSnapshot.snapshot}
                batchBodyId={`batch-report-${context.athlete.id}`}
                onBatchReady={noteBatchReady}
              />
            ))}
          </div>
        )}

      </div>
  );

  if (mode === 'page') {
    return (
      <main id="print-root" className="report-page-host">
        {composer}
      </main>
    );
  }

  return (
    <div
      onClick={onClose}
      style={{
        position: 'fixed', inset: 0, background: 'rgba(5,8,12,.78)',
        backdropFilter: 'blur(5px)', zIndex: 200,
        display: 'grid', placeItems: 'center', padding: 24,
        animation: 'fade .2s ease both',
      }}
    >
      {composer}
    </div>
  );
}

Object.assign(window, { ReportModal });

// ══════════════════════════════════════════════════════════════════════════════
//  CMJ FORCE-PLATE REPORT  ·  RETIRED (FORCE-4, 2026-07-10)
// ══════════════════════════════════════════════════════════════════════════════
// Directed retirement per docs/decisions/2026-07-09-force-report-unification.md
// §2.4 + §3 FORCE-4 (FORCE-D1 finale). CMJReportModal + CMJReportPrintBody +
// CMJReportPreview and their exclusive helpers (sparkPath, CMJ_METRICS, deltaClass,
// deltaLabel, HIGHER_BETTER, CMJ_FORCE_CLASSIFICATION_STATEMENTS, CMJ_TRAINING_
// IMPLICATIONS/_LABELS, formatNormRule*/formatNormDatasetStatus, formatReviewSavedTime)
// were removed here. The four-chart CONSUMPTION that lived in CMJReportPrintBody went
// with it; the chart COMPONENTS remain byte-identical in cmj.jsx (pit #14) and are now
// consumed by force-report.jsx (ForceReportPrintBody). The unified 测力台报告
// (ForceReportWorkbench) is the sole force-plate report surface — link-outs now open it
// at the CMJ tab. The embedded CMJ force-session REVIEW authoring (classification /
// training-implication / knowledge-rule capture) retired with the modal; SJ/IMTP/field/
// manual inline review authoring in the Review Database is unaffected.

// ══════════════════════════════════════════════════════════════════════════════
//  TEAM REPORT  ·  report.jsx  extension
// ══════════════════════════════════════════════════════════════════════════════

// ── TEAM-3 (2026-07-09): composite team-report faces retired. Ruling
// docs/decisions/2026-07-09-team-three-persona-direction.md §4/§6. This body now
// renders OBJECTIVE paper modules whose data comes verbatim from the TeamModules
// builders (buildModuleData) — 无派生复合分、无严重度分级、无处方。Sections +
// order are driven by a persona `sections`
// list (mirrors the CMJ bundle's moduleOrder). Fingerprint hash regenerated
// (scripts/.rd1-bundle-export-fingerprint.json, dated note).

// Paper renderer: SWC 显著变化 (reuses buildModuleData('swc-movers')). Rows =
// athlete + per-metric raw signed Δ list + N↓/N↑ chip, sorted declined desc.
function TeamPaperSwcMovers({ data }) {
  const rows = (data && Array.isArray(data.rows)) ? data.rows : [];
  if (!rows.length) return <div className="ruled"><div className="ln"></div></div>;
  const fmtDelta = (d, unit) => {
    if (d == null || !isFinite(d)) return '—';
    const s = (d > 0 ? '+' : '') + (Math.abs(d) >= 100 ? d.toFixed(0) : d.toFixed(2));
    return unit ? `${s} ${unit}` : s;
  };
  return (
    <>
      <div className="mtable">
        <div className="mrow head" style={{ gridTemplateColumns: '150px 1fr 120px' }}>
          <div>运动员</div><div>显著变化项（原始 Δ）</div><div style={{ textAlign: 'right' }}>下降 / 提升</div>
        </div>
        {rows.map((r) => (
          <div key={r.athleteId} className="mrow" style={{ gridTemplateColumns: '150px 1fr 120px' }}>
            <div className="nm">{r.name}</div>
            <div style={{ fontSize: 12, color: 'var(--ink-2)', lineHeight: 1.7 }}>
              {(r.details || []).map((d, i) => (
                <span key={i} style={{ marginRight: 14, whiteSpace: 'nowrap' }}>
                  {d.label} <span className={`d ${d.improved ? 'pos' : 'neg'}`} style={{ display: 'inline' }}>{fmtDelta(d.deltaRaw, d.unit)}</span>
                </span>
              ))}
            </div>
            <div className="band" style={{ textAlign: 'right' }}>
              <span className="d neg" style={{ display: 'inline' }}>{r.declined}↓</span>{' · '}
              <span className="d pos" style={{ display: 'inline' }}>{r.improved}↑</span>
            </div>
          </div>
        ))}
      </div>
      <div className="pfoot" style={{ border: 0, marginTop: 6 }}><span>按下降项数排序 · 无严重度分级 · 需人工查看</span></div>
    </>
  );
}

// Paper renderer: 指标横向对比 (reuses buildModuleData('metric-compare')). Athlete
// × raw value + Δ rows; bars scale to layout max only, displayed numbers are raw.
function TeamPaperMetricCompare({ data }) {
  const sel = data && data.selected;
  const rows = (data && Array.isArray(data.rows)) ? data.rows : [];
  const max = (data && data.max) || 0;
  if (!sel || !rows.length) return <div className="ruled"><div className="ln"></div></div>;
  const unit = sel.unit ? ` ${sel.unit}` : '';
  return (
    <>
      <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-2)', marginBottom: 8 }}>
        {sel.label}{sel.provenance ? ` · ${sel.provenance}` : ''}
      </div>
      {rows.map((r) => {
        const pct = max > 0 ? Math.max(2, Math.round((Math.abs(r.value) / max) * 100)) : 2;
        const dCls = r.delta == null ? 'zero' : r.delta > 0 ? 'pos' : r.delta < 0 ? 'neg' : 'zero';
        const dTxt = r.delta == null ? '—' : (r.delta > 0 ? '+' : '') + (Math.abs(r.delta) >= 100 ? r.delta.toFixed(0) : r.delta.toFixed(2));
        return (
          <div key={r.athleteId} className="prof-row" style={{ gridTemplateColumns: '150px 1fr 70px 54px' }}>
            <div className="lbl">{r.jersey != null ? <span className="num" style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--muted-2)', marginRight: 6 }}>#{String(r.jersey).padStart(2, '0')}</span> : null}{r.name}</div>
            <div className="bar-wrap"><div className="bar-fill" style={{ width: `${pct}%`, background: 'var(--warm)' }}></div></div>
            <div className="score">{Math.abs(r.value) >= 100 ? r.value.toFixed(0) : r.value.toFixed(2)}<span style={{ fontSize: 9, color: 'var(--muted)' }}>{unit}</span></div>
            <div className={`delta ${dCls}`}>{dTxt}</div>
          </div>
        );
      })}
      <div className="pfoot" style={{ border: 0, marginTop: 6 }}><span>原始实测值 · 长条仅用于版面比例</span></div>
    </>
  );
}

// Paper renderer: 测试覆盖 (reuses buildModuleData('test-coverage')). Athlete ×
// type binary grid, green/gray; window footer. No overdue judgment.
function TeamPaperTestCoverage({ data }) {
  const rows = (data && Array.isArray(data.rows)) ? data.rows : [];
  const cols = (data && Array.isArray(data.columns)) ? data.columns : [];
  if (!rows.length || !cols.length) return <div className="ruled"><div className="ln"></div></div>;
  return (
    <>
      <div className="heat" style={{ gridTemplateColumns: `150px repeat(${cols.length}, 1fr)` }}>
        <div></div>
        {cols.map((c, i) => <div key={i} className="hh">{c}</div>)}
        {rows.map((r) => (
          <React.Fragment key={r.athleteId}>
            <div className="hn">{r.name}</div>
            {r.cells.map((cell, i) => (
              <div key={i} className="cellh" style={{ background: cell.covered ? 'rgba(22,163,74,.16)' : 'rgba(15,23,42,.05)' }}>
                {cell.date ? (cell.covered ? '✓' : cell.date.slice(5)) : '无记录'}
              </div>
            ))}
          </React.Fragment>
        ))}
      </div>
      <div className="pfoot" style={{ border: 0, marginTop: 6 }}><span>覆盖窗口 {data.windowDays} 天{data.anchor ? ` · 截至 ${data.anchor}` : ''} · 仅事实</span></div>
    </>
  );
}

function TeamPaperForceSummary({ data }) {
  const types = (data && Array.isArray(data.types)) ? data.types : [];
  const rows = (data && Array.isArray(data.confirmedRows)) ? data.confirmedRows : [];
  if (!types.length) return <div className="ruled"><div className="ln"></div></div>;
  return (
    <>
      <div className="kpis" style={{ marginBottom: 12 }}>
        {types.map(type => (
          <div className="kpi" key={type.id}>
            <div className="k">{type.label} · 测试 / 确认</div>
            <div className="v">{type.athletesTested}<span className="u">人</span> / {type.confirmed}<span className="u">项</span></div>
            <div className="sub" style={{ fontSize: 9, color: 'var(--muted)', marginTop: 4 }}>{type.sessions} 次会话 · {type.latestDate || '无日期'}</div>
          </div>
        ))}
      </div>
      {rows.length ? (
        <div className="mtable">
          <div className="mrow head" style={{ gridTemplateColumns: '72px 130px 88px 1fr' }}>
            <div>类型</div><div>运动员</div><div>确认日期</div><div>运动科学结论</div>
          </div>
          {rows.slice(0, 10).map(row => (
            <div className="mrow" key={row.id} style={{ gridTemplateColumns: '72px 130px 88px 1fr' }}>
              <div className="band">{row.type.toUpperCase()}</div>
              <div className="nm">{row.athleteName}</div>
              <div className="val" style={{ fontSize: 10 }}>{row.date ? row.date.slice(0, 10) : '—'}</div>
              <div>{row.conclusion}</div>
            </div>
          ))}
        </div>
      ) : <div className="ruled"><div className="lab">当前队列暂无已确认测力台结论</div><div className="ln"></div></div>}
      <div className="pfoot" style={{ border: 0, marginTop: 6 }}>
        <span>测试覆盖来自已存会话 · 结论只来自运动科学家已确认审核 · 不自动推断</span>
      </div>
    </>
  );
}

// Paper renderer: 出勤 / 可用 (reuses buildModuleData('attendance-facts')). 3 count
// tiles + 零记录 list. Honest counts, no rate/score.
function TeamPaperAttendance({ data }) {
  const d = data || {};
  const zero = Array.isArray(d.zeroLog) ? d.zeroLog : [];
  return (
    <>
      <div className="kpis" style={{ marginBottom: 12 }}>
        <div className="kpi"><div className="k">本周记录训练</div><div className="v">{d.trainedThisWeek || 0}<span className="u">/{d.total || 0}</span></div></div>
        <div className="kpi"><div className="k">总课次</div><div className="v">{d.sessions || 0}<span className="u">次</span></div></div>
        <div className="kpi"><div className="k">在案伤病</div><div className="v">{d.injured || 0}<span className="u">人</span></div></div>
        <div className="kpi"><div className="k">零记录</div><div className="v">{zero.length}<span className="u">人</span></div></div>
      </div>
      {zero.length > 0 && (
        <div style={{ fontSize: 12, color: 'var(--ink-2)', lineHeight: 1.8 }}>
          <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--muted)', marginRight: 8 }}>零记录</span>
          {zero.map((z) => z.name).join(' · ')}
        </div>
      )}
    </>
  );
}

// Paper renderer: 数据缺口 (reuses buildModuleData('data-gaps')). Fact list.
function TeamPaperDataGaps({ data }) {
  const rows = (data && Array.isArray(data.rows)) ? data.rows : [];
  if (!rows.length) return <div style={{ fontSize: 12.5, color: 'var(--muted)' }}>无数据缺口</div>;
  return (
    <div className="mtable">
      {rows.map((r, i) => (
        <div key={i} className="mrow" style={{ gridTemplateColumns: '150px 1fr' }}>
          <div className="nm">{r.name}</div>
          <div style={{ fontSize: 12.5, color: 'var(--ink-2)' }}>{r.factText}</div>
        </div>
      ))}
    </div>
  );
}

// Section registry: id → { title, render }. Presets pick + order these ids.
const TEAM_PAPER_SECTIONS = {
  'swc-movers':      { defaultTitle: 'SWC 显著变化 · 需人工查看', render: (md) => <TeamPaperSwcMovers data={md['swc-movers']} /> },
  'metric-compare':  { defaultTitle: '指标横向对比', render: (md) => <TeamPaperMetricCompare data={md['metric-compare']} /> },
  'test-coverage':   { defaultTitle: '测试覆盖', render: (md) => <TeamPaperTestCoverage data={md['test-coverage']} /> },
  'force-summary':   { defaultTitle: '测力台联动 · 测试覆盖与已确认结论', render: (md) => <TeamPaperForceSummary data={md['force-summary']} /> },
  'attendance-facts':{ defaultTitle: '出勤 / 可用', render: (md) => <TeamPaperAttendance data={md['attendance-facts']} /> },
  'data-gaps':       { defaultTitle: '数据缺口', render: (md) => <TeamPaperDataGaps data={md['data-gaps']} /> },
};

function TeamReportPrintBody({ squad, season, groups, roster = [], moduleData = {}, sections = [], narrative, coachNotes, titles = {}, paperBg = 'warm', kpi = null }) {
  const sessionDate = window.formatDate ? window.formatDate(season, 'short') : season;
  const subsetCount = kpi ? kpi.subsetCount : roster.length;
  const dataAsOf = kpi && kpi.dataAsOf ? kpi.dataAsOf : '—';
  const confirmedCount = kpi ? (kpi.confirmedCount || 0) : 0;
  const movedCount = kpi ? (kpi.movedCount || 0) : 0;

  let secNo = 0;
  const secHead = (title) => { secNo += 1; return <div className="sec-h"><span className="num">{String(secNo).padStart(2, '0')}</span><span className="lbl">{title}</span><span className="ln"></span></div>; };

  return (
    <div id="team-report-body" className="rpt">
      <article className={`paper${paperBg !== 'white' ? ' warm' : ''}`}>

        {/* ── Report header ── */}
        <div className="ph">
          <div>
            <h2>团队报告 · 队列概览</h2>
            <div className="ph-sub">Performance Dashboard · Squad Brief</div>
          </div>
          <div className="ph-meta">
            <div><span className="k">PERIOD</span>{sessionDate}</div>
            <div><span className="k">SQUAD</span>{squad || 'First Team'} · N={subsetCount}</div>
          </div>
        </div>

        {/* ── KPI strip · four objective tiles (TEAM-3) ── */}
        <div className="kpis" style={{ marginTop: 4 }}>
          <div className="kpi">
            <div className="k">子集人数</div>
            <div className="v">{subsetCount}<span className="u">人</span></div>
          </div>
          <div className="kpi">
            <div className="k">数据截至</div>
            <div className="v" style={{ fontSize: 14 }}>{dataAsOf}</div>
          </div>
          <div className="kpi">
            <div className="k">本周确认</div>
            <div className="v">{confirmedCount}<span className="u">项</span></div>
          </div>
          <div className="kpi">
            <div className="k">显著变化</div>
            <div className="v">{movedCount}<span className="u">人</span></div>
          </div>
        </div>

        {/* ── Narrative (coach summary) — raw-count language, when included ── */}
        {sections.indexOf('narrative') !== -1 && narrative && (
          <div className="insight" style={{ marginTop: 18 }}>
            <div className="body">
              <span className="qmark">"</span>
              {narrative}
            </div>
            <div className="by">{titles.narrative || '教练摘要'} · 原始计数与人审确认</div>
          </div>
        )}

        {/* ── Persona-ordered paper sections ── */}
        {sections.map((id) => {
          const sec = TEAM_PAPER_SECTIONS[id];
          if (!sec) return null;
          return (
            <React.Fragment key={id}>
              {secHead(titles[id] || sec.defaultTitle)}
              {sec.render(moduleData)}
            </React.Fragment>
          );
        })}

        {/* ── Coach notes ── */}
        {secHead(titles.notes || '教练备注 / 跨周追踪')}
        {coachNotes ? (
          <div style={{ fontFamily: 'var(--font-serif)', fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.8, whiteSpace: 'pre-wrap' }}>
            {coachNotes}
          </div>
        ) : (
          <div className="ruled">
            <div className="ln"></div><div className="ln"></div>
          </div>
        )}

        <div className="pfoot">
          <span>Performance Dashboard · v3.4 · Squad Brief</span>
          <span>页 1 / 1 · {sessionDate}</span>
        </div>

      </article>
    </div>
  );
}

// ── TEAM-3 (2026-07-09): objective report model + persona presets + raw-count
// narrative. Shared by TeamReportModal (report.jsx) and the RD-1 bundle staging
// body (app-modals.jsx) so the compute lives in ONE place next to the print body.
// computeTeamReportModel reuses ONLY TeamModules.buildModuleData outputs — 无派生
// 复合分、无严重度分级、无处方。Ruling §4/§6.
function computeTeamReportModel(input) {
  const TM = (typeof window !== 'undefined') && window.TeamModules;
  const build = (id) => (TM && typeof TM.buildModuleData === 'function') ? TM.buildModuleData(id, input) : null;
  const moduleData = {
    'swc-movers': build('swc-movers'),
    'metric-compare': build('metric-compare'),
    'test-coverage': build('test-coverage'),
    'force-summary': build('force-summary'),
    'attendance-facts': build('attendance-facts'),
    'data-gaps': build('data-gaps'),
    'weekly-confirmed': build('weekly-confirmed'),
  };
  const athletes = (input && Array.isArray(input.athletes)) ? input.athletes : [];
  const wc = moduleData['weekly-confirmed'] || {};
  const movers = (moduleData['swc-movers'] && Array.isArray(moduleData['swc-movers'].rows)) ? moduleData['swc-movers'].rows : [];
  // 数据截至: latest test/entry date across the subset's coverage cells (honest
  // fact), falling back to the active season anchor when no dated record exists.
  let dataAsOf = null;
  const tc = moduleData['test-coverage'] || {};
  (Array.isArray(tc.rows) ? tc.rows : []).forEach((r) => (r.cells || []).forEach((c) => {
    if (c && c.date && (dataAsOf === null || c.date > dataAsOf)) dataAsOf = c.date;
  }));
  if (!dataAsOf) dataAsOf = (input && input.activeSeasonDate) || null;
  const kpi = {
    subsetCount: athletes.length,
    dataAsOf: dataAsOf || '—',
    confirmedCount: wc.confirmed || 0,
    movedCount: movers.length,
  };
  return { moduleData, kpi };
}

// Persona report presets — each is an explicit section-toggle set (mirrors the
// individual report's 常规/精简 presets). Listed ids are the paper module ids +
// 'narrative'; the KPI header and 教练备注 footer are always rendered by the print
// body. Individual section toggles still apply after a preset is chosen.
const TEAM_REPORT_PRESETS = [
  { id: 'headcoach', label: '主教练 · 讨论简报', sections: ['narrative', 'force-summary', 'swc-movers', 'metric-compare'], narrTpl: 'coach' },
  { id: 'sc',        label: '体能 · 技术汇总',   sections: ['force-summary', 'metric-compare', 'swc-movers', 'test-coverage'], narrTpl: 'none' },
  { id: 'admin',     label: '行政 · 事实汇总',   sections: ['attendance-facts', 'test-coverage', 'data-gaps'], narrTpl: 'none' },
];

// Raw-count narrative templates (mirror RD-5): honest counts + human-confirmed
// facts only — 无复合分、无严重度、无处方。Zero-fallbacks
// state the absence honestly; the mechanism (+ 自定义) is preserved.
const TEAM_REPORT_NARR = [
  { id: 'coach',    label: '教练口吻 · 原始计数' },
  { id: 'data',     label: '数据综述' },
  { id: 'declines', label: '下降信号 · 计数' },
  { id: 'progress', label: '提升信号 · 计数' },
  { id: 'none',     label: '无叙事 · 仅数据' },
  { id: 'custom',   label: '✎ 自定义', custom: true },
];

function buildTeamNarrative(id, model) {
  const kpi = (model && model.kpi) || {};
  const swc = (model && model.moduleData && model.moduleData['swc-movers']) || {};
  const rows = Array.isArray(swc.rows) ? swc.rows : [];
  const N = kpi.subsetCount || 0;
  const M = kpi.movedCount || 0;
  const up = rows.reduce((s, r) => s + (r.improved || 0), 0);
  const down = rows.reduce((s, r) => s + (r.declined || 0), 0);
  const upAth = rows.filter((r) => (r.improved || 0) > 0).length;
  const downAth = rows.filter((r) => (r.declined || 0) > 0).length;
  switch (id) {
    case 'coach':
      return M > 0
        ? `本期 ${N} 人中 ${M} 人有超过 SWC 阈值的实测变化：提升 ${up} 项、下降 ${down} 项。逐项见下方清单，需人工查看。`
        : `本期 ${N} 人中 0 人有超过 SWC 阈值的实测变化，与当前记录一致。`;
    case 'data':
      return `子集 ${N} 人 · 显著变化 ${M} 人 · 本周确认 ${kpi.confirmedCount || 0} 项 · 数据截至 ${kpi.dataAsOf || '—'}。`;
    case 'declines':
      return down > 0
        ? `本期有 ${down} 项实测指标下降，涉及 ${downAth} 名运动员，可结合负荷与恢复数据人工查看。`
        : `本期无超过 SWC 阈值的下降项。`;
    case 'progress':
      return up > 0
        ? `本期有 ${up} 项实测指标提升，涉及 ${upAth} 名运动员。`
        : `本期无超过 SWC 阈值的提升项。`;
    default:
      return '';
  }
}

Object.assign(window, { computeTeamReportModel, TEAM_REPORT_PRESETS, TEAM_REPORT_NARR, buildTeamNarrative });

function TeamReportModal({ open, onClose, squad, season, groups, athletesWithScores, moduleInput }) {
  const [coachNotes, setCoachNotes] = rUseS('');
  const [fmt, setFmt] = rUseS('pdf');
  const roster = athletesWithScores || [];
  const baseInput = moduleInput || {};

  // ── 子集选择（人）──
  const positions = rUseM(() => [...new Set(roster.map(a => a.position).filter(Boolean))], [roster]);
  const [selAth, setSelAth] = rUseS(() => new Set(roster.map(a => a.id)));
  rUseE(() => { setSelAth(new Set(roster.map(a => a.id))); }, [roster.length]); // eslint-disable-line
  const toggleAth = id => setSelAth(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
  const subset = rUseM(() => roster.filter(a => selAth.has(a.id)), [roster, selAth]);

  // ── 报告预设（视角）+ 板块开关 ── (TEAM-3: mirrors the individual report's
  // preset chips — a preset is an explicit action that sets the section toggles;
  // individual toggles still work after. Default on open: 主教练.)
  const DEFAULT_PRESET = (window.TEAM_REPORT_PRESETS || [])[0] || { id: 'headcoach', sections: ['narrative', 'swc-movers', 'metric-compare'], narrTpl: 'coach' };
  const [activePreset, setActivePreset] = rUseS(DEFAULT_PRESET.id);
  const [sections, setSections] = rUseS(() => DEFAULT_PRESET.sections.slice());
  const [narrTpl, setNarrTpl] = rUseS(DEFAULT_PRESET.narrTpl || 'coach');
  const [customNarr, setCustomNarr] = rUseS('');
  const [compareMetricId, setCompareMetricId] = rUseS(null);
  const applyPreset = (p) => { setActivePreset(p.id); setSections(p.sections.slice()); setNarrTpl(p.narrTpl || 'none'); };
  const toggleSection = (id) => { setActivePreset(null); setSections(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]); };
  const secOn = (id) => sections.includes(id);

  // ── 客观报告模型（复用 TeamModules.buildModuleData，零派生复合分）──
  const subsetInput = rUseM(() => ({
    ...baseInput,
    athletes: subset,
    reviewedRows: (Array.isArray(baseInput.reviewedRows) ? baseInput.reviewedRows : []).filter(r => selAth.has(r && r.athleteId)),
    selectedMetricId: compareMetricId,
  }), [baseInput, subset, selAth, compareMetricId]);
  const model = rUseM(() => computeTeamReportModel(subsetInput), [subsetInput]);
  const metricOptions = (model.moduleData['metric-compare'] && Array.isArray(model.moduleData['metric-compare'].options))
    ? model.moduleData['metric-compare'].options : [];
  // Default metric-compare selection: a 体测/seasons metric with data (non-force
  // origin first), else the first plottable option. Editor-configurable below.
  rUseE(() => {
    if (compareMetricId != null || !metricOptions.length) return;
    const seasonsMetric = metricOptions.find(o => !String(o.origin || '').startsWith('force'));
    setCompareMetricId((seasonsMetric || metricOptions[0]).id);
  }, [compareMetricId, metricOptions.length]); // eslint-disable-line

  // 全队 SWC 显著变化 ids — 快速子集用（原始 Δ，无分级）。
  const moversAll = rUseM(() => {
    const TM = window.TeamModules;
    if (!TM) return [];
    const d = TM.buildModuleData('swc-movers', { ...baseInput, athletes: roster }) || {};
    return Array.isArray(d.rows) ? d.rows : [];
  }, [baseInput, roster]);

  // ── 叙事（原始计数模板 + 自定义）──
  const narrText = rUseM(() => narrTpl === 'custom' ? customNarr : buildTeamNarrative(narrTpl, model), [narrTpl, customNarr, model]);

  // ── 可编辑小标题 ──
  const [titles, setTitles] = rUseS({ narrative: '教练摘要', notes: '教练备注 / 跨周追踪' });
  const setTitle = (k, v) => setTitles(s => ({ ...s, [k]: v }));
  const secTitle = (id) => titles[id] || (TEAM_PAPER_SECTIONS[id] && TEAM_PAPER_SECTIONS[id].defaultTitle) || id;

  // ── 背景色 + 底部选项 ──
  const [paperBg, setPaperBg] = rUseS('warm');
  const [footOpts, setFootOpts] = rUseS({ copy: true, archive: false });
  const toggleFoot = k => setFootOpts(s => ({ ...s, [k]: !s[k] }));

  // ── 命名模板（团队独立命名空间）──
  const TEAM_TPL_KEY = 'report_templates_team_v1';
  const [templates, setTemplates] = rUseS(() => { try { return JSON.parse(localStorage.getItem(TEAM_TPL_KEY)) || []; } catch { return []; } });
  const [activeTpl, setActiveTpl] = rUseS(null);
  rUseE(() => { try { localStorage.setItem(TEAM_TPL_KEY, JSON.stringify(templates)); } catch {} }, [templates]);
  const teamHexA = (h, a) => { if (typeof h !== 'string' || h[0] !== '#') return h; let s = h.slice(1); if (s.length === 3) s = s.split('').map(c => c + c).join(''); const n = parseInt(s, 16); return `rgba(${(n>>16)&255}, ${(n>>8)&255}, ${n&255}, ${a})`; };

  if (!open) return null;

  const captureConfig = () => ({ selAth: [...selAth], sections: [...sections], activePreset, narrTpl, customNarr, compareMetricId, titles: { ...titles }, paperBg, footOpts: { ...footOpts } });
  const applyConfig = (c) => {
    if (!c) return;
    if (c.selAth) setSelAth(new Set(c.selAth));
    if (Array.isArray(c.sections)) setSections(c.sections);
    if ('activePreset' in c) setActivePreset(c.activePreset);
    if (c.narrTpl) setNarrTpl(c.narrTpl);
    if (typeof c.customNarr === 'string') setCustomNarr(c.customNarr);
    if ('compareMetricId' in c) setCompareMetricId(c.compareMetricId);
    if (c.titles) setTitles(c.titles);
    if (c.paperBg) setPaperBg(c.paperBg);
    if (c.footOpts) setFootOpts(c.footOpts);
  };
  const saveAsTemplate = () => { const name = (window.prompt('模板名称（如：周报标准版 / 仅显著变化版）', `模板 ${templates.length + 1}`) || '').trim(); if (!name) return; const id = 'tpl_' + Date.now(); setTemplates(ts => [...ts.filter(t => t.name !== name), { id, name, config: captureConfig() }]); setActiveTpl(id); };
  const applyTemplate = (t) => { setActiveTpl(t.id); applyConfig(t.config); };
  const deleteTemplate = (id, e) => { e.stopPropagation(); setTemplates(ts => ts.filter(t => t.id !== id)); if (activeTpl === id) setActiveTpl(null); };

  const doPrint = () => {
    const prev = document.title;
    document.title = `Squad Brief · ${season}`;
    const body = document.getElementById('team-report-body');
    if (!body) { window.print(); return; }
    const clone = body.cloneNode(true);
    clone.id = 'print-clone';
    clone.style.cssText = 'display:block;';
    document.body.appendChild(clone);
    fitReportToOnePage(clone);
    document.body.classList.add('printing-report');
    const pageStyle = document.createElement('style');
    pageStyle.id = 'rpt-page-override';
    pageStyle.textContent = '@media print { @page { size: A4; margin: 0; } }';
    document.head.appendChild(pageStyle);
    window.addEventListener('afterprint', () => {
      document.body.classList.remove('printing-report');
      document.title = prev;
      document.getElementById('print-clone')?.remove();
      document.getElementById('rpt-page-override')?.remove();
    }, { once: true });
    setTimeout(() => window.print(), 80);
  };

  const sessionDate = window.formatDate ? window.formatDate(season, 'short') : season;

  return (
    <div
      onClick={onClose}
      style={{
        position: 'fixed', inset: 0, background: 'rgba(5,8,12,.78)',
        backdropFilter: 'blur(5px)', zIndex: 200,
        display: 'grid', placeItems: 'center', padding: 24,
        animation: 'fade .2s ease both',
      }}
    >
      <div id="report-modal-root" className="individual-report-composer team-report-composer" onClick={e => e.stopPropagation()}>

        <div className="m-head">
          <div className="ttl">
            导出团队报告
            <span className="who">· 团队报告 · 队列概览 · {sessionDate}</span>
          </div>
          <button className="m-x" onClick={onClose} aria-label="关闭">✕</button>
        </div>

        <div className="m-sub">
          <span className="l">
            <strong>{squad || 'First Team'} · 子集 {subset.length} / {roster.length}</strong>
            {model.kpi.movedCount > 0 && ` · ${model.kpi.movedCount} 人显著变化`}
          </span>
        </div>

        {/* 命名模板条 */}
        <div className="m-tpls">
          <span className="k">模板</span>
          {templates.map(t => (
            <button key={t.id} className={`tpl-chip${activeTpl === t.id ? ' on' : ''}`} onClick={() => applyTemplate(t)} title="点击套用此模板">
              {t.name}<span className="x" onClick={(e) => deleteTemplate(t.id, e)} title="删除模板">✕</span>
            </button>
          ))}
          {!templates.length && <span className="tpl-empty">尚无自定义模板 · 调好后「另存为」可复用</span>}
          <button className="tpl-save" onClick={saveAsTemplate}><span className="plus">+</span>另存为模板</button>
        </div>

        <div className="m-body individual-report-composer-grid workbench-grid team-report-composer-grid">
          <div className="individual-report-context report-pane team-report-context">
            <div className="pane-bar"><span className="pane-title">编辑对象</span></div>
            <div className="pane-content">

            <div className="group-lbl">固定 · 始终包含</div>
            <div className="sec-row locked">
              <span className="cbx"></span>
              <span className="sec-nm">报告头部 + 运动员条 <span className="lock">必选</span></span>
            </div>

            {/* 时段 + 子集 */}
            <div className="group-lbl">时段 + 子集 <span style={{ fontFamily: 'var(--font-sans)', fontSize: 10.5, color: 'var(--muted)', textTransform: 'none', letterSpacing: 0, fontWeight: 400, marginLeft: 6 }}>团队报告特有</span></div>
            <div className="tmpl-block full">
              <div className="period-grid">
                <div className="p-cell">
                  <div className="p-k">时段</div>
                  <div className="p-select"><span className="v">{sessionDate}</span><span className="chev">▾</span></div>
                  <div className="p-meta">全队 {roster.length} 人</div>
                </div>
                <div className="p-cell">
                  <div className="p-k">子集</div>
                  <div className="p-select"><span className="v">{subset.length} 人</span></div>
                  <div className="p-meta">下方可单独勾选</div>
                </div>
              </div>
              <div className="p-baseline">
                <span className="p-k" style={{ marginRight: 6 }}>快速子集</span>
                <button className="chip" onClick={() => setSelAth(new Set(roster.map(a => a.id)))}>全队 · {roster.length}</button>
                {positions.map(p => {
                  const ids = roster.filter(a => a.position === p).map(a => a.id);
                  return <button key={p} className="chip" onClick={() => setSelAth(new Set(ids))}>{p} 组 · {ids.length}</button>;
                })}
                <button className="chip" onClick={() => setSelAth(new Set(moversAll.map(r => r.athleteId)))}>仅显著变化 · {moversAll.length}</button>
              </div>
              <div className="athletes-wrap">
                <div className="athletes-head">
                  <span className="k">已选 {subset.length} / {roster.length} 人 · 可单独勾掉</span>
                  <span className="actions">
                    <button onClick={() => setSelAth(new Set(roster.map(a => a.id)))}>全选</button>
                    <button onClick={() => setSelAth(new Set())}>清除</button>
                  </span>
                </div>
                <div className="athletes">
                  {roster.map(a => {
                    const ini = a.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
                    return (
                      <span key={a.id} className={`ath-chip${selAth.has(a.id) ? ' on' : ''}`} onClick={() => toggleAth(a.id)}>
                        <span className="av">{ini}</span>
                        <span className="num">#{String(a.jersey).padStart(2, '0')}</span>
                        {a.name.split(' ')[0]}
                      </span>
                    );
                  })}
                </div>
              </div>
            </div>

            </div>
          </div>

          <div className="m-left individual-report-sections report-pane team-report-settings">
            <div className="pane-bar"><span className="pane-title">报告设置</span></div>
            <div className="pane-content">

            {/* 报告预设（视角）*/}
            <div className="group-lbl">报告预设 <span style={{ fontFamily: 'var(--font-sans)', fontSize: 10.5, color: 'var(--muted)', textTransform: 'none', letterSpacing: 0, fontWeight: 400, marginLeft: 6 }}>一键套用视角板块 · 之后可单独增减</span></div>
            <div className="tmpl-block">
              <div className="tmpl-row">
                {(window.TEAM_REPORT_PRESETS || []).map(p => (
                  <button key={p.id} className={`tmpl${activePreset === p.id ? ' on' : ''}`} onClick={() => applyPreset(p)}>{p.label}</button>
                ))}
              </div>
            </div>

            {/* 报告板块（客观模块 · 逐项开关）*/}
            <div className="group-lbl">报告板块 <span style={{ fontFamily: 'var(--font-sans)', fontSize: 10.5, color: 'var(--muted)', textTransform: 'none', letterSpacing: 0, fontWeight: 400, marginLeft: 6 }}>客观事实 · 原始实测 + 人审确认</span></div>

            {/* 教练摘要（叙事 · 原始计数）*/}
            <div className={`sec-row${secOn('narrative') ? ' on' : ''}`} onClick={() => toggleSection('narrative')} style={{ cursor: 'pointer' }}>
              <span className="cbx"></span>
              <span className="sec-nm" onClick={e => e.stopPropagation()}><NmEdit value={titles.narrative} onChange={v => setTitle('narrative', v)} size={6} /><span className="hint">一段原始计数叙述</span></span>
              <span className="pgcount">+1 段</span>
            </div>
            {secOn('narrative') && (
              <div className="tmpl-block">
                <div className="tmpl-row">
                  <span className="k">模板</span>
                  {(window.TEAM_REPORT_NARR || []).map(t => (
                    <button key={t.id} className={`tmpl${t.custom ? ' custom' : ''}${narrTpl === t.id ? ' on' : ''}`} onClick={() => setNarrTpl(t.id)}>{t.label}</button>
                  ))}
                </div>
                {narrTpl === 'custom' ? (
                  <textarea value={customNarr} onChange={e => setCustomNarr(e.target.value)} placeholder="自定义摘要（原始计数 / 人审结论）…"
                    style={{ width: '100%', minHeight: 44, resize: 'vertical', marginTop: 6, background: 'var(--paper)', border: '1px solid var(--line)', borderRadius: 6, padding: '6px 8px', color: 'var(--ink)', font: '12px/1.6 var(--font-sans)', outline: 'none' }} />
                ) : (
                  <div className="tmpl-body" style={{ minHeight: 40 }}>{narrText || <span style={{ color: 'var(--muted-2)' }}>（无叙事）</span>}</div>
                )}
              </div>
            )}

            {/* 测力台联动：覆盖事实 + 已确认运动科学结论 */}
            <div className={`sec-row${secOn('force-summary') ? ' on' : ''}`} onClick={() => toggleSection('force-summary')} style={{ cursor: 'pointer' }}>
              <span className="cbx"></span>
              <span className="sec-nm" onClick={e => e.stopPropagation()}>
                <NmEdit value={secTitle('force-summary')} onChange={v => setTitle('force-summary', v)} size={12} />
                <span className="hint">CMJ / SJ / IMTP 覆盖 + 已确认结论 · 可追溯</span>
              </span>
              <span className="pgcount">~ 1/3 页</span>
            </div>

            {/* SWC 显著变化 */}
            <div className={`sec-row${secOn('swc-movers') ? ' on' : ''}`} onClick={() => toggleSection('swc-movers')} style={{ cursor: 'pointer' }}>
              <span className="cbx"></span>
              <span className="sec-nm" onClick={e => e.stopPropagation()}><NmEdit value={secTitle('swc-movers')} onChange={v => setTitle('swc-movers', v)} size={12} /><span className="hint">{(model.moduleData['swc-movers'] && model.moduleData['swc-movers'].rows ? model.moduleData['swc-movers'].rows.length : 0)} 人 · 原始 Δ · 无分级</span></span>
              <span className="pgcount">~ 1/3 页</span>
            </div>

            {/* 指标横向对比（可选指标）*/}
            <div className={`sec-row${secOn('metric-compare') ? ' on' : ''}`} onClick={() => toggleSection('metric-compare')} style={{ cursor: 'pointer' }}>
              <span className="cbx"></span>
              <span className="sec-nm" onClick={e => e.stopPropagation()}><NmEdit value={secTitle('metric-compare')} onChange={v => setTitle('metric-compare', v)} size={8} /><span className="hint">{metricOptions.length} 指标可选 · 原始实测值</span></span>
              <span className="pgcount">~ 1/3 页</span>
            </div>
            {secOn('metric-compare') && metricOptions.length > 0 && (
              <div className="nest">
                <div className="nest-h"><span className="k">对比指标 · 单选</span></div>
                <select value={compareMetricId || ''} onChange={e => setCompareMetricId(e.target.value)}
                  style={{ width: '100%', background: 'var(--paper)', border: '1px solid var(--line)', borderRadius: 6, padding: '6px 8px', color: 'var(--ink)', font: '12px var(--font-sans)', outline: 'none' }}>
                  {metricOptions.map(o => <option key={o.id} value={o.id}>{o.label}{o.provenance ? ` · ${o.provenance}` : ''}</option>)}
                </select>
              </div>
            )}

            {/* 测试覆盖 */}
            <div className={`sec-row${secOn('test-coverage') ? ' on' : ''}`} onClick={() => toggleSection('test-coverage')} style={{ cursor: 'pointer' }}>
              <span className="cbx"></span>
              <span className="sec-nm" onClick={e => e.stopPropagation()}><NmEdit value={secTitle('test-coverage')} onChange={v => setTitle('test-coverage', v)} size={6} /><span className="hint">最近测试日期矩阵 · 仅事实</span></span>
              <span className="pgcount">~ 1/3 页</span>
            </div>

            {/* 出勤 / 可用 */}
            <div className={`sec-row${secOn('attendance-facts') ? ' on' : ''}`} onClick={() => toggleSection('attendance-facts')} style={{ cursor: 'pointer' }}>
              <span className="cbx"></span>
              <span className="sec-nm" onClick={e => e.stopPropagation()}><NmEdit value={secTitle('attendance-facts')} onChange={v => setTitle('attendance-facts', v)} size={6} /><span className="hint">出勤 / 可用 计数 · 无评率</span></span>
              <span className="pgcount">~ 1/4 页</span>
            </div>

            {/* 数据缺口 */}
            <div className={`sec-row${secOn('data-gaps') ? ' on' : ''}`} onClick={() => toggleSection('data-gaps')} style={{ cursor: 'pointer' }}>
              <span className="cbx"></span>
              <span className="sec-nm" onClick={e => e.stopPropagation()}><NmEdit value={secTitle('data-gaps')} onChange={v => setTitle('data-gaps', v)} size={6} /><span className="hint">缺测 / 字段空洞 · 仅事实</span></span>
              <span className="pgcount">~ 1/4 页</span>
            </div>

            {/* 手写区 */}
            <div className="group-lbl">手写区</div>
            <div className="sec-row on">
              <span className="cbx"></span>
              <span className="sec-nm"><NmEdit value={titles.notes} onChange={v => setTitle('notes', v)} size={11} /><span className="hint">跨周观察记录</span></span>
              <span className="pgcount">2 行</span>
            </div>
            <div style={{ marginTop: 6, marginLeft: 30 }}>
              <textarea
                value={coachNotes}
                onChange={e => setCoachNotes(e.target.value)}
                placeholder="跨周追踪记录 / 下周训练重点…"
                style={{
                  width: '100%', minHeight: 64, resize: 'vertical',
                  background: 'var(--paper)', border: '1px solid var(--line)',
                  borderRadius: 6, padding: '8px 10px',
                  color: 'var(--ink)', font: '12.5px/1.6 var(--font-sans)',
                  fontFamily: 'var(--font-serif)', outline: 'none',
                }}
                onFocus={e => e.target.style.borderColor = 'var(--warm)'}
                onBlur={e => e.target.style.borderColor = 'var(--line)'}
              />
            </div>

            </div>
          </div>

          <ReportPreview paperBg={paperBg}>
            <div className="workbench-canvas team-report-canvas">
              <TeamReportPrintBody
                squad={squad}
                season={season}
                roster={subset}
                moduleData={model.moduleData}
                sections={sections}
                narrative={narrText}
                coachNotes={coachNotes}
                titles={titles}
                paperBg={paperBg}
                kpi={model.kpi}
              />
            </div>
          </ReportPreview>

        </div>

        <div className="m-foot">
          <div className="fmt">
            <button className={fmt === 'pdf' ? 'on' : ''} onClick={() => setFmt('pdf')}><span className="ic">⎙</span> PDF</button>
            <button className={fmt === 'png' ? 'on' : ''} onClick={() => setFmt('png')}><span className="ic">⤓</span> PNG</button>
            <button className={fmt === 'print' ? 'on' : ''} onClick={() => setFmt('print')}><span className="ic">⊞</span> 打印</button>
          </div>
          <div className="opts">
            <button className={`opt${paperBg === 'warm' ? ' on' : ''}`} onClick={() => setPaperBg(p => p === 'warm' ? 'white' : 'warm')} title="黑白打印可关掉背景色"><span className="dot"></span>{paperBg === 'warm' ? '暖纸背景' : '纯白背景'}</button>
            <button className={`opt${footOpts.copy ? ' on' : ''}`} onClick={() => toggleFoot('copy')}><span className="dot"></span>含队内副本</button>
            <button className={`opt${footOpts.archive ? ' on' : ''}`} onClick={() => toggleFoot('archive')}><span className="dot"></span>归档</button>
          </div>
          <span className="grow"></span>
          <button className="ghost" onClick={onClose}>取消</button>
          <button className="pri" onClick={doPrint}>
            导出报告 <span className="pgs">· 1 页</span>
          </button>
        </div>

      </div>
    </div>
  );
}

Object.assign(window, { TeamReportModal });
