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

feat(web-app): 新增基础研究页面(menu/Chart/fundamental),完善导航与路由

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

+ 26 - 0
package-lock.json

@@ -1435,6 +1435,16 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/echarts": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
+      "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "6.1.0"
+      }
+    },
     "node_modules/encoding-sniffer": {
       "version": "0.2.1",
       "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
@@ -3357,6 +3367,12 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD"
+    },
     "node_modules/typed-array-buffer": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@@ -3786,9 +3802,19 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/zrender": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
+      "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
+    },
     "web-app": {
       "version": "1.0.0",
       "dependencies": {
+        "echarts": "^6.1.0",
         "vue": "^3.4.38",
         "vue-router": "^4.4.5"
       },

+ 1 - 0
web-app/package.json

@@ -10,6 +10,7 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "echarts": "^6.1.0",
     "vue": "^3.4.38",
     "vue-router": "^4.4.5"
   },

+ 5 - 0
web-app/src/api/menu.js

@@ -0,0 +1,5 @@
+import { http } from '@/utils/http'
+
+export function fetchCurrentMenu() {
+  return http.get('/api/menu/current')
+}

+ 65 - 0
web-app/src/components/Chart.vue

@@ -0,0 +1,65 @@
+<template>
+  <div ref="elRef" class="echart-box" :style="{ height }"></div>
+</template>
+
+<script setup>
+import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
+import * as echarts from 'echarts'
+
+const props = defineProps({
+  option: { type: Object, required: true },
+  height: { type: String, default: '280px' },
+})
+
+const elRef = ref(null)
+let chart = null
+let resizeObs = null
+
+function init() {
+  if (!elRef.value) return
+  chart = echarts.init(elRef.value)
+  chart.setOption(props.option)
+}
+
+function dispose() {
+  if (resizeObs) {
+    resizeObs.disconnect()
+    resizeObs = null
+  }
+  if (chart) {
+    chart.dispose()
+    chart = null
+  }
+}
+
+onMounted(() => {
+  init()
+  resizeObs = new ResizeObserver(() => chart && chart.resize())
+  resizeObs.observe(elRef.value)
+  window.addEventListener('resize', handleResize)
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', handleResize)
+  dispose()
+})
+
+function handleResize() {
+  if (chart) chart.resize()
+}
+
+watch(
+  () => props.option,
+  (val) => {
+    if (chart && val) chart.setOption(val, true)
+  },
+  { deep: true },
+)
+</script>
+
+<style scoped>
+.echart-box {
+  width: 100%;
+  min-width: 0;
+}
+</style>

+ 49 - 54
web-app/src/components/SideNav.vue

@@ -1,75 +1,54 @@
 <template>
   <aside class="sidebar">
-    <div v-for="group in groups" :key="group.label" class="sb-section">
-      <div class="sb-label">{{ group.label }}</div>
+    <div v-if="topLabel" class="sb-label sb-top">{{ topLabel }}</div>
+
+    <div v-if="items.length" class="sb-section">
       <div
-        v-for="item in group.items"
-        :key="item.key"
+        v-for="item in items"
+        :key="item.id"
         :class="['sb-item', { active: isActive(item) }]"
         @click="onClick(item)"
       >
-        <span class="ico">{{ item.icon }}</span>
-        <span class="label">{{ item.label }}</span>
-        <span v-if="item.badge" class="num">{{ item.badge }}</span>
+        <span class="ico">●</span>
+        <span class="label">{{ item.name }}</span>
       </div>
-      <div v-if="group.divider" class="sb-divider"></div>
     </div>
+    <div v-else class="sb-placeholder">该分类暂无子菜单</div>
   </aside>
 </template>
 
 <script setup>
+import { computed, onMounted } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
+import { useMenu } from '@/store/menu'
 
 const router = useRouter()
 const route = useRoute()
+const { state, load, subMenus, activeTopMenu } = useMenu()
+
+const items = computed(() => subMenus.value)
+const topLabel = computed(() => activeTopMenu.value?.name || '')
 
-const groups = [
-  // {
-  //   label: '主导航',
-  //   divider: true,
-  //   items: [
-  //     { key: 'dashboard', label: '工作台', icon: '◉', to: '/' },
-  //     { key: 'board', label: '看板广场', icon: '▦' },
-  //     { key: 'watch', label: '自选股', icon: '★', badge: 12 },
-  //     { key: 'alert', label: '预警中心', icon: '⏰', badge: 3 },
-  //     { key: 'download', label: '数据下载', icon: '⬇' },
-  //   ],
-  // },
-  {
-    label: '基本面',
-    divider: true,
-    items: [
-      { key: 'macro', label: '宏观总览', icon: '●', to: '/fundamentals', match: 'fundamentals' },
-      { key: 'gdp', label: 'GDP 与增长', icon: '●' },
-      { key: 'invest', label: '投资分析', icon: '●' },
-      { key: 'trade', label: '进出口贸易', icon: '●' },
-      { key: 'consume', label: '消费与就业', icon: '●' },
-      { key: 'cpi', label: '通胀 (CPI/PPI)', icon: '●' },
-      { key: 'supply', label: '工业与供给', icon: '●' },
-      { key: 'pmi', label: '景气度 (PMI/BCI)', icon: '●' },
-    ],
-  },
-  // {
-  //   label: '模型工具',
-  //   divider: false,
-  //   items: [
-  //     { key: 'gdp-now', label: 'GDP 周频预测', icon: '⚙' },
-  //     { key: 'cpi-fcst', label: '通胀预测模型', icon: '⚙' },
-  //     { key: 'pmi-mix', label: 'PMI 合成', icon: '⚙' },
-  //   ],
-  // },
-]
+onMounted(() => {
+  if (!state.loaded) load()
+})
 
 function isActive(item) {
-  if (item.to) return route.path === item.to
-  if (item.match) return route.path.startsWith(`/${item.match}`)
-  return false
+  if (!item?.route) return false
+  return route.path === item.route || route.path.startsWith(`${item.route}/`)
 }
 
 function onClick(item) {
-  if (item.to) {
-    router.push(item.to)
-  }
+  if (!item?.route) return
+  if (route.path !== item.route) router.push(item.route)
+}
+
+function iconChar(item) {
+  if (!item?.icon) return '●'
+  const trimmed = String(item.icon).trim()
+  if (!trimmed) return '●'
+  // 简单把首字符作为图标占位(未引入图标库时退化为单字符)
+  return trimmed.charAt(0).toUpperCase()
 }
 </script>
 
@@ -96,6 +75,12 @@ function onClick(item) {
   letter-spacing: 1px;
 }
 
+.sb-top {
+  font-size: 12px;
+  color: rgba(255, 255, 255, 0.7);
+  letter-spacing: 0.5px;
+}
+
 .sb-item {
   display: flex;
   align-items: center;
@@ -127,10 +112,14 @@ function onClick(item) {
 .sb-item .ico {
   width: 14px;
   height: 14px;
-  font-size: 14px;
-  display: inline-grid;
-  place-items: center;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  line-height: 1;
   color: rgba(255, 255, 255, 0.6);
+  font-weight: 700;
+  flex: none;
 }
 
 .sb-item .label {
@@ -154,4 +143,10 @@ function onClick(item) {
   background: rgba(255, 255, 255, 0.1);
   margin: 8px 14px;
 }
-</style>
+
+.sb-placeholder {
+  padding: 12px 26px;
+  font-size: 12px;
+  color: rgba(255, 255, 255, 0.4);
+}
+</style>

+ 75 - 74
web-app/src/components/TopNavBar.vue

@@ -1,28 +1,21 @@
 <template>
   <header class="topbar">
     <div class="brand">
-      <div class="brand-logo">投</div>
-      <span class="brand-name">投研平台</span>
+      <SystemMark class="brand-logo" />
+      <span class="brand-name">投资研究系统</span>
     </div>
 
     <nav class="nav-main">
-      <a class="on">基本面</a>
-      <a>政策面</a>
-      <a>资金面</a>
-      <a>债市分析</a>
-      <a>权益分析</a>
-      <a>多资产</a>
-      <!-- <a>研报</a>
-      <a>模型工具</a> -->
+      <a
+        v-for="item in topMenus"
+        :key="item.id"
+        :class="{ on: item.id === activeId }"
+        @click="onSelect(item)"
+      >{{ item.name }}</a>
+      <span v-if="!loading && topMenus.length === 0" class="nav-empty">暂无菜单</span>
     </nav>
 
-    <div class="search-box">
-      <input placeholder="搜索股票/债券/基金/研报/指标..." />
-    </div>
-
     <div class="top-actions">
-      <div class="icon-btn">⚙</div>
-      <div class="icon-btn">🔔<span class="badge-dot"></span></div>
       <div class="user-info" @click="$emit('logout')">
         <div class="user-avatar">{{ avatarText }}</div>
         <div class="meta">
@@ -35,12 +28,61 @@
 </template>
 
 <script setup>
-import { computed } from 'vue'
+import { computed, onMounted, watch } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
 import { useAuth } from '@/store/auth'
+import { useMenu } from '@/store/menu'
+import SystemMark from '@/components/login/SystemMark.vue'
 
 defineEmits(['logout'])
 
+const router = useRouter()
+const route = useRoute()
 const { auth } = useAuth()
+const { state, load, setActive, activeTopMenu } = useMenu()
+
+const topMenus = computed(() => state.topMenus)
+const activeId = computed(() => state.activeId)
+const loading = computed(() => state.loading)
+
+onMounted(() => {
+  if (!state.loaded) load()
+})
+
+let didInitNav = false
+watch(
+  () => state.loaded,
+  (loaded) => {
+    if (!loaded || didInitNav) return
+    didInitNav = true
+    const first = state.topMenus[0]
+    if (!first) return
+    let target = null
+    if (first.route) {
+      target = first
+    } else if (Array.isArray(first.children) && first.children.length > 0) {
+      target = first.children[0]
+    }
+    if (target?.route && route.path !== target.route) {
+      router.replace(target.route)
+    }
+  },
+  { immediate: true },
+)
+
+function onSelect(item) {
+  setActive(item.id)
+  const active = activeTopMenu.value
+  if (!active) return
+  if (active.route) {
+    router.push(active.route)
+    return
+  }
+  const firstChild = Array.isArray(active.children) ? active.children[0] : null
+  if (firstChild?.route && route.path !== firstChild.route) {
+    router.push(firstChild.route)
+  }
+}
 
 const displayName = computed(() => auth.account)
 const avatarText = computed(() => (auth.account ? auth.account.charAt(0) : '空'))
@@ -80,14 +122,12 @@ const roleText = ''
 .brand-logo {
   width: 30px;
   height: 30px;
-  background: linear-gradient(135deg, var(--brand), #0c2b5e);
-  border-radius: 7px;
-  display: grid;
-  place-items: center;
-  color: #fff;
-  font-size: 14px;
-  font-weight: 700;
-  letter-spacing: 0.5px;
+  display: block;
+  flex: none;
+}
+.brand-logo.system-mark {
+  width: 30px;
+  height: 30px;
 }
 
 .brand-name {
@@ -98,7 +138,10 @@ const roleText = ''
 .nav-main {
   display: flex;
   gap: 6px;
-  margin-left: 18px;
+  margin-left: 50px;
+  flex: 1;
+  min-width: 0;
+  overflow-x: auto;
 }
 
 .nav-main a {
@@ -108,6 +151,7 @@ const roleText = ''
   color: var(--mut);
   transition: 0.15s;
   cursor: pointer;
+  white-space: nowrap;
 }
 
 .nav-main a:hover {
@@ -121,53 +165,10 @@ const roleText = ''
   font-weight: 600;
 }
 
-.search-box {
-  position: relative;
-  flex: 0 1 300px;
-  margin-left: 14px;
-}
-
-.search-box input {
-  width: 100%;
-  height: 32px;
-  border: 1px solid var(--line);
-  border-radius: 6px;
-  padding: 0 12px 0 32px;
-  background: #fafbfe;
-  font-size: 12.5px;
-  font-family: inherit;
-  outline: none;
-  color: var(--ink);
-  transition: 0.15s;
-}
-
-.search-box input:focus {
-  border-color: var(--brand);
-  background: #fff;
-  box-shadow: 0 0 0 3px rgba(21, 82, 208, 0.08);
-}
-
-.search-box::before {
-  content: '';
-  position: absolute;
-  left: 11px;
-  top: 50%;
-  transform: translateY(-50%);
-  width: 14px;
-  height: 14px;
-  border: 1.6px solid var(--mut);
-  border-radius: 50%;
-}
-
-.search-box::after {
-  content: '';
-  position: absolute;
-  left: 6px;
-  top: 11px;
-  width: 7px;
-  height: 1.6px;
-  background: var(--mut);
-  transform: rotate(45deg);
+.nav-empty {
+  padding: 7px 14px;
+  font-size: 12px;
+  color: var(--faint);
 }
 
 .top-actions {
@@ -240,4 +241,4 @@ const roleText = ''
   font-size: 10.5px;
   color: var(--faint);
 }
-</style>
+</style>

+ 4 - 4
web-app/src/components/login/SystemMark.vue

@@ -29,8 +29,8 @@
   margin: 0 auto;
 }
 
-.ms__outer { stroke: var(--login-gold); stroke-opacity: 0.7; stroke-width: 1.2; }
-.ms__dash  { stroke: var(--login-gold); stroke-opacity: 0.3; stroke-width: 0.6; stroke-dasharray: 2 2; }
-.ms__dots  { fill: var(--login-gold); }
-.ms__core  { fill: var(--login-gold); }
+.ms__outer { stroke: var(--login-gold, #C8A95F); stroke-opacity: 0.7; stroke-width: 1.2; }
+.ms__dash  { stroke: var(--login-gold, #C8A95F); stroke-opacity: 0.3; stroke-width: 0.6; stroke-dasharray: 2 2; }
+.ms__dots  { fill: var(--login-gold, #C8A95F); }
+.ms__core  { fill: var(--login-gold, #C8A95F); }
 </style>

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

@@ -12,18 +12,24 @@ const router = createRouter({
       component: () => import('@/views/Login.vue'),
       meta: { layout: 'blank', public: true, title: '登录' },
     },
-    {
-      path: '/',
-      name: 'home',
-      component: () => import('@/views/Home.vue'),
-      meta: { layout: 'app', title: '工作台' },
-    },
+    // {
+    //   path: '/',
+    //   name: 'home',
+    //   component: () => import('@/views/Home.vue'),
+    //   meta: { layout: 'app', title: '工作台' },
+    // },
     {
       path: '/list',
       name: 'list',
       component: () => import('@/views/List.vue'),
       meta: { layout: 'app', title: '业务列表' },
     },
+    {
+      path: '/fundamental/index',
+      name: 'fundamental-index',
+      component: () => import('@/views/fundamental/index/index.vue'),
+      meta: { layout: 'app', title: '基本面首页' },
+    },
   ],
 })
 

+ 60 - 0
web-app/src/store/menu.js

@@ -0,0 +1,60 @@
+import { reactive, computed } from 'vue'
+import { fetchCurrentMenu } from '@/api/menu'
+
+const state = reactive({
+  loaded: false,
+  loading: false,
+  error: null,
+  topMenus: [],
+  activeId: null,
+})
+
+function bySort(a, b) {
+  return (a?.sort ?? 0) - (b?.sort ?? 0)
+}
+
+function sortTree(list) {
+  if (!Array.isArray(list)) return []
+  return list
+    .slice()
+    .sort(bySort)
+    .map((m) => ({ ...m, children: Array.isArray(m.children) ? sortTree(m.children) : [] }))
+}
+
+export function useMenu() {
+  async function load(force = false) {
+    if (state.loading) return
+    if (state.loaded && !force) return
+    state.loading = true
+    state.error = null
+    try {
+      const data = await fetchCurrentMenu()
+      const list = Array.isArray(data) ? data : []
+      state.topMenus = sortTree(list)
+      state.loaded = true
+      if (!state.topMenus.some((m) => m.id === state.activeId)) {
+        state.activeId = state.topMenus[0]?.id ?? null
+      }
+    } catch (err) {
+      state.error = err
+    } finally {
+      state.loading = false
+    }
+  }
+
+  function setActive(id) {
+    if (state.activeId === id) return
+    state.activeId = id
+  }
+
+  const activeTopMenu = computed(
+    () => state.topMenus.find((m) => m.id === state.activeId) ?? null,
+  )
+
+  const subMenus = computed(() => {
+    const cur = activeTopMenu.value
+    return cur && Array.isArray(cur.children) ? cur.children : []
+  })
+
+  return { state, load, setActive, activeTopMenu, subMenus }
+}

+ 429 - 0
web-app/src/views/fundamental/index/charts.js

@@ -0,0 +1,429 @@
+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,
+  }
+}
+
+function dirColor(v, palette = { up: C.up, down: C.down, zero: C.mut }) {
+  if (typeof v !== 'number' || Math.abs(v) < 0.005) return palette.zero
+  return v > 0 ? palette.up : palette.down
+}
+
+const sliceDate = (s) => s.map((x) => x[0].slice(2))
+
+// ============== §1.1 GDP 现价 当季同比 ==============
+export function gdpNominalOption(series) {
+  const cats = sliceDate(series)
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis' },
+    grid: { top: 24, left: 50, right: 18, bottom: 28 },
+    xAxis: { type: 'category', data: cats, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10 } }) },
+    yAxis: { type: 'value', name: '% YoY', ...axisStyle() },
+    series: [{
+      name: 'GDP 现价同比',
+      type: 'line',
+      smooth: true,
+      showSymbol: false,
+      symbol: 'circle',
+      symbolSize: 5,
+      data: series,
+      lineStyle: { color: C.brand, width: 2.5 },
+      itemStyle: { color: C.brand },
+      areaStyle: {
+        type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
+        colorStops: [{ offset: 0, color: 'rgba(21,82,208,.18)' }, { offset: 1, color: 'rgba(21,82,208,0)' }],
+      },
+      label: { show: true, position: 'top', color: C.mut, fontSize: 10, formatter: (p) => p.value.toFixed(1) },
+      markLine: {
+        symbol: ['none', 'none'],
+        data: [{ yAxis: 0, lineStyle: { color: C.faint, type: 'dashed', width: 1 } }],
+        label: { show: false },
+      },
+    }],
+  }
+}
+
+// ============== §1.x GDP 模型预测 ==============
+// 实际值 (实线) + 预测值 (虚线) + 置信区间 (area)
+export function gdpForecastOption(actual, forecast, upper, lower) {
+  // 合并 x 轴
+  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,
+    },
+    grid: { top: 32, left: 48, right: 18, bottom: 28 },
+    xAxis: {
+      type: 'category',
+      data: allDates.map((d) => d.slice(2, 7)),
+      ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10 } }),
+    },
+    yAxis: { type: 'value', name: '%', ...axisStyle() },
+    series: [
+      {
+        name: '实际值',
+        type: 'line',
+        smooth: true,
+        showSymbol: false,
+        symbol: 'circle',
+        symbolSize: 5,
+        data: allDates.map((d) => actualMap.get(d) ?? null),
+        lineStyle: { color: C.brand, width: 2.5 },
+        itemStyle: { color: C.brand },
+        connectNulls: false,
+      },
+      {
+        name: '预测值',
+        type: 'line',
+        smooth: true,
+        showSymbol: false,
+        symbol: 'circle',
+        symbolSize: 5,
+        data: allDates.map((d) => forecastMap.get(d) ?? null),
+        lineStyle: { color: C.up, width: 2.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,
+      },
+    ],
+  }
+}
+
+// ============== §2.1 固定资产投资分项 · 累计同比 ==============
+export function fixedInvestmentOption(data) {
+  const cats = data.categories.map((c) => c.slice(2))
+  const palette = [
+    { name: '固投总体', color: C.brand },
+    { name: '地产', color: C.up },
+    { name: '基建', color: C.warn },
+    { name: '制造业', color: C.down },
+    { name: '高技术', color: C.brandD },
+  ]
+  const keys = ['total', 'estate', 'infra', 'mfg', 'hiTech']
+  const series = keys.map((k, i) => ({
+    name: palette[i].name,
+    type: 'line',
+    smooth: true,
+    showSymbol: false,
+    symbol: 'circle',
+    symbolSize: 4,
+    data: data.series[k],
+    lineStyle: { color: palette[i].color, width: 2 },
+    itemStyle: { color: palette[i].color },
+  }))
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis' },
+    legend: {
+      data: palette.map((p) => p.name),
+      top: 4, textStyle: { color: C.mut, fontSize: 11 }, itemWidth: 14,
+    },
+    grid: { top: 32, left: 50, right: 18, bottom: 28 },
+    xAxis: { type: 'category', data: cats, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10 } }) },
+    yAxis: { type: 'value', name: '% YoY', ...axisStyle() },
+    series,
+  }
+}
+
+// ============== §3.1 出口金额 vs 出口增速 (双轴) ==============
+export function exportsDualOption(data) {
+  const cats = data.categories.map((c) => c.slice(2))
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis', axisPointer: { type: 'shadow' } },
+    legend: {
+      data: ['出口金额(亿美元)', '出口同比 %'],
+      top: 6, textStyle: { color: C.mut, fontSize: 11 }, itemWidth: 14, itemHeight: 8,
+    },
+    grid: { top: 32, left: 56, right: 56, bottom: 28 },
+    xAxis: { type: 'category', data: cats, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10, hideOverlap: true } }) },
+    yAxis: [
+      { type: 'value', name: '亿美元', ...axisStyle({ nameTextStyle: { color: C.mut, fontSize: 10 }, nameGap: 14 }) },
+      { type: 'value', name: '% YoY', ...axisStyle({ nameTextStyle: { color: C.mut, fontSize: 10 }, nameGap: 14 }) },
+    ],
+    series: [
+      {
+        name: '出口金额(亿美元)',
+        type: 'bar',
+        data: data.amount,
+        barWidth: 12,
+        itemStyle: {
+          color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: '#3D7BE0' }, { offset: 1, color: '#1552D0' }] },
+          borderRadius: [3, 3, 0, 0],
+        },
+      },
+      {
+        name: '出口同比 %',
+        type: 'line',
+        smooth: true,
+        showSymbol: false,
+        symbol: 'circle',
+        symbolSize: 5,
+        data: data.yoy,
+        yAxisIndex: 1,
+        lineStyle: { color: C.up, width: 2.2 },
+        itemStyle: { color: C.up },
+      },
+    ],
+  }
+}
+
+// ============== §4.1 社会消费品零售总额 · 当月同比 ==============
+export function retailOption(data) {
+  const cats = data.categories.map((c) => c.slice(2))
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis' },
+    grid: { top: 18, left: 46, right: 18, bottom: 28 },
+    xAxis: { type: 'category', data: cats, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10, hideOverlap: true } }) },
+    yAxis: { type: 'value', name: '%', ...axisStyle() },
+    series: [{
+      name: '社零当月同比',
+      type: 'bar',
+      data: data.values,
+      barWidth: 12,
+      itemStyle: {
+        color: (p) => {
+          if (p.value >= 0) return { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: '#3D7BE0' }, { offset: 1, color: '#1552D0' }] }
+          return { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: '#4FCB9A' }, { offset: 1, color: '#189A65' }] }
+        },
+        borderRadius: [3, 3, 0, 0],
+      },
+      label: { show: true, position: 'top', fontSize: 10, color: C.mut, formatter: (p) => p.value.toFixed(1) },
+      markLine: {
+        symbol: ['none', 'none'],
+        data: [{ yAxis: 0, lineStyle: { color: C.faint, type: 'dashed', width: 1 } }],
+        label: { show: false },
+      },
+    }],
+  }
+}
+
+// ============== §5.1 CPI 当月同比 / 环比 ==============
+export function cpiDualOption(data) {
+  const cats = data.categories.map((c) => c.slice(2))
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis' },
+    legend: {
+      data: ['CPI 同比', 'CPI 环比'],
+      top: 6, textStyle: { color: C.mut, fontSize: 11 }, itemWidth: 14, itemHeight: 8,
+    },
+    grid: { top: 32, left: 46, right: 46, bottom: 28 },
+    xAxis: { type: 'category', data: cats, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10, hideOverlap: true } }) },
+    yAxis: [
+      { type: 'value', name: '同比 %', ...axisStyle({ nameTextStyle: { color: C.mut, fontSize: 10 }, nameGap: 14 }) },
+      { type: 'value', name: '环比 %', ...axisStyle({ nameTextStyle: { color: C.mut, fontSize: 10 }, nameGap: 14 }) },
+    ],
+    series: [
+      {
+        name: 'CPI 同比',
+        type: 'line',
+        smooth: true,
+        showSymbol: false,
+        symbol: 'circle',
+        symbolSize: 5,
+        data: data.yoy,
+        lineStyle: { color: C.up, width: 2.2 },
+        itemStyle: { color: C.up },
+        areaStyle: {
+          type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
+          colorStops: [{ offset: 0, color: 'rgba(225,59,59,.14)' }, { offset: 1, color: 'rgba(225,59,59,0)' }],
+        },
+      },
+      {
+        name: 'CPI 环比',
+        type: 'line',
+        smooth: true,
+        showSymbol: false,
+        symbol: 'circle',
+        symbolSize: 4,
+        data: data.mom,
+        yAxisIndex: 1,
+        lineStyle: { color: C.brand, width: 2, type: 'dashed' },
+        itemStyle: { color: C.brand },
+      },
+    ],
+  }
+}
+
+// ============== §5.3 PPI 当月同比 / 环比 ==============
+export function ppiDualOption(data) {
+  const cats = data.categories.map((c) => c.slice(2))
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis' },
+    legend: {
+      data: ['PPI 同比', 'PPI 环比'],
+      top: 6, textStyle: { color: C.mut, fontSize: 11 }, itemWidth: 14, itemHeight: 8,
+    },
+    grid: { top: 32, left: 46, right: 46, bottom: 28 },
+    xAxis: { type: 'category', data: cats, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10, hideOverlap: true } }) },
+    yAxis: [
+      { type: 'value', name: '同比 %', ...axisStyle({ nameTextStyle: { color: C.mut, fontSize: 10 }, nameGap: 14 }) },
+      { type: 'value', name: '环比 %', ...axisStyle({ nameTextStyle: { color: C.mut, fontSize: 10 }, nameGap: 14 }) },
+    ],
+    series: [
+      {
+        name: 'PPI 同比',
+        type: 'line',
+        smooth: true,
+        showSymbol: false,
+        symbol: 'circle',
+        symbolSize: 5,
+        data: data.yoy,
+        lineStyle: { color: C.brand, width: 2.2 },
+        itemStyle: { color: C.brand },
+        areaStyle: {
+          type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
+          colorStops: [{ offset: 0, color: 'rgba(21,82,208,.14)' }, { offset: 1, color: 'rgba(21,82,208,0)' }],
+        },
+      },
+      {
+        name: 'PPI 环比',
+        type: 'line',
+        smooth: true,
+        showSymbol: false,
+        symbol: 'circle',
+        symbolSize: 4,
+        data: data.mom,
+        yAxisIndex: 1,
+        lineStyle: { color: C.warn, width: 2, type: 'dashed' },
+        itemStyle: { color: C.warn },
+      },
+    ],
+  }
+}
+
+// ============== §6.1 工业增加值 同比 / 环比 ==============
+export function industrialDualOption(data) {
+  const cats = data.categories.map((c) => c.slice(2))
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis' },
+    legend: {
+      data: ['工业增加值同比', '环比(季调)'],
+      top: 6, textStyle: { color: C.mut, fontSize: 11 }, itemWidth: 14, itemHeight: 8,
+    },
+    grid: { top: 32, left: 46, right: 46, bottom: 28 },
+    xAxis: { type: 'category', data: cats, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10, hideOverlap: true } }) },
+    yAxis: [
+      { type: 'value', name: '同比 %', ...axisStyle({ nameTextStyle: { color: C.mut, fontSize: 10 }, nameGap: 14 }) },
+      { type: 'value', name: '环比 %', ...axisStyle({ nameTextStyle: { color: C.mut, fontSize: 10 }, nameGap: 14 }) },
+    ],
+    series: [
+      {
+        name: '工业增加值同比',
+        type: 'line',
+        smooth: true,
+        showSymbol: false,
+        symbol: 'circle',
+        symbolSize: 5,
+        data: data.yoy,
+        lineStyle: { color: C.brand, width: 2.2 },
+        itemStyle: { color: C.brand },
+        areaStyle: {
+          type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
+          colorStops: [{ offset: 0, color: 'rgba(21,82,208,.14)' }, { offset: 1, color: 'rgba(21,82,208,0)' }],
+        },
+      },
+      {
+        name: '环比(季调)',
+        type: 'line',
+        smooth: true,
+        showSymbol: false,
+        symbol: 'circle',
+        symbolSize: 4,
+        data: data.mom,
+        yAxisIndex: 1,
+        lineStyle: { color: C.warn, width: 2, type: 'dashed' },
+        itemStyle: { color: C.warn },
+      },
+    ],
+  }
+}
+
+// ============== §7.3 制造业 PMI ==============
+export function pmiOption(data) {
+  const cats = data.categories.map((c) => c.slice(2))
+  const values = data.values
+  const min = Math.min(48, ...values) - 1
+  const max = Math.max(52, ...values) + 1
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis' },
+    grid: { top: 18, left: 42, right: 18, bottom: 28 },
+    xAxis: { type: 'category', data: cats, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10, hideOverlap: true } }) },
+    yAxis: { type: 'value', min, max, ...axisStyle() },
+    series: [{
+      name: '制造业 PMI',
+      type: 'line',
+      smooth: true,
+      showSymbol: false,
+      symbol: 'circle',
+      symbolSize: 4,
+      data: values,
+      lineStyle: { color: C.brand, width: 2 },
+      itemStyle: { color: C.brand },
+      areaStyle: {
+        type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
+        colorStops: [{ offset: 0, color: 'rgba(21,82,208,.18)' }, { offset: 1, color: 'rgba(21,82,208,0)' }],
+      },
+      label: { show: true, position: 'top', color: C.mut, fontSize: 10, formatter: (p) => p.value.toFixed(1) },
+      markLine: {
+        symbol: ['none', 'none'],
+        data: [{
+          yAxis: 50,
+          lineStyle: { color: C.warn, type: 'dashed', width: 1 },
+          label: { formatter: '荣枯线 50', position: 'insideEndTop', color: C.warn, fontSize: 10 },
+        }],
+      },
+    }],
+  }
+}
+
+// 工具:单指标最新读数 + 涨跌色
+export function latestWithDelta(series, { sign = true } = {}) {
+  if (!series || !series.length) return null
+  const last = series[series.length - 1]
+  return { value: last[1], date: last[0] }
+}

+ 201 - 0
web-app/src/views/fundamental/index/data.js

@@ -0,0 +1,201 @@
+// 基本面看板 — Mock 数据
+// 时间格式: 'YYYY-MM-DD' 或 'YYYY-MM' 或 'YYYY-Qn'
+// 数据形态统一为 [[date, value], ...]
+
+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
+
+// ============== GDP 现价 · 当季同比 ==============
+// 数据源: 国家统计局 · 季度
+export const gdpNominal = {
+  name: 'GDP 现价',
+  unit: '%',
+  latest: '2026-06-30',
+  value: 5.89,
+  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],
+  ],
+}
+
+// ============== GDP 模型预测 ==============
+// 实际值 + 模型预测 (dashed) + 置信区间
+export const gdpForecast = {
+  actual: [
+    ['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],
+  ],
+  forecast: [
+    ['2026-06-30', 4.3],
+    ['2026-09-30', 4.2],
+    ['2026-12-31', 4.5],
+    ['2027-03-31', 4.6],
+  ],
+  // 上下沿
+  upper: [
+    ['2026-09-30', 4.5],
+    ['2026-12-31', 4.9],
+    ['2027-03-31', 5.1],
+  ],
+  lower: [
+    ['2026-09-30', 3.9],
+    ['2026-12-31', 4.1],
+    ['2027-03-31', 4.2],
+  ],
+  // 模型诊断
+  meta: {
+    nextPeriod: '2026Q3',
+    nextValue: 4.2,
+    range: 0.3,
+    r2: 0.91,
+    contributions: [
+      { name: '最终消费支出', value: 52 },
+      { name: '资本形成总额', value: 28 },
+      { name: '货物和服务净出口', value: 16 },
+      { name: '其他', value: 4 },
+    ],
+  },
+}
+
+// ============== 固定资产投资分项 · 累计同比 ==============
+// 5 条系列: 总 / 地产 / 基建 / 制造业 / 高技术
+export const fixedInvestment = {
+  categories: [
+    '2024-08',
+    '2024-09',
+    '2024-10',
+    '2024-11',
+    '2024-12',
+    '2025-02',
+    '2025-03',
+    '2025-04',
+    '2025-05',
+    '2025-06',
+    '2025-07',
+    '2025-08',
+    '2025-09',
+    '2025-10',
+    '2025-11',
+    '2025-12',
+    '2026-02',
+    '2026-03',
+    '2026-04',
+    '2026-05',
+    '2026-06',
+    '2026-07',
+    '2026-08',
+  ],
+  series: {
+    total: [3.4, 3.4, 3.4, 3.3, 3.2, 4.1, 4.2, 4.0, 3.7, 3.5, 3.2, 2.8, 2.4, 1.8, 1.1, 0.5, 1.7, 1.7, -1.6, -4.1, -5.7, -6.7, -7.2],
+    estate: [-9.8, -9.1, -8.6, -8.0, -7.5, -6.8, -6.5, -7.2, -8.5, -9.2, -10.1, -11.5, -12.8, -13.5, -14.2, -15.1, -16.5, -17.8, -18.6, -19.2, -19.5, -19.7, -19.9],
+    infra: [4.4, 4.1, 3.9, 3.6, 3.2, 5.2, 5.5, 5.6, 5.4, 5.1, 4.8, 4.5, 4.2, 3.9, 3.6, 3.3, 4.5, 4.8, 3.9, 2.1, 0.8, -0.5, -1.6],
+    mfg: [5.1, 4.9, 4.7, 4.5, 4.3, 6.5, 6.8, 7.2, 7.5, 7.6, 7.4, 7.1, 6.8, 6.5, 6.2, 5.9, 6.8, 7.0, 6.5, 5.8, 4.9, 4.2, 3.5],
+    hiTech: [8.5, 8.2, 8.0, 7.8, 7.5, 9.5, 9.8, 10.2, 10.5, 10.6, 10.3, 10.0, 9.7, 9.4, 9.1, 8.8, 9.8, 10.0, 9.5, 8.8, 7.9, 7.2, 6.5],
+  },
+}
+
+// ============== 出口金额 vs 出口增速 ==============
+export const exports = {
+  categories: [
+    '2024-09', '2024-10', '2024-11', '2024-12',
+    '2025-02', '2025-03', '2025-04', '2025-05', '2025-06', '2025-07', '2025-08', '2025-09', '2025-10', '2025-11', '2025-12',
+    '2026-02', '2026-03', '2026-04', '2026-05', '2026-06', '2026-07', '2026-08',
+  ],
+  amount: [3248, 3142, 3268, 3385, 2156, 3125, 3058, 3185, 3298, 3352, 3412, 3386, 3294, 3285, 3348, 2158, 3210, 3591, 3763, 4118, 3978, 3892],
+  yoy: [2.4, 4.6, 5.8, 6.2, 8.1, 9.4, 7.2, 5.6, 4.8, 5.2, 6.5, 7.2, 6.8, 6.0, 5.5, 8.2, 9.5, 11.8, 14.2, 18.6, 16.4, 14.1],
+}
+
+// ============== 社会消费品零售总额 · 当月同比 ==============
+export const retail = {
+  categories: [
+    '2024-09', '2024-10', '2024-11', '2024-12',
+    '2025-02', '2025-03', '2025-04', '2025-05', '2025-06', '2025-07', '2025-08', '2025-09', '2025-10', '2025-11', '2025-12',
+    '2026-02', '2026-03', '2026-04', '2026-05', '2026-06', '2026-07', '2026-08',
+  ],
+  values: [3.2, 4.8, 4.5, 3.7, 4.6, 5.2, 4.9, 4.2, 3.8, 3.5, 3.2, 2.8, 2.5, 2.1, 1.8, 2.4, 1.8, 1.2, 0.8, 0.6, 0.5, 0.4],
+}
+
+// ============== CPI 当月同比 / 环比 ==============
+export const cpi = {
+  categories: [
+    '2024-09', '2024-10', '2024-11', '2024-12',
+    '2025-02', '2025-03', '2025-04', '2025-05', '2025-06', '2025-07', '2025-08', '2025-09', '2025-10', '2025-11', '2025-12',
+    '2026-02', '2026-03', '2026-04', '2026-05', '2026-06', '2026-07', '2026-08',
+  ],
+  yoy: [0.2, 0.3, 0.5, 0.8, 1.0, 1.2, 1.0, 0.6, 0.4, 0.5, 0.6, 0.5, 0.4, 0.3, 0.2, 0.3, 0.5, 0.6, 0.4, 0.2, 0.1, -0.1],
+  mom: [0.0, -0.1, 0.0, 0.1, 0.2, 0.3, 0.1, -0.2, -0.1, 0.3, 0.2, -0.1, -0.1, 0.0, 0.0, -0.2, 0.1, 0.0, -0.1, 0.1, 0.2, -0.2],
+}
+
+// ============== PPI 当月同比 / 环比 ==============
+export const ppi = {
+  categories: [
+    '2024-09', '2024-10', '2024-11', '2024-12',
+    '2025-02', '2025-03', '2025-04', '2025-05', '2025-06', '2025-07', '2025-08', '2025-09', '2025-10', '2025-11', '2025-12',
+    '2026-02', '2026-03', '2026-04', '2025-05', '2026-06', '2026-07', '2026-08',
+  ],
+  yoy: [-2.5, -2.8, -2.6, -2.3, -2.0, -1.8, -1.6, -1.4, -1.2, -1.0, -0.8, -0.9, -1.2, -1.5, -1.8, -2.1, -2.0, -1.8, -1.6, -1.4, -1.2, -1.5],
+  mom: [-0.2, -0.3, -0.1, -0.1, 0.1, 0.2, 0.1, 0.0, -0.1, -0.2, -0.3, -0.1, -0.2, -0.3, -0.2, -0.3, -0.1, 0.1, 0.0, -0.1, -0.2, -0.3],
+}
+
+// ============== 工业增加值 · 同比 / 环比 ==============
+export const industrial = {
+  categories: [
+    '2024-09', '2024-10', '2024-11', '2024-12',
+    '2025-02', '2025-03', '2025-04', '2025-05', '2025-06', '2025-07', '2025-08', '2025-09', '2025-10', '2025-11', '2025-12',
+    '2026-02', '2026-03', '2026-04', '2026-05', '2026-06', '2026-07', '2026-08',
+  ],
+  yoy: [5.4, 5.6, 5.8, 6.2, 6.5, 6.8, 6.5, 6.0, 5.8, 5.5, 5.2, 5.0, 4.8, 4.6, 4.5, 4.8, 5.2, 5.5, 5.6, 5.8, 5.5, 5.2],
+  mom: [0.4, 0.3, 0.5, 0.6, 0.5, 0.7, 0.4, 0.3, 0.4, 0.2, 0.1, 0.2, 0.3, 0.2, 0.3, 0.4, 0.5, 0.3, 0.2, 0.4, 0.1, 0.2],
+}
+
+// ============== 制造业 PMI ==============
+export const pmi = {
+  categories: [
+    '2024-09', '2024-10', '2024-11', '2024-12',
+    '2025-02', '2025-03', '2025-04', '2025-05', '2025-06', '2025-07', '2025-08', '2025-09', '2025-10', '2025-11', '2025-12',
+    '2026-02', '2026-03', '2026-04', '2026-05', '2026-06', '2026-07', '2026-08',
+  ],
+  values: [49.8, 50.1, 50.3, 50.2, 50.5, 50.8, 50.4, 50.2, 50.1, 50.0, 49.8, 50.2, 50.4, 50.6, 50.5, 50.8, 51.0, 50.6, 50.4, 50.2, 50.0, 49.8],
+}

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

@@ -0,0 +1,891 @@
+<template>
+  <div class="fundamentals">
+    <header class="page-hd">
+      <div>
+        <div class="page-title">基本面看板</div>
+        <div class="page-sub">宏观经济跟踪 · 数据源 Wind / 国家统计局 / 海关总署 </div>
+      </div>
+    </header>
+
+    <!-- <nav class="subnav">
+      <div class="subnav-tab">
+        <a :class="{ on: tab === 'overview' }" @click="tab = 'overview'">总览</a>
+        <a :class="{ on: tab === 'gdp' }" @click="tab = 'gdp'">GDP</a>
+        <a :class="{ on: tab === 'inv' }" @click="tab = 'inv'">投资</a>
+        <a :class="{ on: tab === 'exp' }" @click="tab = 'exp'">出口</a>
+        <a :class="{ on: tab === 'cons' }" @click="tab = 'cons'">消费</a>
+        <a :class="{ on: tab === 'cpi' }" @click="tab = 'cpi'">通胀</a>
+        <a :class="{ on: tab === 'sup' }" @click="tab = 'sup'">供给</a>
+        <a :class="{ on: tab === 'pmi' }" @click="tab = 'pmi'">景气度</a>
+      </div>
+      <div class="subnav-time">
+        <span>时间</span><span class="chip">近 6 年 · 月度</span>
+      </div>
+    </nav> -->
+
+    <!-- KPI 概览条 -->
+    <section class="kpi-grid">
+      <div v-for="k in kpis" :key="k.label" class="kpi-card" :class="{ forecast: k.forecast }">
+        <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 }}
+        </div>
+      </div>
+    </section>
+
+    <!-- §1 GDP 与增长 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§1 GDP 与增长</h2>
+        <span class="meta">季度数据 · Wind / 国家统计局</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: gdpPill === 'yoy' }" @click="gdpPill = 'yoy'">同比</a>
+                <a :class="{ on: gdpPill === 'qoq' }" @click="gdpPill = 'qoq'">季环比</a>
+              </div>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="gdpNomOpt" height="280px" />
+          </div>
+        </div>
+
+        <div class="card fc-card">
+          <div class="head">
+            <div>
+              <div class="name">🔮 GDP 模型预测</div>
+              <div style="font-size:11px;color:var(--mut);margin-top:3px">基于 MM6 周频 + 高频指标</div>
+            </div>
+            <span class="badge">R²={{ gdpForecast.meta.r2 }}</span>
+          </div>
+          <div class="pred">
+            <span class="v" style="color:var(--ink)">{{ gdpForecast.meta.nextValue.toFixed(1) }}<span style="font-size:13px;color:var(--mut)">%</span></span>
+            <span :class="forecastDelta.dirCls"><span class="arrow">{{ forecastDelta.arrow }}</span> {{ forecastDelta.text }}</span>
+          </div>
+          <div class="range">
+            <span>{{ (gdpForecast.meta.nextValue - gdpForecast.meta.range).toFixed(1) }}%</span>
+            <span>{{ gdpForecast.meta.nextPeriod }} 预测 {{ gdpForecast.meta.nextValue.toFixed(1) }}% ±{{ gdpForecast.meta.range.toFixed(1) }}</span>
+            <span>{{ (gdpForecast.meta.nextValue + gdpForecast.meta.range).toFixed(1) }}%</span>
+          </div>
+          <div class="hrange"><div class="pin" :style="{ left: forecastPinLeft + '%' }"></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)">分项贡献度</span>
+              <span style="font-size:11px;color:var(--mut)">最新一期</span>
+            </div>
+            <div v-for="c in gdpForecast.meta.contributions" :key="c.name" class="contrib-row">
+              <div class="top"><span>{{ c.name }}</span><span style="color:var(--brand);font-weight:600">{{ c.value }}%</span></div>
+              <div class="bar"><div class="fill" :style="{ width: c.value + '%' }"></div></div>
+            </div>
+          </div>
+        </div>
+      </div>
+<!-- 
+      <div class="card-grid cols-1-1" style="margin-top:14px">
+        <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" style="font-size:11px">最近更新 2026-09-12</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="gdpFcOpt" height="300px" />
+          </div>
+        </div>
+      </div> -->
+    </section>
+
+    <!-- §2 投资分析 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§2 投资分析</h2>
+        <span class="meta">累计同比 · 国家统计局</span>
+      </div>
+
+      <div class="card-grid cols-1">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">2.1</span>
+              <span class="title-text">固定资产投资分项 · 累计同比</span>
+            </div>
+            <!-- <div class="acts">
+              <div class="pill-group">
+                <a :class="{ on: invPill === 'all' }" @click="invPill = 'all'">全部</a>
+                <a :class="{ on: invPill === 'estate' }" @click="invPill = 'estate'">地产</a>
+                <a :class="{ on: invPill === 'infra' }" @click="invPill = 'infra'">基建</a>
+                <a :class="{ on: invPill === 'mfg' }" @click="invPill = 'mfg'">制造业</a>
+                <a :class="{ on: invPill === 'hi' }" @click="invPill = 'hi'">高技术</a>
+              </div>
+            </div> -->
+          </div>
+          <div class="card-body">
+            <Chart :option="invOpt" height="320px" />
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §3 进出口贸易 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§3 进出口贸易</h2>
+        <span class="meta">月度 · 海关总署</span>
+      </div>
+
+      <div class="card-grid cols-1">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">3.1</span>
+              <span class="title-text">出口金额 vs 出口增速 (双轴)</span>
+            </div>
+            <div class="acts">
+              <div class="pill-group">
+                <a :class="{ on: expPill === 'm' }" @click="expPill = 'm'">当月</a>
+                <a :class="{ on: expPill === 'acc' }" @click="expPill = 'acc'">累计</a>
+                <a :class="{ on: expPill === 'usd' }" @click="expPill = 'usd'">美元</a>
+                <a :class="{ on: expPill === 'cny' }" @click="expPill = 'cny'">人民币</a>
+              </div>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="expOpt" height="320px" />
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §4 消费与就业 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§4 消费与就业</h2>
+        <span class="meta">月度 · 国家统计局</span>
+      </div>
+
+      <div class="card-grid cols-1">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">4.1</span>
+              <span class="title-text">社会消费品零售总额 · 当月同比</span>
+            </div>
+            <div class="acts">
+              <span class="meta" style="font-size:11px">最新 {{ retail.values[retail.values.length - 1] }}%</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="retailOpt" height="280px" />
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §5 通胀 CPI / PPI -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§5 通胀 · CPI / PPI</h2>
+        <span class="meta">月度 · 国家统计局</span>
+      </div>
+
+      <div class="card-grid cols-2">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">5.1</span>
+              <span class="title-text">CPI 当月同比 / 环比</span>
+            </div>
+            <div class="acts">
+              <span class="meta" style="font-size:11px">最新 {{ cpi.yoy[cpi.yoy.length - 1] }}% / {{ cpi.mom[cpi.mom.length - 1] }}%</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="cpiOpt" height="280px" />
+          </div>
+        </div>
+
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">5.2</span>
+              <span class="title-text">PPI 当月同比 / 环比</span>
+            </div>
+            <div class="acts">
+              <span class="meta" style="font-size:11px">最新 {{ ppi.yoy[ppi.yoy.length - 1] }}% / {{ ppi.mom[ppi.mom.length - 1] }}%</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="ppiOpt" height="280px" />
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §6 工业与供给 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§6 工业与供给</h2>
+        <span class="meta">月度 · 国家统计局</span>
+      </div>
+
+      <div class="card-grid cols-1">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">6.1</span>
+              <span class="title-text">工业增加值 · 同比 / 环比</span>
+            </div>
+            <div class="acts">
+              <span class="meta" style="font-size:11px">最新 {{ industrial.yoy[industrial.yoy.length - 1] }}% / {{ industrial.mom[industrial.mom.length - 1] }}%</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="industrialOpt" height="300px" />
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §7 景气度 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§7 景气度</h2>
+        <span class="meta">月度 · 国家统计局</span>
+      </div>
+
+      <div class="card-grid cols-2-1">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">7.1</span>
+              <span class="title-text">制造业 PMI</span>
+            </div>
+            <div class="acts">
+              <span class="meta" style="font-size:11px">最新 {{ pmi.values[pmi.values.length - 1] }} · {{ pmiLatestTag }}</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <div class="pmi-hero">
+              <div class="mut">最新读数</div>
+              <div :class="['pmi-val', pmiLatestCls]">{{ pmi.values[pmi.values.length - 1] }}</div>
+              <span :class="['tag', pmiLatestTagCls]">{{ pmiLatestTag }}</span>
+            </div>
+            <Chart :option="pmiOpt" height="220px" />
+          </div>
+        </div>
+
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">7.2</span>
+              <span class="title-text">景气度结构 · PMI 5 项扩散指数</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <div class="stat-list">
+              <div v-for="row in pmiSub" :key="row.name" class="stat-row">
+                <span class="name">{{ row.name }}</span>
+                <span class="num">{{ row.value.toFixed(1) }}</span>
+                <span :class="['delta', row.cls]">{{ row.arrow }} {{ row.delta.toFixed(1) }}</span>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <footer class="footer">
+      数据源:Wind · 国家统计局 · 海关总署 · 更新于 2026-09-21 18:00 · 基本面看板 v2.0
+    </footer>
+  </div>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+import Chart from '@/components/Chart.vue'
+import {
+  gdpNominal, gdpForecast, fixedInvestment, exports, retail, cpi, ppi, industrial, pmi,
+} from './data'
+import {
+  gdpNominalOption, gdpForecastOption, fixedInvestmentOption, exportsDualOption,
+  retailOption, cpiDualOption, ppiDualOption, industrialDualOption, pmiOption,
+} from './charts'
+
+const tab = ref('overview')
+const gdpPill = ref('yoy')
+const invPill = ref('all')
+const expPill = ref('m')
+
+const kpis = computed(() => ([
+  { label: 'GDP 现价同比', icon: '📈', value: gdpNominal.value, unit: '%', date: gdpNominal.latest, sign: true, forecast: false },
+  { label: 'CPI 同比', icon: '📊', value: cpi.yoy[cpi.yoy.length - 1], unit: '%', date: cpi.categories[cpi.categories.length - 1], sign: true, forecast: false },
+  { label: 'PPI 同比', icon: '🏷', value: ppi.yoy[ppi.yoy.length - 1], unit: '%', date: ppi.categories[ppi.categories.length - 1], sign: false, forecast: false },
+  { label: '固定资产投资', icon: '🏗', value: fixedInvestment.series.total[fixedInvestment.series.total.length - 1], unit: '%', date: fixedInvestment.categories[fixedInvestment.categories.length - 1], sign: false, forecast: false },
+  { label: '出口金额同比', icon: '📦', value: exports.yoy[exports.yoy.length - 1], unit: '%', date: exports.categories[exports.categories.length - 1], sign: true, forecast: false },
+  { label: '社零总额同比', icon: '🛒', value: retail.values[retail.values.length - 1], unit: '%', date: retail.categories[retail.categories.length - 1], sign: true, forecast: false },
+  { label: '制造业 PMI', icon: '📋', value: pmi.values[pmi.values.length - 1], unit: '', date: pmi.categories[pmi.categories.length - 1], sign: true, forecast: false },
+]))
+
+const gdpNomOpt = computed(() => gdpNominalOption(gdpNominal.series))
+const gdpFcOpt = computed(() => gdpForecastOption(gdpForecast.actual, gdpForecast.forecast, gdpForecast.upper, gdpForecast.lower))
+
+const invOpt = computed(() => fixedInvestmentOption(fixedInvestment))
+const expOpt = computed(() => exportsDualOption(exports))
+const retailOpt = computed(() => retailOption(retail))
+const cpiOpt = computed(() => cpiDualOption(cpi))
+const ppiOpt = computed(() => ppiDualOption(ppi))
+const industrialOpt = computed(() => industrialDualOption(industrial))
+const pmiOpt = computed(() => pmiOption(pmi))
+
+// KPI helpers
+function formatNum(v) {
+  if (typeof v !== 'number') return v
+  if (Math.abs(v) >= 100) return v.toFixed(0)
+  return v.toFixed(2)
+}
+function chgCls(v, sign = true) {
+  if (Math.abs(v) < 0.005) return 'flat'
+  // For non-sign KPIs (e.g., PPI, 固投 期望下行/上升方向按用户视角)
+  const isUp = sign ? v > 0 : v < 0
+  return isUp ? 'up' : 'down'
+}
+function arrow(v) {
+  if (Math.abs(v) < 0.005) return '—'
+  return v > 0 ? '▲' : '▼'
+}
+
+// GDP forecast pin position (0..100)
+const forecastPinLeft = computed(() => {
+  const lo = gdpForecast.meta.nextValue - gdpForecast.meta.range
+  const hi = gdpForecast.meta.nextValue + gdpForecast.meta.range
+  const min = lo - 0.5
+  const max = hi + 0.5
+  const pct = (gdpForecast.meta.nextValue - min) / (max - min)
+  return Math.max(0, Math.min(100, pct * 100))
+})
+const forecastDelta = computed(() => {
+  // 预测相对上一期实际值的方向
+  const lastActual = gdpForecast.actual[gdpForecast.actual.length - 1][1]
+  const next = gdpForecast.meta.nextValue
+  const delta = next - lastActual
+  const dir = delta > 0.01 ? 'up' : (delta < -0.01 ? 'down' : 'flat')
+  const arrow_ = delta > 0.01 ? '▲' : (delta < -0.01 ? '▼' : '—')
+  return {
+    dirCls: dir,
+    arrow: arrow_,
+    text: `${arrow_} ${Math.abs(delta).toFixed(1)}pct 同比`,
+  }
+})
+
+// PMI 摘要
+const pmiLatestCls = computed(() => {
+  const v = pmi.values[pmi.values.length - 1]
+  return v >= 50 ? 'up' : 'down'
+})
+const pmiLatestTagCls = computed(() => pmiLatestCls.value === 'up' ? 'r' : 'd')
+const pmiLatestTag = computed(() => {
+  const v = pmi.values[pmi.values.length - 1]
+  return v >= 50 ? '扩张区间 · 荣枯线上' : '荣枯线下 · 收缩压力'
+})
+
+// PMI 五项扩散指数(mock)
+const pmiSub = computed(() => {
+  const subItems = [
+    { name: '生产指数', value: 50.6 },
+    { name: '新订单', value: 49.5 },
+    { name: '原材料库存', value: 47.8 },
+    { name: '从业人员', value: 48.4 },
+    { name: '供应商配送时间', value: 50.2 },
+  ]
+  return subItems.map((it, i) => {
+    const delta = it.value - 50
+    const cls = delta > 0.01 ? 'up-bg' : (delta < -0.01 ? 'down-bg' : 'flat-bg')
+    const arrow_ = delta > 0.01 ? '▲' : (delta < -0.01 ? '▼' : '—')
+    return { ...it, delta, cls, arrow: arrow_ }
+  })
+})
+</script>
+
+<style scoped>
+.fundamentals {
+  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.primary {
+  background: var(--brand, #1552D0);
+  border-color: var(--brand, #1552D0);
+  color: #fff;
+}
+.btn.primary:hover {
+  background: var(--brand-d, #0E3A96);
+  border-color: var(--brand-d, #0E3A96);
+}
+
+/* === 子导航 === */
+.subnav {
+  display: flex;
+  background: #fff;
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 8px;
+  padding: 4px;
+  gap: 4px;
+  margin-bottom: 14px;
+  align-items: center;
+}
+.subnav-tab {
+  display: flex;
+  gap: 4px;
+  flex-wrap: wrap;
+}
+.subnav-tab a {
+  padding: 6px 12px;
+  border-radius: 5px;
+  font-size: 12.5px;
+  color: var(--mut, #6B7A96);
+  cursor: pointer;
+  transition: 0.15s;
+}
+.subnav-tab a:hover {
+  background: #F0F3F8;
+  color: var(--ink, #1A2438);
+}
+.subnav-tab a.on {
+  background: var(--brand-lt, #EAF1FF);
+  color: var(--brand-d, #0E3A96);
+  font-weight: 600;
+}
+.subnav-time {
+  margin-left: auto;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 12px;
+  color: var(--mut, #6B7A96);
+  padding: 0 8px;
+}
+.subnav-time .chip {
+  padding: 3px 9px;
+  background: var(--brand-lt, #EAF1FF);
+  color: var(--brand-d, #0E3A96);
+  font-size: 11.5px;
+  border-radius: 10px;
+  font-weight: 600;
+}
+
+/* === KPI === */
+.kpi-grid {
+  display: grid;
+  grid-template-columns: repeat(7, 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;
+}
+.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;
+}
+
+/* === 卡片网格 === */
+.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;
+  width: 18px;
+  height: 18px;
+  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);
+}
+
+/* === 预测卡 === */
+.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;
+}
+
+/* === 颜色工具 === */
+.up { color: #E13B3B !important; }
+.down { color: #189A65 !important; }
+.flat { color: var(--mut, #6B7A96) !important; }
+
+/* === PMI hero === */
+.pmi-hero {
+  text-align: center;
+  padding: 10px 0 14px;
+  border-bottom: 1px dashed var(--line, #E4E9F2);
+  margin-bottom: 12px;
+}
+.pmi-hero .mut {
+  font-size: 11.5px;
+  color: var(--mut, #6B7A96);
+}
+.pmi-val {
+  font-size: 32px;
+  font-weight: 700;
+  font-variant-numeric: tabular-nums;
+  margin: 4px 0 6px;
+  letter-spacing: -1px;
+}
+.pmi-val.up { color: #189A65; }
+.pmi-val.down { color: #E13B3B; }
+
+.tag {
+  display: inline-block;
+  padding: 2px 8px;
+  font-size: 10.5px;
+  border-radius: 3px;
+  font-weight: 600;
+  line-height: 1.5;
+}
+.tag.r { background: #FDEAEA; color: #E13B3B; }
+.tag.d { background: #E4F5EE; color: #189A65; }
+.tag.b { background: #EAF1FF; color: #0E3A96; }
+
+/* === stat-list === */
+.stat-list { padding: 6px 0; }
+.stat-row {
+  display: flex;
+  align-items: center;
+  padding: 9px 0;
+  border-bottom: 1px dashed var(--line, #E4E9F2);
+  font-size: 12.5px;
+}
+.stat-row:last-child { border-bottom: none; }
+.stat-row .name { flex: 1; color: var(--mut, #6B7A96); }
+.stat-row .num {
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+  color: var(--ink, #1A2438);
+  margin-right: 8px;
+}
+.stat-row .delta {
+  font-size: 11px;
+  padding: 1px 6px;
+  border-radius: 3px;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+}
+.up-bg { background: #FDEAEA; color: #E13B3B; }
+.down-bg { background: #E4F5EE; color: #189A65; }
+.flat-bg { background: #EEF2F7; color: var(--mut, #6B7A96); }
+
+.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(4, minmax(0, 1fr)); }
+}
+@media (max-width: 980px) {
+  .kpi-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+  .card-grid.cols-2-1,
+  .card-grid.cols-1-2,
+  .card-grid.cols-1-1,
+  .card-grid.cols-2 { grid-template-columns: 1fr; }
+}
+</style>