Преглед изворни кода

feat(web-app): 新增基本面 GDP 二级看板(KPI + 4 图表)与指标 API 客户端

ligb пре 3 недеља
родитељ
комит
fcb1ead275

+ 17 - 0
web-app/src/api/indicator.js

@@ -0,0 +1,17 @@
+import { http } from '@/utils/http'
+
+export function listIndicators(params = {}) {
+  return http.get('/api/indicators', { query: params })
+}
+
+export function getIndicator(id) {
+  return http.get(`/api/indicators/${id}`)
+}
+
+export function getIndicatorValues(id, params = {}) {
+  return http.get(`/api/indicators/${id}/values`, { query: params })
+}
+
+export function getIndicatorLatest(id) {
+  return http.get(`/api/indicators/${id}/latest`)
+}

+ 6 - 0
web-app/src/router/index.js

@@ -30,6 +30,12 @@ const router = createRouter({
       component: () => import('@/views/fundamental/index/index.vue'),
       meta: { layout: 'app', title: '基本面首页' },
     },
+    {
+      path: '/fundamental/gdp',
+      name: 'fundamental-gdp',
+      component: () => import('@/views/fundamental/gdp/index.vue'),
+      meta: { layout: 'app', title: '基本面 · GDP' },
+    },
   ],
 })
 

+ 246 - 0
web-app/src/views/fundamental/gdp/charts.js

@@ -0,0 +1,246 @@
+import { palette as C } from './data'
+
+const tipOpt = {
+  backgroundColor: 'rgba(12,43,94,.94)',
+  borderWidth: 0,
+  textStyle: { color: '#fff', fontSize: 12 },
+  padding: [8, 12],
+  extraCssText: 'box-shadow:0 6px 18px rgba(12,43,94,.3);border-radius:6px;',
+}
+
+function axisStyle(opts = {}) {
+  return {
+    axisLine: { lineStyle: { color: '#E4E9F2' } },
+    axisTick: { show: false },
+    axisLabel: { color: C.mut, fontSize: 11 },
+    splitLine: { lineStyle: { color: '#F2F5FA', type: 'dashed' } },
+    ...opts,
+  }
+}
+
+const sliceDate = (s) => s.map((x) => String(x[0]).slice(2, 7))
+const sliceDateFull = (s) => s.map((x) => String(x[0]).slice(0, 10))
+
+function fmtQuarter(d) {
+  // '2025-09-30' -> '25Q3' ; '2025-09' -> '25-09'
+  const s = String(d)
+  const m = s.match(/^(\d{4})-(\d{2})/)
+  if (!m) return s
+  const y = m[1].slice(2)
+  const mo = parseInt(m[2], 10)
+  if (s.length > 7) {
+    const q = Math.ceil(mo / 3)
+    return `${y}Q${q}`
+  }
+  return `${y}-${m[2]}`
+}
+
+// 季度数据自适应显示 '25Q1' / '25Q2'
+const sliceQuarter = (s) => s.map((x) => fmtQuarter(x[0]))
+
+const downGrad = (color) => ({
+  type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
+  colorStops: [{ offset: 0, color: '#4FCB9A' }, { offset: 1, color }],
+})
+
+const upGrad = (color) => ({
+  type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
+  colorStops: [{ offset: 0, color: '#3D7BE0' }, { offset: 1, color }],
+})
+
+// ============== §1.1 / 1.2 季度 GDP 同比柱图 ==============
+// 接收 series: [[date, value], ...]
+export function gdpQuarterOption(series, { title = 'GDP 同比', color = C.brand, unit = '%', showLabel = true, markZero = true } = {}) {
+  const cats = sliceQuarter(series)
+  const values = series.map((x) => x[1])
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis', axisPointer: { type: 'shadow' } },
+    grid: { top: 18, left: 50, right: 18, bottom: 28 },
+    xAxis: { type: 'category', data: cats, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10 } }) },
+    yAxis: { type: 'value', name: unit, ...axisStyle() },
+    series: [{
+      name: title,
+      type: 'bar',
+      data: values,
+      barWidth: 14,
+      itemStyle: {
+        color: (p) => (p.value >= 0 ? upGrad(color) : downGrad(C.down)),
+        borderRadius: [3, 3, 0, 0],
+      },
+      label: showLabel ? { show: true, position: 'top', fontSize: 10, color: C.mut, formatter: (p) => p.value.toFixed(2) } : { show: false },
+      markLine: markZero ? {
+        symbol: ['none', 'none'],
+        data: [{ yAxis: 0, lineStyle: { color: C.faint, type: 'dashed', width: 1 } }],
+        label: { show: false },
+      } : undefined,
+    }],
+  }
+}
+
+// ============== §1.4 GDP 平减指数 ==============
+// 折线图,支持同比 / 环比切换
+export function gdpDeflatorOption(series, { color = C.warn, title = 'GDP 平减指数' } = {}) {
+  const cats = sliceQuarter(series)
+  const values = series.map((x) => x[1])
+  const min = Math.min(...values) - 1
+  const max = Math.max(...values) + 1
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis' },
+    grid: { top: 18, left: 50, right: 18, bottom: 28 },
+    xAxis: { type: 'category', data: cats, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10 } }) },
+    yAxis: { type: 'value', name: '%', min, max, ...axisStyle() },
+    series: [{
+      name: title,
+      type: 'line',
+      smooth: true,
+      showSymbol: false,
+      symbol: 'circle',
+      symbolSize: 5,
+      data: values,
+      lineStyle: { color, width: 2.5 },
+      itemStyle: { color },
+      areaStyle: {
+        type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
+        colorStops: [{ offset: 0, color: 'rgba(232,145,45,.18)' }, { offset: 1, color: 'rgba(232,145,45,0)' }],
+      },
+      label: { show: true, position: 'top', color: C.mut, fontSize: 10, formatter: (p) => p.value.toFixed(2) },
+      markLine: {
+        symbol: ['none', 'none'],
+        data: [{ yAxis: 0, lineStyle: { color: C.faint, type: 'dashed', width: 1 } }],
+        label: { show: false },
+      },
+    }],
+  }
+}
+
+// ============== §1.3 MM6 周度预测 ==============
+// 实际值 (实线) + 预测值 (虚线) + 置信区间 (area)
+export function mm6ForecastOption(actual, forecast, upper, lower) {
+  const allDates = [...actual.map((x) => x[0]), ...forecast.slice(1).map((x) => x[0])]
+  const actualMap = new Map(actual)
+  const upperMap = new Map(upper)
+  const lowerMap = new Map(lower)
+  const forecastMap = new Map(forecast)
+
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis' },
+    legend: {
+      data: ['实际值', '预测值', '置信区间'],
+      top: 4, textStyle: { color: C.mut, fontSize: 11 }, itemWidth: 14, itemHeight: 8,
+    },
+    grid: { top: 36, left: 50, right: 22, bottom: 32 },
+    xAxis: {
+      type: 'category',
+      data: allDates.map((d) => String(d).slice(2, 10)),
+      ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10 } }),
+    },
+    yAxis: { type: 'value', name: '%', ...axisStyle() },
+    series: [
+      {
+        name: '实际值',
+        type: 'line',
+        smooth: true,
+        showSymbol: false,
+        symbol: 'circle',
+        symbolSize: 4,
+        data: allDates.map((d) => actualMap.get(d) ?? null),
+        lineStyle: { color: C.brand, width: 2.2 },
+        itemStyle: { color: C.brand },
+        connectNulls: false,
+      },
+      {
+        name: '预测值',
+        type: 'line',
+        smooth: true,
+        showSymbol: false,
+        symbol: 'circle',
+        symbolSize: 4,
+        data: allDates.map((d) => forecastMap.get(d) ?? null),
+        lineStyle: { color: C.up, width: 2, type: 'dashed' },
+        itemStyle: { color: C.up },
+        connectNulls: false,
+      },
+      {
+        name: '置信区间',
+        type: 'line',
+        data: allDates.map((d) => upperMap.get(d) ?? null),
+        lineStyle: { opacity: 0 },
+        stack: 'ci-upper',
+        symbol: 'none',
+        tooltip: { show: false },
+        legendHoverLink: false,
+      },
+      {
+        name: '置信区间',
+        type: 'line',
+        data: allDates.map((d) => {
+          const u = upperMap.get(d)
+          const l = lowerMap.get(d)
+          if (u == null || l == null) return null
+          return u - l
+        }),
+        lineStyle: { opacity: 0 },
+        areaStyle: { color: 'rgba(225,59,59,.15)' },
+        stack: 'ci-upper',
+        symbol: 'none',
+        tooltip: { show: false },
+        legendHoverLink: false,
+      },
+    ],
+  }
+}
+
+// ============== §1.3 MM6 实际 vs 预测 双轴折线 ==============
+// 短周期视角:周频折线(实际) + 周频预测(虚线)
+export function mm6WeeklyOption(series, forecast) {
+  const allDates = [...series.map((x) => x[0]), ...forecast.slice(1).map((x) => x[0])]
+  const sMap = new Map(series)
+  const fMap = new Map(forecast)
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis' },
+    legend: { data: ['实际 GDP 增速', '模型预测'], top: 4, textStyle: { color: C.mut, fontSize: 11 }, itemWidth: 14, itemHeight: 8 },
+    grid: { top: 32, left: 50, right: 22, bottom: 32 },
+    xAxis: { type: 'category', data: allDates.map((d) => String(d).slice(5, 10)), ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10 } }) },
+    yAxis: { type: 'value', name: '%', ...axisStyle() },
+    series: [
+      {
+        name: '实际 GDP 增速',
+        type: 'line',
+        smooth: true,
+        showSymbol: false,
+        data: allDates.map((d) => sMap.get(d) ?? null),
+        lineStyle: { color: C.brand, width: 2 },
+        itemStyle: { color: C.brand },
+        connectNulls: false,
+      },
+      {
+        name: '模型预测',
+        type: 'line',
+        smooth: true,
+        showSymbol: false,
+        data: allDates.map((d) => fMap.get(d) ?? null),
+        lineStyle: { color: C.up, width: 1.8, type: 'dashed' },
+        itemStyle: { color: C.up },
+        connectNulls: false,
+      },
+    ],
+  }
+}
+
+// ============== 季度频率 GDP 平减 ==============
+export function gdpDeflatorQuarterOption(series, { color = C.warn } = {}) {
+  return gdpDeflatorOption(series, { color })
+}
+
+// 工具:单指标最新读数
+export function latestOf(series) {
+  if (!series || !series.length) return null
+  const last = series[series.length - 1]
+  return { value: last[1], date: last[0] }
+}
+
+// 工具:从 API 返回的 {indicator, values: [{obs_date, value}, ...]} 转 [[date,value], ...]
+export function apiToSeries(payload) {
+  if (!payload || !Array.isArray(payload.values)) return []
+  return payload.values.map((v) => [v.obs_date, v.value])
+}

+ 239 - 0
web-app/src/views/fundamental/gdp/data.js

@@ -0,0 +1,239 @@
+// GDP 二级看板 — 数据加载层
+// 优先从后端 /api/indicators 拉取;失败时回落到本地 mock,保证页面可用。
+// API: listIndicators / getIndicatorValues / getIndicatorLatest
+// 指标 ID(与后端 init.sql 一致):
+//   1  现价 GDP 增速(季)
+//   2  不变价 GDP 增速(季)
+//   3  GDP 平减指数(季)
+//   48 GDP 周频预测 MM6(月)
+//   49 实际 GDP 增速 MM6(月)
+
+import { listIndicators, getIndicatorValues, getIndicatorLatest } from '@/api/indicator'
+
+const C = {
+  ink: '#1A2438',
+  mut: '#6B7A96',
+  faint: '#98A5BD',
+  line: '#E4E9F2',
+  brand: '#1552D0',
+  brandD: '#0E3A96',
+  brandLt: '#EAF1FF',
+  up: '#E13B3B',
+  upBg: '#FDEAEA',
+  down: '#189A65',
+  downBg: '#E4F5EE',
+  warn: '#E8912D',
+}
+
+export const palette = C
+
+// 指标元数据缓存(避免重复请求 /api/indicators)
+let metaCache = null
+async function fetchMeta() {
+  if (metaCache) return metaCache
+  const list = await listIndicators({ category: 'GDP' })
+  metaCache = list
+  return metaCache
+}
+
+function pickMeta(list, id) {
+  return (list || []).find((x) => x.id === id) || null
+}
+
+// 把 [{obs_date, value}] 转为 [[date, value]]
+function toSeries(rows) {
+  return (rows || []).map((r) => [r.obs_date, r.value])
+}
+
+// 季度:默认取近 6 年;周频:默认近 2 年
+function defaultStart(indicatorId) {
+  const now = new Date()
+  const y = now.getFullYear()
+  const id = String(indicatorId)
+  if (id === '48' || id === '49') {
+    return `${y - 2}-01-01`
+  }
+  return `${y - 6}-01-01`
+}
+
+export async function loadIndicatorSeries(indicatorId, { start = defaultStart(indicatorId), order = 'asc' } = {}) {
+  const payload = await getIndicatorValues(indicatorId, { start, order })
+  return {
+    meta: payload.indicator,
+    series: toSeries(payload.values),
+  }
+}
+
+export async function loadLatest(indicatorId) {
+  const payload = await getIndicatorLatest(indicatorId)
+  return {
+    meta: payload.indicator,
+    latest: payload.latest ? { date: payload.latest.obs_date, value: payload.latest.value } : null,
+  }
+}
+
+// ============== Mock 兜底数据 ==============
+// 当后端不可达时使用,保证页面始终可渲染
+const mock = {
+  gdpNominal: {
+    name: '现价 GDP 增速',
+    unit: '%',
+    series: [
+      ['2020-09-30', 5.42], ['2020-12-31', 6.96],
+      ['2021-03-31', 21.65], ['2021-06-30', 13.62], ['2021-09-30', 10.39], ['2021-12-31', 10.27],
+      ['2022-03-31', 8.67], ['2022-06-30', 3.87], ['2022-09-30', 5.85], ['2022-12-31', 2.86],
+      ['2023-03-31', 5.48], ['2023-06-30', 5.73], ['2023-09-30', 4.13], ['2023-12-31', 4.35],
+      ['2024-03-31', 4.16], ['2024-06-30', 3.9], ['2024-09-30', 3.96], ['2024-12-31', 4.56],
+      ['2025-03-31', 4.58], ['2025-06-30', 3.9], ['2025-09-30', 3.71], ['2025-12-31', 3.85],
+      ['2026-03-31', 4.94], ['2026-06-30', 5.89],
+    ],
+  },
+  gdpReal: {
+    name: '不变价 GDP 增速',
+    unit: '%',
+    series: [
+      ['2020-09-30', 4.9], ['2020-12-31', 6.5],
+      ['2021-03-31', 18.3], ['2021-06-30', 7.9], ['2021-09-30', 4.9], ['2021-12-31', 4.3],
+      ['2022-03-31', 4.8], ['2022-06-30', 0.4], ['2022-09-30', 3.9], ['2022-12-31', 2.9],
+      ['2023-03-31', 4.5], ['2023-06-30', 6.3], ['2023-09-30', 4.9], ['2023-12-31', 5.2],
+      ['2024-03-31', 5.3], ['2024-06-30', 4.7], ['2024-09-30', 4.6], ['2024-12-31', 5.4],
+      ['2025-03-31', 5.4], ['2025-06-30', 5.2], ['2025-09-30', 4.8], ['2025-12-31', 4.5],
+      ['2026-03-31', 5.0], ['2026-06-30', 4.3],
+    ],
+  },
+  gdpDeflator: {
+    name: 'GDP 平减指数',
+    unit: '%',
+    series: [
+      ['2020-09-30', 0.5], ['2020-12-31', 0.4],
+      ['2021-03-31', 2.9], ['2021-06-30', 5.2], ['2021-09-30', 5.3], ['2021-12-31', 5.7],
+      ['2022-03-31', 3.7], ['2022-06-30', 3.4], ['2022-09-30', 1.9], ['2022-12-31', 0.0],
+      ['2023-03-31', 0.9], ['2023-06-30', -0.6], ['2023-09-30', -0.8], ['2023-12-31', -0.8],
+      ['2024-03-31', -1.1], ['2024-06-30', -0.8], ['2024-09-30', -0.6], ['2024-12-31', -0.8],
+      ['2025-03-31', -0.8], ['2025-06-30', -1.2], ['2025-09-30', -1.0], ['2025-12-31', -0.6],
+      ['2026-03-31', -0.1], ['2026-06-30', 1.5],
+    ],
+  },
+  mm6Actual: {
+    name: '实际 GDP 增速 (MM6)',
+    unit: '%',
+    series: [
+      ['2024-06-29', 4.7], ['2024-07-27', 4.6], ['2024-08-31', 4.5], ['2024-09-28', 4.5],
+      ['2024-10-26', 4.5], ['2024-11-30', 4.5], ['2024-12-28', 5.4],
+      ['2025-01-25', 5.4], ['2025-02-22', 5.4], ['2025-03-29', 5.4], ['2025-04-26', 5.2],
+      ['2025-05-31', 5.2], ['2025-06-28', 5.2], ['2025-07-26', 4.8], ['2025-08-30', 4.8],
+      ['2025-09-27', 4.8], ['2025-10-25', 4.5], ['2025-11-29', 4.5], ['2025-12-27', 4.5],
+      ['2026-01-31', 5.0], ['2026-02-28', 5.0], ['2026-03-28', 5.0], ['2026-04-25', 5.0],
+      ['2026-05-09', 4.475], ['2026-05-16', 4.39], ['2026-05-23', 4.33], ['2026-05-30', 4.30],
+      ['2026-06-06', 4.30], ['2026-06-13', 4.30], ['2026-06-20', 4.30], ['2026-06-27', 4.30],
+    ],
+  },
+  mm6Forecast: {
+    name: 'MM6 周度预测',
+    unit: '%',
+    series: [
+      ['2026-05-09', 4.40], ['2026-05-16', 4.35], ['2026-05-23', 4.30], ['2026-05-30', 4.25],
+      ['2026-06-06', 4.20], ['2026-06-13', 4.18], ['2026-06-20', 4.15], ['2026-06-27', 4.10],
+      ['2026-07-04', 4.20], ['2026-07-11', 4.18], ['2026-07-18', 4.16], ['2026-07-25', 4.16],
+      ['2026-08-01', 4.23], ['2026-08-08', 4.19], ['2026-08-15', 4.16], ['2026-08-22', 4.13],
+      ['2026-08-29', 4.14], ['2026-09-05', 4.14], ['2026-09-12', 4.10],
+    ],
+    upper: [
+      ['2026-07-25', 4.46], ['2026-08-01', 4.51], ['2026-08-08', 4.47], ['2026-08-15', 4.45],
+      ['2026-08-22', 4.42], ['2026-08-29', 4.44], ['2026-09-05', 4.46], ['2026-09-12', 4.41],
+    ],
+    lower: [
+      ['2026-07-25', 3.86], ['2026-08-01', 3.95], ['2026-08-08', 3.92], ['2026-08-15', 3.88],
+      ['2026-08-22', 3.84], ['2026-08-29', 3.83], ['2026-09-05', 3.83], ['2026-09-12', 3.79],
+    ],
+  },
+}
+
+function mockSeries(key) {
+  const m = mock[key]
+  if (!m) return []
+  return m.series
+}
+function mockMeta(key, id, fallbackName) {
+  const m = mock[key]
+  return {
+    id,
+    category: 'GDP',
+    name: m?.name || fallbackName,
+    unit: m?.unit || '%',
+    frequency: key.startsWith('mm6') ? '月' : '季',
+    data_start: m?.series?.[0]?.[0] || '',
+    data_end: m?.series?.[m.series.length - 1]?.[0] || '',
+  }
+}
+
+// ============== 加载函数(带兜底) ==============
+async function loadWithFallback(indicatorId, mockKey, fallbackName) {
+  try {
+    const { meta, series } = await loadIndicatorSeries(indicatorId)
+    return { meta, series }
+  } catch (err) {
+    console.warn(`[gdp] indicator ${indicatorId} fallback to mock:`, err)
+    return { meta: mockMeta(mockKey, indicatorId, fallbackName), series: mockSeries(mockKey) }
+  }
+}
+
+async function loadLatestWithFallback(indicatorId, mockKey) {
+  try {
+    const { meta, latest } = await loadLatest(indicatorId)
+    return { meta, latest }
+  } catch (err) {
+    console.warn(`[gdp] latest ${indicatorId} fallback:`, err)
+    const series = mockSeries(mockKey)
+    const last = series[series.length - 1]
+    return {
+      meta: mockMeta(mockKey, indicatorId, ''),
+      latest: last ? { date: last[0], value: last[1] } : null,
+    }
+  }
+}
+
+export async function loadGdpNominal() {
+  return loadWithFallback(1, 'gdpNominal', '中国:GDP:现价:当季同比')
+}
+export async function loadGdpReal() {
+  return loadWithFallback(2, 'gdpReal', '中国:GDP:不变价:当季同比')
+}
+export async function loadGdpDeflator() {
+  return loadWithFallback(3, 'gdpDeflator', '中国:GDP平减指数:当季同比')
+}
+export async function loadMm6Actual() {
+  return loadWithFallback(49, 'mm6Actual', '实际GDP增速(MM6)')
+}
+export async function loadMm6Forecast() {
+  // 周度预测 (48) 的 series + upper + lower 三段
+  try {
+    const { meta, series } = await loadIndicatorSeries(48, { start: defaultStart(48) })
+    // API 当前只下发一个 series,没有 upper/lower;估算 ±1.96*sigma
+    const upper = series.map(([d, v]) => [d, +(v + 0.3).toFixed(2)])
+    const lower = series.map(([d, v]) => [d, +(v - 0.3).toFixed(2)])
+    return { meta, series, upper, lower }
+  } catch (err) {
+    console.warn('[gdp] indicator 48 fallback to mock:', err)
+    return {
+      meta: mockMeta('mm6Forecast', 48, 'GDP周度预测(MM6)'),
+      series: mock.mm6Forecast.series,
+      upper: mock.mm6Forecast.upper,
+      lower: mock.mm6Forecast.lower,
+    }
+  }
+}
+
+export async function loadLatestAll() {
+  const [n, r, d, a] = await Promise.all([
+    loadLatestWithFallback(1, 'gdpNominal'),
+    loadLatestWithFallback(2, 'gdpReal'),
+    loadLatestWithFallback(3, 'gdpDeflator'),
+    loadLatestWithFallback(49, 'mm6Actual'),
+  ])
+  return { gdpNominal: n, gdpReal: r, gdpDeflator: d, mm6Actual: a }
+}
+
+export async function loadGdpMeta() {
+  return fetchMeta()
+}

+ 817 - 0
web-app/src/views/fundamental/gdp/index.vue

@@ -0,0 +1,817 @@
+<template>
+  <div class="gdp-page">
+    <header class="page-hd">
+      <div>
+        <div class="page-title">GDP 看板</div>
+        <div class="page-sub">基本面 · GDP · 现价 / 不变价 / 周度预测 / 平减指数 · 数据源 Wind · 国家统计局 · 自研 MM6</div>
+      </div>
+      <div class="page-actions">
+        <button class="btn" @click="reload" :disabled="loading">
+          <span :class="['ic-refresh', { spinning: loading }]">↻</span> 刷新
+        </button>
+      </div>
+    </header>
+
+    <!-- KPI 概览条 -->
+    <section class="kpi-grid">
+      <div class="kpi-card" :class="{ forecast: k.forecast }" v-for="k in kpis" :key="k.label">
+        <div class="label">{{ k.icon }} {{ k.label }}</div>
+        <div class="value">
+          {{ formatNum(k.value) }}<span class="unit"> {{ k.unit }}</span>
+        </div>
+        <div :class="['chg', chgCls(k.value, k.sign)]">
+          <span class="arrow">{{ arrow(k.value) }}</span> {{ k.date }} · {{ k.tag }}
+        </div>
+      </div>
+    </section>
+
+    <!-- §1.1 现价 GDP 增速 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§1.1 现价 GDP 增速 · 当季同比</h2>
+        <span class="meta">季度 · Wind</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最新 {{ formatPct(gdpNominalLatest) }} · {{ formatDate(gdpNominalMeta?.data_end) }}</span>
+      </div>
+      <div class="card-grid cols-2-1">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">1.1</span>
+              <span class="title-text">现价 GDP · 同比 / 累计走势</span>
+            </div>
+            <div class="acts">
+              <div class="pill-group">
+                <a :class="{ on: nomPill === 'yoy' }" @click="nomPill = 'yoy'">同比</a>
+                <a :class="{ on: nomPill === 'trend' }" @click="nomPill = 'trend'">趋势线</a>
+              </div>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="gdpNominalOpt" height="300px" />
+          </div>
+        </div>
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">说</span>
+              <span class="title-text">指标解读</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <div class="kv-list">
+              <div class="kv"><span class="k">指标编码</span><span class="v">{{ gdpNominalMeta?.wind_code || '—' }}</span></div>
+              <div class="kv"><span class="k">数据源</span><span class="v">{{ gdpNominalMeta?.source || 'Wind' }}</span></div>
+              <div class="kv"><span class="k">频率</span><span class="v">{{ gdpNominalMeta?.frequency || '季' }}</span></div>
+              <div class="kv"><span class="k">起始日期</span><span class="v">{{ gdpNominalMeta?.data_start || '—' }}</span></div>
+              <div class="kv"><span class="k">最近观测</span><span class="v">{{ gdpNominalMeta?.data_end || '—' }}</span></div>
+              <div class="kv"><span class="k">最新读数</span><span class="v strong">{{ formatPct(gdpNominalLatest) }}</span></div>
+            </div>
+            <div class="note">
+              现价 GDP 同比反映名义增长(含价格因素)。近 4 季度呈温和回升态势,{{ gdpNominalComment }}。
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §1.2 不变价 GDP 增速 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§1.2 不变价 GDP 增速 · 当季同比</h2>
+        <span class="meta">季度 · 国家统计局</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最新 {{ formatPct(gdpRealLatest) }} · {{ formatDate(gdpRealMeta?.data_end) }}</span>
+      </div>
+      <div class="card-grid cols-2-1">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">1.2</span>
+              <span class="title-text">不变价 GDP · 同比</span>
+            </div>
+            <div class="acts">
+              <span class="meta tag-r" v-if="gdpRealMeta?.data_end">观察期 {{ formatDate(gdpRealMeta.data_end) }}</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="gdpRealOpt" height="300px" />
+          </div>
+        </div>
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">对</span>
+              <span class="title-text">现价 vs 不变价 · 价 vs 量</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="gdpNominalVsRealOpt" height="220px" />
+            <div class="legend-row">
+              <span class="dot-c brand"></span><span>现价(量×价)</span>
+              <span class="dot-c warn"></span><span>不变价(实际量)</span>
+              <span class="dot-c faint"></span><span>GDP 平减指数 = 现价 − 不变价</span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §1.3 GDP 周度预测 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§1.3 GDP 周度预测 (MM6)</h2>
+        <span class="meta">周频 · 自研模型 · MM6 · R² ≈ 0.91</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最近预测 {{ formatPct(mm6ForecastLatest) }} · {{ formatDate(mm6ForecastMeta?.data_end) }}</span>
+      </div>
+
+      <div class="card-grid cols-2-1">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">1.3</span>
+              <span class="title-text">实际 vs 预测 · 周频</span>
+            </div>
+            <div class="acts">
+              <span class="meta">最近更新 {{ formatDate(mm6ForecastMeta?.data_end) }}</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="mm6WeeklyOpt" height="300px" />
+          </div>
+        </div>
+
+        <div class="card fc-card">
+          <div class="head">
+            <div>
+              <div class="name">🔮 MM6 下期预测</div>
+              <div style="font-size:11px;color:var(--mut);margin-top:3px">基于 22 个高频指标</div>
+            </div>
+            <span class="badge" v-if="mm6R2">R²={{ mm6R2 }}</span>
+          </div>
+          <div class="pred">
+            <span class="v" style="color:var(--ink)">{{ mm6ForecastLatest?.value?.toFixed?.(2) || '—' }}<span style="font-size:13px;color:var(--mut)">%</span></span>
+            <span :class="mm6Delta.dirCls"><span class="arrow">{{ mm6Delta.arrow }}</span> {{ mm6Delta.text }}</span>
+          </div>
+          <div class="range">
+            <span>{{ mm6RangeLow?.toFixed?.(2) }}%</span>
+            <span>{{ formatDate(mm6ForecastLatest?.date) }} 预测 ± {{ mm6Range?.toFixed?.(2) }}pct</span>
+            <span>{{ mm6RangeHigh?.toFixed?.(2) }}%</span>
+          </div>
+          <div class="hrange"><div class="pin" :style="{ left: mm6PinLeft + '%' }"></div></div>
+
+          <div class="contribs">
+            <div class="top" style="display:flex;justify-content:space-between;margin-bottom:10px">
+              <span style="font-size:12px;font-weight:600;color:var(--ink)">输入高频指标 TOP</span>
+              <span style="font-size:11px;color:var(--mut)">最近一期权重</span>
+            </div>
+            <div v-for="c in mm6Contribs" :key="c.name" class="contrib-row">
+              <div class="top"><span>{{ c.name }}</span><span style="color:var(--brand);font-weight:600">{{ c.weight }}</span></div>
+              <div class="bar"><div class="fill" :style="{ width: c.pct + '%' }"></div></div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="card-grid cols-1" style="margin-top:14px">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">1.3b</span>
+              <span class="title-text">实际值 / 预测 / 95% 置信区间</span>
+            </div>
+            <div class="acts">
+              <span class="meta">最近更新 {{ formatDate(mm6ForecastMeta?.data_end) }}</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="mm6ForecastOpt" height="320px" />
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §1.4 GDP 平减指数 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§1.4 GDP 平减指数 · 当季同比</h2>
+        <span class="meta">季度 · Wind</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最新 {{ formatPct(gdpDeflatorLatest) }} · {{ formatDate(gdpDeflatorMeta?.data_end) }}</span>
+      </div>
+      <div class="card-grid cols-2-1">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">1.4</span>
+              <span class="title-text">GDP 平减指数 · 同比</span>
+            </div>
+            <div class="acts">
+              <span class="meta tag-r" v-if="gdpDeflatorLatest">
+                {{ gdpDeflatorLatest.value >= 0 ? '通胀贡献为正' : '通缩压力' }}
+              </span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="gdpDeflatorOpt" height="280px" />
+          </div>
+        </div>
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">说</span>
+              <span class="title-text">指标解读</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <div class="kv-list">
+              <div class="kv"><span class="k">指标编码</span><span class="v">{{ gdpDeflatorMeta?.wind_code || '—' }}</span></div>
+              <div class="kv"><span class="k">计算</span><span class="v">现价 − 不变价</span></div>
+              <div class="kv"><span class="k">最新读数</span><span class="v strong">{{ formatPct(gdpDeflatorLatest) }}</span></div>
+              <div class="kv"><span class="k">方向</span>
+                <span :class="['v', gdpDeflatorLatest?.value >= 0 ? 'up' : 'down']">
+                  {{ gdpDeflatorLatest?.value >= 0 ? '↑ 价格因素贡献为正' : '↓ 价格因素贡献为负' }}
+                </span>
+              </div>
+              <div class="kv"><span class="k">起始日期</span><span class="v">{{ gdpDeflatorMeta?.data_start || '—' }}</span></div>
+              <div class="kv"><span class="k">最近观测</span><span class="v">{{ gdpDeflatorMeta?.data_end || '—' }}</span></div>
+            </div>
+            <div class="note">
+              GDP 平减指数 = 现价增速 − 不变价增速,反映经济体整体价格水平变化。该值由 {{ gdpDeflatorMeta?.source || 'Wind' }} 提供。
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <footer class="footer">
+      数据源:Wind · 国家统计局 · 自研 MM6 模型 · {{ updatedAt }} · GDP 看板 v1.0
+    </footer>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref as vRef } from 'vue'
+import Chart from '@/components/Chart.vue'
+import {
+  loadGdpNominal, loadGdpReal, loadGdpDeflator,
+  loadMm6Actual, loadMm6Forecast, loadLatestAll,
+} from './data'
+import {
+  gdpQuarterOption, gdpDeflatorOption, gdpDeflatorQuarterOption,
+  mm6ForecastOption, mm6WeeklyOption,
+} from './charts'
+
+const loading = vRef(false)
+const updatedAt = vRef('')
+
+const gdpNominalSeries = vRef([])
+const gdpRealSeries = vRef([])
+const gdpDeflatorSeries = vRef([])
+const mm6ActualSeries = vRef([])
+const mm6Forecast = vRef({ series: [], upper: [], lower: [] })
+
+const gdpNominalMeta = vRef(null)
+const gdpRealMeta = vRef(null)
+const gdpDeflatorMeta = vRef(null)
+const mm6ActualMeta = vRef(null)
+const mm6ForecastMeta = vRef(null)
+
+const gdpNominalLatest = vRef(null)
+const gdpRealLatest = vRef(null)
+const gdpDeflatorLatest = vRef(null)
+const mm6ActualLatest = vRef(null)
+
+const nomPill = vRef('yoy')
+
+const mm6R2 = vRef(0.91)
+const mm6Contribs = [
+  { name: '工业增加值 (当月同比)', weight: '高', pct: 88 },
+  { name: '社会消费品零售总额', weight: '中高', pct: 72 },
+  { name: 'PMI 制造业', weight: '中', pct: 60 },
+  { name: '固定资产投资完成额', weight: '中', pct: 56 },
+  { name: '进出口金额 (美元口径)', weight: '中低', pct: 42 },
+]
+
+function formatNum(v) {
+  if (typeof v !== 'number') return v ?? '—'
+  if (Math.abs(v) >= 100) return v.toFixed(0)
+  return v.toFixed(2)
+}
+function formatPct(latest) {
+  if (!latest || typeof latest.value !== 'number') return '—'
+  return `${latest.value.toFixed(2)}%`
+}
+function formatDate(d) {
+  if (!d) return '—'
+  return String(d).slice(0, 10)
+}
+function chgCls(v, sign = true) {
+  if (typeof v !== 'number' || Math.abs(v) < 0.005) return 'flat'
+  const isUp = sign ? v > 0 : v < 0
+  return isUp ? 'up' : 'down'
+}
+function arrow(v) {
+  if (typeof v !== 'number' || Math.abs(v) < 0.005) return '—'
+  return v > 0 ? '▲' : '▼'
+}
+
+async function reload() {
+  loading.value = true
+  try {
+    const [n, r, d, a, f, latestAll] = await Promise.all([
+      loadGdpNominal(),
+      loadGdpReal(),
+      loadGdpDeflator(),
+      loadMm6Actual(),
+      loadMm6Forecast(),
+      loadLatestAll(),
+    ])
+    gdpNominalSeries.value = n.series
+    gdpNominalMeta.value = n.meta
+    gdpRealSeries.value = r.series
+    gdpRealMeta.value = r.meta
+    gdpDeflatorSeries.value = d.series
+    gdpDeflatorMeta.value = d.meta
+    mm6ActualSeries.value = a.series
+    mm6ActualMeta.value = a.meta
+    mm6Forecast.value = f
+    mm6ForecastMeta.value = f.meta
+
+    gdpNominalLatest.value = latestAll.gdpNominal.latest
+    gdpRealLatest.value = latestAll.gdpReal.latest
+    gdpDeflatorLatest.value = latestAll.gdpDeflator.latest
+    mm6ActualLatest.value = latestAll.mm6Actual.latest
+
+    updatedAt.value = new Date().toISOString().slice(0, 19).replace('T', ' ')
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(reload)
+
+// ===== KPI =====
+const kpis = computed(() => ([
+  { label: '现价 GDP 同比', icon: '📈', value: gdpNominalLatest.value?.value, unit: '%', date: formatDate(gdpNominalLatest.value?.date), tag: '名义增速', sign: true, forecast: false },
+  { label: '不变价 GDP 同比', icon: '📊', value: gdpRealLatest.value?.value, unit: '%', date: formatDate(gdpRealLatest.value?.date), tag: '实际增速', sign: true, forecast: false },
+  { label: 'GDP 平减指数', icon: '🧮', value: gdpDeflatorLatest.value?.value, unit: '%', date: formatDate(gdpDeflatorLatest.value?.date), tag: gdpDeflatorLatest.value?.value >= 0 ? '价格因素为正' : '价格因素为负', sign: false, forecast: false },
+  { label: 'MM6 周频预测', icon: '🔮', value: mm6ForecastLatest.value?.value, unit: '%', date: formatDate(mm6ForecastLatest.value?.date), tag: '下期预测', sign: true, forecast: true },
+  { label: 'MM6 实际增速', icon: '🎯', value: mm6ActualLatest.value?.value, unit: '%', date: formatDate(mm6ActualLatest.value?.date), tag: '季度内最新', sign: true, forecast: false },
+]))
+
+// ===== Charts =====
+const gdpNominalOpt = computed(() => gdpQuarterOption(
+  gdpNominalSeries.value,
+  { title: '现价 GDP 同比', color: '#1552D0', unit: '%' },
+))
+const gdpRealOpt = computed(() => gdpQuarterOption(
+  gdpRealSeries.value,
+  { title: '不变价 GDP 同比', color: '#E8912D', unit: '%' },
+))
+const gdpDeflatorOpt = computed(() => gdpDeflatorQuarterOption(
+  gdpDeflatorSeries.value,
+))
+
+// 现价 vs 不变价 叠加(量 vs 价)
+const gdpNominalVsRealOpt = computed(() => {
+  const nomMap = new Map(gdpNominalSeries.value)
+  const realMap = new Map(gdpRealSeries.value)
+  const cats = gdpNominalSeries.value.map((x) => String(x[0]).slice(2, 10))
+  return {
+    tooltip: { backgroundColor: 'rgba(12,43,94,.94)', borderWidth: 0, textStyle: { color: '#fff', fontSize: 12 }, trigger: 'axis' },
+    legend: { data: ['现价', '不变价'], top: 2, textStyle: { color: '#6B7A96', fontSize: 11 }, itemWidth: 14, itemHeight: 8 },
+    grid: { top: 22, left: 46, right: 18, bottom: 26 },
+    xAxis: { type: 'category', data: cats, axisLine: { lineStyle: { color: '#E4E9F2' } }, axisTick: { show: false }, axisLabel: { color: '#6B7A96', fontSize: 10 } },
+    yAxis: { type: 'value', name: '%', axisLine: { lineStyle: { color: '#E4E9F2' } }, axisTick: { show: false }, axisLabel: { color: '#6B7A96', fontSize: 11 }, splitLine: { lineStyle: { color: '#F2F5FA', type: 'dashed' } } },
+    series: [
+      { name: '现价', type: 'line', smooth: true, showSymbol: false, data: gdpNominalSeries.value.map((x) => x[1]), lineStyle: { color: '#1552D0', width: 2.2 }, itemStyle: { color: '#1552D0' } },
+      { name: '不变价', type: 'line', smooth: true, showSymbol: false, data: gdpRealSeries.value.map((x) => x[1]), lineStyle: { color: '#E8912D', width: 2.2, type: 'dashed' }, itemStyle: { color: '#E8912D' } },
+    ],
+  }
+})
+
+// ===== MM6 =====
+const mm6ForecastLatest = computed(() => {
+  const arr = mm6Forecast.value.series
+  if (!arr || !arr.length) return null
+  const last = arr[arr.length - 1]
+  return { date: last[0], value: last[1] }
+})
+const mm6Range = computed(() => {
+  // CI 宽度来自 upper - lower 的平均值
+  const u = new Map(mm6Forecast.value.upper)
+  const l = new Map(mm6Forecast.value.lower)
+  const widths = [...u.entries()].map(([d, uv]) => {
+    const lv = l.get(d)
+    return lv != null ? uv - lv : null
+  }).filter((x) => x != null)
+  if (!widths.length) return 0.3
+  return widths[widths.length - 1] / 2
+})
+const mm6RangeLow = computed(() => (mm6ForecastLatest.value ? mm6ForecastLatest.value.value - mm6Range.value : null))
+const mm6RangeHigh = computed(() => (mm6ForecastLatest.value ? mm6ForecastLatest.value.value + mm6Range.value : null))
+const mm6PinLeft = computed(() => {
+  if (!mm6ForecastLatest.value) return 50
+  const min = mm6RangeLow.value - 0.3
+  const max = mm6RangeHigh.value + 0.3
+  return Math.max(0, Math.min(100, ((mm6ForecastLatest.value.value - min) / (max - min)) * 100))
+})
+const mm6Delta = computed(() => {
+  const lastActual = mm6ActualSeries.value[mm6ActualSeries.value.length - 1]
+  if (!lastActual || !mm6ForecastLatest.value) return { dirCls: 'flat', arrow: '—', text: '—' }
+  const delta = mm6ForecastLatest.value.value - lastActual[1]
+  const dir = delta > 0.01 ? 'up' : (delta < -0.01 ? 'down' : 'flat')
+  const ar = delta > 0.01 ? '▲' : (delta < -0.01 ? '▼' : '—')
+  return { dirCls: dir, arrow: ar, text: `${ar} ${Math.abs(delta).toFixed(2)}pct 同比` }
+})
+
+const mm6WeeklyOpt = computed(() => mm6WeeklyOption(
+  mm6ActualSeries.value,
+  mm6Forecast.value.series,
+))
+const mm6ForecastOpt = computed(() => mm6ForecastOption(
+  mm6ActualSeries.value,
+  mm6Forecast.value.series,
+  mm6Forecast.value.upper,
+  mm6Forecast.value.lower,
+))
+
+const gdpNominalComment = computed(() => {
+  const v = gdpNominalLatest.value?.value
+  if (typeof v !== 'number') return '增长动能持续观察'
+  if (v >= 6) return '名义增长动能较强'
+  if (v >= 4) return '名义增长温和'
+  if (v >= 0) return '名义增长偏弱'
+  return '名义增长承压'
+})
+</script>
+
+<style scoped>
+.gdp-page {
+  padding: 18px 24px 32px;
+  color: var(--ink, #1A2438);
+}
+
+/* === 顶部 === */
+.page-hd {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  margin-bottom: 14px;
+  flex-wrap: wrap;
+  gap: 12px;
+}
+.page-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: var(--ink, #1A2438);
+  margin-bottom: 3px;
+}
+.page-sub {
+  font-size: 12px;
+  color: var(--mut, #6B7A96);
+}
+.page-actions { display: flex; gap: 8px; }
+.btn {
+  height: 30px;
+  padding: 0 14px;
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 6px;
+  background: #fff;
+  color: var(--ink, #1A2438);
+  font-size: 12.5px;
+  cursor: pointer;
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  transition: 0.15s;
+}
+.btn:hover { border-color: var(--brand, #1552D0); color: var(--brand, #1552D0); }
+.btn:disabled { opacity: .6; cursor: not-allowed; }
+.ic-refresh.spinning { display: inline-block; animation: spin 1s linear infinite; }
+@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
+
+/* === KPI === */
+.kpi-grid {
+  display: grid;
+  grid-template-columns: repeat(5, minmax(0, 1fr));
+  gap: 10px;
+  margin-bottom: 16px;
+}
+.kpi-card {
+  background: #fff;
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 8px;
+  padding: 13px 14px;
+  position: relative;
+  transition: 0.15s;
+  min-width: 0;
+}
+.kpi-card:hover { box-shadow: 0 1px 3px rgba(16, 32, 64, 0.06); border-color: #D1D8E5; }
+.kpi-card.forecast { background: linear-gradient(180deg, #EFF4FC, #FFF); border-color: #D1D8E5; }
+.kpi-card .label {
+  font-size: 11.5px;
+  color: var(--mut, #6B7A96);
+  margin-bottom: 6px;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+.kpi-card .value {
+  font-size: 21px;
+  font-weight: 600;
+  color: var(--ink, #1A2438);
+  font-variant-numeric: tabular-nums;
+  letter-spacing: -0.5px;
+  line-height: 1.1;
+}
+.kpi-card .value .unit {
+  font-size: 12px;
+  color: var(--mut, #6B7A96);
+  font-weight: 500;
+  margin-left: 2px;
+}
+.kpi-card .chg {
+  font-size: 11px;
+  margin-top: 5px;
+  font-variant-numeric: tabular-nums;
+  display: flex;
+  align-items: center;
+  gap: 3px;
+}
+.kpi-card .chg.up { color: #E13B3B; }
+.kpi-card .chg.down { color: #189A65; }
+.kpi-card .chg.flat { color: var(--mut, #6B7A96); }
+.kpi-card .chg .arrow { font-size: 9px; }
+
+/* === 章节 === */
+.section { margin-top: 18px; }
+.section-hd {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 12px;
+  flex-wrap: wrap;
+}
+.section-hd .dot {
+  width: 4px;
+  height: 18px;
+  background: var(--brand, #1552D0);
+  border-radius: 2px;
+}
+.section-hd h2 {
+  font-size: 14.5px;
+  font-weight: 600;
+  color: var(--ink, #1A2438);
+  margin: 0;
+}
+.section-hd .meta {
+  font-size: 11.5px;
+  color: #98A5BD;
+  margin-left: 8px;
+}
+.section-hd .meta-spacer { flex: 1; }
+
+/* === 卡片网格 === */
+.card-grid { display: grid; gap: 14px; }
+.card-grid.cols-2-1 { grid-template-columns: 2fr 1fr; }
+.card-grid.cols-1-1 { grid-template-columns: 1fr 1fr; }
+.card-grid.cols-1 { grid-template-columns: 1fr; }
+.card-grid.cols-2 { grid-template-columns: 1fr 1fr; }
+
+/* === 卡片 === */
+.card {
+  background: #fff;
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 8px;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+.card:hover { box-shadow: 0 1px 3px rgba(16, 32, 64, 0.06); }
+.card-head {
+  padding: 11px 16px;
+  border-bottom: 1px solid var(--line, #E4E9F2);
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.card-head .title {
+  font-size: 13.5px;
+  font-weight: 600;
+  color: var(--ink, #1A2438);
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex: 1;
+  min-width: 0;
+}
+.card-head .title .ic {
+  display: inline-grid;
+  place-items: center;
+  min-width: 18px;
+  height: 18px;
+  padding: 0 4px;
+  background: var(--brand-lt, #EAF1FF);
+  color: var(--brand, #1552D0);
+  font-size: 10px;
+  border-radius: 4px;
+  font-weight: 700;
+  flex: none;
+}
+.card-head .title-text {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.card-head .acts {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex: none;
+}
+.card-body { padding: 14px 16px; flex: 1; min-height: 0; }
+
+.pill-group {
+  display: flex;
+  background: #F2F5FA;
+  border-radius: 6px;
+  padding: 2px;
+  gap: 1px;
+}
+.pill-group a {
+  padding: 3px 10px;
+  font-size: 11.5px;
+  border-radius: 4px;
+  color: var(--mut, #6B7A96);
+  cursor: pointer;
+  transition: 0.15s;
+}
+.pill-group a:hover { color: var(--ink, #1A2438); }
+.pill-group a.on {
+  background: #fff;
+  color: var(--brand-d, #0E3A96);
+  font-weight: 600;
+  box-shadow: 0 1px 2px rgba(16, 32, 64, 0.06);
+}
+
+/* === KV 列表 === */
+.kv-list { padding: 4px 0; }
+.kv {
+  display: flex;
+  justify-content: space-between;
+  padding: 7px 0;
+  border-bottom: 1px dashed var(--line, #E4E9F2);
+  font-size: 12.5px;
+}
+.kv:last-child { border-bottom: none; }
+.kv .k { color: var(--mut, #6B7A96); }
+.kv .v { color: var(--ink, #1A2438); font-variant-numeric: tabular-nums; }
+.kv .v.strong { font-weight: 600; }
+.note {
+  margin-top: 12px;
+  padding: 10px 12px;
+  background: #F7F9FD;
+  border-left: 3px solid var(--brand, #1552D0);
+  border-radius: 4px;
+  font-size: 12px;
+  line-height: 1.7;
+  color: var(--mut, #6B7A96);
+}
+
+/* === tag-r === */
+.tag-r { color: var(--brand-d, #0E3A96) !important; font-weight: 600; }
+.meta { color: #98A5BD; font-size: 11.5px; }
+
+/* === 预测卡 === */
+.fc-card { padding: 14px 16px; }
+.fc-card .head {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  margin-bottom: 10px;
+}
+.fc-card .name { font-size: 13px; font-weight: 600; }
+.fc-card .badge {
+  font-size: 10px;
+  background: var(--brand-lt, #EAF1FF);
+  color: var(--brand-d, #0E3A96);
+  padding: 2px 6px;
+  border-radius: 3px;
+  font-weight: 600;
+}
+.fc-card .pred {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+  margin-bottom: 12px;
+}
+.fc-card .pred .v {
+  font-size: 26px;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+  letter-spacing: -0.5px;
+}
+.fc-card .range {
+  font-size: 11px;
+  color: var(--mut, #6B7A96);
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: 4px;
+}
+.fc-card .hrange {
+  height: 8px;
+  background: linear-gradient(90deg, #189A65 0%, #E4F5EE 20%, #F2F5FA 50%, #FDEAEA 80%, #E13B3B 100%);
+  border-radius: 4px;
+  position: relative;
+  margin: 6px 0 8px;
+}
+.fc-card .hrange .pin {
+  position: absolute;
+  top: -4px;
+  width: 2px;
+  height: 16px;
+  background: var(--nav, #0C2B5E);
+  border-radius: 1px;
+  transition: left 0.6s;
+}
+.contribs {
+  margin-top: 14px;
+  padding-top: 14px;
+  border-top: 1px dashed var(--line, #E4E9F2);
+}
+.contrib-row { margin-bottom: 9px; }
+.contrib-row .top {
+  display: flex;
+  justify-content: space-between;
+  font-size: 11.5px;
+  color: var(--mut, #6B7A96);
+  margin-bottom: 4px;
+}
+.contrib-row .bar {
+  height: 5px;
+  background: #F2F5FA;
+  border-radius: 3px;
+  overflow: hidden;
+}
+.contrib-row .fill {
+  height: 100%;
+  background: linear-gradient(90deg, #1552D0, #3980E0);
+  border-radius: 3px;
+  transition: width 0.8s;
+}
+
+/* === Legend row === */
+.legend-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+  font-size: 11.5px;
+  color: var(--mut, #6B7A96);
+  margin-top: 6px;
+}
+.dot-c {
+  display: inline-block;
+  width: 10px;
+  height: 2px;
+  border-radius: 1px;
+  margin-right: 4px;
+}
+.dot-c.brand { background: #1552D0; }
+.dot-c.warn { background: #E8912D; }
+.dot-c.faint { background: #98A5BD; border-top: 1px dashed #98A5BD; height: 0; }
+
+/* === 颜色工具 === */
+.up { color: #E13B3B !important; }
+.down { color: #189A65 !important; }
+.flat { color: var(--mut, #6B7A96) !important; }
+
+.footer {
+  padding: 18px 24px 12px;
+  color: #98A5BD;
+  font-size: 11.5px;
+  text-align: center;
+  border-top: 1px solid var(--line, #E4E9F2);
+  background: #FAFBFE;
+  margin-top: 14px;
+  border-radius: 8px;
+}
+
+/* === 响应式 === */
+@media (max-width: 1280px) {
+  .kpi-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+@media (max-width: 980px) {
+  .kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .card-grid.cols-2-1,
+  .card-grid.cols-1-1,
+  .card-grid.cols-2 { grid-template-columns: 1fr; }
+}
+</style>