// norms.jsx v2 — 常模证据库：记录、来源治理、覆盖矩阵与只读知识文档。
// FieldDataStore 仍是唯一写入源；NormKnowledgeReadModel 只投影，不判断运动员或生成建议。

const normCss = `
.norm-axis-page{--na-bg:#f5f6f8;--na-card:#fff;--na-ink:#202938;--na-muted:#7b8492;--na-line:#e4e7ec;--na-soft:#f7f8fa;--na-blue:#2778d4;--na-green:#2f9368;--na-amber:#b17921;flex:1;min-width:0;min-height:0;overflow:auto;background:var(--na-bg);color:var(--na-ink);-webkit-font-smoothing:antialiased}
.norm-axis-page *{box-sizing:border-box}.norm-shell{width:min(1500px,100%);margin:0 auto;padding:30px 34px 80px}.norm-hero{display:flex;align-items:flex-end;justify-content:space-between;gap:24px}.norm-kicker{font-size:10px;font-weight:800;letter-spacing:.13em;color:#969eaa}.norm-hero h1{margin:7px 0 4px;font-size:30px;line-height:1.1;letter-spacing:-.04em;text-wrap:balance}.norm-hero p{max-width:720px;margin:0;color:var(--na-muted);font-size:13px;line-height:1.6;text-wrap:pretty}.norm-actions,.norm-tabs,.norm-row-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.norm-btn,.norm-icon,.norm-tab,.norm-filter-button,.norm-record{font:inherit;cursor:pointer;transition-property:transform,background-color,color,box-shadow;transition-duration:.15s}.norm-btn:active,.norm-icon:active,.norm-tab:active,.norm-filter-button:active,.norm-record:active{transform:scale(.96)}.norm-btn{min-height:42px;padding:0 14px;border:0;border-radius:10px;background:#fff;color:#445064;box-shadow:0 0 0 1px rgba(31,41,55,.09),0 5px 14px rgba(31,41,55,.05);font-size:12px;font-weight:700}.norm-btn:hover{background:#fbfcfd}.norm-btn.primary{background:#29344a;color:#fff;box-shadow:0 7px 18px rgba(41,52,74,.18)}.norm-btn.danger{color:#b24f56}.norm-btn:disabled{cursor:not-allowed;opacity:.45}.norm-icon{display:grid;place-items:center;width:42px;height:42px;border:0;border-radius:10px;background:#fff;color:#77808d;box-shadow:0 0 0 1px rgba(31,41,55,.09)}
.norm-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:24px 0 18px;border-radius:14px;background:var(--na-card);box-shadow:0 0 0 1px rgba(31,41,55,.07),0 12px 30px rgba(31,41,55,.04);overflow:hidden}.norm-stat{min-height:104px;padding:18px 20px;border-right:1px solid var(--na-line)}.norm-stat:last-child{border-right:0}.norm-stat span,.norm-stat small,.norm-stat b{display:block}.norm-stat span{font-size:11px;color:var(--na-muted)}.norm-stat b{margin:8px 0 4px;font-size:26px;font-variant-numeric:tabular-nums}.norm-stat b.warn{color:var(--na-amber)}.norm-stat small{font-size:10px;color:#a0a7b1}
.norm-tabs{margin-bottom:16px}.norm-tab{min-height:40px;padding:0 14px;border:0;border-radius:9px;background:transparent;color:#747d8a;font-size:12px;font-weight:750}.norm-tab.on{background:#fff;color:#273248;box-shadow:0 0 0 1px rgba(31,41,55,.08),0 5px 15px rgba(31,41,55,.05)}
.norm-card{border-radius:14px;background:var(--na-card);box-shadow:0 0 0 1px rgba(31,41,55,.07),0 10px 28px rgba(31,41,55,.04);overflow:hidden}.norm-workbench{display:grid;grid-template-columns:210px minmax(520px,1fr) 310px;gap:14px;align-items:start}.norm-filter{padding:14px}.norm-search,.norm-select,.norm-input{width:100%;min-height:42px;border:1px solid var(--na-line);border-radius:9px;background:#fff;color:var(--na-ink);padding:0 11px;font:inherit;font-size:12px;outline:none}.norm-search:focus,.norm-select:focus,.norm-input:focus{border-color:#a7c9ee;box-shadow:0 0 0 3px rgba(39,120,212,.08)}.norm-filter-title{margin:18px 8px 6px;font-size:9px;font-weight:800;letter-spacing:.11em;color:#9ba3ae}.norm-filter-button{display:flex;align-items:center;justify-content:space-between;width:100%;min-height:40px;border:0;border-radius:8px;padding:0 9px;background:transparent;color:#657080;font-size:11px;text-align:left}.norm-filter-button:hover{background:#f6f7f9}.norm-filter-button.on{background:#edf5ff;color:#286daf;font-weight:750}.norm-filter-button span{font-variant-numeric:tabular-nums;color:#9aa2ad}
.norm-card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:64px;padding:13px 16px;border-bottom:1px solid var(--na-line)}.norm-card-head b,.norm-card-head small{display:block}.norm-card-head b{font-size:13px}.norm-card-head small{margin-top:4px;color:var(--na-muted);font-size:9.5px}.norm-tools{display:flex;gap:7px}.norm-tools .norm-select{width:auto;min-height:36px}
.norm-table-head,.norm-record{display:grid;grid-template-columns:minmax(145px,1.15fr) minmax(140px,1.05fr) minmax(110px,.8fr) minmax(120px,1fr);gap:12px;align-items:center}.norm-table-head{padding:9px 14px;background:#f8f9fa;color:#929aa6;font-size:9px;font-weight:800;letter-spacing:.07em}.norm-record{width:100%;min-height:74px;border:0;border-bottom:1px solid #eceef1;padding:11px 14px;background:#fff;color:var(--na-ink);text-align:left}.norm-record:hover{background:#fafbfc}.norm-record.on{background:#eef6ff}.norm-record b,.norm-record small{display:block}.norm-record b{font-size:11px;line-height:1.35}.norm-record small{margin-top:4px;color:var(--na-muted);font-size:9px;line-height:1.4}.norm-metric-badge,.norm-chip,.norm-state{display:inline-flex;align-items:center;border-radius:999px;font-weight:750}.norm-metric-badge{margin-top:5px;padding:3px 7px;background:#eef0f4;color:#657080;font-size:8px}.norm-state{gap:5px}.norm-state:before{content:"";width:6px;height:6px;border-radius:50%;background:var(--na-green)}.norm-state.partial:before,.norm-state.incomplete:before{background:var(--na-amber)}
.norm-inspector{position:sticky;top:18px}.norm-inspector-body{padding:16px}.norm-record-title{margin:0 0 7px;font-size:18px;letter-spacing:-.025em;text-wrap:balance}.norm-chips{display:flex;gap:5px;flex-wrap:wrap}.norm-chip{padding:4px 8px;background:#eef0f4;color:#687280;font-size:8.5px}.norm-facts{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:15px 0}.norm-fact{padding:11px;border-radius:9px;background:#f6f7f9}.norm-fact span,.norm-fact b{display:block}.norm-fact span{color:var(--na-muted);font-size:9px}.norm-fact b{margin-top:5px;font-size:12px;font-variant-numeric:tabular-nums}.norm-provenance,.norm-boundary{padding:11px;border-radius:9px;font-size:9px;line-height:1.55}.norm-provenance{background:#f6f7f9;color:#687280}.norm-provenance.warn,.norm-boundary{background:#fff8ea;color:#836326}.norm-provenance b{display:block;margin-bottom:4px;color:#394454}.norm-preview{margin-top:12px;padding:12px;border:1px solid var(--na-line);border-radius:10px}.norm-preview-top{display:flex;align-items:flex-end;justify-content:space-between;gap:10px}.norm-preview label{display:block;color:var(--na-muted);font-size:9px}.norm-preview .norm-input{width:104px;min-height:36px;margin-top:4px}.norm-pct{font-size:25px;font-weight:800;color:var(--na-blue);font-variant-numeric:tabular-nums}.norm-scale{position:relative;height:8px;margin:14px 0 7px;border-radius:999px;background:linear-gradient(90deg,#f3dfe1,#f4ead8,#dff0e7)}.norm-scale i{position:absolute;top:50%;width:13px;height:13px;border:3px solid #fff;border-radius:50%;background:var(--na-blue);box-shadow:0 2px 8px rgba(31,41,55,.2);transform:translate(-50%,-50%)}.norm-scale-labels{display:flex;justify-content:space-between;color:#9aa1ac;font-size:8px}.norm-boundary{margin-top:12px}
.norm-empty{padding:48px 20px;text-align:center;color:var(--na-muted);font-size:12px}.norm-dataset-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.norm-dataset{min-height:220px;padding:17px}.norm-dataset-top{display:flex;justify-content:space-between;gap:12px}.norm-dataset h3{margin:0;font-size:15px;text-wrap:balance}.norm-dataset p{margin:6px 0;color:var(--na-muted);font-size:10px;line-height:1.5}.norm-dataset-count{font-size:23px;font-weight:800;font-variant-numeric:tabular-nums}.norm-dataset-count small{display:block;color:var(--na-muted);font-size:8px;font-weight:500}.norm-dataset .norm-chips{margin:14px 0}.norm-dataset-foot{display:flex;justify-content:space-between;gap:10px;margin-top:14px;padding-top:12px;border-top:1px solid var(--na-line);color:var(--na-muted);font-size:9px}
.norm-matrix-wrap{overflow:auto}.norm-matrix{display:grid;min-width:800px}.norm-matrix>div{min-height:68px;padding:11px;border-right:1px solid #eceef1;border-bottom:1px solid #eceef1}.norm-matrix .head{min-height:56px;background:#f8f9fa;color:#687280;font-size:9px;font-weight:750}.norm-matrix .metric{font-size:11px;font-weight:750}.norm-matrix small{display:block;margin-top:4px;color:var(--na-muted);font-size:8px;font-weight:500}.norm-cell{font-size:10px;cursor:pointer}.norm-cell:hover{background:#eef6ff}.norm-cell.empty{color:#a1a8b2}
.norm-form-wrap{margin-bottom:16px;padding:18px}.norm-form-wrap h2{margin:0 0 14px;font-size:17px}.norm-form-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:11px}.norm-form-grid label{color:var(--na-muted);font-size:10px}.norm-form-grid label span{display:block;margin-bottom:5px}.norm-form-grid .wide{grid-column:span 2}.norm-form-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}.norm-form-note{margin-top:12px;color:var(--na-muted);font-size:9px}
.norm-import{margin-bottom:16px;padding:17px}.norm-import h2{margin:0 0 6px;font-size:16px}.norm-import p{margin:0;color:var(--na-muted);font-size:10px}.norm-import-summary{display:flex;gap:9px;margin:13px 0}.norm-import-summary span{padding:6px 9px;border-radius:8px;background:#f5f7f9;font-size:10px;font-variant-numeric:tabular-nums}.norm-import-errors{max-height:120px;overflow:auto;padding:9px;border-radius:8px;background:#fff3f1;color:#9b4a43;font-size:9px;line-height:1.6}
@media(max-width:1180px){.norm-workbench{grid-template-columns:190px minmax(440px,1fr)}.norm-inspector{position:static;grid-column:1/-1}.norm-dataset-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.norm-form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:760px){.norm-shell{padding:20px 12px 74px}.norm-hero{align-items:flex-start;flex-direction:column}.norm-actions{width:100%}.norm-actions .norm-btn{flex:1}.norm-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.norm-stat:nth-child(2){border-right:0}.norm-stat:nth-child(-n+2){border-bottom:1px solid var(--na-line)}.norm-workbench{grid-template-columns:1fr}.norm-filter{display:none}.norm-table-head{display:none}.norm-record{grid-template-columns:1fr 1fr}.norm-record>span:nth-child(n+3){padding-top:5px}.norm-dataset-grid{grid-template-columns:1fr}.norm-form-grid{grid-template-columns:1fr}.norm-form-grid .wide{grid-column:auto}.norm-card-head{align-items:flex-start;flex-direction:column}.norm-tools{width:100%}.norm-tools .norm-select{min-width:0;flex:1}.norm-tabs{overflow:auto;flex-wrap:nowrap;padding-bottom:3px}.norm-tab{white-space:nowrap}}
`;

function normStatText(stats, unit) {
  const st = stats || {};
  if (st.mean != null) return Number(st.mean) + (st.sd != null ? ' ± ' + Number(st.sd) : '') + (unit ? ' ' + unit : '');
  if (st.percentiles) return 'P' + Object.keys(st.percentiles).map(key => String(key).replace(/\D/g, '')).filter(Boolean).join('–P');
  if (Array.isArray(st.bands)) return st.bands.length + ' 个分级';
  return '—';
}

function NormForm({ catalog, existing, onSave, onCancel }) {
  const initial = existing || {};
  const [metricKey, setMetricKey] = React.useState(initial.metricKey || (catalog[0]?.id || ''));
  const [population, setPopulation] = React.useState({ sport: '', level: '', sex: '', ageBand: '', ...(initial.population || {}) });
  const [stats, setStats] = React.useState({
    mean: initial.stats?.mean ?? '',
    sd: initial.stats?.sd ?? '',
  });
  const [source, setSource] = React.useState({
    authors: '', year: '', title: '', doi: '', n: '', notes: '', ...(initial.source || {}),
  });
  const [saving, setSaving] = React.useState(false);
  const hasExistingStats = !!(existing && existing.stats && Object.keys(existing.stats).length);
  const hasMeanSd = stats.mean !== '' && stats.sd !== '' && Number.isFinite(Number(stats.mean)) && Number(stats.sd) > 0;
  const canSave = !!metricKey && (hasMeanSd || hasExistingStats) && !saving;
  const field = (label, value, setter, options = {}) => (
    <label className={options.wide ? 'wide' : ''}>
      <span>{label}</span>
      {options.catalog ? (
        <select className="norm-select" value={value} onChange={event => setter(event.target.value)}>
          {catalog.map(metric => <option key={metric.id} value={metric.id}>{metric.group} · {metric.label}</option>)}
        </select>
      ) : <input className="norm-input" value={value} inputMode={options.numeric ? 'decimal' : undefined} placeholder={options.placeholder || ''} onChange={event => setter(event.target.value)} />}
    </label>
  );
  const updatePopulation = key => value => setPopulation(current => ({ ...current, [key]: value }));
  const updateStats = key => value => setStats(current => ({ ...current, [key]: value }));
  const updateSource = key => value => setSource(current => ({ ...current, [key]: value }));
  const submit = async () => {
    if (!canSave) return;
    setSaving(true);
    const clean = object => Object.fromEntries(Object.entries(object).filter(([, value]) => value !== '' && value != null));
    const nextStats = { ...(initial.stats || {}) };
    if (hasMeanSd) {
      nextStats.mean = Number(stats.mean);
      nextStats.sd = Number(stats.sd);
    }
    const nextSource = clean(source);
    if (nextSource.n != null) nextSource.n = Number(nextSource.n);
    try {
      await onSave({
        ...initial,
        metricKey,
        population: clean(population),
        stats: nextStats,
        source: nextSource,
      });
    } finally {
      setSaving(false);
    }
  };
  return (
    <section className="norm-card norm-form-wrap">
      <h2>{existing ? '编辑常模记录' : '新建常模记录'}</h2>
      <div className="norm-form-grid">
        {field('测试指标', metricKey, setMetricKey, { catalog: true, wide: true })}
        {field('运动项目', population.sport, updatePopulation('sport'), { placeholder: '如 Basketball' })}
        {field('竞技水平', population.level, updatePopulation('level'), { placeholder: '精英 / 职业 / 业余' })}
        {field('性别', population.sex, updatePopulation('sex'), { placeholder: '男 / 女' })}
        {field('年龄段', population.ageBand, updatePopulation('ageBand'), { placeholder: 'U18 / 18-23 / 成年' })}
        {field('均值 mean', stats.mean, updateStats('mean'), { numeric: true })}
        {field('标准差 SD', stats.sd, updateStats('sd'), { numeric: true })}
        {field('来源作者 / 机构', source.authors, updateSource('authors'), { placeholder: 'Author et al. / Internal' })}
        {field('年份', source.year, updateSource('year'), { numeric: true })}
        {field('样本量 n', source.n, updateSource('n'), { numeric: true })}
        {field('文献标题', source.title, updateSource('title'), { wide: true })}
        {field('DOI', source.doi, updateSource('doi'), { wide: true })}
        {field('来源备注', source.notes, updateSource('notes'), { wide: true })}
      </div>
      <div className="norm-form-actions">
        <button type="button" className="norm-btn" onClick={onCancel}>取消</button>
        <button type="button" className="norm-btn primary" disabled={!canSave} onClick={submit}>{saving ? '保存中…' : '保存记录'}</button>
      </div>
      <div className="norm-form-note">既有百分位表或分级阈值会在编辑时保留；本表单只更新均值 / SD、适用人群与来源。批量百分位请使用 CSV。</div>
    </section>
  );
}

function NormsView({ groups = [] }) {
  const FS = window.FieldDataStore;
  const RM = window.NormKnowledgeReadModel;
  const [norms, setNorms] = React.useState([]);
  const [customTests, setCustomTests] = React.useState([]);
  const [view, setView] = React.useState('library');
  const [search, setSearch] = React.useState('');
  const [category, setCategory] = React.useState('');
  const [populationFilter, setPopulationFilter] = React.useState('');
  const [sourceStatus, setSourceStatus] = React.useState('');
  const [selectedId, setSelectedId] = React.useState(null);
  const [editing, setEditing] = React.useState(null);
  const [previewValue, setPreviewValue] = React.useState('');
  const [importPreview, setImportPreview] = React.useState(null);
  const [importing, setImporting] = React.useState(false);

  const refresh = React.useCallback(async () => {
    if (!FS) return;
    try {
      await FS.init('default');
      const nextNorms = await FS.listNorms();
      setNorms(nextNorms);
      setCustomTests(await FS.listTestDefs());
      setSelectedId(current => nextNorms.some(norm => norm.id === current) ? current : (nextNorms[0]?.id || null));
    } catch (error) {
      console.warn('norms refresh failed', error);
    }
  }, [FS]);
  React.useEffect(() => { refresh(); }, [refresh]);

  const catalog = React.useMemo(() => {
    const entries = [];
    groups.forEach(group => (group.metrics || []).filter(metric => !metric.computed).forEach(metric => entries.push({
      id: metric.id,
      label: metric.label,
      unit: metric.unit || '',
      dir: metric.dir || 'neutral',
      group: group.label || group.id || '未分类',
    })));
    customTests.forEach(test => entries.push({
      id: test.id,
      label: test.label,
      unit: test.unit || '',
      dir: test.dir || 'neutral',
      group: '自定义',
    }));
    const forceCatalog = window.ForceMetricCatalog || null;
    if (forceCatalog && typeof forceCatalog.listDefinitions === 'function') {
      const internals = window.__FORCE_TEST_INTERNALS__ || {};
      [
        { id: 'cmj', label: 'CMJ', definitions: internals.cmj?.ALL_SUMMARY_METRICS || [] },
        { id: 'sj', label: 'SJ', definitions: window.SJ_SUMMARY_METRICS || [] },
        { id: 'imtp', label: 'IMTP', definitions: window.IMTP_SUMMARY_METRICS || [] },
      ].forEach(testType => {
        const definitions = forceCatalog.listDefinitions(testType.id, testType.definitions);
        definitions.forEach(definition => entries.push({
          id: 'force:' + testType.id + ':' + definition.key,
          label: definition.label,
          unit: definition.unit || '',
          dir: definition.better === 'lower' ? 'lower' : (definition.better === 'higher' ? 'higher' : 'neutral'),
          group: '测力台 · ' + testType.label,
        }));
      });
    }
    const seen = new Set();
    return entries.filter(entry => {
      const id = String(entry.id);
      if (seen.has(id)) return false;
      seen.add(id);
      return true;
    });
  }, [groups, customTests]);
  const metricByKey = React.useMemo(() => new Map(catalog.map(metric => [String(metric.id), metric])), [catalog]);
  const documents = React.useMemo(() => RM ? RM.listKnowledgeDocuments(norms, catalog) : [], [RM, norms, catalog]);
  const filtered = React.useMemo(() => RM ? RM.queryKnowledgeDocuments(documents, {
    text: search,
    category,
    population: populationFilter,
    sourceStatus,
  }) : [], [RM, documents, search, category, populationFilter, sourceStatus]);
  const selected = documents.find(document => document.id === selectedId) || filtered[0] || documents[0] || null;
  const selectedRaw = norms.find(norm => norm.id === selected?.id) || null;
  const categories = Array.from(new Set(documents.map(document => document.metric.category)));
  const populations = Array.from(new Map(documents.map(document => [document.populationKey, {
    key: document.populationKey,
    label: document.populationLabel,
  }])).values());
  const sourceGroups = RM ? RM.groupBySource(documents) : [];
  const matrix = RM ? RM.buildCoverageMatrix(documents) : null;
  const sourceCount = sourceGroups.length;
  const incompleteCount = documents.filter(document => document.quality.status !== 'complete').length;
  const missingProvenance = selected ? [
    ...selected.quality.missingRequired,
    ...selected.quality.missingRecommended,
  ] : [];

  React.useEffect(() => {
    if (!selectedRaw) return;
    const stats = selectedRaw.stats || {};
    const defaultValue = stats.mean != null ? stats.mean : (stats.percentiles ? Object.values(stats.percentiles)[0] : '');
    setPreviewValue(defaultValue == null ? '' : String(defaultValue));
  }, [selectedId]);

  const compare = selectedRaw && FS
    ? FS.normCompare(selectedRaw, previewValue, selected?.metric.direction)
    : null;
  const percentile = compare?.percentile;

  const resolveMetricKey = value => {
    const text = String(value == null ? '' : value).trim();
    if (!text) return null;
    return catalog.find(metric => metric.id === text)?.id
      || catalog.find(metric => metric.label === text)?.id
      || text;
  };
  const parseCSV = async file => {
    if (!file || !window.XLSX) {
      window.alert('CSV 解析不可用');
      return;
    }
    try {
      const text = await file.text();
      const workbook = window.XLSX.read(text, { type: 'string' });
      const rows = window.XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], { defval: '' });
      const drafts = [];
      const errors = [];
      rows.forEach((row, index) => {
        const metricKey = resolveMetricKey(row.metric || row.metricKey || row.指标);
        const stats = {};
        if (row.mean !== '' && Number.isFinite(Number(row.mean))) stats.mean = Number(row.mean);
        if (row.sd !== '' && Number.isFinite(Number(row.sd))) stats.sd = Number(row.sd);
        const percentiles = {};
        Object.keys(row).forEach(key => {
          const match = String(key).match(/^p(\d+)$/i);
          if (match && row[key] !== '' && Number.isFinite(Number(row[key]))) percentiles['p' + match[1]] = Number(row[key]);
        });
        if (Object.keys(percentiles).length) stats.percentiles = percentiles;
        if (!metricKey || !Object.keys(stats).length) {
          errors.push('第 ' + (index + 2) + ' 行：缺少指标或有效统计量');
          return;
        }
        const population = {};
        [['sport', '运动项目'], ['level', '水平'], ['sex', '性别'], ['ageBand', '年龄段']].forEach(([key, zh]) => {
          const value = row[key] ?? row[zh];
          if (value !== '') population[key] = String(value);
        });
        const source = {};
        ['authors', 'year', 'title', 'doi', 'n', 'notes'].forEach(key => {
          if (row[key] !== undefined && row[key] !== '') source[key] = key === 'n' ? Number(row[key]) : row[key];
        });
        drafts.push({ metricKey, population, stats, source });
      });
      setImportPreview({ fileName: file.name, drafts, errors, total: rows.length });
    } catch (error) {
      window.alert('导入预检失败：' + error.message);
    }
  };
  const confirmImport = async () => {
    if (!importPreview?.drafts.length || importing) return;
    setImporting(true);
    let saved = 0;
    try {
      for (const draft of importPreview.drafts) {
        await FS.saveNorm(draft);
        saved += 1;
      }
      await refresh();
      setImportPreview(null);
      window.alert('导入完成：新增 ' + saved + ' 条');
    } catch (error) {
      window.alert('导入在第 ' + (saved + 1) + ' 条失败；已保存 ' + saved + ' 条。原因：' + error.message);
      await refresh();
    } finally {
      setImporting(false);
    }
  };
  const save = async draft => {
    await FS.saveNorm(draft);
    await refresh();
    setEditing(null);
  };
  const remove = async id => {
    if (!window.confirm('删除该常模记录？该操作不会改变历史测试结果。')) return;
    await FS.deleteNorm(id);
    await refresh();
  };
  const downloadTemplate = () => {
    const csv = 'metric,sport,level,sex,ageBand,mean,sd,p5,p25,p50,p75,p95,authors,year,title,doi,n,notes\\n';
    const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
    const anchor = document.createElement('a');
    anchor.href = url;
    anchor.download = 'axis-norm-template.csv';
    anchor.click();
    URL.revokeObjectURL(url);
  };
  const selectDocument = document => {
    setSelectedId(document.id);
    setView('library');
  };

  return (
    <main className="norm-axis-page">
      <style>{normCss}</style>
      <div className="norm-shell">
        <section className="norm-hero">
          <div>
            <div className="norm-kicker">KNOWLEDGE / NORM LIBRARY</div>
            <h1>常模证据库</h1>
            <p>管理机构私有常模，明确指标、适用人群与来源。这里提供可追溯的参考解释，不替代运动科学家的判断。</p>
          </div>
          <div className="norm-actions">
            <button type="button" className="norm-btn" onClick={downloadTemplate}>下载 CSV 模板</button>
            <label className="norm-btn" style={{ display: 'inline-grid', placeItems: 'center' }}>
              导入 CSV
              <input type="file" accept=".csv,.txt,.tsv" style={{ display: 'none' }} onChange={event => {
                const file = event.target.files?.[0];
                if (file) parseCSV(file);
                event.target.value = '';
              }} />
            </label>
            <button type="button" className="norm-btn primary" onClick={() => setEditing({})}>＋ 新建常模</button>
          </div>
        </section>

        <section className="norm-stats">
          <article className="norm-stat"><span>常模记录</span><b>{documents.length}</b><small>覆盖 {new Set(documents.map(document => document.metric.key)).size} 个指标</small></article>
          <article className="norm-stat"><span>适用人群</span><b>{populations.length}</b><small>项目 × 水平 × 性别 × 年龄</small></article>
          <article className="norm-stat"><span>来源集合</span><b>{sourceCount}</b><small>按来源字段聚合，不新增数据库</small></article>
          <article className="norm-stat"><span>来源待补</span><b className={incompleteCount ? 'warn' : ''}>{incompleteCount}</b><small>缺必填或建议来源字段</small></article>
        </section>

        {editing ? <NormForm catalog={catalog} existing={editing.id ? editing : null} onSave={save} onCancel={() => setEditing(null)} /> : null}
        {importPreview ? (
          <section className="norm-card norm-import">
            <h2>导入预检 · {importPreview.fileName}</h2>
            <p>确认前不会写入；通过行会沿用现有 FieldDataStore 保存路径。</p>
            <div className="norm-import-summary"><span>总行数 {importPreview.total}</span><span>可导入 {importPreview.drafts.length}</span><span>跳过 {importPreview.errors.length}</span></div>
            {importPreview.errors.length ? <div className="norm-import-errors">{importPreview.errors.map(error => <div key={error}>{error}</div>)}</div> : null}
            <div className="norm-form-actions">
              <button type="button" className="norm-btn" onClick={() => setImportPreview(null)}>取消</button>
              <button type="button" className="norm-btn primary" disabled={!importPreview.drafts.length || importing} onClick={confirmImport}>{importing ? '导入中…' : '确认导入'}</button>
            </div>
          </section>
        ) : null}

        <nav className="norm-tabs" aria-label="常模库视图">
          {[['library', '记录工作台'], ['datasets', '来源数据集'], ['matrix', '覆盖矩阵']].map(([id, label]) => (
            <button type="button" key={id} className={'norm-tab' + (view === id ? ' on' : '')} onClick={() => setView(id)}>{label}</button>
          ))}
        </nav>

        {view === 'library' ? (
          <section className="norm-workbench">
            <aside className="norm-card norm-filter">
              <input className="norm-search" value={search} onChange={event => setSearch(event.target.value)} placeholder="搜索指标、来源或人群…" />
              <div className="norm-filter-title">指标类别</div>
              <button type="button" className={'norm-filter-button' + (!category ? ' on' : '')} onClick={() => setCategory('')}>全部指标 <span>{documents.length}</span></button>
              {categories.map(item => <button type="button" key={item} className={'norm-filter-button' + (category === item ? ' on' : '')} onClick={() => setCategory(item)}>{item}<span>{documents.filter(document => document.metric.category === item).length}</span></button>)}
              <div className="norm-filter-title">来源状态</div>
              {[['', '全部状态'], ['complete', '来源完整'], ['partial', '建议补全'], ['incomplete', '关键字段缺失']].map(([id, label]) => <button type="button" key={label} className={'norm-filter-button' + (sourceStatus === id ? ' on' : '')} onClick={() => setSourceStatus(id)}>{label}<span>{id ? documents.filter(document => document.quality.status === id).length : documents.length}</span></button>)}
            </aside>
            <section className="norm-card">
              <header className="norm-card-head">
                <div><b>常模记录</b><small>{filtered.length} 条 · 选择一条查看来源与匹配预览</small></div>
                <div className="norm-tools">
                  <select className="norm-select" value={populationFilter} onChange={event => setPopulationFilter(event.target.value)}>
                    <option value="">全部人群</option>
                    {populations.map(item => <option key={item.key} value={item.key}>{item.label}</option>)}
                  </select>
                </div>
              </header>
              <div className="norm-table-head"><span>指标</span><span>适用人群</span><span>统计口径</span><span>来源</span></div>
              {filtered.length ? filtered.map(document => (
                <button type="button" key={document.id} className={'norm-record' + (selected?.id === document.id ? ' on' : '')} onClick={() => setSelectedId(document.id)}>
                  <span><b>{document.metric.label}</b><i className="norm-metric-badge">{document.metric.key} · {document.metric.unit || '无单位'}</i></span>
                  <span><b>{document.populationLabel}</b><small>{document.scope === 'global' ? '共享常模' : '机构私有'}</small></span>
                  <span><b>{normStatText(document.statistics, document.metric.unit)}</b><small>{document.statistics.mean != null ? 'Mean ± SD' : document.statistics.percentiles ? '百分位表' : '分级阈值'}</small></span>
                  <span><b className={'norm-state ' + document.quality.status}>{document.provenance.authors || document.provenance.title || '来源待补'}</b><small>{[document.provenance.year, document.provenance.n ? 'n=' + document.provenance.n : ''].filter(Boolean).join(' · ') || '缺年份 / 样本量'}</small></span>
                </button>
              )) : <div className="norm-empty">没有符合当前筛选的常模记录。</div>}
            </section>
            <aside className="norm-card norm-inspector">
              <header className="norm-card-head"><div><b>记录详情</b><small>{selected?.id || '未选择'}</small></div></header>
              {selected ? <div className="norm-inspector-body">
                <h2 className="norm-record-title">{selected.metric.label}</h2>
                <div className="norm-chips">{Object.values(selected.population).filter(Boolean).map(value => <span className="norm-chip" key={value}>{value}</span>)}</div>
                <div className="norm-facts">
                  <div className="norm-fact"><span>均值</span><b>{selected.statistics.mean ?? '—'} {selected.metric.unit}</b></div>
                  <div className="norm-fact"><span>标准差</span><b>{selected.statistics.sd ?? '—'} {selected.metric.unit}</b></div>
                  <div className="norm-fact"><span>样本量</span><b>{selected.provenance.n ?? '—'}</b></div>
                  <div className="norm-fact"><span>方向</span><b>{selected.metric.direction === 'lower' ? '越低越好' : selected.metric.direction === 'higher' ? '越高越好' : '中性指标'}</b></div>
                </div>
                <div className={'norm-provenance' + (selected.quality.status !== 'complete' ? ' warn' : '')}>
                  <b>{selected.provenance.authors || selected.provenance.title || '来源信息待补'}</b>
                  {[selected.provenance.year, selected.provenance.title, selected.provenance.doi && 'DOI ' + selected.provenance.doi].filter(Boolean).join(' · ') || '尚未记录可追溯来源'}
                  {missingProvenance.length ? <div>待补：{missingProvenance.join('、')}</div> : null}
                </div>
                <div className="norm-preview">
                  <div className="norm-preview-top">
                    <div><label>输入测试值</label><input className="norm-input" inputMode="decimal" value={previewValue} onChange={event => setPreviewValue(event.target.value)} /></div>
                    <div style={{ textAlign: 'right' }}><label>表现百分位</label><div className="norm-pct">{percentile != null ? 'P' + percentile : '—'}</div></div>
                  </div>
                  <div className="norm-scale"><i style={{ left: Math.max(2, Math.min(98, percentile ?? 50)) + '%' }} /></div>
                  <div className="norm-scale-labels"><span>P5</span><span>P25</span><span>P50</span><span>P75</span><span>P95</span></div>
                </div>
                <div className="norm-boundary"><b>解释边界：</b>百分位沿用现有 normCompare；现场匹配仍由项目、水平、性别与年龄段决定。这里不输出风险或训练建议。</div>
                <div className="norm-row-actions" style={{ marginTop: 13 }}>
                  <button type="button" className="norm-btn" onClick={() => setEditing(selectedRaw)}>编辑</button>
                  <button type="button" className="norm-btn danger" onClick={() => remove(selected.id)}>删除</button>
                </div>
              </div> : <div className="norm-empty">选择一条记录查看详情。</div>}
            </aside>
          </section>
        ) : null}

        {view === 'datasets' ? (
          <section>
            <div className="norm-dataset-grid">
              {sourceGroups.map(group => {
                const first = group.records[0];
                const labels = Array.from(new Set(group.records.flatMap(document => [document.metric.category, document.population.sport, document.population.level]).filter(Boolean))).slice(0, 5);
                return <article className="norm-card norm-dataset" key={group.key}>
                  <div className="norm-dataset-top"><div><h3>{group.label}{group.year ? ' · ' + group.year : ''}</h3><p>{group.title || '按相同作者、年份与 DOI 聚合的来源记录'}</p></div><div className="norm-dataset-count">{group.records.length}<small>records</small></div></div>
                  <div className="norm-chips">{labels.map(label => <span className="norm-chip" key={label}>{label}</span>)}</div>
                  <div className={'norm-provenance' + (group.status !== 'complete' ? ' warn' : '')}><b>{group.status === 'complete' ? '来源完整' : group.status === 'partial' ? '建议补全' : '关键来源待补'}</b>{group.doi ? 'DOI 已记录' : 'DOI 待补'} · {first?.provenance.n ? 'n=' + first.provenance.n : '样本量待补'}</div>
                  <div className="norm-dataset-foot"><span>{group.records.length} 条常模</span><button type="button" className="norm-btn" onClick={() => selectDocument(first)}>打开记录 →</button></div>
                </article>;
              })}
            </div>
            {!sourceGroups.length ? <div className="norm-card norm-empty">还没有可聚合的来源记录。</div> : null}
          </section>
        ) : null}

        {view === 'matrix' && matrix ? (
          <section className="norm-card">
            <header className="norm-card-head"><div><b>指标 × 适用人群矩阵</b><small>快速发现覆盖与缺口；每个单元格仍指向原始常模记录</small></div></header>
            <div className="norm-matrix-wrap">
              <div className="norm-matrix" style={{ gridTemplateColumns: '190px repeat(' + Math.max(1, matrix.populations.length) + ', minmax(180px,1fr))' }}>
                <div className="head">指标</div>
                {matrix.populations.map(population => <div className="head" key={population.key}>{population.label}</div>)}
                {matrix.metrics.map(metric => <React.Fragment key={metric.key}>
                  <div className="metric">{metric.label}<small>{metric.unit || '无单位'} · {metric.direction === 'lower' ? '越低越好' : metric.direction === 'higher' ? '越高越好' : '中性'}</small></div>
                  {matrix.populations.map(population => {
                    const records = matrix.get(metric.key, population.key);
                    return records.length ? <div className="norm-cell" key={population.key} role="button" tabIndex="0" onClick={() => selectDocument(records[0])} onKeyDown={event => { if (event.key === 'Enter') selectDocument(records[0]); }}><b>{normStatText(records[0].statistics, metric.unit)}</b><small>{records[0].provenance.authors || '来源待补'}{records.length > 1 ? ' · ' + records.length + ' 条' : ''}</small></div> : <div className="norm-cell empty" key={population.key}>暂无常模</div>;
                  })}
                </React.Fragment>)}
              </div>
            </div>
          </section>
        ) : null}
      </div>
    </main>
  );
}
