Browse Source

feat(web-app): 新增政策面 / 文档管理 / PDF 文档库三大模块

- utils/http: request 支持 responseType: 'blob',用于文件下载
- router: 注册 /policy/{财政,货币政策,重要会议,产业政策}、/pdf-library、/data/document 路由
- api/document: 文档列表/上传/分类/版本等接口
- components/policy: PolicySubnav / PolicyPdfPreview / PolicyLatestWithHistory / PolicyDocumentLibrary 通用组件
- views/policy: 财政、货币、重要会议、产业政策 4 个看板(含 charts / data / index)
- views/document: 数据管理 · 文档管理页
- views/pdf-library: PDF 文档库(含 PdfPreviewPanel)
- views/List: 通用列表占位页(原 List.vue 移除后的占位)
ligb 1 week ago
parent
commit
378a0f8fa3

+ 155 - 0
web-app/src/api/document.js

@@ -0,0 +1,155 @@
+import { http } from '@/utils/http'
+
+// 查询
+
+export function getCategories() {
+  return http.get('/api/documents/categories')
+}
+
+export function listDocuments(params = {}) {
+  return http.get('/api/documents', { query: params })
+}
+
+export function getLatestDocuments() {
+  return http.get('/api/documents/latest')
+}
+
+export function getDocument(id) {
+  return http.get(`/api/documents/${id}`)
+}
+
+export async function fetchDocumentBlob(id, { preview = false } = {}) {
+  const path = preview
+    ? `/api/documents/${id}/preview`
+    : `/api/documents/${id}/download`
+  const blob = await http.get(path, { responseType: 'blob' })
+  if (!blob) return null
+  return URL.createObjectURL(blob)
+}
+
+// 管理(multipart/form-data)
+
+/**
+ * 创建文档(上传文件 + 元数据)
+ * @param {{
+ *   file: File,
+ *   categoryId: number|string,
+ *   period: string,
+ *   title: string,
+ *   periodYear?: number|string,
+ *   publishDate?: string,
+ *   pageCount?: number|string,
+ *   isLatest?: 0|1|boolean,
+ *   sortOrder?: number|string,
+ * }} payload
+ */
+export function createDocument(payload) {
+  const fd = new FormData()
+  fd.append('file', payload.file)
+  fd.append('categoryId', String(payload.categoryId))
+  fd.append('period', payload.period)
+  fd.append('title', payload.title)
+  if (payload.periodYear !== undefined && payload.periodYear !== null && payload.periodYear !== '') {
+    fd.append('periodYear', String(payload.periodYear))
+  }
+  if (payload.publishDate) fd.append('publishDate', payload.publishDate)
+  if (payload.pageCount !== undefined && payload.pageCount !== null && payload.pageCount !== '') {
+    fd.append('pageCount', String(payload.pageCount))
+  }
+  if (payload.isLatest !== undefined && payload.isLatest !== null) {
+    fd.append('isLatest', payload.isLatest ? '1' : '0')
+  } else {
+    fd.append('isLatest', '1')
+  }
+  if (payload.sortOrder !== undefined && payload.sortOrder !== null && payload.sortOrder !== '') {
+    fd.append('sortOrder', String(payload.sortOrder))
+  }
+  return http.post('/api/documents', fd)
+}
+
+/**
+ * 更新文档元数据(可选替换文件)
+ * @param {number|string} id
+ * @param {{
+ *   file?: File,
+ *   categoryId?: number|string,
+ *   period?: string,
+ *   title?: string,
+ *   periodYear?: number|string|null,
+ *   publishDate?: string|null,
+ *   pageCount?: number|string|null,
+ *   isLatest?: 0|1|boolean,
+ *   sortOrder?: number|string,
+ * }} payload  传入 null 表示清空(如 publishDate)
+ */
+export function updateDocument(id, payload) {
+  const fd = new FormData()
+  if (payload.file) fd.append('file', payload.file)
+  if (payload.categoryId !== undefined && payload.categoryId !== null && payload.categoryId !== '') {
+    fd.append('categoryId', String(payload.categoryId))
+  }
+  if (payload.period) fd.append('period', payload.period)
+  if (payload.title) fd.append('title', payload.title)
+  if (payload.periodYear !== undefined) {
+    fd.append('periodYear', payload.periodYear === null || payload.periodYear === '' ? '' : String(payload.periodYear))
+  }
+  if (payload.publishDate !== undefined) {
+    fd.append('publishDate', payload.publishDate ?? '')
+  }
+  if (payload.pageCount !== undefined) {
+    fd.append('pageCount', payload.pageCount === null || payload.pageCount === '' ? '' : String(payload.pageCount))
+  }
+  if (payload.isLatest !== undefined && payload.isLatest !== null) {
+    fd.append('isLatest', payload.isLatest ? '1' : '0')
+  }
+  if (payload.sortOrder !== undefined && payload.sortOrder !== null && payload.sortOrder !== '') {
+    fd.append('sortOrder', String(payload.sortOrder))
+  }
+  return http.put(`/api/documents/${id}`, fd)
+}
+
+export function deleteDocument(id) {
+  return http.del(`/api/documents/${id}`)
+}
+
+/**
+ * 整体替换式 upsert 文档摘要
+ * @param {number|string} id
+ * @param {{
+ *   overallTone?: string,
+ *   policyStance?: string,
+ *   riskControl?: string,
+ *   keyPointsJson?: string,
+ * }} payload
+ */
+export function updateDocumentSummary(id, payload) {
+  return http.put(`/api/documents/${id}/summary`, payload)
+}
+
+// 工具
+
+export function formatFileSize(bytes) {
+  if (typeof bytes !== 'number' || bytes <= 0) return '—'
+  const units = ['B', 'KB', 'MB', 'GB']
+  let i = 0
+  let v = bytes
+  while (v >= 1024 && i < units.length - 1) {
+    v /= 1024
+    i++
+  }
+  return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)} ${units[i]}`
+}
+
+export function getFileExtension(name) {
+  if (!name) return ''
+  const i = String(name).lastIndexOf('.')
+  return i >= 0 ? String(name).slice(i + 1).toLowerCase() : ''
+}
+
+export const ALLOWED_EXTS = [
+  'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv', 'json',
+]
+
+export function isAllowedExt(ext) {
+  return ALLOWED_EXTS.includes(String(ext || '').toLowerCase())
+}

+ 161 - 0
web-app/src/components/policy/PolicyDocumentLibrary.vue

@@ -0,0 +1,161 @@
+<template>
+  <div class="doc-library">
+    <div class="doc-grid">
+      <a
+        v-for="d in docs"
+        :key="d.key"
+        :class="['doc-card', { disabled: d.disabled, featured: d.featured }]"
+        :href="d.href || '#'"
+        @click.prevent="$emit('open', d)"
+      >
+        <div class="doc-icon" :style="iconStyle(d)">{{ d.icon || '📄' }}</div>
+        <div class="doc-title">{{ d.title }}</div>
+        <div class="doc-meta">
+          <b>{{ d.count }}</b> 份 · {{ d.range }}
+        </div>
+        <div v-if="d.subtitle" class="doc-sub">{{ d.subtitle }}</div>
+        <div class="doc-foot">
+          <span class="doc-tag">{{ d.tag }}</span>
+          <span class="doc-go">{{ d.disabled ? '敬请期待' : '打开 →' }}</span>
+        </div>
+      </a>
+    </div>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  docs: { type: Array, required: true },
+})
+defineEmits(['open'])
+
+function iconStyle(d) {
+  if (d.featured) {
+    return {
+      background: 'linear-gradient(135deg,#0C2B5E,#1552D0)',
+      color: '#fff',
+    }
+  }
+  const bg = d.iconBg || 'linear-gradient(135deg,#EAF1FF,#1552D0)'
+  const fg = d.iconFg || '#fff'
+  return { background: bg, color: fg }
+}
+</script>
+
+<style scoped>
+.doc-library { margin-bottom: 14px; }
+.doc-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 12px;
+}
+
+.doc-card {
+  background: #fff;
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 10px;
+  padding: 16px;
+  display: flex;
+  flex-direction: column;
+  text-decoration: none;
+  color: var(--ink, #1A2438);
+  transition: transform .18s, box-shadow .18s, border-color .18s;
+  position: relative;
+  overflow: hidden;
+  cursor: pointer;
+  box-shadow: 0 1px 3px rgba(12, 43, 94, .04);
+}
+.doc-card:hover:not(.disabled) {
+  border-color: var(--brand, #1552D0);
+  transform: translateY(-2px);
+  box-shadow: 0 6px 20px rgba(21, 82, 208, .12);
+}
+.doc-card.disabled { opacity: .55; }
+.doc-card.featured {
+  background: linear-gradient(135deg, #0C2B5E, #1552D0);
+  color: #fff;
+  border-color: #1552D0;
+}
+.doc-card.featured .doc-title { color: #fff; }
+.doc-card.featured .doc-meta { color: rgba(255, 255, 255, .7); }
+.doc-card.featured .doc-foot { border-top-color: rgba(255, 255, 255, .2); }
+
+.doc-icon {
+  width: 40px;
+  height: 40px;
+  border-radius: 8px;
+  display: grid;
+  place-items: center;
+  font-size: 18px;
+  font-weight: 700;
+  margin-bottom: 10px;
+  flex: none;
+}
+.doc-card.featured .doc-icon { background: rgba(255, 255, 255, .15); }
+
+.doc-title {
+  font-size: 14px;
+  font-weight: 700;
+  margin-bottom: 6px;
+  letter-spacing: .3px;
+}
+.doc-meta {
+  font-size: 11.5px;
+  color: var(--mut, #6B7A96);
+  margin-bottom: 14px;
+  flex: 1;
+  line-height: 1.5;
+}
+.doc-meta b {
+  color: var(--brand-d, #0E3A96);
+  font-weight: 700;
+  font-variant-numeric: tabular-nums;
+  font-size: 13px;
+}
+.doc-card.featured .doc-meta b { color: #fff; }
+.doc-sub {
+  font-size: 11.5px;
+  color: var(--mut, #6B7A96);
+  margin-bottom: 10px;
+  line-height: 1.5;
+}
+.doc-card.featured .doc-sub { color: rgba(255, 255, 255, .75); }
+
+.doc-foot {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-size: 11.5px;
+  padding-top: 10px;
+  border-top: 1px solid var(--line, #E4E9F2);
+}
+.doc-tag {
+  background: var(--brand-lt, #EAF1FF);
+  color: var(--brand-d, #0E3A96);
+  padding: 2px 8px;
+  border-radius: 3px;
+  font-weight: 500;
+  font-size: 10.5px;
+}
+.doc-card.featured .doc-tag {
+  background: rgba(255, 255, 255, .2);
+  color: #fff;
+}
+.doc-go {
+  color: var(--brand-d, #0E3A96);
+  font-weight: 600;
+  font-size: 11.5px;
+}
+.doc-card.disabled .doc-go { color: var(--faint, #98A5BD); }
+.doc-card.featured .doc-go { color: #fff; }
+
+@media (max-width: 1280px) {
+  .doc-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+@media (max-width: 980px) {
+  .doc-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+@media (max-width: 600px) {
+  .doc-grid { grid-template-columns: 1fr; }
+}
+</style>

+ 287 - 0
web-app/src/components/policy/PolicyLatestWithHistory.vue

@@ -0,0 +1,287 @@
+<template>
+  <section class="latest-history">
+    <div class="main-card">
+      <div class="card-head">
+        <div class="title">
+          <span class="ic">{{ icon || '📄' }}</span>
+          <span class="title-text">{{ title }}</span>
+        </div>
+        <div class="head-acts">
+          <span class="badge-new">最新</span>
+          <a v-if="latestHref" :href="latestHref" class="link-btn">查看完整 PDF →</a>
+          <a v-else-if="pdfHref" :href="pdfHref" class="link-btn">{{ pdfHref }}</a>
+        </div>
+      </div>
+      <div class="card-body">
+        <slot name="latest" />
+      </div>
+    </div>
+
+    <aside class="history-list">
+      <header class="hist-head">
+        <h4>
+          <span class="ic">📚</span>
+          历史文档({{ history.length }} 份)
+        </h4>
+        <a v-if="libraryHref" :href="libraryHref" class="more">查看全部 →</a>
+        <a v-else-if="historyMoreHref" :href="historyMoreHref" class="more">查看全部 →</a>
+      </header>
+      <div class="hist-body">
+        <a
+          v-for="(item, i) in history"
+          :key="item.key || i"
+          :class="['hist-item', { active: i === 0 && markFirstActive }]"
+          :href="historyItemHref(item)"
+        >
+          <span class="ico">PDF</span>
+          <span class="meta">
+            <span class="name">{{ item.name }}</span>
+            <span class="info">{{ item.info }}</span>
+          </span>
+          <span class="sz">{{ item.size }}</span>
+          <span v-if="i === 0 && markFirstActive" class="badge">NEW</span>
+        </a>
+        <a
+          v-if="libraryHref || historyMoreHref"
+          class="hist-item hist-more"
+          :href="libraryHref || historyMoreHref"
+        >
+          查看全部 {{ history.length }} 份历史文档 →
+        </a>
+      </div>
+    </aside>
+  </section>
+</template>
+
+<script setup>
+const props = defineProps({
+  title: { type: String, required: true },
+  icon: { type: String, default: '' },
+  pdfHref: { type: String, default: '' },
+  history: { type: Array, required: true },
+  historyMoreHref: { type: String, default: '' },
+  markFirstActive: { type: Boolean, default: true },
+  latestDoc: { type: Object, default: null },
+  latestHref: { type: String, default: '' },
+  libraryHref: { type: String, default: '' },
+  sectionKey: { type: String, default: '' },
+})
+
+function historyItemHref(item) {
+  if (props.libraryHref && props.sectionKey && item?.key != null) {
+    return `${props.libraryHref}#${props.sectionKey}:${item.key}`
+  }
+  if (props.libraryHref) return props.libraryHref
+  return props.historyMoreHref || '#'
+}
+</script>
+
+<style scoped>
+.latest-history {
+  display: grid;
+  grid-template-columns: 3fr 1fr;
+  gap: 14px;
+  margin-bottom: 14px;
+}
+.latest-history.wide { grid-template-columns: 2fr 1fr; }
+
+.main-card,
+.history-list {
+  background: #fff;
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 8px;
+  box-shadow: 0 1px 3px rgba(12, 43, 94, .06), 0 4px 16px rgba(12, 43, 94, .04);
+  overflow: hidden;
+}
+
+.main-card .card-head {
+  padding: 12px 16px;
+  border-bottom: 1px solid var(--line, #E4E9F2);
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+.main-card .title {
+  font-size: 13.5px;
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-width: 0;
+}
+.main-card .title .ic {
+  display: inline-grid;
+  place-items: center;
+  min-width: 22px;
+  height: 22px;
+  padding: 0 4px;
+  background: var(--brand-lt, #EAF1FF);
+  color: var(--brand-d, #0E3A96);
+  font-size: 12px;
+  border-radius: 4px;
+  font-weight: 700;
+  flex: none;
+}
+.main-card .title-text {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.head-acts {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  flex: none;
+}
+.badge-new {
+  font-size: 10.5px;
+  background: var(--brand-lt, #EAF1FF);
+  color: var(--brand-d, #0E3A96);
+  padding: 2px 8px;
+  border-radius: 3px;
+  font-weight: 600;
+  letter-spacing: .3px;
+}
+.link-btn {
+  height: 26px;
+  padding: 0 10px;
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 6px;
+  background: #fff;
+  color: var(--ink, #1A2438);
+  font-size: 11.5px;
+  display: inline-flex;
+  align-items: center;
+  text-decoration: none;
+  cursor: pointer;
+  transition: .15s;
+  font-family: inherit;
+}
+.link-btn:hover {
+  border-color: var(--brand, #1552D0);
+  color: var(--brand-d, #0E3A96);
+}
+.main-card .card-body { padding: 14px 16px; }
+
+.history-list .hist-head {
+  padding: 10px 14px;
+  border-bottom: 1px solid var(--line, #E4E9F2);
+  background: #FAFBFD;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.history-list .hist-head h4 {
+  font-size: 12.5px;
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin: 0;
+}
+.history-list .hist-head .ic {
+  width: 18px;
+  height: 18px;
+  border-radius: 4px;
+  background: var(--brand-lt, #EAF1FF);
+  color: var(--brand-d, #0E3A96);
+  display: grid;
+  place-items: center;
+  font-size: 10px;
+  font-weight: 700;
+}
+.history-list .more {
+  font-size: 11px;
+  color: var(--brand-d, #0E3A96);
+  text-decoration: none;
+  font-weight: 500;
+  background: transparent;
+  border: 0;
+  cursor: pointer;
+  padding: 0;
+  font-family: inherit;
+}
+.history-list .more:hover { text-decoration: underline; }
+
+.hist-body {
+  max-height: 480px;
+  overflow-y: auto;
+  padding: 4px 0;
+}
+.hist-item {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 8px 14px;
+  cursor: pointer;
+  border: 0;
+  border-left: 3px solid transparent;
+  background: transparent;
+  color: inherit;
+  text-align: left;
+  font: inherit;
+  text-decoration: none;
+  transition: background .15s;
+}
+.hist-item:hover { background: #F8FAFD; }
+.hist-item.active {
+  background: var(--brand-lt, #EAF1FF);
+  border-left-color: var(--brand, #1552D0);
+}
+.hist-item .ico {
+  width: 24px;
+  height: 30px;
+  border-radius: 3px;
+  background: linear-gradient(180deg, #FCEAEA, #F8D4D4);
+  color: var(--up, #E13B3B);
+  display: grid;
+  place-items: center;
+  font-size: 9px;
+  font-weight: 700;
+  flex: none;
+  border: 1px solid #F5C0C0;
+}
+.hist-item .meta { flex: 1; min-width: 0; }
+.hist-item .name {
+  font-size: 12px;
+  line-height: 1.3;
+  display: block;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.hist-item .info {
+  font-size: 10px;
+  color: var(--faint, #98A5BD);
+  margin-top: 2px;
+  display: block;
+  font-variant-numeric: tabular-nums;
+}
+.hist-item .sz {
+  font-size: 10px;
+  color: var(--mut, #6B7A96);
+  font-variant-numeric: tabular-nums;
+  flex: none;
+}
+.hist-item .badge {
+  background: var(--brand, #1552D0);
+  color: #fff;
+  font-size: 9px;
+  padding: 1px 5px;
+  border-radius: 3px;
+  font-weight: 600;
+  flex: none;
+}
+.hist-more {
+  justify-content: center;
+  color: var(--brand-d, #0E3A96);
+  font-weight: 600;
+  font-size: 11.5px;
+}
+
+@media (max-width: 1100px) {
+  .latest-history { grid-template-columns: 1fr; }
+}
+</style>

+ 186 - 0
web-app/src/components/policy/PolicyPdfPreview.vue

@@ -0,0 +1,186 @@
+<template>
+  <AppModal
+    :model-value="modelValue"
+    :title="doc?.name || '文档预览'"
+    width="780"
+    @update:model-value="$emit('update:modelValue', $event)"
+  >
+    <div v-if="doc" class="pdf-preview">
+      <div class="pdf-hero">
+        <div class="pdf-thumb">
+          <span class="pdf-thumb-label">PDF</span>
+          <span class="pdf-pages">{{ doc.pages ? `${doc.pages} 页` : '会议纪要' }}</span>
+        </div>
+        <div class="pdf-meta">
+          <div class="pdf-name">{{ doc.name }}</div>
+          <div class="pdf-info">{{ doc.info }}</div>
+          <div class="pdf-tag-row">
+            <span v-if="doc.new" class="chip chip-blue">最新</span>
+            <span v-if="doc.tag" class="chip chip-gray">{{ doc.tag }}</span>
+            <span class="chip chip-gray">{{ doc.size }}</span>
+          </div>
+        </div>
+      </div>
+
+      <div class="pdf-section">
+        <div class="pdf-section-hd">文档信息</div>
+        <div class="kv-list">
+          <div class="kv"><span class="k">文档编号</span><span class="v">{{ doc.key || '—' }}</span></div>
+          <div class="kv"><span class="k">发布日期</span><span class="v">{{ doc.publishDate || (doc.info || '').split(' · ')[0] || '—' }}</span></div>
+          <div class="kv"><span class="k">页数</span><span class="v">{{ doc.pages || '—' }}</span></div>
+          <div class="kv"><span class="k">文件大小</span><span class="v">{{ doc.size || '—' }}</span></div>
+          <div class="kv"><span class="k">来源</span><span class="v">{{ doc.source || '—' }}</span></div>
+        </div>
+      </div>
+
+      <div class="pdf-section">
+        <div class="pdf-section-hd">核心内容</div>
+        <div class="pdf-body">
+          <p v-for="(line, i) in (doc.body || defaultBody)" :key="i" v-html="line"></p>
+          <p v-if="!doc.body && !doc.summary" class="mut">本文档尚未录入摘要,可通过文末操作下载或在线查看。</p>
+        </div>
+      </div>
+
+      <div v-if="doc.tags?.length" class="pdf-section">
+        <div class="pdf-section-hd">主题标签</div>
+        <div class="tag-row">
+          <span
+            v-for="t in doc.tags"
+            :key="t.label"
+            :class="['chip', `chip-${t.tone || 'gray'}`]"
+          >{{ t.label }}</span>
+        </div>
+      </div>
+    </div>
+    <template #footer>
+      <button class="btn ghost" @click="$emit('download', doc)">下载 PDF</button>
+      <button class="btn primary" @click="$emit('open-original', doc)">打开原文</button>
+      <button class="btn" @click="$emit('update:modelValue', false)">关闭</button>
+    </template>
+  </AppModal>
+</template>
+
+<script setup>
+import AppModal from '@/components/common/AppModal.vue'
+
+defineProps({
+  modelValue: { type: Boolean, default: false },
+  doc: { type: Object, default: null },
+})
+defineEmits(['update:modelValue', 'download', 'open-original'])
+
+const defaultBody = [
+  '本文件为政策面研究文档,用于跟踪与解读最新政策动态。',
+  '请通过正文操作按钮下载完整 PDF,或在原文链接中查看。',
+]
+</script>
+
+<style scoped>
+.pdf-preview { display: flex; flex-direction: column; gap: 16px; }
+
+.pdf-hero {
+  display: flex;
+  align-items: stretch;
+  gap: 16px;
+  padding: 16px;
+  background: linear-gradient(135deg, #F4F7FD, #EAF1FF);
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 8px;
+}
+
+.pdf-thumb {
+  width: 90px;
+  flex: none;
+  border-radius: 6px;
+  background: linear-gradient(180deg, #FCEAEA, #F8D4D4);
+  border: 1px solid #F5C0C0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: var(--up, #E13B3B);
+  font-weight: 700;
+  padding: 8px 4px;
+  text-align: center;
+}
+.pdf-thumb-label { font-size: 14px; letter-spacing: 1px; }
+.pdf-pages { font-size: 10px; margin-top: 4px; opacity: .8; }
+
+.pdf-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
+.pdf-name { font-size: 15px; font-weight: 700; color: var(--ink, #1A2438); line-height: 1.4; }
+.pdf-info { font-size: 12px; color: var(--mut, #6B7A96); }
+.pdf-tag-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
+
+.pdf-section {
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 8px;
+  overflow: hidden;
+}
+.pdf-section-hd {
+  padding: 10px 14px;
+  background: #FAFBFD;
+  border-bottom: 1px solid var(--line, #E4E9F2);
+  font-size: 12.5px;
+  font-weight: 600;
+  color: var(--ink, #1A2438);
+}
+.pdf-section .kv-list { padding: 4px 14px; }
+.pdf-section .kv {
+  display: flex;
+  justify-content: space-between;
+  padding: 8px 0;
+  border-bottom: 1px dashed var(--line, #E4E9F2);
+  font-size: 12.5px;
+}
+.pdf-section .kv:last-child { border-bottom: none; }
+.pdf-section .kv .k { color: var(--mut, #6B7A96); }
+.pdf-section .kv .v { color: var(--ink, #1A2438); font-variant-numeric: tabular-nums; }
+
+.pdf-body { padding: 12px 14px; font-size: 13px; line-height: 1.8; color: var(--ink, #1A2438); }
+.pdf-body p { margin-bottom: 8px; }
+.pdf-body p:last-child { margin-bottom: 0; }
+.pdf-body :deep(b) { color: var(--brand-d, #0E3A96); font-weight: 600; }
+.pdf-body :deep(.hl-up) { color: var(--up, #E13B3B); font-weight: 600; }
+.pdf-body :deep(.hl-dn) { color: var(--down, #189A65); font-weight: 600; }
+.pdf-body .mut { color: var(--mut, #6B7A96); }
+
+.tag-row { display: flex; gap: 6px; flex-wrap: wrap; padding: 10px 14px 14px; }
+.chip {
+  display: inline-block;
+  padding: 2px 8px;
+  border-radius: 10px;
+  font-size: 11px;
+  font-weight: 500;
+  font-variant-numeric: tabular-nums;
+}
+.chip-blue   { background: var(--brand-lt, #EAF1FF); color: var(--brand-d, #0E3A96); }
+.chip-green  { background: #E8F5EE; color: var(--down, #189A65); }
+.chip-yellow { background: #FFF6E5; color: #B25E00; }
+.chip-red    { background: #FCEAEA; color: var(--up, #E13B3B); }
+.chip-gray   { background: #EEF2F7; color: var(--mut, #6B7A96); }
+
+.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: .15s;
+}
+.btn:hover { border-color: var(--brand, #1552D0); color: var(--brand-d, #0E3A96); }
+.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); color: #fff; }
+.btn.ghost {
+  background: transparent;
+}
+</style>

+ 55 - 0
web-app/src/components/policy/PolicySubnav.vue

@@ -0,0 +1,55 @@
+<template>
+  <nav class="policy-subnav">
+    <a
+      v-for="item in items"
+      :key="item.key"
+      :class="{ on: item.active }"
+      :href="item.to || '#'"
+      @click.prevent="onSelect(item)"
+    >{{ item.label }}</a>
+  </nav>
+</template>
+
+<script setup>
+import { useRouter } from 'vue-router'
+
+const props = defineProps({
+  items: { type: Array, required: true },
+  modelValue: { type: String, default: '' },
+})
+const emit = defineEmits(['update:modelValue', 'select'])
+const router = useRouter()
+
+function onSelect(item) {
+  emit('update:modelValue', item.key)
+  emit('select', item)
+  if (item.to) router.push(item.to)
+}
+</script>
+
+<style scoped>
+.policy-subnav {
+  display: inline-flex;
+  background: #fff;
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 8px;
+  padding: 4px;
+  gap: 2px;
+  margin-bottom: 14px;
+}
+.policy-subnav a {
+  padding: 7px 18px;
+  border-radius: 6px;
+  font-size: 12.5px;
+  color: var(--mut, #6B7A96);
+  text-decoration: none;
+  cursor: pointer;
+  transition: .15s;
+}
+.policy-subnav a:hover { color: var(--ink, #1A2438); }
+.policy-subnav a.on {
+  background: var(--brand-lt, #EAF1FF);
+  color: var(--brand-d, #0E3A96);
+  font-weight: 600;
+}
+</style>

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

@@ -71,6 +71,36 @@ const router = createRouter({
       component: () => import('@/views/fundamental/prosperity/index.vue'),
       meta: { layout: 'app', title: '基本面 · 景气度' },
     },
+    {
+      path: '/policy/fiscal',
+      name: 'policy-fiscal',
+      component: () => import('@/views/policy/fiscal/index.vue'),
+      meta: { layout: 'app', title: '政策面 · 财政政策' },
+    },
+    {
+      path: '/policy/monetary',
+      name: 'policy-monetary',
+      component: () => import('@/views/policy/monetary/index.vue'),
+      meta: { layout: 'app', title: '政策面 · 货币政策' },
+    },
+    {
+      path: '/policy/meeting',
+      name: 'policy-meeting',
+      component: () => import('@/views/policy/meeting/index.vue'),
+      meta: { layout: 'app', title: '政策面 · 重要会议' },
+    },
+    {
+      path: '/policy/industry',
+      name: 'policy-industry',
+      component: () => import('@/views/policy/industry/index.vue'),
+      meta: { layout: 'app', title: '政策面 · 产业政策' },
+    },
+    {
+      path: '/pdf-library',
+      name: 'pdf-library',
+      component: () => import('@/views/pdf-library/index.vue'),
+      meta: { layout: 'app', title: 'PDF 文档库' },
+    },
     {
       path: '/data/indicator',
       name: 'data-indicator',
@@ -83,6 +113,12 @@ const router = createRouter({
       component: () => import('@/views/data/value/index.vue'),
       meta: { layout: 'app', title: '数据管理 · 指标数据' },
     },
+    {
+      path: '/data/document',
+      name: 'document-index',
+      component: () => import('@/views/document/index.vue'),
+      meta: { layout: 'app', title: '数据管理 · 文档管理' },
+    },
     {
       path: '/system/user',
       name: 'system-user',

+ 21 - 6
web-app/src/utils/http.js

@@ -31,7 +31,10 @@ function buildURL(path, query) {
   return qs ? `${base}${cleanPath}?${qs}` : `${base}${cleanPath}`
 }
 
-async function parseBody(response) {
+async function parseBody(response, responseType) {
+  if (responseType === 'blob') {
+    try { return await response.blob() } catch { return null }
+  }
   const contentType = response.headers.get('content-type') || ''
   if (contentType.includes('application/json')) {
     try { return await response.json() } catch { return null }
@@ -39,13 +42,13 @@ async function parseBody(response) {
   try { return await response.text() } catch { return null }
 }
 
-export async function request(path, { method = 'GET', body, query, headers, timeout = DEFAULT_TIMEOUT, auth = true } = {}) {
+export async function request(path, { method = 'GET', body, query, headers, timeout = DEFAULT_TIMEOUT, auth = true, responseType = 'json' } = {}) {
   const controller = new AbortController()
   const timer = setTimeout(() => controller.abort(), timeout)
 
   const finalHeaders = {
-    Accept: 'application/json',
-    ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
+    ...(responseType === 'blob' ? {} : { Accept: 'application/json' }),
+    ...(body !== undefined && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}),
     ...headers,
   }
   if (auth) {
@@ -58,7 +61,12 @@ export async function request(path, { method = 'GET', body, query, headers, time
     response = await fetch(buildURL(path, query), {
       method,
       headers: finalHeaders,
-      body: body !== undefined ? JSON.stringify(body) : undefined,
+      body:
+        body === undefined
+          ? undefined
+          : body instanceof FormData
+            ? body
+            : JSON.stringify(body),
       signal: controller.signal,
     })
   } catch (err) {
@@ -70,7 +78,14 @@ export async function request(path, { method = 'GET', body, query, headers, time
   }
   clearTimeout(timer)
 
-  const data = await parseBody(response)
+  if (responseType === 'blob') {
+    if (response.ok) {
+      try { return await response.blob() } catch { return null }
+    }
+    throw new HttpError({ code: `HTTP_${response.status}`, message: response.statusText || '下载失败', status: response.status })
+  }
+
+  const data = await parseBody(response, responseType)
   if (response.ok) {
     return data
   }

+ 29 - 0
web-app/src/views/List.vue

@@ -0,0 +1,29 @@
+<template>
+  <div class="list-stub">
+    <header class="page-hd">
+      <div>
+        <div class="page-title">业务列表</div>
+        <div class="page-sub">占位页 · 原有 List.vue 已被移除</div>
+      </div>
+    </header>
+    <div class="stub-card">该页面暂未上线,请使用左侧菜单导航到其他模块。</div>
+  </div>
+</template>
+
+<script setup>
+</script>
+
+<style scoped>
+.list-stub { padding: 18px 24px; color: var(--ink, #1A2438); }
+.page-title { font-size: 18px; font-weight: 600; margin-bottom: 3px; }
+.page-sub { font-size: 12px; color: var(--mut, #6B7A96); margin-bottom: 14px; }
+.stub-card {
+  background: #fff;
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 8px;
+  padding: 48px 16px;
+  text-align: center;
+  color: var(--mut, #6B7A96);
+  font-size: 13px;
+}
+</style>

+ 996 - 0
web-app/src/views/document/index.vue

@@ -0,0 +1,996 @@
+<template>
+  <AdminPage
+    title="文档管理"
+    subtitle="统一管理投研文档(央行货政报告、重要会议、产业政策、研究报告等)。支持上传 / 检索 / 预览 / 下载 / 摘要"
+  >
+    <template #filter>
+      <div class="filter-row">
+        <select v-model="filterCategory" class="form-input" @change="onCategoryFilterChange">
+          <option value="">全部一级分类</option>
+          <option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
+        </select>
+        <select v-model="filterSubcategory" class="form-input" :disabled="!filterCategory">
+          <option value="">全部二级分类</option>
+          <option v-for="s in subcategoryOptions" :key="s" :value="s">{{ s }}</option>
+        </select>
+        <input
+          v-model="keyword"
+          class="search-input"
+          placeholder="搜索标题 / 周期 / 路径"
+          @keyup.enter="reload"
+        />
+        <label class="check-inline">
+          <input type="checkbox" v-model="latestOnly" @change="reload" />
+          <span>仅最新</span>
+        </label>
+        <AppButton type="primary" size="sm" @click="reload">查询</AppButton>
+        <AppButton size="sm" @click="resetFilter">重置</AppButton>
+      </div>
+    </template>
+
+    <template #tableSummary>
+      共 <strong>{{ total }}</strong> 篇 ·
+      当前展示 <strong>{{ filtered.length }}</strong> 篇 ·
+      总大小 <strong>{{ totalSizeLabel }}</strong>
+    </template>
+    <template #tableActions>
+      <AppButton size="sm" @click="reload" :disabled="loading">↻ 刷新</AppButton>
+      <AppButton v-if="canCreate" type="primary" size="sm" @click="openCreate">+ 上传文档</AppButton>
+    </template>
+
+    <div class="doc-layout">
+      <!-- 左侧分类树 -->
+      <aside class="doc-tree">
+        <div class="tree-hd">
+          <span class="ttl">📚 分类导航</span>
+          <button class="link" @click="reloadCategories" :disabled="catLoading">刷新</button>
+        </div>
+        <div v-if="catLoading && !categoryGroups.length" class="tree-empty">加载中…</div>
+        <div v-else-if="!categoryGroups.length" class="tree-empty">暂无文档分类</div>
+        <div v-else class="tree-bd">
+          <div
+            class="tree-row tree-all"
+            :class="{ active: !selectedCategory && !selectedSubcategory }"
+            @click="selectCategory(null, null)"
+          >
+            <span class="name">全部分类</span>
+            <span class="ct">{{ categoryGroups.reduce((s, g) => s + g.doc_count, 0) }}</span>
+          </div>
+          <div v-for="g in categoryGroups" :key="g.category" class="tree-group">
+            <div
+              class="tree-row tree-cat"
+              :class="{ active: selectedCategory === g.category && !selectedSubcategory }"
+              @click="selectCategory(g.category, null)"
+            >
+              <span class="caret" :class="{ open: openGroups[g.category] }" @click.stop="toggleGroup(g.category)">▸</span>
+              <span class="name">{{ g.category }}</span>
+              <span class="ct">{{ g.doc_count }}</span>
+            </div>
+            <div v-show="openGroups[g.category]" class="tree-sub">
+              <div
+                v-for="s in g.sub_list"
+                :key="`${g.category}/${s.subcategory}`"
+                class="tree-row tree-sub-row"
+                :class="{ active: selectedCategory === g.category && selectedSubcategory === s.subcategory }"
+                @click="selectCategory(g.category, s.subcategory)"
+              >
+                <span class="name" :title="s.indicator_name || s.subcategory">{{ s.subcategory }}</span>
+                <span class="ct">{{ s.doc_count }}</span>
+              </div>
+            </div>
+          </div>
+        </div>
+      </aside>
+
+      <!-- 右侧文档列表 -->
+      <section class="doc-main">
+        <table class="meta-table">
+          <thead>
+            <tr>
+              <th style="width: 60px">ID</th>
+              <th style="width: 110px">分类</th>
+              <th>标题 / 周期</th>
+              <th style="width: 90px">文件</th>
+              <th style="width: 80px">大小</th>
+              <th style="width: 100px">发布日期</th>
+              <th style="width: 50px">页数</th>
+              <th style="width: 60px">最新</th>
+              <th style="width: 70px">排序</th>
+              <th style="width: 200px">操作</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-if="loading && filtered.length === 0">
+              <td colspan="10" class="td-empty">加载中…</td>
+            </tr>
+            <tr v-else-if="filtered.length === 0">
+              <td colspan="10" class="td-empty">暂无文档</td>
+            </tr>
+            <tr v-for="d in filtered" :key="d.id">
+              <td class="td-id">{{ d.id }}</td>
+              <td>
+                <div class="cat-cell">
+                  <div class="cat">{{ d.category || '—' }}</div>
+                  <div class="sub" :title="d.subcategory">{{ d.subcategory || '—' }}</div>
+                </div>
+              </td>
+              <td>
+                <div class="td-title">
+                  <div class="title">{{ d.title }}</div>
+                  <div class="mut">{{ d.period }}{{ d.period_year ? ` · ${d.period_year}` : '' }}<span v-if="d.indicator_name"> · {{ d.indicator_name }}</span></div>
+                </div>
+              </td>
+              <td>
+                <code class="code-chip" :title="d.file_path">{{ getFileExt(d) }}</code>
+              </td>
+              <td class="td-num">{{ formatFileSize(d.file_size) }}</td>
+              <td class="td-num">{{ (d.publish_date || '—').slice(0, 10) }}</td>
+              <td class="td-num">{{ d.page_count ?? '—' }}</td>
+              <td>
+                <span v-if="d.is_latest === 1 || d.is_latest === true" class="badge badge-yes">最新</span>
+                <span v-else class="badge badge-no">—</span>
+              </td>
+              <td class="td-num">{{ d.sort_order ?? 0 }}</td>
+              <td>
+                <div class="ops-row">
+                  <AppButton type="ghost" size="sm" :disabled="!canPreview(d)" @click="openPreview(d)">预览</AppButton>
+                  <AppButton type="ghost" size="sm" @click="downloadDoc(d)">下载</AppButton>
+                  <AppButton v-if="canEdit" type="ghost" size="sm" @click="openEdit(d)">编辑</AppButton>
+                  <AppButton v-if="canSummary" type="ghost" size="sm" @click="openSummary(d)">摘要</AppButton>
+                  <AppButton v-if="canDelete" type="danger" size="sm" @click="askDelete(d)">删除</AppButton>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </section>
+    </div>
+
+    <!-- 预览弹窗 -->
+    <AppModal v-model="previewOpen" :title="previewTitle" width="900">
+      <div v-if="previewLoading" class="preview-status">加载中…</div>
+      <div v-else-if="previewError" class="preview-status err">{{ previewError }}</div>
+      <div v-else-if="previewKind === 'pdf'">
+        <iframe :src="previewUrl" class="preview-iframe" :title="previewTitle" />
+      </div>
+      <div v-else-if="previewKind === 'text'">
+        <iframe :src="previewUrl" class="preview-iframe" :title="previewTitle" />
+      </div>
+      <div v-else class="preview-status">
+        当前文件类型(.{{ previewExt }})浏览器无法直接预览,请点击下载查看。
+        <div style="margin-top: 10px">
+          <AppButton type="primary" size="sm" @click="downloadDoc(previewDoc)">⇩ 下载文件</AppButton>
+        </div>
+      </div>
+      <template #footer>
+        <AppButton size="sm" @click="previewOpen = false">关闭</AppButton>
+        <AppButton v-if="previewDoc" type="primary" size="sm" @click="downloadDoc(previewDoc)">⇩ 下载</AppButton>
+      </template>
+    </AppModal>
+
+    <!-- 上传 / 编辑 -->
+    <AppModal v-model="formOpen" :title="formMode === 'edit' ? '编辑文档' : '上传文档'" width="640">
+      <div class="form-grid">
+        <FormField label="关联指标" :error="formError.categoryId" required class="full">
+          <select v-model.number="form.categoryId" class="form-input" :disabled="loadingIndicators">
+            <option :value="null" disabled>请选择指标(决定分类 / 子分类)</option>
+            <!-- <option v-for="g in indicatorGroups" :key="g.category" :value="null" disabled>{{ g.category }}</option> -->
+            <option v-for="it in flatIndicators" :key="it.id" :value="it.id">
+              {{ it.category }} / {{ it.subcategory || '—' }} / {{ it.name }} (#{{ it.id }})
+            </option>
+          </select>
+          <div v-if="loadingIndicators" class="field-hint">指标加载中…</div>
+        </FormField>
+        <FormField label="周期" :error="formError.period" required>
+          <input v-model="form.period" class="form-input" placeholder="例:2026Q2 / 2025年 / 2026-07-30" />
+        </FormField>
+        <FormField label="年度" :error="formError.periodYear">
+          <input v-model.number="form.periodYear" type="number" class="form-input" placeholder="可选" />
+        </FormField>
+        <FormField label="标题" :error="formError.title" required class="full">
+          <input v-model="form.title" class="form-input" placeholder="例:2026Q2-中国货币政策例会" />
+        </FormField>
+        <FormField label="发布日期" :error="formError.publishDate">
+          <input v-model="form.publishDate" type="date" class="form-input" />
+        </FormField>
+        <FormField label="页数" :error="formError.pageCount">
+          <input v-model.number="form.pageCount" type="number" class="form-input" placeholder="可选" />
+        </FormField>
+        <FormField label="排序" :error="formError.sortOrder">
+          <input v-model.number="form.sortOrder" type="number" class="form-input" placeholder="默认 0" />
+        </FormField>
+        <FormField label="是否最新" :error="formError.isLatest">
+          <label class="check-inline">
+            <input type="checkbox" v-model="form.isLatest" />
+            <span>标记为该分类最新文档(自动取消其它最新标记)</span>
+          </label>
+        </FormField>
+        <FormField
+          label="文件"
+          :error="formError.file"
+          :hint="formMode === 'edit' ? '留空则保留原文件' : `支持扩展名:${ALLOWED_EXTS.join(', ')},单文件 ≤ 20MB`"
+          class="full"
+        >
+          <input ref="fileInputRef" type="file" class="form-input file-input" @change="onFileChange" />
+          <div v-if="form.file" class="file-meta">
+            <span>📎 {{ form.file.name }}</span>
+            <span class="mut">{{ formatFileSize(form.file.size) }}</span>
+            <button class="link" type="button" @click="clearFile">移除</button>
+          </div>
+          <div v-else-if="formMode === 'edit' && form.filePath" class="file-meta">
+            <span>📄 当前:{{ form.filePath }}</span>
+          </div>
+        </FormField>
+      </div>
+      <template #footer>
+        <AppButton size="sm" @click="formOpen = false">取消</AppButton>
+        <AppButton type="primary" size="sm" :loading="submitting" @click="submitForm">
+          {{ formMode === 'edit' ? '保存' : '上传' }}
+        </AppButton>
+      </template>
+    </AppModal>
+
+    <!-- 摘要编辑 -->
+    <AppModal v-model="summaryOpen" :title="`编辑摘要 · ${summaryDoc?.title || ''}`" width="640">
+      <div class="form-grid">
+        <FormField label="整体基调" class="full">
+          <textarea v-model="summaryForm.overallTone" class="form-input" rows="2" placeholder="如:坚持稳中求进" />
+        </FormField>
+        <FormField label="政策立场" class="full">
+          <textarea v-model="summaryForm.policyStance" class="form-input" rows="2" placeholder="如:稳健偏宽松" />
+        </FormField>
+        <FormField label="风险防控" class="full">
+          <textarea v-model="summaryForm.riskControl" class="form-input" rows="2" placeholder="如:防范资金空转" />
+        </FormField>
+        <FormField label="要点 (JSON 字符串)" hint='例:[{"section":"汇率","text":"防止超调","change":"强调"}]' class="full">
+          <textarea v-model="summaryForm.keyPointsJson" class="form-input mono" rows="5" placeholder='[{"section":"...","text":"...","change":"..."}]' />
+        </FormField>
+      </div>
+      <template #footer>
+        <AppButton size="sm" @click="summaryOpen = false">取消</AppButton>
+        <AppButton type="primary" size="sm" :loading="summarySubmitting" @click="submitSummary">保存摘要</AppButton>
+      </template>
+    </AppModal>
+
+    <!-- 删除确认 -->
+    <AppConfirm
+      v-model="confirmOpen"
+      title="删除文档"
+      :message="`确认删除 #${confirmDoc?.id ?? ''} ${confirmDoc?.title || ''}?文件及摘要将一并删除,此操作不可恢复。`"
+      ok-text="删除"
+      type="danger"
+      :loading="confirmLoading"
+      @ok="doDelete"
+    />
+  </AdminPage>
+</template>
+
+<script setup>
+import { computed, onMounted, reactive, ref, watch } from 'vue'
+import AdminPage from '@/components/common/AdminPage.vue'
+import AppButton from '@/components/common/AppButton.vue'
+import AppModal from '@/components/common/AppModal.vue'
+import AppConfirm from '@/components/common/AppConfirm.vue'
+import FormField from '@/components/common/FormField.vue'
+import { useToast } from '@/composables/useToast'
+import { useAuth } from '@/store/auth'
+import { HttpError } from '@/utils/http'
+import {
+  getCategories,
+  listDocuments,
+  getDocument,
+  fetchDocumentBlob,
+  createDocument,
+  updateDocument,
+  deleteDocument,
+  updateDocumentSummary,
+  formatFileSize,
+  getFileExtension,
+  isAllowedExt,
+  ALLOWED_EXTS,
+} from '@/api/document'
+import { listIndicators } from '@/api/indicator'
+
+const toast = useToast()
+const { hasPermission, hasRole } = useAuth()
+
+const canCreate = computed(() => hasRole('ADMIN') && hasPermission('document:create'))
+const canEdit = computed(() => hasRole('ADMIN') && hasPermission('document:update'))
+const canDelete = computed(() => hasRole('ADMIN') && hasPermission('document:delete'))
+const canSummary = computed(() => hasRole('ADMIN') && hasPermission('document:summary:write'))
+
+// ===== 列表 / 过滤 =====
+const allDocs = ref([])
+const loading = ref(false)
+const keyword = ref('')
+const latestOnly = ref(false)
+const filterCategory = ref('')
+const filterSubcategory = ref('')
+
+const selectedCategory = ref('')
+const selectedSubcategory = ref('')
+
+const total = computed(() => allDocs.value.length)
+const totalSize = computed(() => allDocs.value.reduce((s, x) => s + (x.file_size || 0), 0))
+const totalSizeLabel = computed(() => formatFileSize(totalSize.value))
+
+const categoryOptions = computed(() => {
+  const s = new Set()
+  for (const d of allDocs.value) if (d.category) s.add(d.category)
+  return Array.from(s)
+})
+
+const subcategoryOptions = computed(() => {
+  if (!filterCategory.value) return []
+  const s = new Set()
+  for (const d of allDocs.value) {
+    if (d.category === filterCategory.value && d.subcategory) s.add(d.subcategory)
+  }
+  return Array.from(s)
+})
+
+const filtered = computed(() => {
+  const kw = keyword.value.trim().toLowerCase()
+  return allDocs.value.filter((d) => {
+    if (selectedCategory.value && d.category !== selectedCategory.value) return false
+    if (selectedSubcategory.value && d.subcategory !== selectedSubcategory.value) return false
+    if (filterCategory.value && d.category !== filterCategory.value) return false
+    if (filterSubcategory.value && d.subcategory !== filterSubcategory.value) return false
+    if (latestOnly.value && !(d.is_latest === 1 || d.is_latest === true)) return false
+    if (kw) {
+      const hay = `${d.title || ''} ${d.period || ''} ${d.file_path || ''}`.toLowerCase()
+      if (!hay.includes(kw)) return false
+    }
+    return true
+  })
+})
+
+// ===== 左侧分类树 =====
+const categoryGroups = ref([])
+const catLoading = ref(false)
+const openGroups = reactive({})
+
+async function reloadCategories() {
+  catLoading.value = true
+  try {
+    const data = await getCategories()
+    const list = Array.isArray(data) ? data : []
+    const map = new Map()
+    for (const it of list) {
+      const cat = it.category || '未分类'
+      if (!map.has(cat)) map.set(cat, { category: cat, doc_count: 0, sub_list: [] })
+      const g = map.get(cat)
+      g.doc_count += it.doc_count || 0
+      g.sub_list.push({
+        subcategory: it.subcategory || '',
+        doc_count: it.doc_count || 0,
+        indicator_id: it.indicator_id,
+        indicator_name: it.indicator_name || '',
+        latest_period: it.latest_period || '',
+        latest_title: it.latest_title || '',
+        latest_id: it.latest_id,
+      })
+    }
+    categoryGroups.value = Array.from(map.values()).sort((a, b) => a.category.localeCompare(b.category, 'zh-CN'))
+    for (const g of categoryGroups.value) {
+      g.sub_list.sort((a, b) => a.subcategory.localeCompare(b.subcategory, 'zh-CN'))
+      if (openGroups[g.category] === undefined) openGroups[g.category] = true
+    }
+  } catch (err) {
+    const msg = err instanceof HttpError ? err.message : '加载分类失败'
+    toast.error(msg)
+    categoryGroups.value = []
+  } finally {
+    catLoading.value = false
+  }
+}
+
+function toggleGroup(cat) {
+  openGroups[cat] = !openGroups[cat]
+}
+
+function selectCategory(cat, sub) {
+  selectedCategory.value = cat || ''
+  selectedSubcategory.value = sub || ''
+  if (cat) {
+    openGroups[cat] = true
+    filterCategory.value = cat
+    filterSubcategory.value = sub || ''
+  } else {
+    filterCategory.value = ''
+    filterSubcategory.value = ''
+  }
+}
+
+function onCategoryFilterChange() {
+  filterSubcategory.value = ''
+  selectedCategory.value = filterCategory.value
+  selectedSubcategory.value = ''
+}
+
+// ===== 数据加载 =====
+async function reload() {
+  loading.value = true
+  try {
+    const params = {}
+    if (filterCategory.value) params.category = filterCategory.value
+    if (filterSubcategory.value) params.subcategory = filterSubcategory.value
+    const data = await listDocuments(params)
+    const items = Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : []
+    allDocs.value = items.slice()
+  } catch (err) {
+    const msg = err instanceof HttpError ? err.message : '加载文档失败'
+    toast.error(msg)
+    allDocs.value = []
+  } finally {
+    loading.value = false
+  }
+}
+
+function resetFilter() {
+  keyword.value = ''
+  latestOnly.value = false
+  filterCategory.value = ''
+  filterSubcategory.value = ''
+  selectedCategory.value = ''
+  selectedSubcategory.value = ''
+  reload()
+}
+
+// ===== 操作:预览 / 下载 =====
+const previewOpen = ref(false)
+const previewDoc = ref(null)
+const previewUrl = ref('')
+const previewLoading = ref(false)
+const previewError = ref('')
+const previewExt = computed(() => previewDoc.value ? getFileExtension(previewDoc.value.file_path || previewDoc.value.title) : '')
+const previewTitle = computed(() => previewDoc.value?.title || '文档预览')
+
+const previewKind = computed(() => {
+  const e = previewExt.value
+  if (e === 'pdf') return 'pdf'
+  if (['txt', 'md', 'csv', 'json'].includes(e)) return 'text'
+  return 'other'
+})
+
+function canPreview(d) {
+  return Boolean(d && d.file_path)
+}
+
+async function openPreview(d) {
+  if (!canPreview(d)) return
+  previewDoc.value = d
+  previewOpen.value = true
+  previewLoading.value = true
+  previewError.value = ''
+  if (previewUrl.value) {
+    URL.revokeObjectURL(previewUrl.value)
+    previewUrl.value = ''
+  }
+  try {
+    const url = await fetchDocumentBlob(d.id, { preview: true })
+    if (!url) {
+      previewError.value = '文件为空或加载失败'
+      return
+    }
+    if (previewKind.value === 'other') {
+      previewUrl.value = ''
+    } else {
+      previewUrl.value = url
+    }
+  } catch (err) {
+    const msg = err instanceof HttpError ? err.message : '预览加载失败'
+    previewError.value = msg
+  } finally {
+    previewLoading.value = false
+  }
+}
+
+function closePreview() {
+  if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
+  previewUrl.value = ''
+  previewDoc.value = null
+  previewOpen.value = false
+}
+
+watch(previewOpen, (v) => { if (!v) closePreview() })
+
+async function downloadDoc(d) {
+  if (!d) return
+  try {
+    const url = await fetchDocumentBlob(d.id, { preview: false })
+    if (!url) {
+      toast.error('文件为空')
+      return
+    }
+    const a = document.createElement('a')
+    const ext = getFileExtension(d.file_path || d.title)
+    const base = (d.title || `document-${d.id}`).replace(/[\\/:*?"<>|]/g, '_')
+    a.href = url
+    a.download = ext ? `${base}.${ext}` : base
+    document.body.appendChild(a)
+    a.click()
+    document.body.removeChild(a)
+    setTimeout(() => URL.revokeObjectURL(url), 4000)
+  } catch (err) {
+    const msg = err instanceof HttpError ? err.message : '下载失败'
+    toast.error(msg)
+  }
+}
+
+// ===== 操作:上传 / 编辑 =====
+const formOpen = ref(false)
+const formMode = ref('create')
+const submitting = ref(false)
+const form = reactive({
+  id: null,
+  categoryId: null,
+  period: '',
+  periodYear: null,
+  title: '',
+  publishDate: '',
+  pageCount: null,
+  isLatest: true,
+  sortOrder: 0,
+  file: null,
+  filePath: '',
+})
+const formError = reactive({
+  categoryId: '', period: '', periodYear: '', title: '',
+  publishDate: '', pageCount: '', sortOrder: '', isLatest: '', file: '',
+})
+const fileInputRef = ref(null)
+
+const indicators = ref([])
+const loadingIndicators = ref(false)
+const indicatorGroups = computed(() => {
+  const m = new Map()
+  for (const it of indicators.value) {
+    const c = it.category || '未分类'
+    if (!m.has(c)) m.set(c, c)
+  }
+  return Array.from(m.values()).map((category) => ({ category }))
+})
+const flatIndicators = computed(() =>
+  indicators.value
+    .slice()
+    .sort((a, b) => {
+      if (a.category !== b.category) return (a.category || '').localeCompare(b.category || '', 'zh-CN')
+      if ((a.subcategory || '') !== (b.subcategory || '')) return (a.subcategory || '').localeCompare(b.subcategory || '', 'zh-CN')
+      return (a.id || 0) - (b.id || 0)
+    }),
+)
+
+async function loadIndicators() {
+  loadingIndicators.value = true
+  try {
+    const data = await listIndicators()
+    indicators.value = Array.isArray(data) ? data : []
+  } catch (err) {
+    const msg = err instanceof HttpError ? err.message : '加载指标失败'
+    toast.error(msg)
+    indicators.value = []
+  } finally {
+    loadingIndicators.value = false
+  }
+}
+
+function resetForm() {
+  Object.assign(form, {
+    id: null,
+    categoryId: null,
+    period: '',
+    periodYear: null,
+    title: '',
+    publishDate: '',
+    pageCount: null,
+    isLatest: true,
+    sortOrder: 0,
+    file: null,
+    filePath: '',
+  })
+  Object.assign(formError, {
+    categoryId: '', period: '', periodYear: '', title: '',
+    publishDate: '', pageCount: '', sortOrder: '', isLatest: '', file: '',
+  })
+  if (fileInputRef.value) fileInputRef.value.value = ''
+}
+
+function openCreate() {
+  if (!canCreate.value) {
+    toast.warning('无上传权限(需 document:create)')
+    return
+  }
+  resetForm()
+  formMode.value = 'create'
+  formOpen.value = true
+  if (!indicators.value.length) loadIndicators()
+}
+
+function openEdit(d) {
+  if (!canEdit.value) {
+    toast.warning('无编辑权限(需 document:update)')
+    return
+  }
+  resetForm()
+  formMode.value = 'edit'
+  form.id = d.id
+  form.categoryId = d.category_id ?? null
+  form.period = d.period || ''
+  form.periodYear = d.period_year ?? null
+  form.title = d.title || ''
+  form.publishDate = (d.publish_date || '').slice(0, 10)
+  form.pageCount = d.page_count ?? null
+  form.isLatest = d.is_latest === 1 || d.is_latest === true
+  form.sortOrder = d.sort_order ?? 0
+  form.filePath = d.file_path || ''
+  formOpen.value = true
+  if (!indicators.value.length) loadIndicators()
+}
+
+function onFileChange(e) {
+  const file = e.target.files?.[0] || null
+  form.file = file
+  formError.file = ''
+  if (file) {
+    const ext = getFileExtension(file.name)
+    if (!isAllowedExt(ext)) {
+      formError.file = `不支持的扩展名 .${ext}(允许:${ALLOWED_EXTS.join(', ')})`
+      form.file = null
+      e.target.value = ''
+    } else if (file.size > 20 * 1024 * 1024) {
+      formError.file = '文件超过 20MB 上限'
+      form.file = null
+      e.target.value = ''
+    }
+  }
+}
+
+function clearFile() {
+  form.file = null
+  if (fileInputRef.value) fileInputRef.value.value = ''
+}
+
+function validateForm() {
+  let ok = true
+  if (!form.categoryId) { formError.categoryId = '请选择关联指标'; ok = false }
+  if (!form.period || !form.period.trim()) { formError.period = '请输入周期'; ok = false }
+  if (!form.title || !form.title.trim()) { formError.title = '请输入标题'; ok = false }
+  if (form.title && form.title.length > 256) { formError.title = '标题最长 256 字符'; ok = false }
+  if (form.pageCount !== null && form.pageCount !== '' && (!Number.isInteger(Number(form.pageCount)) || Number(form.pageCount) < 0)) {
+    formError.pageCount = '页数必须为非负整数'; ok = false
+  }
+  if (form.sortOrder !== null && form.sortOrder !== '' && !Number.isInteger(Number(form.sortOrder))) {
+    formError.sortOrder = '排序必须为整数'; ok = false
+  }
+  if (formMode.value === 'create' && !form.file) {
+    formError.file = '请选择要上传的文件'; ok = false
+  }
+  return ok
+}
+
+async function submitForm() {
+  if (submitting.value) return
+  if (!validateForm()) return
+  submitting.value = true
+  try {
+    const payload = {
+      categoryId: form.categoryId,
+      period: form.period.trim(),
+      title: form.title.trim(),
+      periodYear: form.periodYear ?? '',
+      publishDate: form.publishDate || '',
+      pageCount: form.pageCount ?? '',
+      isLatest: form.isLatest ? 1 : 0,
+      sortOrder: form.sortOrder ?? 0,
+    }
+    if (form.file) payload.file = form.file
+    let res
+    if (formMode.value === 'create') {
+      res = await createDocument(payload)
+      toast.success(`已上传 #${res?.id ?? ''} ${res?.title || form.title}`)
+    } else {
+      res = await updateDocument(form.id, payload)
+      toast.success(`已更新 #${res?.id ?? form.id} ${res?.title || form.title}`)
+    }
+    formOpen.value = false
+    await Promise.all([reload(), reloadCategories()])
+  } catch (err) {
+    if (err instanceof HttpError) toast.error(err.message || '保存失败')
+    else toast.error('保存失败')
+  } finally {
+    submitting.value = false
+  }
+}
+
+// ===== 操作:摘要 =====
+const summaryOpen = ref(false)
+const summarySubmitting = ref(false)
+const summaryDoc = ref(null)
+const summaryForm = reactive({
+  overallTone: '',
+  policyStance: '',
+  riskControl: '',
+  keyPointsJson: '',
+})
+
+async function openSummary(d) {
+  if (!canSummary.value) {
+    toast.warning('无摘要写入权限(需 document:summary:write)')
+    return
+  }
+  summaryDoc.value = d
+  summaryOpen.value = true
+  summaryForm.overallTone = ''
+  summaryForm.policyStance = ''
+  summaryForm.riskControl = ''
+  summaryForm.keyPointsJson = ''
+  try {
+    const detail = await getDocument(d.id)
+    const s = detail?.summary
+    if (s) {
+      summaryForm.overallTone = s.overall_tone || ''
+      summaryForm.policyStance = s.policy_stance || ''
+      summaryForm.riskControl = s.risk_control || ''
+      summaryForm.keyPointsJson = s.key_points_json || ''
+    }
+  } catch (err) {
+    const msg = err instanceof HttpError ? err.message : '加载文档详情失败'
+    toast.error(msg)
+  }
+}
+
+async function submitSummary() {
+  if (summarySubmitting.value || !summaryDoc.value) return
+  if (summaryForm.keyPointsJson.trim()) {
+    try {
+      JSON.parse(summaryForm.keyPointsJson)
+    } catch {
+      toast.error('要点 JSON 解析失败,请检查格式')
+      return
+    }
+  }
+  summarySubmitting.value = true
+  try {
+    await updateDocumentSummary(summaryDoc.value.id, {
+      overallTone: summaryForm.overallTone,
+      policyStance: summaryForm.policyStance,
+      riskControl: summaryForm.riskControl,
+      keyPointsJson: summaryForm.keyPointsJson,
+    })
+    toast.success(`已更新摘要 #${summaryDoc.value.id}`)
+    summaryOpen.value = false
+    await reload()
+  } catch (err) {
+    if (err instanceof HttpError) toast.error(err.message || '保存摘要失败')
+    else toast.error('保存摘要失败')
+  } finally {
+    summarySubmitting.value = false
+  }
+}
+
+// ===== 操作:删除 =====
+const confirmOpen = ref(false)
+const confirmDoc = ref(null)
+const confirmLoading = ref(false)
+
+function askDelete(d) {
+  if (!canDelete.value) {
+    toast.warning('无删除权限(需 document:delete)')
+    return
+  }
+  confirmDoc.value = d
+  confirmOpen.value = true
+}
+
+async function doDelete() {
+  if (!confirmDoc.value) return
+  confirmLoading.value = true
+  try {
+    const res = await deleteDocument(confirmDoc.value.id)
+    const deleted = res?.dbDeleted ?? res?.deleted ?? true
+    toast.success(`已删除 #${confirmDoc.value.id}${res?.fileDeleted === false ? '(磁盘文件未删除)' : ''}`)
+    confirmOpen.value = false
+    confirmDoc.value = null
+    await Promise.all([reload(), reloadCategories()])
+  } catch (err) {
+    if (err instanceof HttpError) toast.error(err.message || '删除失败')
+    else toast.error('删除失败')
+  } finally {
+    confirmLoading.value = false
+  }
+}
+
+function getFileExt(d) {
+  const ext = getFileExtension(d?.file_path || d?.title)
+  return ext ? ext.toUpperCase() : '—'
+}
+
+onMounted(async () => {
+  await Promise.all([reload(), reloadCategories()])
+})
+</script>
+
+<style scoped>
+.filter-row {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  flex-wrap: wrap;
+  width: 100%;
+}
+.form-input {
+  height: 30px;
+  padding: 0 10px;
+  border: 1px solid #E4E9F2;
+  border-radius: 6px;
+  font-size: 12.5px;
+  font-family: inherit;
+  outline: none;
+  transition: 0.15s;
+  background: #fff;
+  color: #1A2438;
+  min-width: 140px;
+}
+.form-input:focus { border-color: #1552D0; }
+.form-input:disabled { background: #F7F9FD; color: #98A5BD; }
+.search-input {
+  flex: 1;
+  min-width: 200px;
+  max-width: 320px;
+  height: 30px;
+  padding: 0 10px;
+  border: 1px solid #E4E9F2;
+  border-radius: 6px;
+  font-size: 12.5px;
+  font-family: inherit;
+  outline: none;
+}
+.search-input:focus { border-color: #1552D0; }
+.check-inline { display: inline-flex; gap: 5px; align-items: center; font-size: 12.5px; color: #6B7A96; }
+.file-input { padding: 4px 6px; height: auto; }
+.file-meta {
+  margin-top: 6px;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  font-size: 12px;
+  color: #1A2438;
+}
+.file-meta .mut { color: #98A5BD; }
+.file-meta .link { background: transparent; border: none; color: #1552D0; cursor: pointer; font-size: 12px; }
+
+.doc-layout {
+  display: grid;
+  grid-template-columns: 280px 1fr;
+  gap: 0;
+  min-height: 400px;
+}
+
+.doc-tree {
+  border-right: 1px solid #E4E9F2;
+  background: #FAFBFE;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+.tree-hd {
+  padding: 10px 14px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border-bottom: 1px solid #E4E9F2;
+  font-size: 12.5px;
+  font-weight: 600;
+  color: #1A2438;
+}
+.tree-hd .link { background: transparent; border: none; color: #1552D0; font-size: 12px; cursor: pointer; }
+.tree-empty { padding: 24px; text-align: center; color: #98A5BD; font-size: 12.5px; }
+.tree-bd { overflow-y: auto; max-height: 600px; }
+.tree-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 7px 14px;
+  font-size: 12.5px;
+  color: #1A2438;
+  cursor: pointer;
+  transition: 0.12s;
+}
+.tree-row:hover { background: #F0F4FB; }
+.tree-row.active { background: #EAF1FF; color: #1552D0; font-weight: 500; }
+.tree-row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.tree-row .ct { font-variant-numeric: tabular-nums; color: #98A5BD; font-size: 11.5px; }
+.tree-row.active .ct { color: #1552D0; }
+.tree-row .caret { display: inline-block; width: 12px; text-align: center; color: #98A5BD; transition: 0.15s; }
+.tree-row .caret.open { transform: rotate(90deg); color: #1552D0; }
+.tree-cat { font-weight: 500; }
+.tree-sub-row { padding-left: 28px; font-size: 12px; color: #6B7A96; }
+
+.doc-main {
+  padding: 0;
+  overflow-x: auto;
+  min-width: 0;
+}
+
+.meta-table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 12.5px;
+}
+.meta-table thead th {
+  background: #F7F9FD;
+  color: #6B7A96;
+  font-weight: 500;
+  text-align: left;
+  padding: 10px 12px;
+  border-bottom: 1px solid #E4E9F2;
+  white-space: nowrap;
+  position: sticky;
+  top: 0;
+  z-index: 1;
+}
+.meta-table tbody td {
+  padding: 10px 12px;
+  border-bottom: 1px solid #F0F3F8;
+  color: #1A2438;
+  vertical-align: top;
+}
+.meta-table tbody tr:hover td { background: #F7F9FD; }
+.meta-table tbody tr:last-child td { border-bottom: none; }
+.td-empty { text-align: center; color: #98A5BD; padding: 28px 0 !important; }
+.td-id { font-variant-numeric: tabular-nums; color: #6B7A96; }
+.td-num { font-variant-numeric: tabular-nums; color: #6B7A96; font-size: 12px; }
+.cat-cell .cat { color: #1A2438; font-weight: 500; }
+.cat-cell .sub { color: #98A5BD; font-size: 11.5px; margin-top: 2px; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.td-title .title { color: #1A2438; font-weight: 500; }
+.td-title .mut { color: #98A5BD; font-size: 11.5px; margin-top: 2px; }
+.code-chip {
+  display: inline-block;
+  background: #F4F7FC;
+  color: #6B7A96;
+  font-family: 'SFMono-Regular', Consolas, monospace;
+  font-size: 11px;
+  padding: 2px 6px;
+  border-radius: 4px;
+  border: 1px solid #E4E9F2;
+}
+.badge {
+  display: inline-block;
+  padding: 2px 6px;
+  border-radius: 4px;
+  font-size: 11px;
+  font-weight: 500;
+}
+.badge-yes { background: #EAF1FF; color: #0E3A96; }
+.badge-no { background: #F4F7FC; color: #98A5BD; }
+
+.ops-row { display: flex; gap: 4px; flex-wrap: nowrap; }
+
+.form-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 0 18px;
+}
+.form-grid :deep(.field) { margin-bottom: 10px; }
+.form-grid :deep(.field.full) { grid-column: 1 / -1; }
+.form-grid textarea.form-input { resize: vertical; min-height: 30px; padding: 6px 10px; line-height: 1.5; }
+.form-grid textarea.mono { font-family: 'SFMono-Regular', Consolas, monospace; font-size: 12px; }
+@media (max-width: 640px) { .form-grid { grid-template-columns: 1fr; } }
+
+.preview-status { padding: 40px 0; text-align: center; color: #6B7A96; font-size: 13px; }
+.preview-status.err { color: #E13B3B; }
+.preview-iframe {
+  width: 100%;
+  height: 70vh;
+  border: 1px solid #E4E9F2;
+  border-radius: 6px;
+  background: #fff;
+}
+
+@media (max-width: 960px) {
+  .doc-layout { grid-template-columns: 1fr; }
+  .doc-tree { border-right: none; border-bottom: 1px solid #E4E9F2; }
+  .tree-bd { max-height: 280px; }
+}
+</style>

+ 259 - 0
web-app/src/views/pdf-library/PdfPreviewPanel.vue

@@ -0,0 +1,259 @@
+<template>
+  <div :class="['pdf-panel', { compact }]">
+    <header class="panel-hero">
+      <div class="hero-thumb">
+        <span class="thumb-label">PDF</span>
+        <span v-if="pages" class="thumb-pages">{{ pages }} 页</span>
+      </div>
+      <div class="hero-meta">
+        <div class="hero-title">{{ doc?.title || '—' }}</div>
+        <div class="hero-sub">
+          <span>{{ section?.subcategory || '—' }}</span>
+          <span v-if="doc?.period">· {{ doc.period }}</span>
+          <span v-if="doc?.publish_date">· 发布 {{ String(doc.publish_date).slice(0, 10) }}</span>
+          <span v-if="sizeLabel">· {{ sizeLabel }}</span>
+        </div>
+        <div class="hero-tags">
+          <span v-if="isLatest" class="chip chip-blue">最新</span>
+          <span v-if="section" class="chip chip-gray">{{ section.subcategory || '—' }}</span>
+          <span v-if="section?.doc_count" class="chip chip-gray">共 {{ section.doc_count }} 份</span>
+          <span class="chip chip-warn">预览版</span>
+        </div>
+      </div>
+    </header>
+
+    <section v-if="hasSummary" class="panel-section">
+      <div class="panel-section-hd">文档摘要</div>
+      <div class="panel-section-bd">
+        <div v-if="summary.overallTone" class="sum-row">
+          <span class="k">整体基调</span>
+          <span class="v">{{ summary.overallTone }}</span>
+        </div>
+        <div v-if="summary.policyStance" class="sum-row">
+          <span class="k">政策立场</span>
+          <span class="v">{{ summary.policyStance }}</span>
+        </div>
+        <div v-if="summary.riskControl" class="sum-row">
+          <span class="k">风险防控</span>
+          <span class="v">{{ summary.riskControl }}</span>
+        </div>
+      </div>
+    </section>
+
+    <section v-if="keyPoints.length" class="panel-section">
+      <div class="panel-section-hd">要点摘录</div>
+      <table class="policy-tbl">
+        <thead>
+          <tr><th>章节</th><th>核心表述</th><th>变化</th></tr>
+        </thead>
+        <tbody>
+          <tr v-for="(p, i) in keyPoints" :key="i">
+            <td class="sym">{{ p.section || '—' }}</td>
+            <td>{{ p.text || '—' }}</td>
+            <td>
+              <span v-if="p.change" class="chip chip-blue">{{ p.change }}</span>
+              <span v-else>—</span>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </section>
+
+    <div v-if="!hasSummary && !keyPoints.length && !docLoading" class="empty-tip">
+      <span class="tip-ic">📝</span>
+      <div>
+        <div class="tip-title">暂无摘要</div>
+        <div class="tip-note">该文档尚未录入摘要,可在管理后台编辑。</div>
+      </div>
+    </div>
+
+    <div v-if="docLoading" class="empty-tip">摘要加载中…</div>
+
+    <div v-if="doc?.empty" class="empty">
+      <div class="ic">📂</div>
+      <div class="ttl">该章节暂无文件</div>
+      <div class="sub">文件正在采集中,敬请期待</div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { formatFileSize } from '@/api/document'
+
+const props = defineProps({
+  doc: { type: Object, default: null },
+  section: { type: Object, default: null },
+  isLatest: { type: Boolean, default: false },
+  compact: { type: Boolean, default: false },
+  docLoading: { type: Boolean, default: false },
+})
+
+const summary = computed(() => props.doc?.summary || {})
+const hasSummary = computed(() =>
+  Boolean(summary.value.overallTone || summary.value.policyStance || summary.value.riskControl),
+)
+
+const keyPoints = computed(() => {
+  const raw = props.doc?.summary?.key_points_json || props.doc?.summary?.keyPointsJson
+  if (!raw) return []
+  let parsed
+  try {
+    parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
+  } catch {
+    return []
+  }
+  if (!Array.isArray(parsed)) return []
+  return parsed
+    .filter((p) => p && (p.section || p.text))
+    .map((p) => ({
+      section: p.section || '',
+      text: p.text || '',
+      change: p.change || '',
+    }))
+})
+
+const pages = computed(() => props.doc?.page_count ?? props.doc?.pg ?? 0)
+const sizeLabel = computed(() => props.doc ? formatFileSize(props.doc.file_size || 0) : '')
+</script>
+
+<style scoped>
+.pdf-panel {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  padding: 18px 22px;
+  overflow-y: auto;
+  height: 100%;
+  background: #FAFBFE;
+}
+.pdf-panel.compact { padding: 14px 18px; gap: 10px; }
+
+.panel-hero {
+  display: flex;
+  gap: 16px;
+  padding: 18px;
+  background: linear-gradient(135deg, #F4F7FD, #EAF1FF);
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 10px;
+}
+.hero-thumb {
+  width: 96px;
+  flex: none;
+  border-radius: 6px;
+  background: linear-gradient(180deg, #FCEAEA, #F8D4D4);
+  border: 1px solid #F5C0C0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: var(--up, #E13B3B);
+  font-weight: 700;
+  padding: 8px 4px;
+  text-align: center;
+}
+.thumb-label { font-size: 16px; letter-spacing: 1px; }
+.thumb-pages { font-size: 11px; margin-top: 6px; opacity: .8; font-variant-numeric: tabular-nums; }
+.hero-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
+.hero-title {
+  font-size: 16px;
+  font-weight: 700;
+  color: var(--ink, #1A2438);
+  line-height: 1.4;
+  word-break: break-all;
+}
+.hero-sub {
+  font-size: 12px;
+  color: var(--mut, #6B7A96);
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+.hero-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
+
+.panel-section {
+  background: #fff;
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 8px;
+  overflow: hidden;
+}
+.panel-section-hd {
+  padding: 10px 14px;
+  background: #FAFBFD;
+  border-bottom: 1px solid var(--line, #E4E9F2);
+  font-size: 12.5px;
+  font-weight: 600;
+  color: var(--ink, #1A2438);
+}
+.panel-section-bd { padding: 14px; }
+
+.sum-row {
+  display: grid;
+  grid-template-columns: 80px 1fr;
+  gap: 12px;
+  padding: 6px 0;
+  font-size: 12.5px;
+  line-height: 1.6;
+  color: var(--ink, #1A2438);
+}
+.sum-row + .sum-row { border-top: 1px dashed #EEF2F7; }
+.sum-row .k { color: var(--brand-d, #0E3A96); font-weight: 600; }
+.sum-row .v { color: var(--ink, #1A2438); }
+
+.policy-tbl { width: 100%; border-collapse: collapse; }
+.policy-tbl th {
+  text-align: left;
+  padding: 9px 12px;
+  font-size: 11.5px;
+  color: var(--mut, #6B7A96);
+  font-weight: 500;
+  background: #FAFBFD;
+  border-bottom: 1px solid var(--line, #E4E9F2);
+}
+.policy-tbl td {
+  padding: 10px 12px;
+  font-size: 12.5px;
+  border-bottom: 1px solid #F0F3F8;
+  font-variant-numeric: tabular-nums;
+}
+.policy-tbl tr:last-child td { border-bottom: none; }
+.policy-tbl .sym { font-weight: 600; color: var(--ink, #1A2438); }
+
+.chip {
+  display: inline-block;
+  padding: 2px 8px;
+  border-radius: 10px;
+  font-size: 11px;
+  font-weight: 500;
+  font-variant-numeric: tabular-nums;
+}
+.chip-blue   { background: var(--brand-lt, #EAF1FF); color: var(--brand-d, #0E3A96); }
+.chip-warn   { background: #FFF6E5; color: #B25E00; }
+.chip-gray   { background: #EEF2F7; color: var(--mut, #6B7A96); }
+
+.empty-tip {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 14px;
+  border-radius: 8px;
+  background: #FFFBEA;
+  border: 1px dashed #F5C572;
+  color: #8B6B1A;
+  font-size: 12.5px;
+}
+.empty-tip .tip-ic { font-size: 22px; line-height: 1; }
+.empty-tip .tip-title { font-weight: 600; }
+.empty-tip .tip-note { color: #B25E00; margin-top: 2px; font-size: 11.5px; }
+
+.empty {
+  margin: auto;
+  padding: 80px 40px;
+  text-align: center;
+  color: rgba(26, 36, 56, .55);
+  font-size: 13px;
+}
+.empty .ic { font-size: 48px; margin-bottom: 16px; opacity: .5; }
+.empty .ttl { font-size: 15px; color: var(--ink, #1A2438); font-weight: 600; margin-bottom: 8px; }
+.empty .sub { font-size: 12px; color: var(--mut, #6B7A96); }
+</style>

+ 1013 - 0
web-app/src/views/pdf-library/index.vue

@@ -0,0 +1,1013 @@
+<template>
+  <div class="pdf-lib">
+    <header class="viewer-bar">
+      <div class="title">
+        📄 PDF 文档库 · 政策面
+        <small class="crumbs">
+          <a href="/policy/monetary">← 返回政策面主页</a>
+        </small>
+      </div>
+      <div class="ctrls">
+        <button class="vbtn">⇩ 全部下载</button>
+        <button class="vbtn primary">+ 订阅更新</button>
+      </div>
+    </header>
+
+    <div class="body">
+      <aside class="file-pane">
+        <div class="head">
+          <h3>
+            <span class="ic">📚</span>
+            政策文件库
+          </h3>
+          <div class="meta">
+            共 <b>{{ totalCount }}</b> 份 PDF · 涵盖 {{ sections.length }} 个章节
+            <span v-if="dataAsOf" class="data-as-of">数据截至 {{ dataAsOf }}</span>
+            <span class="acts" @click="expandAll">全部展开</span>
+            <span class="acts" @click="collapseAll">全部折叠</span>
+          </div>
+        </div>
+        <div class="search-box">
+          <input
+            v-model="searchQuery"
+            placeholder="搜索文件名 / 关键词…"
+          >
+        </div>
+        <div v-if="loading && !sections.length" class="loading-tip">章节加载中…</div>
+        <div v-else-if="loadError" class="loading-tip err">{{ loadError }}</div>
+        <div class="file-list">
+          <div
+            v-for="sec in sections"
+            :key="sec.key"
+            class="file-sec"
+          >
+            <div
+              :class="['file-sec-hd', { active: activeSectionKey === sec.key }]"
+              @click="toggleSection(sec.key)"
+            >
+              <span :class="['arr', { open: isOpen(sec.key) }]">▶</span>
+              <span class="id">{{ sec.sectionNo }}</span>
+              <span class="nm" :title="sec.title">{{ sec.title }}</span>
+              <span class="ct">{{ remoteFiles[sec.key]?.length ?? sec.doc_count }}</span>
+            </div>
+            <div :class="['file-sec-bd', { open: isOpen(sec.key) }]">
+              <div
+                v-for="(f, i) in (remoteFiles[sec.key] || [])"
+                :key="`${sec.key}-${f.id ?? i}`"
+                :class="['file-item', { active: isActive(sec.key, i) }]"
+                @click="selectFile(sec, i)"
+              >
+                <div class="ico">PDF</div>
+                <div class="meta">
+                  <div class="name">{{ f.f }}</div>
+                  <div class="info">{{ f.pg ? `${f.pg} 页` : '—' }}</div>
+                </div>
+                <div class="sz-stack">
+                  <div class="sz">{{ f.sz }}</div>
+                  <span v-if="f.latest" class="badge">NEW</span>
+                </div>
+              </div>
+              <div v-if="secLoading[sec.key]" class="file-empty">加载中…</div>
+              <div v-else-if="!remoteFiles[sec.key]?.length" class="file-empty">该章节暂无文件</div>
+            </div>
+          </div>
+          <div v-if="!loading && !sections.length" class="file-empty">暂无可用章节</div>
+        </div>
+      </aside>
+
+      <section class="pdf-pane">
+        <div class="pdf-bar">
+          <div class="info">
+            <b :title="curDoc?.title || '—'">{{ curDoc?.title || '—' }}</b>
+            <span class="pg">{{ curDoc?.page_count ? `${curDoc.page_count} 页` : '— 页' }}</span>
+            <span class="size">{{ curDoc ? formatFileSize(curDoc.file_size || 0) : '—' }}</span>
+          </div>
+          <div class="acts">
+            <button class="vbtn" @click="downloadCur" :disabled="!curDoc">
+              ⇩ 下载
+            </button>
+            <button class="vbtn primary" @click="openModal" :disabled="!curDoc">
+              ⤢ 弹窗全屏
+            </button>
+          </div>
+        </div>
+        <div class="pdf-frame">
+          <div class="pdf-summary-wrap">
+            <PdfPreviewPanel
+              v-if="curDoc"
+              :doc="curDoc"
+              :section="curSection"
+              :is-latest="isLatestSelected"
+              :doc-loading="docLoading"
+            />
+            <div v-else class="empty">
+              <div class="ic">📂</div>
+              <div class="ttl">请选择左侧文件</div>
+              <div class="sub">点击任意 PDF 文件即可在此处预览</div>
+            </div>
+          </div>
+          <div v-if="curDoc" class="pdf-iframe-wrap">
+            <div class="pdf-iframe-hd">
+              <span class="ttl">📄 原文档预览</span>
+              <span v-if="pdfLoading" class="loading">加载中…</span>
+              <span v-else-if="pdfError" class="error">{{ pdfError }}</span>
+              <button v-if="pdfBlobUrl" class="vbtn-link" @click="downloadCur">⇩ 下载</button>
+            </div>
+            <iframe
+              v-if="pdfBlobUrl"
+              :src="pdfBlobUrl"
+              class="pdf-iframe"
+              :title="curDoc?.title || 'PDF'"
+            />
+            <div v-else-if="pdfError" class="pdf-iframe-empty">
+              该文档暂未上传至服务器,请联系管理员补录。
+            </div>
+            <div v-else-if="!pdfLoading" class="pdf-iframe-empty">
+              点击左侧文件以加载 PDF 预览
+            </div>
+          </div>
+        </div>
+      </section>
+    </div>
+
+    <Teleport to="body">
+      <transition name="modal-fade">
+        <div
+          v-if="modalOpen"
+          class="modal-bg"
+          @click.self="closeModal"
+        >
+          <div class="modal">
+            <header class="modal-head">
+              <h3>{{ curDoc?.title || 'PDF 预览' }}</h3>
+              <div class="close" @click="closeModal">×</div>
+            </header>
+            <div class="modal-body">
+              <aside class="modal-file-pane">
+                <div class="head">
+                  <h3><span class="ic">📚</span>同章节文件</h3>
+                </div>
+                <div class="file-list">
+                  <div
+                    v-for="(f, i) in (curSection ? (remoteFiles[curSection.key] || []) : [])"
+                    :key="`m-${f.id ?? i}`"
+                    :class="['file-item', { active: isActive(curSection?.key, i) }]"
+                    @click="selectFile(curSection, i)"
+                  >
+                    <div class="ico">PDF</div>
+                    <div class="meta">
+                      <div class="name">{{ f.f }}</div>
+                      <div class="info">{{ f.pg ? `${f.pg} 页` : '—' }} · {{ f.sz }}</div>
+                    </div>
+                  </div>
+                </div>
+              </aside>
+              <PdfPreviewPanel
+                v-if="curDoc"
+                :doc="curDoc"
+                :section="curSection"
+                :is-latest="isLatestSelected"
+                :doc-loading="docLoading"
+                compact
+              />
+            </div>
+          </div>
+        </div>
+      </transition>
+    </Teleport>
+  </div>
+</template>
+
+<script setup>
+import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import PdfPreviewPanel from './PdfPreviewPanel.vue'
+import {
+  getCategories,
+  listDocuments,
+  getDocument,
+  fetchDocumentBlob,
+  formatFileSize,
+} from '@/api/document'
+
+const router = useRouter()
+const route = useRoute()
+
+const sections = ref([])
+const sectionsLoading = ref(false)
+const loadError = ref('')
+const dataAsOf = ref('')
+
+const remoteFiles = ref({})
+const secLoading = reactive({})
+
+const curDoc = ref(null)
+const curDocCache = reactive({})
+const docLoading = ref(false)
+const pdfBlobUrl = ref('')
+const pdfLoading = ref(false)
+const pdfError = ref('')
+
+const activeSectionKey = ref('')
+const activeFileIdx = ref(0)
+const collapsed = reactive({})
+const searchQuery = ref('')
+const modalOpen = ref(false)
+
+function sectionKeyOf(item) {
+  const sub = (item?.subcategory || '').trim()
+  const m = sub.match(/^(\S+)/)
+  return m ? m[1] : sub || `${item.category || ''}::${item.subcategory || ''}`
+}
+
+function sectionTitleOf(item) {
+  if (item.indicator_name) return item.indicator_name
+  const sub = (item.subcategory || '').trim()
+  const m = sub.match(/^\S+\s+(.+)$/)
+  return m ? m[1] : sub
+}
+
+function sectionNoOf(item) {
+  const sub = (item.subcategory || '').trim()
+  const m = sub.match(/^(\S+)/)
+  return m ? `§${m[1]}` : '§'
+}
+
+function toSection(item) {
+  return {
+    key: sectionKeyOf(item),
+    sectionNo: sectionNoOf(item),
+    title: sectionTitleOf(item),
+    category: item.category || '',
+    subcategory: item.subcategory || '',
+    doc_count: item.doc_count || 0,
+    indicator_id: item.indicator_id,
+    latest_id: item.latest_id,
+    latest_title: item.latest_title,
+    latest_period: item.latest_period,
+  }
+}
+
+async function loadSections() {
+  sectionsLoading.value = true
+  loadError.value = ''
+  try {
+    const data = await getCategories()
+    const list = Array.isArray(data) ? data : []
+    const filtered = list.filter((x) => (x.category || '') === '政策面')
+    sections.value = filtered.map(toSection)
+    dataAsOf.value = new Date().toISOString().slice(0, 10)
+  } catch (e) {
+    loadError.value = e?.message || '章节加载失败'
+    sections.value = []
+  } finally {
+    sectionsLoading.value = false
+  }
+}
+
+function toViewFile(d) {
+  return {
+    id: d.id,
+    f: d.title || d.file_path?.split('/').pop() || `文档 #${d.id}`,
+    pg: d.page_count || 0,
+    sz: formatFileSize(d.file_size || 0),
+    latest: d.is_latest === 1 || d.is_latest === true,
+  }
+}
+
+async function loadSectionFiles(sec) {
+  if (!sec.category || !sec.subcategory) {
+    remoteFiles.value[sec.key] = []
+    return
+  }
+  secLoading[sec.key] = true
+  try {
+    const data = await listDocuments({ category: sec.category, subcategory: sec.subcategory })
+    const items = Array.isArray(data?.items) ? data.items : []
+    remoteFiles.value[sec.key] = items.length ? items.map(toViewFile) : []
+  } catch (e) {
+    console.warn('[pdf-library] load section files failed', sec.key, e)
+    remoteFiles.value[sec.key] = []
+  } finally {
+    secLoading[sec.key] = false
+  }
+}
+
+const totalCount = computed(() =>
+  sections.value.reduce((s, x) => {
+    const remote = remoteFiles.value[x.key]
+    if (remote && remote.length) return s + remote.length
+    return s + (x.doc_count || 0)
+  }, 0),
+)
+
+const curSection = computed(() => sections.value.find((s) => s.key === activeSectionKey.value) || null)
+
+const isLatestSelected = computed(() => Boolean(curDoc.value?.is_latest === 1 || curDoc.value?.is_latest === true))
+
+function isOpen(key) {
+  return collapsed[key] === true
+}
+
+function isActive(secKey, idx) {
+  return secKey === activeSectionKey.value && idx === activeFileIdx.value
+}
+
+function toggleSection(key) {
+  collapsed[key] = collapsed[key] === true ? false : true
+}
+
+function expandAll() {
+  sections.value.forEach((s) => { collapsed[s.key] = true })
+}
+
+function collapseAll() {
+  sections.value.forEach((s) => { collapsed[s.key] = false })
+}
+
+async function loadDocDetail(id) {
+  if (!id) return null
+  if (curDocCache[id]) return curDocCache[id]
+  docLoading.value = true
+  try {
+    const detail = await getDocument(id)
+    if (detail) curDocCache[id] = detail
+    return detail
+  } catch (e) {
+    console.warn('[pdf-library] getDocument failed', id, e)
+    return null
+  } finally {
+    docLoading.value = false
+  }
+}
+
+async function selectFile(sec, idx, fromHash = false) {
+  if (!sec) return
+  activeSectionKey.value = sec.key
+  activeFileIdx.value = idx
+  if (!fromHash) {
+    sections.value.forEach((s) => { collapsed[s.key] = s.key === sec.key })
+  }
+  const files = remoteFiles.value[sec.key] || []
+  const f = files[idx]
+  const hashId = f?.id != null ? f.id : idx
+  const hash = `#${sec.key}:${hashId}`
+  if (route.hash !== hash) {
+    router.replace({ hash })
+  }
+  nextTick(() => {
+    const el = document.getElementById('viewer-frame-' + sec.key)
+    if (el) el.scrollIntoView({ block: 'start' })
+  })
+  if (f && f.id != null) {
+    const detail = await loadDocDetail(f.id)
+    curDoc.value = detail || { id: f.id, title: f.f, page_count: f.pg, file_size: 0, is_latest: f.latest ? 1 : 0, summary: null }
+  } else if (f) {
+    curDoc.value = { id: f.id, title: f.f, page_count: f.pg, file_size: 0, is_latest: f.latest ? 1 : 0, summary: null }
+  } else {
+    curDoc.value = null
+  }
+  loadPdfBlob(f)
+}
+
+function revokeBlob() {
+  if (pdfBlobUrl.value) {
+    URL.revokeObjectURL(pdfBlobUrl.value)
+    pdfBlobUrl.value = ''
+  }
+}
+
+async function loadPdfBlob(f) {
+  revokeBlob()
+  pdfError.value = ''
+  if (!f || f.id == null) return
+  pdfLoading.value = true
+  try {
+    const url = await fetchDocumentBlob(f.id)
+    if (url) pdfBlobUrl.value = url
+    else pdfError.value = '文档加载失败'
+  } catch (e) {
+    console.warn('[pdf-library] fetch blob failed', e)
+    pdfError.value = e?.message || '文档加载失败'
+  } finally {
+    pdfLoading.value = false
+  }
+}
+
+function openModal() {
+  if (!curDoc.value) return
+  modalOpen.value = true
+}
+
+function closeModal() {
+  modalOpen.value = false
+}
+
+async function downloadCur() {
+  const f = curDoc.value
+  if (!f) return
+  if (f.id != null) {
+    const a = document.createElement('a')
+    a.href = `/api/documents/${f.id}/download`
+    a.download = f.title || `document-${f.id}`
+    document.body.appendChild(a)
+    a.click()
+    a.remove()
+    return
+  }
+  if (pdfBlobUrl.value) {
+    const a = document.createElement('a')
+    a.href = pdfBlobUrl.value
+    a.download = f.title || 'document'
+    document.body.appendChild(a)
+    a.click()
+    a.remove()
+  }
+}
+
+function readHashAnchor() {
+  const raw = String(route.hash || '').replace(/^#/, '')
+  if (!raw) return
+  const [secKey, idxStr] = raw.split(':')
+  const sec = sections.value.find((s) => s.key === secKey)
+  if (!sec) return
+  let files = remoteFiles.value[sec.key] || []
+  let idx = files.length - 1
+  if (idxStr !== undefined && idxStr !== '') {
+    const byId = files.findIndex((x) => String(x.id) === idxStr)
+    if (byId >= 0) {
+      idx = byId
+    } else {
+      const n = parseInt(idxStr, 10)
+      if (!Number.isNaN(n) && n >= 0 && n < files.length) idx = n
+    }
+  }
+  activeSectionKey.value = sec.key
+  activeFileIdx.value = idx
+  sections.value.forEach((s) => { collapsed[s.key] = s.key === sec.key })
+}
+
+watch(() => route.hash, readHashAnchor)
+
+onMounted(async () => {
+  window.addEventListener('keydown', onKey)
+  await loadSections()
+  await Promise.all(sections.value.map(loadSectionFiles))
+  if (route.hash) readHashAnchor()
+  if (!activeSectionKey.value && sections.value.length) {
+    const first = sections.value[0]
+    const files = remoteFiles.value[first.key] || []
+    if (files.length) {
+      activeSectionKey.value = first.key
+      const latestIdx = files.findIndex((x) => x.latest)
+      activeFileIdx.value = latestIdx >= 0 ? latestIdx : files.length - 1
+      collapsed[first.key] = true
+      const f = files[activeFileIdx.value]
+      const hashId = f?.id != null ? f.id : activeFileIdx.value
+      if (route.hash !== `#${first.key}:${hashId}`) {
+        router.replace({ hash: `#${first.key}:${hashId}` })
+      }
+    }
+  }
+  if (activeSectionKey.value) {
+    const sec = sections.value.find((s) => s.key === activeSectionKey.value)
+    if (sec) await selectFile(sec, activeFileIdx.value, true)
+  }
+})
+
+function onKey(e) {
+  if (e.key === 'Escape' && modalOpen.value) modalOpen.value = false
+}
+
+onBeforeUnmount(() => {
+  window.removeEventListener('keydown', onKey)
+  revokeBlob()
+})
+</script>
+
+<style scoped>
+.pdf-lib {
+  display: flex;
+  flex-direction: column;
+  height: calc(100vh - 56px);
+  background: var(--bg, #F2F5FA);
+}
+
+.viewer-bar {
+  height: 48px;
+  background: #fff;
+  border-bottom: 1px solid var(--line, #E4E9F2);
+  padding: 0 20px;
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-shrink: 0;
+}
+.viewer-bar .title {
+  font-size: 14px;
+  font-weight: 600;
+  flex: 1;
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  color: var(--ink, #1A2438);
+}
+.viewer-bar .title small.crumbs {
+  color: var(--mut, #6B7A96);
+  font-weight: 400;
+  font-size: 12px;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+.viewer-bar .title small.crumbs a {
+  color: var(--brand-d, #0E3A96);
+  text-decoration: none;
+}
+.viewer-bar .title small.crumbs a:hover { text-decoration: underline; }
+.viewer-bar .ctrls { display: flex; gap: 6px; align-items: center; flex: none; }
+.vbtn {
+  height: 30px;
+  padding: 0 12px;
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 6px;
+  background: #fff;
+  color: var(--ink, #1A2438);
+  font-size: 12px;
+  cursor: pointer;
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  font-family: inherit;
+  transition: .15s;
+}
+.vbtn:hover:not(:disabled) {
+  border-color: var(--brand, #1552D0);
+  color: var(--brand-d, #0E3A96);
+}
+.vbtn:disabled { opacity: .45; cursor: not-allowed; }
+.vbtn.primary {
+  background: var(--brand, #1552D0);
+  border-color: var(--brand, #1552D0);
+  color: #fff;
+}
+.vbtn.primary:hover:not(:disabled) {
+  background: var(--brand-d, #0E3A96);
+  border-color: var(--brand-d, #0E3A96);
+  color: #fff;
+}
+
+.body {
+  flex: 1;
+  display: grid;
+  grid-template-columns: 340px 1fr;
+  overflow: hidden;
+  background: #fff;
+}
+
+.file-pane {
+  border-right: 1px solid var(--line, #E4E9F2);
+  display: flex;
+  flex-direction: column;
+  background: #FAFBFD;
+  min-height: 0;
+}
+.file-pane .head {
+  padding: 14px 16px;
+  border-bottom: 1px solid var(--line, #E4E9F2);
+  background: #fff;
+}
+.file-pane .head h3 {
+  font-size: 14px;
+  font-weight: 700;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin: 0;
+  color: var(--ink, #1A2438);
+}
+.file-pane .head h3 .ic {
+  width: 22px;
+  height: 22px;
+  border-radius: 5px;
+  background: var(--brand-lt, #EAF1FF);
+  color: var(--brand-d, #0E3A96);
+  display: grid;
+  place-items: center;
+  font-size: 11px;
+  font-weight: 700;
+}
+.file-pane .head .meta {
+  font-size: 11.5px;
+  color: var(--mut, #6B7A96);
+  margin-top: 4px;
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 6px;
+}
+.file-pane .head .meta b {
+  color: var(--brand-d, #0E3A96);
+  font-weight: 700;
+}
+.file-pane .head .meta .acts {
+  color: var(--brand-d, #0E3A96);
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+}
+.file-pane .head .meta .acts:hover { text-decoration: underline; }
+.file-pane .head .meta .data-as-of {
+  color: var(--faint, #98A5BD);
+  margin-left: auto;
+}
+
+.search-box {
+  position: relative;
+  padding: 12px 16px;
+  border-bottom: 1px solid var(--line, #E4E9F2);
+  background: #fff;
+}
+.search-box input {
+  width: 100%;
+  height: 32px;
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 6px;
+  padding: 0 12px 0 30px;
+  font-size: 12px;
+  font-family: inherit;
+  outline: none;
+  background: #F8FAFC;
+  color: var(--ink, #1A2438);
+}
+.search-box input:focus { border-color: var(--brand, #1552D0); background: #fff; }
+.search-box::before {
+  content: "⌕";
+  position: absolute;
+  left: 24px;
+  top: 50%;
+  transform: translateY(-50%);
+  color: var(--mut, #6B7A96);
+  font-size: 14px;
+  pointer-events: none;
+}
+
+.file-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: 8px 0;
+  min-height: 0;
+}
+.loading-tip {
+  padding: 14px 16px;
+  font-size: 12px;
+  color: var(--mut, #6B7A96);
+}
+.loading-tip.err { color: var(--up, #E13B3B); }
+.file-empty {
+  padding: 8px 16px 8px 32px;
+  font-size: 11.5px;
+  color: var(--faint, #98A5BD);
+}
+
+.file-sec { display: block; }
+.file-sec-hd {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 9px 16px 6px;
+  font-size: 11px;
+  color: var(--ink, #1A2438);
+  letter-spacing: .5px;
+  cursor: pointer;
+  user-select: none;
+  background: transparent;
+  font-weight: 600;
+}
+.file-sec-hd:hover { background: #F0F4FA; }
+.file-sec-hd .arr {
+  font-size: 9px;
+  color: var(--mut, #6B7A96);
+  transition: transform .15s;
+  width: 10px;
+  display: inline-block;
+  flex-shrink: 0;
+}
+.file-sec-hd .arr.open { transform: rotate(90deg); }
+.file-sec-hd .id {
+  color: var(--brand-d, #0E3A96);
+  font-weight: 700;
+  font-size: 11px;
+  margin-right: 2px;
+}
+.file-sec-hd .nm {
+  flex: 1;
+  font-size: 11.5px;
+  font-weight: 600;
+  color: var(--ink, #1A2438);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.file-sec-hd .ct {
+  font-size: 10px;
+  background: #E4E9F2;
+  color: var(--mut, #6B7A96);
+  padding: 1px 7px;
+  border-radius: 8px;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+}
+.file-sec-hd.active { background: var(--brand-lt, #EAF1FF); }
+.file-sec-hd.active .nm { color: var(--brand-d, #0E3A96); }
+.file-sec-hd.active .id { color: var(--brand-d, #0E3A96); }
+.file-sec-hd.active .ct { background: var(--brand, #1552D0); color: #fff; }
+
+.file-sec-bd {
+  max-height: 0;
+  overflow: hidden;
+  transition: max-height .2s ease;
+}
+.file-sec-bd.open { max-height: 5000px; }
+
+.file-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 8px 16px 8px 32px;
+  cursor: pointer;
+  border-left: 3px solid transparent;
+  font-size: 12.5px;
+  color: var(--ink, #1A2438);
+  transition: background .15s;
+}
+.file-item:hover { background: #fff; }
+.file-item.active {
+  background: #fff;
+  border-left-color: var(--brand, #1552D0);
+  font-weight: 600;
+  color: var(--brand-d, #0E3A96);
+}
+.file-item .ico {
+  width: 24px;
+  height: 30px;
+  border-radius: 3px;
+  background: linear-gradient(180deg, #FCEAEA, #F8D4D4);
+  color: var(--up, #E13B3B);
+  display: grid;
+  place-items: center;
+  font-size: 9px;
+  font-weight: 700;
+  flex-shrink: 0;
+  border: 1px solid #F5C0C0;
+}
+.file-item .meta { flex: 1; min-width: 0; }
+.file-item .name {
+  font-size: 12.5px;
+  line-height: 1.3;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.file-item .name.empty {
+  color: var(--faint, #98A5BD);
+  font-style: italic;
+}
+.file-item .info {
+  font-size: 10.5px;
+  color: var(--faint, #98A5BD);
+  margin-top: 2px;
+  font-variant-numeric: tabular-nums;
+}
+.file-item .sz-stack {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 3px;
+  flex-shrink: 0;
+}
+.file-item .sz {
+  font-size: 10.5px;
+  color: var(--mut, #6B7A96);
+  font-variant-numeric: tabular-nums;
+  margin-left: 6px;
+}
+.file-item .badge {
+  background: var(--brand, #1552D0);
+  color: #fff;
+  font-size: 9.5px;
+  padding: 1px 5px;
+  border-radius: 3px;
+  font-weight: 600;
+}
+
+.pdf-pane {
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  background: #E4E9F2;
+  min-width: 0;
+}
+.pdf-bar {
+  padding: 8px 16px;
+  background: #fff;
+  border-bottom: 1px solid var(--line, #E4E9F2);
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+.pdf-bar .info {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  font-size: 12px;
+  min-width: 0;
+}
+.pdf-bar .info b {
+  color: var(--ink, #1A2438);
+  font-weight: 600;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 50%;
+}
+.pdf-bar .info .pg {
+  color: var(--mut, #6B7A96);
+  font-variant-numeric: tabular-nums;
+  white-space: nowrap;
+}
+.pdf-bar .info .size {
+  color: var(--faint, #98A5BD);
+  white-space: nowrap;
+}
+.pdf-bar .acts { display: flex; gap: 6px; flex-shrink: 0; }
+.pdf-frame {
+  flex: 1;
+  overflow: hidden;
+  background: #F2F5FA;
+  display: flex;
+  flex-direction: column;
+}
+.pdf-summary-wrap {
+  flex: 0 1 auto;
+  max-height: 45%;
+  min-height: 180px;
+  overflow: hidden;
+  display: flex;
+}
+.pdf-summary-wrap > * {
+  flex: 1;
+  min-height: 0;
+}
+
+.pdf-iframe-wrap {
+  flex: 1;
+  min-height: 320px;
+  display: flex;
+  flex-direction: column;
+  border-top: 1px solid var(--line, #E4E9F2);
+  background: #fff;
+}
+.pdf-iframe-hd {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 8px 16px;
+  background: #FAFBFD;
+  border-bottom: 1px solid var(--line, #E4E9F2);
+  font-size: 12.5px;
+  color: var(--ink, #1A2438);
+  flex: none;
+}
+.pdf-iframe-hd .ttl { font-weight: 600; }
+.pdf-iframe-hd .loading { color: var(--mut, #6B7A96); font-size: 11.5px; }
+.pdf-iframe-hd .error { color: var(--up, #E13B3B); font-size: 11.5px; }
+.pdf-iframe-hd .vbtn-link {
+  margin-left: auto;
+  height: 24px;
+  padding: 0 10px;
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 4px;
+  background: #fff;
+  font-size: 11.5px;
+  color: var(--brand-d, #0E3A96);
+  cursor: pointer;
+  font-family: inherit;
+}
+.pdf-iframe-hd .vbtn-link:hover { border-color: var(--brand, #1552D0); }
+.pdf-iframe {
+  flex: 1;
+  width: 100%;
+  border: 0;
+  background: #525659;
+}
+.pdf-iframe-empty {
+  flex: 1;
+  display: grid;
+  place-items: center;
+  color: var(--mut, #6B7A96);
+  font-size: 12.5px;
+  padding: 20px;
+}
+
+.modal-bg {
+  position: fixed;
+  inset: 0;
+  background: rgba(12, 43, 94, .55);
+  z-index: 9999;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.modal {
+  background: #fff;
+  border-radius: 10px;
+  width: 90vw;
+  height: 90vh;
+  max-width: 1400px;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  box-shadow: 0 20px 60px rgba(0, 0, 0, .4);
+}
+.modal-head {
+  padding: 14px 20px;
+  border-bottom: 1px solid var(--line, #E4E9F2);
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background: #FAFBFD;
+}
+.modal-head h3 {
+  font-size: 15px;
+  font-weight: 600;
+  margin: 0;
+  color: var(--ink, #1A2438);
+}
+.modal-head .close {
+  width: 30px;
+  height: 30px;
+  border-radius: 6px;
+  background: #F2F5FA;
+  display: grid;
+  place-items: center;
+  cursor: pointer;
+  color: var(--mut, #6B7A96);
+  font-size: 18px;
+  line-height: 1;
+  user-select: none;
+}
+.modal-head .close:hover { background: #FFE0E0; color: var(--up, #E13B3B); }
+
+.modal-body {
+  flex: 1;
+  display: grid;
+  grid-template-columns: 280px 1fr;
+  overflow: hidden;
+}
+.modal-file-pane {
+  border-right: 1px solid var(--line, #E4E9F2);
+  background: #FAFBFD;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+.modal-file-pane .head {
+  padding: 14px 16px;
+  border-bottom: 1px solid var(--line, #E4E9F2);
+  background: #fff;
+}
+.modal-file-pane .head h3 {
+  font-size: 13px;
+  font-weight: 700;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin: 0;
+  color: var(--ink, #1A2438);
+}
+.modal-file-pane .head h3 .ic {
+  width: 18px;
+  height: 18px;
+  border-radius: 4px;
+  background: var(--brand-lt, #EAF1FF);
+  color: var(--brand-d, #0E3A96);
+  display: grid;
+  place-items: center;
+  font-size: 10px;
+  font-weight: 700;
+}
+.modal-fade-enter-active,
+.modal-fade-leave-active { transition: opacity .2s; }
+.modal-fade-enter-from,
+.modal-fade-leave-to { opacity: 0; }
+
+@media (max-width: 1100px) {
+  .body { grid-template-columns: 280px 1fr; }
+}
+@media (max-width: 768px) {
+  .body { grid-template-columns: 1fr; }
+  .file-pane { border-right: none; border-bottom: 1px solid var(--line, #E4E9F2); max-height: 50%; }
+}
+</style>

+ 670 - 0
web-app/src/views/policy/_shared/policy-documents.js

@@ -0,0 +1,670 @@
+// 政策面 · 文档资料数据层
+// 用于:央行货政报告 / 货政例会 / 中央经济工作会议 / 政府工作报告 / 政治局会议 / 十五五规划
+
+// ===== §2.9 央行货币政策执行报告 =====
+export const monetaryReportLatest = {
+  title: '2026年第二季度 中国货币政策执行报告',
+  name: '2026Q2-货币政策执行报告',
+  pdfHref: '#2.9:37',
+  publishDate: '2026-08-15',
+  pages: 65,
+  size: '3.3 MB',
+  source: '中国人民银行',
+  summary: '稳健的货币政策灵活适度,保持流动性合理充裕,加大逆周期调节力度。',
+  tags: [
+    { label: '货币政策', tone: 'blue' },
+    { label: '稳健偏宽松', tone: 'green' },
+    { label: '逆周期调节', tone: 'yellow' },
+  ],
+  body: [
+    '<b>总基调</b>:坚持稳中求进,加大逆周期调节力度,保持经济运行在合理区间。',
+    '<b>货币政策</b>:稳健偏宽松,保持流动性合理充裕,引导信贷均衡投放。',
+    '<b>结构性工具</b>:科技创新再贷款 4000 亿 · 设备更新 5000 亿,加大对重点领域支持。',
+    '<b>风险防控</b>:稳妥处置地方债务、房地产金融风险,守住不发生系统性风险底线。',
+    '<b>汇率政策</b>:保持人民币汇率基本稳定,必要时进行逆周期调节,防止超调。',
+  ],
+  highlights: [
+    { tone: 'brand', label: '总基调', text: '坚持稳中求进,加大逆周期调节' },
+    { tone: 'green', label: '货币政策', text: '稳健偏宽松,保持流动性合理充裕' },
+    { tone: 'warn', label: '风险防控', text: '稳妥处置地方债务、房地产金融风险' },
+  ],
+  changes: [
+    { chapter: '结构性工具', content: '科技创新再贷款 4000 亿 · 设备更新 5000 亿', tag: '新增', tone: 'green' },
+    { chapter: '汇率',       content: '必要时进行逆周期调节,防止超调',          tag: '强调', tone: 'warn' },
+    { chapter: '重点领域',   content: '五篇大文章:科技/绿色/普惠/养老/数字',    tag: '延续', tone: 'blue' },
+    { chapter: '房地产',     content: '保交楼专项再贷款政策延续,扩大白名单',     tag: '加大', tone: 'green' },
+  ],
+}
+
+const MPR_BODY = [
+  '<b>总基调</b>:坚持稳中求进,加大逆周期调节力度。',
+  '<b>货币政策</b>:稳健偏宽松,保持流动性合理充裕。',
+  '<b>风险防控</b>:稳妥处置地方债务、房地产金融风险。',
+  '<b>结构性工具</b>:科技创新再贷款 4000 亿 · 设备更新 5000 亿。',
+]
+
+function buildMonetaryReportHistory() {
+  const arr = []
+  const quarters = [
+    ['2026Q2', '2026-08-15', 65, '3.3 MB', true],
+    ['2026Q1', '2026-05-15', 62, '3.1 MB', false],
+    ['2025Q4', '2026-02-15', 64, '3.2 MB', false],
+    ['2025Q3', '2025-11-15', 58, '2.9 MB', false],
+    ['2025Q2', '2025-08-15', 62, '3.1 MB', false],
+    ['2025Q1', '2025-05-15', 60, '3.0 MB', false],
+    ['2024Q4', '2025-02-15', 64, '3.2 MB', false],
+    ['2024Q3', '2024-11-15', 58, '2.9 MB', false],
+    ['2024Q2', '2024-08-15', 62, '3.1 MB', false],
+    ['2024Q1', '2024-05-15', 60, '3.0 MB', false],
+    ['2023Q4', '2024-02-15', 65, '3.3 MB', false],
+    ['2023Q3', '2023-11-15', 62, '3.1 MB', false],
+    ['2023Q2', '2023-08-15', 60, '3.0 MB', false],
+    ['2023Q1', '2023-05-15', 58, '2.9 MB', false],
+    ['2022Q4', '2023-02-15', 60, '3.0 MB', false],
+    ['2022Q3', '2022-11-15', 58, '2.9 MB', false],
+    ['2022Q2', '2022-08-15', 56, '2.8 MB', false],
+    ['2022Q1', '2022-05-15', 54, '2.7 MB', false],
+    ['2021Q4', '2022-02-15', 56, '2.8 MB', false],
+    ['2021Q3', '2021-11-15', 54, '2.7 MB', false],
+    ['2021Q2', '2021-08-15', 52, '2.6 MB', false],
+    ['2021Q1', '2021-05-15', 50, '2.5 MB', false],
+    ['2020Q4', '2021-02-15', 52, '2.6 MB', false],
+    ['2020Q3', '2020-11-15', 50, '2.5 MB', false],
+    ['2020Q2', '2020-08-15', 48, '2.4 MB', false],
+    ['2020Q1', '2020-05-15', 46, '2.3 MB', false],
+    ['2019Q4', '2020-02-15', 48, '2.4 MB', false],
+    ['2019Q3', '2019-11-15', 46, '2.3 MB', false],
+    ['2019Q2', '2019-08-15', 44, '2.2 MB', false],
+    ['2019Q1', '2019-05-15', 42, '2.1 MB', false],
+    ['2018Q4', '2019-02-15', 44, '2.2 MB', false],
+    ['2018Q3', '2018-11-15', 42, '2.1 MB', false],
+    ['2018Q2', '2018-08-15', 40, '2.0 MB', false],
+    ['2018Q1', '2018-05-15', 38, '1.9 MB', false],
+    ['2017Q4', '2018-02-15', 40, '2.0 MB', false],
+    ['2017Q3', '2017-11-15', 38, '1.9 MB', false],
+    ['2017Q2', '2017-08-15', 36, '1.8 MB', false],
+    ['2017Q1', '2017-05-15', 34, '1.7 MB', false],
+  ]
+  quarters.forEach(([q, date, pages, size, isNew], i) => {
+    arr.push({
+      key: q,
+      name: `${q}-货币政策执行报告`,
+      info: `${date} · ${pages} 页`,
+      size,
+      new: isNew,
+      publishDate: date,
+      pages,
+      summary: `稳健的货币政策灵活适度,流动性合理充裕。`,
+      body: MPR_BODY,
+      tags: [{ label: '货币政策', tone: 'blue' }],
+      source: '中国人民银行',
+    })
+  })
+  return arr
+}
+
+export const monetaryReportHistory = buildMonetaryReportHistory()
+
+// ===== §2.10 央行货币政策委员会例会 =====
+export const monetaryMinuteLatest = {
+  title: '2026Q3 货币政策委员会例会通稿',
+  name: '2026Q3-货币政策例会',
+  pdfHref: '#2.10:21',
+  publishDate: '2026-09-18',
+  pages: 30,
+  size: '1.5 MB',
+  source: '中国人民银行',
+  summary: '继续实施适度宽松的货币政策,加大逆周期和跨周期调节力度,保持流动性合理充裕。',
+  stance: '宽松',
+  stanceTone: 'brand',
+  stanceSummary: '稳健偏宽松 · 加大逆周期调节力度',
+  stanceNote: '会议指出,要加大货币政策调控强度,提高货币政策调控精准性,保持流动性合理充裕',
+  paragraphs: [
+    '完善存款准备金制度,<b>择机降准 0.5 个百分点</b>;5 年期 LPR 下降 10bp,降低实体融资成本',
+    '保持人民币汇率<b>基本稳定</b>,防止汇率超调风险',
+    '加大对重点领域和薄弱环节的支持力度,做好科技金融、绿色金融、普惠金融、养老金融、数字金融五篇大文章',
+  ],
+  body: [
+    '<b>会议内容</b>:分析当前经济金融形势,讨论下一阶段货币政策取向。',
+    '<b>政策定调</b>:继续实施适度宽松的货币政策,加大逆周期和跨周期调节力度。',
+    '完善存款准备金制度,<b>择机降准 0.5 个百分点</b>;5 年期 LPR 下降 10bp,降低实体融资成本。',
+    '保持人民币汇率<b>基本稳定</b>,防止汇率超调风险。',
+    '做好科技金融、绿色金融、普惠金融、养老金融、数字金融<b>五篇大文章</b>。',
+  ],
+  tags: [
+    { label: '宽松', tone: 'blue' },
+    { label: '降准', tone: 'blue' },
+    { label: '降息', tone: 'blue' },
+    { label: '汇率稳定', tone: 'yellow' },
+    { label: '结构性工具', tone: 'gray' },
+    { label: '五篇大文章', tone: 'gray' },
+  ],
+}
+
+function buildMonetaryMinuteHistory() {
+  const arr = []
+  const items = [
+    ['2026Q3', '2026-09-18', 30, '1.5 MB', true],
+    ['2026Q2', '2026-06-20', 28, '1.4 MB', false],
+    ['2026Q1', '2026-03-22', 28, '1.4 MB', false],
+    ['2025Q4', '2025-12-18', 32, '1.6 MB', false],
+    ['2025Q3', '2025-09-22', 30, '1.5 MB', false],
+    ['2025Q2', '2025-06-20', 28, '1.4 MB', false],
+    ['2025Q1', '2025-03-21', 26, '1.3 MB', false],
+    ['2024Q4', '2024-12-20', 30, '1.5 MB', false],
+    ['2024Q3', '2024-09-20', 28, '1.4 MB', false],
+    ['2024Q2', '2024-06-21', 26, '1.3 MB', false],
+    ['2024Q1', '2024-03-22', 28, '1.4 MB', false],
+    ['2023Q4', '2023-12-22', 30, '1.5 MB', false],
+    ['2023Q3', '2023-09-22', 28, '1.4 MB', false],
+    ['2023Q2', '2023-06-20', 26, '1.3 MB', false],
+    ['2023Q1', '2023-03-21', 26, '1.3 MB', false],
+    ['2022Q4', '2022-12-23', 28, '1.4 MB', false],
+    ['2022Q3', '2022-09-22', 26, '1.3 MB', false],
+    ['2022Q2', '2022-06-24', 24, '1.2 MB', false],
+    ['2022Q1', '2022-03-22', 24, '1.2 MB', false],
+    ['2021Q4', '2021-12-24', 26, '1.3 MB', false],
+    ['2021Q3', '2021-09-23', 24, '1.2 MB', false],
+    ['2021Q2', '2021-06-23', 22, '1.1 MB', false],
+  ]
+  items.forEach(([q, date, pages, size, isNew]) => {
+    arr.push({
+      key: q,
+      name: `${q}-货币政策例会`,
+      info: `${date} · ${pages} 页`,
+      size,
+      new: isNew,
+      publishDate: date,
+      pages,
+      summary: '货币政策委员会召开季度例会,讨论当前经济金融形势。',
+      body: [
+        '<b>会议内容</b>:分析当前经济金融形势,讨论下一阶段货币政策取向。',
+        '<b>政策定调</b>:继续实施稳健的货币政策,加大逆周期调节力度。',
+        '<b>重点任务</b>:保持流动性合理充裕,引导信贷平稳增长。',
+      ],
+      tags: [{ label: '货政例会', tone: 'blue' }],
+      source: '中国人民银行',
+    })
+  })
+  return arr
+}
+
+export const monetaryMinuteHistory = buildMonetaryMinuteHistory()
+
+// ===== §3.1 中央经济工作会议 =====
+export const centralEconLatest = {
+  title: '2025 中央经济工作会议通稿',
+  name: '2025-中央经济工作会议',
+  pdfHref: '#3.1:5',
+  publishDate: '2025-12-12',
+  pages: 50,
+  size: '2.5 MB',
+  source: '中共中央',
+  summary: '坚持稳中求进,实施适度宽松的货币政策,14 年来首次货币政策基调转向。',
+  dateLabel: '12月12日',
+  banner: {
+    title: '适度宽松的货币政策 · 加强超常规逆周期调节',
+    note: '中央首次提出"适度宽松" · 14 年来首次货币政策基调转向',
+  },
+  paragraphs: [
+    '<b>核心定调</b>:稳中求进,深化改革开放,大力提振消费 · 扩大高水平开放',
+    '<b>重点任务</b>:统筹推进"五篇大文章"、科技创新、城乡融合、区域协调、绿色发展',
+    '<b>关键变化</b>:货币政策定调从"稳健"转向<span class="hl-up">"适度宽松"</span>,14 年来首次',
+  ],
+  body: [
+    '<b>会议时间</b>:2025 年 12 月 12 日',
+    '<b>核心定调</b>:稳中求进,深化改革开放,大力提振消费,扩大高水平开放。',
+    '<b>重点任务</b>:统筹推进"五篇大文章"、科技创新、城乡融合、区域协调、绿色发展。',
+    '<b>关键变化</b>:货币政策定调从"稳健"转向<span class="hl-up">"适度宽松"</span>,14 年来首次。',
+    '<b>政策方向</b>:加强超常规逆周期调节,更大力度支持经济回升向好。',
+  ],
+  tags: [
+    { label: '中央经济工作会议', tone: 'blue' },
+    { label: '适度宽松', tone: 'red' },
+    { label: '五篇大文章', tone: 'gray' },
+  ],
+}
+
+export const centralEconHistory = [
+  { key: '2025', name: '2025-中央经济工作会议', info: '2025-12-12 · 50 页', size: '2.5 MB', new: true, publishDate: '2025-12-12', pages: 50, source: '中共中央', summary: '坚持稳中求进,实施适度宽松的货币政策。', body: ['<b>核心定调</b>:稳中求进,深化改革开放,大力提振消费。', '<b>货币政策</b>:适度宽松,14 年来首次转向。'] },
+  { key: '2024', name: '2024-中央经济工作会议', info: '2024-12-12 · 48 页', size: '2.4 MB', publishDate: '2024-12-12', pages: 48, source: '中共中央', summary: '实施适度宽松的货币政策,加大逆周期调节力度。', body: ['<b>核心定调</b>:全面贯彻新发展理念,加快构建新发展格局。'] },
+  { key: '2023', name: '2023-中央经济工作会议', info: '2023-12-12 · 46 页', size: '2.3 MB', publishDate: '2023-12-12', pages: 46, source: '中共中央', summary: '稳中求进、以进促稳、先立后破。', body: ['<b>核心定调</b>:稳中求进、以进促稳、先立后破。'] },
+  { key: '2022', name: '2022-中央经济工作会议', info: '2022-12-15 · 48 页', size: '2.4 MB', publishDate: '2022-12-15', pages: 48, source: '中共中央', summary: '优化疫情防控,推动经济运行整体好转。', body: ['<b>核心定调</b>:优化疫情防控,推动经济运行整体好转。'] },
+  { key: '2021', name: '2021-中央经济工作会议', info: '2021-12-10 · 45 页', size: '2.2 MB', publishDate: '2021-12-10', pages: 45, source: '中共中央', summary: '稳字当头、稳中求进,政策发力适度靠前。', body: ['<b>核心定调</b>:稳字当头、稳中求进。'] },
+  { key: '2020', name: '2020-中央经济工作会议', info: '2020-12-18 · 42 页', size: '2.1 MB', publishDate: '2020-12-18', pages: 42, source: '中共中央', summary: '构建新发展格局,迈好第一步。', body: ['<b>核心定调</b>:立足新发展阶段,贯彻新发展理念。'] },
+]
+
+// ===== §3.2 政府工作报告 =====
+export const govWorkReportLatest = {
+  title: '2026 政府工作报告(摘要)',
+  name: '2026-政府工作报告',
+  pdfHref: '#3.2:5',
+  publishDate: '2026-03-05',
+  pages: 45,
+  size: '2.2 MB',
+  source: '国务院',
+  summary: 'GDP 目标 4.5-5%,财政赤字率 4.8% 创历史新高,广义财政扩张 8.0 万亿。',
+  kpis: [
+    { label: 'GDP 目标',     value: '4.5-5%', tone: 'warn' },
+    { label: 'CPI',          value: '~2%',   tone: 'brand' },
+    { label: '财政赤字率',   value: '4.8%',  tone: 'up' },
+    { label: '专项债',       value: '4.4 万亿', tone: 'brand' },
+  ],
+  paragraphs: [
+    '<b>总基调</b>:稳中求进、提质增效,因地制宜发展新质生产力',
+    '<b>核心变化</b>:GDP 目标首次下调至 <span class="hl-up">4.5-5%</span>(此前 5 年均为 ~5%)',
+    '<b>财政扩张</b>:广义财政扩张 <b>8.0 万亿</b> · <span class="hl-up">赤字率创历史新高 4.8%</span>',
+  ],
+  body: [
+    '<b>总基调</b>:稳中求进、提质增效,因地制宜发展新质生产力。',
+    '<b>核心变化</b>:GDP 目标首次下调至 <span class="hl-up">4.5-5%</span>(此前 5 年均为 ~5%)。',
+    '<b>财政扩张</b>:广义财政扩张 <b>8.0 万亿</b> · <span class="hl-up">赤字率创历史新高 4.8%</span>。',
+    '<b>重点任务</b>:加快建设现代化产业体系,深入实施科教兴国战略,深化重点领域改革。',
+    '<b>民生保障</b>:强化就业优先政策,促进居民增收,健全社会保障体系。',
+  ],
+  tags: [
+    { label: '政府工作报告', tone: 'blue' },
+    { label: 'GDP 4.5-5%', tone: 'warn' },
+    { label: '赤字率 4.8%', tone: 'red' },
+    { label: '新质生产力', tone: 'green' },
+  ],
+}
+
+export const govWorkReportHistory = [
+  { key: '2026', name: '2026-政府工作报告', info: '2026-03-05 · 45 页', size: '2.2 MB', new: true, publishDate: '2026-03-05', pages: 45, source: '国务院', summary: 'GDP 目标 4.5-5%,财政赤字率 4.8%。' },
+  { key: '2025', name: '2025-政府工作报告', info: '2025-03-05 · 42 页', size: '2.1 MB', publishDate: '2025-03-05', pages: 42, source: '国务院', summary: 'GDP 目标 5% 左右,实施适度宽松货币政策。' },
+  { key: '2024', name: '2024-政府工作报告', info: '2024-03-05 · 40 页', size: '2.0 MB', publishDate: '2024-03-05', pages: 40, source: '国务院', summary: 'GDP 目标 5% 左右,赤字率 3%。' },
+  { key: '2023', name: '2023-政府工作报告', info: '2023-03-05 · 38 页', size: '1.9 MB', publishDate: '2023-03-05', pages: 38, source: '国务院', summary: 'GDP 目标 5% 左右,恢复合理区间。' },
+  { key: '2022', name: '2022-政府工作报告', info: '2022-03-05 · 42 页', size: '2.1 MB', publishDate: '2022-03-05', pages: 42, source: '国务院', summary: 'GDP 目标 5.5% 左右,保持平稳增长。' },
+  { key: '2021', name: '2021-政府工作报告', info: '2021-03-05 · 40 页', size: '2.0 MB', publishDate: '2021-03-05', pages: 40, source: '国务院', summary: 'GDP 目标 6% 以上,统筹推进经济工作。' },
+]
+
+// ===== §3.3 政治局会议 =====
+export const politburoLatest = {
+  title: '2026-07-30 中央政治局会议',
+  name: '2026-07-30 政治局会议',
+  pdfHref: '#3.3:16',
+  publishDate: '2026-07-30',
+  pages: null,
+  size: '0.8 MB',
+  source: '中共中央政治局',
+  summary: '分析当前经济形势,部署下半年经济工作,宏观政策持续用力、更加给力。',
+  dateLabel: '7月30日',
+  banner: {
+    title: '分析当前经济形势 · 部署下半年经济工作',
+    note: '宏观政策持续用力、更加给力 · 加强预期管理 · 扩大高水平开放',
+  },
+  paragraphs: [
+    '<b>核心定调</b>:做好下半年经济工作,实现全年经济社会发展目标',
+    '<b>政策方向</b>:宏观政策持续用力、更加给力,加强预期管理 · 扩大高水平开放',
+    '<b>重点任务</b>:扩大有效需求 · 防止内卷式竞争 · 培育新质生产力 · 统筹发展和安全',
+  ],
+  body: [
+    '<b>会议时间</b>:2026 年 7 月 30 日',
+    '<b>核心定调</b>:做好下半年经济工作,实现全年经济社会发展目标。',
+    '<b>政策方向</b>:宏观政策持续用力、更加给力,加强预期管理,扩大高水平开放。',
+    '<b>重点任务</b>:扩大有效需求 · 防止内卷式竞争 · 培育新质生产力 · 统筹发展和安全。',
+  ],
+  tags: [
+    { label: '政治局会议', tone: 'blue' },
+    { label: '经济形势', tone: 'warn' },
+    { label: '宏观政策', tone: 'gray' },
+  ],
+}
+
+function buildPolitburoHistory() {
+  const arr = []
+  const meetings = [
+    ['2026-07-30', '2026-07-30', '0.8 MB', true],
+    ['2026-04-25', '2026-04-25', '0.7 MB', false],
+    ['2025-12-08', '2025-12-08', '0.9 MB', false],
+    ['2025-09-26', '2025-09-26', '0.7 MB', false],
+    ['2025-07-30', '2025-07-30', '0.8 MB', false],
+    ['2025-04-25', '2025-04-25', '0.7 MB', false],
+    ['2024-12-09', '2024-12-09', '0.8 MB', false],
+    ['2024-09-26', '2024-09-26', '0.8 MB', false],
+    ['2024-07-30', '2024-07-30', '0.7 MB', false],
+    ['2024-04-30', '2024-04-30', '0.7 MB', false],
+    ['2023-12-08', '2023-12-08', '0.8 MB', false],
+    ['2023-07-24', '2023-07-24', '0.7 MB', false],
+    ['2023-04-28', '2023-04-28', '0.7 MB', false],
+    ['2022-12-06', '2022-12-06', '0.8 MB', false],
+    ['2022-07-28', '2022-07-28', '0.7 MB', false],
+    ['2022-04-29', '2022-04-29', '0.7 MB', false],
+    ['2021-12-06', '2021-12-06', '0.7 MB', false],
+  ]
+  meetings.forEach(([key, date, size, isNew]) => {
+    arr.push({
+      key,
+      name: `${date} 政治局会议`,
+      info: `${date} · 季度`,
+      size,
+      new: isNew,
+      publishDate: date,
+      source: '中共中央政治局',
+      summary: '分析研究当前经济形势,部署经济工作。',
+      body: ['<b>会议内容</b>:分析当前经济形势,部署下一阶段经济工作。'],
+    })
+  })
+  return arr
+}
+
+export const politburoHistory = buildPolitburoHistory()
+
+// ===== §4.1 十五五规划纲要 =====
+export const planLatest = {
+  title: '国民经济和社会发展第十五个五年规划纲要',
+  name: '国民经济和社会发展第十五个五年规划纲要',
+  pdfHref: '#4.1:0',
+  publishDate: '2026-06',
+  pages: 128,
+  size: '6.4 MB',
+  source: '全国人大',
+  summary: '顶层规划 · 2026-2030 年发展方向 · 128 页。',
+  dateLabel: '2026-06',
+  banner: {
+    title: '国民经济和社会发展第十五个五年规划纲要',
+    note: '128 页 · 顶层规划 · 2026-2030 年发展方向',
+  },
+  body: [
+    '<b>总体目标</b>:到 2030 年基本实现社会主义现代化取得决定性进展。',
+    '<b>发展主线</b>:高质量发展,因地制宜发展新质生产力。',
+    '<b>重点方向</b>:科技自立自强、现代化产业体系、全国统一大市场、高水平对外开放、统筹发展与安全。',
+    '<b>关键指标</b>:GDP 年均增速 ~4.5%,人均 GDP 达 16,500 美元,城镇化率 70%,研发投入强度 3.2%。',
+  ],
+  tags: [
+    { label: '十五五规划', tone: 'blue' },
+    { label: '顶层规划', tone: 'warn' },
+    { label: '高质量发展', tone: 'green' },
+    { label: '新质生产力', tone: 'gray' },
+  ],
+  kpiTable: [
+    { indicator: 'GDP 年均增速',        base: '~5.0%',  target: '~4.5%', tone: 'blue', tag: '放缓' },
+    { indicator: '人均 GDP(美元)',      base: '13,800', target: '16,500', tone: 'green', tag: '增长' },
+    { indicator: '城镇化率',               base: '66.2%',  target: '70.0%',  tone: 'green', tag: '提升' },
+    { indicator: '研发投入强度',           base: '2.68%',  target: '3.20%',  tone: 'green', tag: '加强' },
+    { indicator: '单位 GDP 能耗下降',      base: '—',      target: '~15%',   tone: 'green', tag: '降低' },
+    { indicator: '非化石能源占比',         base: '18.0%',  target: '25.0%',  tone: 'green', tag: '提升' },
+  ],
+}
+
+export const planHistory = [
+  { key: '15th', name: '国民经济和社会发展第十五个五年规划纲要', info: '2026-06 · 128 页', size: '6.4 MB', new: true, publishDate: '2026-06', pages: 128, source: '全国人大', summary: '顶层规划 · 2026-2030 年发展方向。', body: ['<b>总体目标</b>:到 2030 年基本实现社会主义现代化取得决定性进展。'] },
+]
+
+export const planStrategies = [
+  {
+    label: '① 科技自立自强',
+    bg: 'brand',
+    text: '新型举国体制 · 集成电路、人工智能、商业航天等关键领域攻关 · 加快成果转化',
+  },
+  {
+    label: '② 现代化产业体系',
+    bg: 'green',
+    text: '制造业重点产业链高质量发展 · 加快人工智能、量子科技、生物制造等前沿产业培育',
+  },
+  {
+    label: '③ 全国统一大市场',
+    bg: 'warn',
+    text: '打破行政壁垒 · 促进要素自由流动 · 公平竞争 · 释放内需潜力',
+  },
+  {
+    label: '④ 高水平对外开放',
+    bg: 'purple',
+    text: '主动对接国际高标准经贸规则 · 扩大制度型开放 · "一带一路"高质量发展',
+  },
+  {
+    label: '⑤ 统筹发展与安全',
+    bg: 'red',
+    text: '粮食、能源资源、产业链供应链安全 · 防范化解房地产、地方债务、金融风险',
+  },
+]
+
+// ===== 文档库分组(供文档库页使用) =====
+// key 与历史文档的 pdfHref 锚点(去掉 ":" )对应 · 例如 #2.9
+// files 是该章节下完整的 PDF 文件列表(原型粒度,带文件名 / 页数 / 大小)
+const monetaryReportFiles = [
+  { f: '2026Q2-中国货币政策执行报告.pdf', pg: 65, sz: '3.3 MB', latest: true },
+  { f: '2026Q1-中国货币政策执行报告.pdf', pg: 62, sz: '3.1 MB' },
+  { f: '2025Q4-中国货币政策执行报告.pdf', pg: 64, sz: '3.2 MB' },
+  { f: '2025Q3-中国货币政策执行报告.pdf', pg: 58, sz: '2.9 MB' },
+  { f: '2025Q2-中国货币政策执行报告.pdf', pg: 62, sz: '3.1 MB' },
+  { f: '2025Q1-中国货币政策执行报告.pdf', pg: 60, sz: '3.0 MB' },
+  { f: '2024Q4-中国货币政策执行报告.pdf', pg: 64, sz: '3.2 MB' },
+  { f: '2024Q3-中国货币政策执行报告.pdf', pg: 58, sz: '2.9 MB' },
+  { f: '2024Q2-中国货币政策执行报告.pdf', pg: 62, sz: '3.1 MB' },
+  { f: '2024Q1-中国货币政策执行报告.pdf', pg: 60, sz: '3.0 MB' },
+  { f: '2023Q4-中国货币政策执行报告.pdf', pg: 65, sz: '3.3 MB' },
+  { f: '2023Q3-中国货币政策执行报告.pdf', pg: 62, sz: '3.1 MB' },
+  { f: '2023Q2-中国货币政策执行报告.pdf', pg: 60, sz: '3.0 MB' },
+  { f: '2023Q1-中国货币政策执行报告.pdf', pg: 58, sz: '2.9 MB' },
+  { f: '2022Q4-中国货币政策执行报告.pdf', pg: 60, sz: '3.0 MB' },
+  { f: '2022Q3-中国货币政策执行报告.pdf', pg: 58, sz: '2.9 MB' },
+  { f: '2022Q2-中国货币政策执行报告.pdf', pg: 56, sz: '2.8 MB' },
+  { f: '2022Q1-中国货币政策执行报告.pdf', pg: 54, sz: '2.7 MB' },
+  { f: '2021Q4-中国货币政策执行报告.pdf', pg: 56, sz: '2.8 MB' },
+  { f: '2021Q3-中国货币政策执行报告.pdf', pg: 54, sz: '2.7 MB' },
+  { f: '2021Q2-中国货币政策执行报告.pdf', pg: 52, sz: '2.6 MB' },
+  { f: '2021Q1-中国货币政策执行报告.pdf', pg: 50, sz: '2.5 MB' },
+  { f: '2020Q4-中国货币政策执行报告.pdf', pg: 52, sz: '2.6 MB' },
+  { f: '2020Q3-中国货币政策执行报告.pdf', pg: 50, sz: '2.5 MB' },
+  { f: '2020Q2-中国货币政策执行报告.pdf', pg: 48, sz: '2.4 MB' },
+  { f: '2020Q1-中国货币政策执行报告.pdf', pg: 46, sz: '2.3 MB' },
+  { f: '2019Q4-中国货币政策执行报告.pdf', pg: 48, sz: '2.4 MB' },
+  { f: '2019Q3-中国货币政策执行报告.pdf', pg: 46, sz: '2.3 MB' },
+  { f: '2019Q2-中国货币政策执行报告.pdf', pg: 44, sz: '2.2 MB' },
+  { f: '2019Q1-中国货币政策执行报告.pdf', pg: 42, sz: '2.1 MB' },
+  { f: '2018Q4-中国货币政策执行报告.pdf', pg: 44, sz: '2.2 MB' },
+  { f: '2018Q3-中国货币政策执行报告.pdf', pg: 42, sz: '2.1 MB' },
+  { f: '2018Q2-中国货币政策执行报告.pdf', pg: 40, sz: '2.0 MB' },
+  { f: '2018Q1-中国货币政策执行报告.pdf', pg: 38, sz: '1.9 MB' },
+  { f: '2017Q4-中国货币政策执行报告.pdf', pg: 40, sz: '2.0 MB' },
+  { f: '2017Q3-中国货币政策执行报告.pdf', pg: 38, sz: '1.9 MB' },
+  { f: '2017Q2-中国货币政策执行报告.pdf', pg: 36, sz: '1.8 MB' },
+  { f: '2017Q1-中国货币政策执行报告.pdf', pg: 34, sz: '1.7 MB' },
+]
+
+const monetaryMinuteFiles = [
+  { f: '2026Q3-货币政策委员会例会.pdf', pg: 30, sz: '1.5 MB', latest: true },
+  { f: '2026Q2-货币政策委员会例会.pdf', pg: 28, sz: '1.4 MB' },
+  { f: '2026Q1-货币政策委员会例会.pdf', pg: 28, sz: '1.4 MB' },
+  { f: '2025Q4-货币政策委员会例会.pdf', pg: 32, sz: '1.6 MB' },
+  { f: '2025Q3-货币政策委员会例会.pdf', pg: 30, sz: '1.5 MB' },
+  { f: '2025Q2-货币政策委员会例会.pdf', pg: 28, sz: '1.4 MB' },
+  { f: '2025Q1-货币政策委员会例会.pdf', pg: 26, sz: '1.3 MB' },
+  { f: '2024Q4-货币政策委员会例会.pdf', pg: 30, sz: '1.5 MB' },
+  { f: '2024Q3-货币政策委员会例会.pdf', pg: 28, sz: '1.4 MB' },
+  { f: '2024Q2-货币政策委员会例会.pdf', pg: 26, sz: '1.3 MB' },
+  { f: '2024Q1-货币政策委员会例会.pdf', pg: 28, sz: '1.4 MB' },
+  { f: '2023Q4-货币政策委员会例会.pdf', pg: 30, sz: '1.5 MB' },
+  { f: '2023Q3-货币政策委员会例会.pdf', pg: 28, sz: '1.4 MB' },
+  { f: '2023Q2-货币政策委员会例会.pdf', pg: 26, sz: '1.3 MB' },
+  { f: '2023Q1-货币政策委员会例会.pdf', pg: 26, sz: '1.3 MB' },
+  { f: '2022Q4-货币政策委员会例会.pdf', pg: 28, sz: '1.4 MB' },
+  { f: '2022Q3-货币政策委员会例会.pdf', pg: 26, sz: '1.3 MB' },
+  { f: '2022Q2-货币政策委员会例会.pdf', pg: 24, sz: '1.2 MB' },
+  { f: '2022Q1-货币政策委员会例会.pdf', pg: 24, sz: '1.2 MB' },
+  { f: '2021Q4-货币政策委员会例会.pdf', pg: 26, sz: '1.3 MB' },
+  { f: '2021Q3-货币政策委员会例会.pdf', pg: 24, sz: '1.2 MB' },
+  { f: '2021Q2-货币政策委员会例会.pdf', pg: 22, sz: '1.1 MB' },
+]
+
+const centralEconFiles = [
+  { f: '2025年-中央经济工作会议.pdf', pg: 48, sz: '2.4 MB', latest: true },
+  { f: '2024年-中央经济工作会议.pdf', pg: 46, sz: '2.3 MB' },
+  { f: '2023年-中央经济工作会议.pdf', pg: 44, sz: '2.2 MB' },
+  { f: '2022年-中央经济工作会议.pdf', pg: 42, sz: '2.1 MB' },
+  { f: '2021年-中央经济工作会议.pdf', pg: 38, sz: '1.9 MB' },
+  { f: '2020年-中央经济工作会议.pdf', pg: 36, sz: '1.8 MB' },
+]
+
+const govWorkReportFiles = [
+  { f: '2026年-政府工作报告.pdf', pg: 82, sz: '4.1 MB', latest: true },
+  { f: '2025年-政府工作报告.pdf', pg: 80, sz: '4.0 MB' },
+  { f: '2024年-政府工作报告.pdf', pg: 76, sz: '3.8 MB' },
+  { f: '2023年-政府工作报告.pdf', pg: 72, sz: '3.6 MB' },
+  { f: '2022年-政府工作报告.pdf', pg: 68, sz: '3.4 MB' },
+  { f: '2021年-政府工作报告.pdf', pg: 62, sz: '3.1 MB' },
+]
+
+const politburoFiles = [
+  { f: '2026-07-30-政治局会议.pdf', pg: 26, sz: '1.3 MB', latest: true },
+  { f: '2026-04-28-政治局会议.pdf', pg: 24, sz: '1.2 MB' },
+  { f: '2025-12-08-政治局会议.pdf', pg: 26, sz: '1.3 MB' },
+  { f: '2025-07-30-政治局会议.pdf', pg: 24, sz: '1.2 MB' },
+  { f: '2025-04-25-政治局会议.pdf', pg: 22, sz: '1.1 MB' },
+  { f: '2024-12-09-政治局会议.pdf', pg: 24, sz: '1.2 MB' },
+  { f: '2024-09-26-政治局会议.pdf', pg: 24, sz: '1.2 MB' },
+  { f: '2024-07-30-政治局会议.pdf', pg: 22, sz: '1.1 MB' },
+  { f: '2024-04-30-政治局会议.pdf', pg: 20, sz: '1.0 MB' },
+  { f: '2023-12-08-政治局会议.pdf', pg: 22, sz: '1.1 MB' },
+  { f: '2023-07-24-政治局会议.pdf', pg: 20, sz: '1.0 MB' },
+  { f: '2023-04-28-政治局会议.pdf', pg: 18, sz: '0.9 MB' },
+  { f: '2022-12-06-政治局会议.pdf', pg: 20, sz: '1.0 MB' },
+  { f: '2022-07-28-政治局会议.pdf', pg: 18, sz: '0.9 MB' },
+  { f: '2022-04-29-政治局会议.pdf', pg: 16, sz: '0.8 MB' },
+  { f: '2021-12-06-政治局会议.pdf', pg: 18, sz: '0.9 MB' },
+  { f: '2021-07-30-政治局会议.pdf', pg: 16, sz: '0.8 MB' },
+]
+
+const planFiles = [
+  { f: '十五五规划纲要.pdf', pg: 128, sz: '6.4 MB', latest: true },
+]
+
+const realEstateFiles = [
+  { f: '(尚无文件)', pg: 0, sz: '0 KB', empty: true },
+]
+
+export const pdfLibrarySections = [
+  {
+    key: '2.9',
+    navKey: 'mpr',
+    sectionNo: '§2.9',
+    title: '央行货币政策执行报告',
+    subtitle: '季度 · 中国人民银行',
+    source: '中国人民银行',
+    range: '2017Q1–2026Q2 · 38 份',
+    count: 38,
+    icon: '⌘',
+    accent: 'linear-gradient(135deg,#EAF1FF,#1552D0)',
+    category: '政策面',
+    subcategory: '2.9 央行货币政策执行报告',
+    latest: monetaryReportLatest,
+    history: monetaryReportHistory,
+    files: monetaryReportFiles,
+  },
+  {
+    key: '2.10',
+    navKey: 'mpc',
+    sectionNo: '§2.10',
+    title: '央行货币政策委员会例会',
+    subtitle: '季度 · 中国人民银行',
+    source: '中国人民银行',
+    range: '2021Q1–2026Q2 · 22 份',
+    count: 22,
+    icon: '⌥',
+    accent: 'linear-gradient(135deg,#FFF6E5,#E8912D)',
+    category: '政策面',
+    subcategory: '2.10 央行货币政策委员会例会',
+    latest: monetaryMinuteLatest,
+    history: monetaryMinuteHistory,
+    files: monetaryMinuteFiles,
+  },
+  {
+    key: '3.1',
+    navKey: 'cee',
+    sectionNo: '§3.1',
+    title: '中央经济工作会议',
+    subtitle: '年度 · 中共中央',
+    source: '中共中央',
+    range: '2020–2025 · 6 份',
+    count: 6,
+    icon: '⌧',
+    accent: 'linear-gradient(135deg,#FCEAEA,#E13B3B)',
+    category: '政策面',
+    subcategory: '3.1 中央经济工作会议',
+    latest: centralEconLatest,
+    history: centralEconHistory,
+    files: centralEconFiles,
+  },
+  {
+    key: '3.2',
+    navKey: 'gwr',
+    sectionNo: '§3.2',
+    title: '政府工作报告',
+    subtitle: '年度 · 国务院',
+    source: '国务院',
+    range: '2021–2026 · 6 份',
+    count: 6,
+    icon: '⊞',
+    accent: 'linear-gradient(135deg,#E8F5EE,#189A65)',
+    category: '政策面',
+    subcategory: '3.2 政府工作报告',
+    latest: govWorkReportLatest,
+    history: govWorkReportHistory,
+    files: govWorkReportFiles,
+  },
+  {
+    key: '3.3',
+    navKey: 'pbs',
+    sectionNo: '§3.3',
+    title: '中央政治局会议',
+    subtitle: '季度 · 中共中央政治局',
+    source: '中共中央政治局',
+    range: '2021-04–2026-07 · 17 份',
+    count: 17,
+    icon: '⊟',
+    accent: 'linear-gradient(135deg,#F2EAFE,#8B5CF6)',
+    category: '政策面',
+    subcategory: '3.3 中央政治局会议',
+    latest: politburoLatest,
+    history: politburoHistory,
+    files: politburoFiles,
+  },
+  {
+    key: '4.1',
+    navKey: 'plan',
+    sectionNo: '§4.1',
+    title: '十五五规划纲要',
+    subtitle: '顶层规划 · 全国人大',
+    source: '全国人大',
+    range: '2026 · 1 份',
+    count: 1,
+    icon: '⊠',
+    accent: 'linear-gradient(135deg,#EAF6FF,#0EA5E9)',
+    category: '政策面',
+    subcategory: '4.1 十五五规划纲要',
+    latest: planLatest,
+    history: planHistory,
+    files: planFiles,
+  },
+  {
+    key: '4.2',
+    navKey: 'real',
+    sectionNo: '§4.2',
+    title: '地产政策文件汇编',
+    subtitle: '中央与地方调控 · 保交楼',
+    source: '中央与地方',
+    range: '采集中 · 0 份',
+    count: 0,
+    icon: '⊡',
+    accent: 'linear-gradient(135deg,#EEF2F7,#98A5BD)',
+    category: '政策面',
+    subcategory: '4.2 地产政策文件汇编',
+    latest: null,
+    history: [],
+    files: realEstateFiles,
+  },
+]
+
+// ===== 文档库首页入口卡片(8 个) =====
+export const policyDocumentLibrary = [
+  { key: 'mpr',  tag: '货币政策', icon: '⌘', iconBg: 'linear-gradient(135deg,#EAF1FF,#1552D0)', title: '2.9 央行货政报告',      count: 38, range: '2017Q1–2026Q2 · 季度报告',  href: '/pdf-library#2.9' },
+  { key: 'mpc',  tag: '货币政策', icon: '⌥', iconBg: 'linear-gradient(135deg,#FFF6E5,#E8912D)', title: '2.10 央行货币政策例会',  count: 22, range: '2021Q1–2026Q2 · 季度通稿',  href: '/pdf-library#2.10' },
+  { key: 'cee',  tag: '重要会议', icon: '⌧', iconBg: 'linear-gradient(135deg,#FCEAEA,#E13B3B)', title: '3.1 中央经济工作会议',  count: 6,  range: '2020–2025 · 年度会议',     href: '/pdf-library#3.1' },
+  { key: 'gwr',  tag: '重要会议', icon: '⊞', iconBg: 'linear-gradient(135deg,#E8F5EE,#189A65)', title: '3.2 政府工作报告',      count: 6,  range: '2021–2026 · 年度报告',     href: '/pdf-library#3.2' },
+  { key: 'pbs',  tag: '重要会议', icon: '⊟', iconBg: 'linear-gradient(135deg,#F2EAFE,#8B5CF6)', title: '3.3 政治局会议',        count: 17, range: '2021-04–2026-07 · 季度会议', href: '/pdf-library#3.3' },
+  { key: 'plan', tag: '产业政策', icon: '⊠', iconBg: 'linear-gradient(135deg,#EAF6FF,#0EA5E9)', title: '4.1 十五五规划纲要',     count: 1,  range: '128 页 · 全文',             href: '/pdf-library#4.1' },
+  { key: 'real', tag: '产业政策', icon: '⊡', iconBg: 'linear-gradient(135deg,#EEF2F7,#98A5BD)', title: '4.2 地产政策',           count: 0,  range: '文件采集中',                  disabled: true, href: '/pdf-library' },
+  { key: 'all',  tag: '全部章节', icon: '📚', title: '进入文档库', count: 89, range: '份全部文件 · 统一预览', featured: true, href: '/pdf-library' },
+]

+ 265 - 0
web-app/src/views/policy/fiscal/charts.js

@@ -0,0 +1,265 @@
+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))
+
+const upGrad = (color) => ({
+  type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
+  colorStops: [{ offset: 0, color: '#3D7BE0' }, { offset: 1, color }],
+})
+const downGrad = (color) => ({
+  type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
+  colorStops: [{ offset: 0, color: '#4FCB9A' }, { offset: 1, color }],
+})
+
+// ============== 月度累计值(柱图) ==============
+export function monthlyAccumulatedOption(series, { title = '累计值', color = C.brand, unit = '亿元' } = {}) {
+  const cats = sliceDate(series)
+  const values = series.map((x) => x[1])
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis', axisPointer: { type: 'shadow' } },
+    grid: { top: 18, left: 60, right: 24, 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: 12,
+      itemStyle: {
+        color: (p) => (p.value >= 0 ? upGrad(color) : downGrad(C.down)),
+        borderRadius: [3, 3, 0, 0],
+      },
+      label: { show: false },
+    }],
+  }
+}
+
+// ============== 月度累计同比(柱图,含 0 轴线) ==============
+export function monthlyYoYOption(series, { title = '累计同比', color = C.brand, unit = '%' } = {}) {
+  const cats = sliceDate(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: 12,
+      itemStyle: {
+        color: (p) => (p.value >= 0 ? upGrad(color) : downGrad(C.down)),
+        borderRadius: [3, 3, 0, 0],
+      },
+      label: {
+        show: true, position: 'top', fontSize: 10, color: C.mut,
+        formatter: (p) => (typeof p.value === 'number' ? p.value.toFixed(2) : ''),
+      },
+      markLine: {
+        symbol: ['none', 'none'],
+        data: [{ yAxis: 0, lineStyle: { color: C.faint, type: 'dashed', width: 1 } }],
+        label: { show: false },
+      },
+    }],
+  }
+}
+
+// ============== 收入 vs 支出 双柱 ==============
+export function incomeVsExpenseOption(income, expense) {
+  const cats = sliceDate(income)
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis', axisPointer: { type: 'shadow' } },
+    legend: {
+      data: ['收入', '支出'],
+      top: 4, textStyle: { color: C.mut, fontSize: 11 }, itemWidth: 14, itemHeight: 8,
+    },
+    grid: { top: 32, left: 60, right: 24, bottom: 28 },
+    xAxis: { type: 'category', data: cats, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10 } }) },
+    yAxis: { type: 'value', name: '亿元', ...axisStyle() },
+    series: [
+      {
+        name: '收入',
+        type: 'bar',
+        data: income.map((x) => x[1]),
+        barWidth: 8,
+        itemStyle: { color: upGrad(C.brand), borderRadius: [2, 2, 0, 0] },
+      },
+      {
+        name: '支出',
+        type: 'bar',
+        data: expense.map((x) => x[1]),
+        barWidth: 8,
+        itemStyle: { color: upGrad(C.warn), borderRadius: [2, 2, 0, 0] },
+      },
+    ],
+  }
+}
+
+// ============== 政府性基金 收入 vs 支出 双柱 ==============
+export function fundIncVsExpOption(income, expense) {
+  const cats = sliceDate(income)
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis', axisPointer: { type: 'shadow' } },
+    legend: {
+      data: ['基金收入', '基金支出'],
+      top: 4, textStyle: { color: C.mut, fontSize: 11 }, itemWidth: 14, itemHeight: 8,
+    },
+    grid: { top: 32, left: 60, right: 24, bottom: 28 },
+    xAxis: { type: 'category', data: cats, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10 } }) },
+    yAxis: { type: 'value', name: '亿元', ...axisStyle() },
+    series: [
+      {
+        name: '基金收入',
+        type: 'bar',
+        data: income.map((x) => x[1]),
+        barWidth: 8,
+        itemStyle: { color: upGrad(C.purple), borderRadius: [2, 2, 0, 0] },
+      },
+      {
+        name: '基金支出',
+        type: 'bar',
+        data: expense.map((x) => x[1]),
+        barWidth: 8,
+        itemStyle: { color: upGrad(C.brand), borderRadius: [2, 2, 0, 0] },
+      },
+    ],
+  }
+}
+
+// ============== 赤字 / 专项债 年度(柱图) ==============
+export function yearlyBarOption(series, { title = '年度', color = C.warn, unit = '亿元' } = {}) {
+  const cats = series.map((x) => String(x[0]).slice(0, 4))
+  const values = series.map((x) => x[1])
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis', axisPointer: { type: 'shadow' } },
+    grid: { top: 18, left: 60, right: 24, 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: 18,
+      itemStyle: { color: upGrad(color), borderRadius: [3, 3, 0, 0] },
+      label: {
+        show: true, position: 'top', fontSize: 10, color: C.mut,
+        formatter: (p) => (typeof p.value === 'number' ? p.value.toLocaleString() : ''),
+      },
+    }],
+  }
+}
+
+// ============== 赤字率 折线 ==============
+export function yearlyLineOption(series, { title = '赤字率', color = C.up, unit = '%' } = {}) {
+  const cats = series.map((x) => String(x[0]).slice(0, 4))
+  const values = series.map((x) => x[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: unit, ...axisStyle() },
+    series: [{
+      name: title,
+      type: 'line',
+      smooth: true,
+      showSymbol: true,
+      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(225,59,59,.16)' }, { offset: 1, color: 'rgba(225,59,59,0)' }],
+      },
+      label: {
+        show: true, position: 'top', color: C.mut, fontSize: 10,
+        formatter: (p) => (typeof p.value === 'number' ? p.value.toFixed(1) + '%' : ''),
+      },
+    }],
+  }
+}
+
+// ============== 政府债余额 月度 折线 ==============
+export function monthlyLineOption(series, { title = '政府债余额', color = C.brand, unit = '万亿元' } = {}) {
+  const cats = sliceDate(series)
+  const values = series.map((x) => x[1])
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis' },
+    grid: { top: 18, left: 60, right: 24, bottom: 28 },
+    xAxis: { type: 'category', data: cats, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10 } }) },
+    yAxis: { type: 'value', name: unit, ...axisStyle() },
+    series: [{
+      name: title,
+      type: 'line',
+      smooth: true,
+      showSymbol: false,
+      symbol: 'circle',
+      symbolSize: 4,
+      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(21,82,208,.16)' }, { offset: 1, color: 'rgba(21,82,208,0)' }],
+      },
+      label: { show: false },
+    }],
+  }
+}
+
+// ============== 收支差 ==============
+export function gapOption(income, expense, { title = '财政收支差', color = C.up, unit = '亿元' } = {}) {
+  const cats = sliceDate(income)
+  const inc = new Map(income)
+  const exp = new Map(expense)
+  const gap = cats.map((_, i) => {
+    const d = String(income[i][0])
+    const iv = inc.get(d)?.[1]
+    const ev = exp.get(d)?.[1]
+    if (typeof iv !== 'number' || typeof ev !== 'number') return null
+    return iv - ev
+  })
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis', axisPointer: { type: 'shadow' } },
+    grid: { top: 18, left: 60, right: 24, 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: gap,
+      barWidth: 12,
+      itemStyle: {
+        color: (p) => (p.value >= 0 ? upGrad(color) : downGrad(C.down)),
+        borderRadius: [3, 3, 0, 0],
+      },
+      label: { show: false },
+      markLine: {
+        symbol: ['none', 'none'],
+        data: [{ yAxis: 0, lineStyle: { color: C.faint, type: 'dashed', width: 1 } }],
+        label: { show: false },
+      },
+    }],
+  }
+}

+ 296 - 0
web-app/src/views/policy/fiscal/data.js

@@ -0,0 +1,296 @@
+// 财政政策 二级看板 — 数据加载层
+// 优先从后端 /api/indicators 拉取;失败时回落到本地 mock,保证页面可用。
+// 指标(与后端 政策面sqlite初始化.sql 一致):
+//   M0046168  一般公共预算收入:累计值   (亿元)
+//   M0046169  一般公共预算收入:累计同比 (%)
+//   M0046166  一般公共预算支出:累计值   (亿元)
+//   M0046167  一般公共预算支出:累计同比 (%)
+//   M0096879  政府性基金收入:累计值     (亿元)
+//   M0096883  政府性基金收入:累计同比   (%)
+//   M0096887  政府性基金支出:累计值     (亿元)
+//   M0096891  政府性基金支出:累计同比   (%)
+//   M5405501  财政赤字(预期目标)        (亿元)
+//   M5405502  财政赤字率(预期目标)      (%)
+//   M5405503  中央财政赤字(预期目标)    (亿元)
+//   M5405504  地方财政赤字(预期目标)    (亿元)
+//   M6405781  地方专项债(预期目标)      (亿元)
+//   M6179494  社会融资规模存量:政府债券 (万亿元)
+
+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',
+  purple: '#7C4DFF',
+}
+
+export const palette = C
+
+// 指标元数据缓存(按 category 拉一次,再用 wind_code 查 id)
+let fiscalCache = null
+async function fetchMeta() {
+  if (fiscalCache) return fiscalCache
+  const list = await listIndicators({ category: '财政政策' })
+  fiscalCache = list
+  return fiscalCache
+}
+
+function pickByCode(list, code) {
+  return (list || []).find((x) => x.wind_code === code) || null
+}
+
+function toSeries(rows) {
+  return (rows || []).map((r) => [r.obs_date, r.value])
+}
+
+// 月频取近 3 年;年频取近 6 年;日频(逆回购利率除外,此处不用)取近 2 年
+function defaultStart(indicator) {
+  const now = new Date()
+  const y = now.getFullYear()
+  const freq = indicator?.frequency || ''
+  if (freq === '年') return `${y - 12}-01-01`
+  if (freq === '日') return `${y - 2}-01-01`
+  return `${y - 3}-01-01`
+}
+
+async function loadValuesByCode(code, list, opts = {}) {
+  const meta = pickByCode(list, code)
+  if (!meta) {
+    return { meta: null, series: [], _code: code }
+  }
+  const payload = await getIndicatorValues(meta.id, {
+    start: opts.start || defaultStart(meta),
+    order: opts.order || 'asc',
+  })
+  return {
+    meta: payload.indicator || meta,
+    series: toSeries(payload.values),
+    _code: code,
+  }
+}
+
+async function loadLatestByCode(code, list) {
+  const meta = pickByCode(list, code)
+  if (!meta) return { meta: null, latest: null, _code: code }
+  const payload = await getIndicatorLatest(meta.id)
+  return {
+    meta: payload.indicator || meta,
+    latest: payload.latest ? { date: payload.latest.obs_date, value: payload.latest.value } : null,
+    _code: code,
+  }
+}
+
+// ============== Mock 兜底数据 ==============
+// 当后端不可达时使用,保证页面始终可渲染
+function mockMonthly(unit, base, jitter = 0.5, start = '2023-01-31', months = 30) {
+  const arr = []
+  let v = base
+  const startDate = new Date(start)
+  for (let i = 0; i < months; i++) {
+    const d = new Date(startDate.getFullYear(), startDate.getMonth() + i, 1)
+    const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0)
+    const y = lastDay.getFullYear()
+    const m = String(lastDay.getMonth() + 1).padStart(2, '0')
+    const dd = String(lastDay.getDate()).padStart(2, '0')
+    if (unit === '%') {
+      v = +(v + (Math.sin(i * 0.8 + base) * jitter) - 0.05).toFixed(2)
+    } else {
+      v = +(v * (1 + (Math.sin(i * 0.6 + base) * 0.04) + 0.02)).toFixed(0)
+    }
+    arr.push([`${y}-${m}-${dd}`, v])
+  }
+  return arr
+}
+
+function mockYearly(base, years = 12) {
+  const arr = []
+  const now = new Date()
+  for (let i = years - 1; i >= 0; i--) {
+    const y = now.getFullYear() - i
+    arr.push([`${y}-12-31`, +(base * (1 + Math.sin(i * 0.5) * 0.1 + i * 0.04)).toFixed(0)])
+  }
+  return arr
+}
+
+const mock = {
+  pubIncomeVal:  { name: '一般公共预算收入:累计值',   unit: '亿元', source: '财政部', code: 'M0046168', series: mockMonthly('亿', 18000, 0.5, '2023-01-31', 30) },
+  pubIncomeYoy:  { name: '一般公共预算收入:累计同比', unit: '%',    source: '财政部', code: 'M0046169', series: mockMonthly('%', 1.2,  1.0, '2023-01-31', 30) },
+  pubExpVal:     { name: '一般公共预算支出:累计值',   unit: '亿元', source: '财政部', code: 'M0046166', series: mockMonthly('亿', 20500, 0.5, '2023-01-31', 30) },
+  pubExpYoy:     { name: '一般公共预算支出:累计同比', unit: '%',    source: '财政部', code: 'M0046167', series: mockMonthly('%', 2.4,  1.0, '2023-01-31', 30) },
+  fundIncVal:    { name: '政府性基金收入:累计值',     unit: '亿元', source: '财政部', code: 'M0096879', series: mockMonthly('亿',  3500, 0.6, '2023-01-31', 30) },
+  fundIncYoy:    { name: '政府性基金收入:累计同比',   unit: '%',    source: '财政部', code: 'M0096883', series: mockMonthly('%', -8.5, 1.5, '2023-01-31', 30) },
+  fundExpVal:    { name: '政府性基金支出:累计值',     unit: '亿元', source: '财政部', code: 'M0096887', series: mockMonthly('亿',  5500, 0.6, '2023-01-31', 30) },
+  fundExpYoy:    { name: '政府性基金支出:累计同比',   unit: '%',    source: '财政部', code: 'M0096891', series: mockMonthly('%', -6.2, 1.5, '2023-01-31', 30) },
+  deficitTotal:  { name: '财政赤字(预期目标)',        unit: '亿元', source: '中国政府网', code: 'M5405501', series: mockYearly(38800, 12) },
+  deficitRatio:  { name: '财政赤字率(预期目标)',      unit: '%',    source: '中国政府网', code: 'M5405502', series: mockYearly(3.0,   12).map((x) => [x[0], +(x[1] / 13000).toFixed(2)]) },
+  centralDef:    { name: '中央财政赤字(预期目标)',    unit: '亿元', source: '中国政府网', code: 'M5405503', series: mockYearly(18600, 12) },
+  localDef:      { name: '地方财政赤字(预期目标)',    unit: '亿元', source: '中国政府网', code: 'M5405504', series: mockYearly(20200, 12) },
+  localSpecBond: { name: '地方专项债(预期目标)',      unit: '亿元', source: '中国政府网', code: 'M6405781', series: mockYearly(39000, 12) },
+  govDebtBal:    { name: '社会融资规模存量:政府债券', unit: '万亿元', source: '中国人民银行', code: 'M6179494', series: mockMonthly('亿', 70, 0.3, '2020-01-31', 60) },
+}
+
+// 修正赤字率序列,使其落在合理区间
+mock.deficitRatio.series = [
+  ['2014-12-31', 2.1], ['2015-12-31', 2.3], ['2016-12-31', 3.0], ['2017-12-31', 3.0],
+  ['2018-12-31', 2.6], ['2019-12-31', 2.8], ['2020-12-31', 3.6], ['2021-12-31', 3.2],
+  ['2022-12-31', 2.8], ['2023-12-31', 3.0], ['2024-12-31', 3.0], ['2025-12-31', 3.0],
+  ['2026-12-31', 3.0],
+]
+// 修正赤字 / 专项债为合理数字(单位 亿元)
+const def = [13800, 16200, 21800, 23800, 23800, 27600, 37600, 35700, 33700, 38800, 33800, 48800, 50800]
+mock.deficitTotal.series = def.map((v, i) => [`${2014 + i}-12-31`, v])
+mock.centralDef.series = def.map((v, i) => [`${2014 + i}-12-31`, Math.round(v * 0.4)])
+mock.localDef.series = def.map((v, i) => [`${2014 + i}-12-31`, Math.round(v * 0.6)])
+mock.localSpecBond.series = [4000, 1000, 4000, 8000, 13500, 21500, 36000, 36500, 36500, 39000, 39500, 40000, 44000].map((v, i) => [`${2014 + i}-12-31`, v])
+// 政府债存量:万亿元
+mock.govDebtBal.series = mock.govDebtBal.series.map((x) => [x[0], +(x[1] / 10000).toFixed(2)])
+
+function mockSeries(key) {
+  return mock[key]?.series || []
+}
+function mockMeta(key) {
+  const m = mock[key]
+  return {
+    id: null,
+    category: '财政政策',
+    name: m?.name || '',
+    unit: m?.unit || '',
+    frequency: key === 'deficitTotal' || key === 'deficitRatio' || key === 'centralDef' || key === 'localDef' || key === 'localSpecBond' ? '年' : '月',
+    wind_code: m?.code || '—',
+    source: m?.source || '财政部',
+    data_start: m?.series?.[0]?.[0] || '',
+    data_end: m?.series?.[m.series.length - 1]?.[0] || '',
+  }
+}
+
+// ============== 加载函数(带兜底) ==============
+async function loadWithFallback(key) {
+  try {
+    const list = await fetchMeta()
+    const m = mock[key]
+    const res = await loadValuesByCode(m.code, list)
+    if (res.meta && res.series.length) return res
+    return { meta: res.meta || mockMeta(key), series: mockSeries(key) }
+  } catch (err) {
+    console.warn(`[fiscal] ${key} fallback to mock:`, err)
+    return { meta: mockMeta(key), series: mockSeries(key) }
+  }
+}
+
+async function loadLatestWithFallback(key) {
+  try {
+    const list = await fetchMeta()
+    const m = mock[key]
+    const res = await loadLatestByCode(m.code, list)
+    if (res.latest) return res
+    const series = mockSeries(key)
+    const last = series[series.length - 1]
+    return {
+      meta: res.meta || mockMeta(key),
+      latest: last ? { date: last[0], value: last[1] } : null,
+    }
+  } catch (err) {
+    console.warn(`[fiscal] latest ${key} fallback:`, err)
+    const series = mockSeries(key)
+    const last = series[series.length - 1]
+    return {
+      meta: mockMeta(key),
+      latest: last ? { date: last[0], value: last[1] } : null,
+    }
+  }
+}
+
+// 公共预算
+export const loadPubIncome = () => loadWithFallback('pubIncomeVal')
+export const loadPubIncomeYoy = () => loadWithFallback('pubIncomeYoy')
+export const loadPubExp = () => loadWithFallback('pubExpVal')
+export const loadPubExpYoy = () => loadWithFallback('pubExpYoy')
+
+// 政府性基金
+export const loadFundInc = () => loadWithFallback('fundIncVal')
+export const loadFundIncYoy = () => loadWithFallback('fundIncYoy')
+export const loadFundExp = () => loadWithFallback('fundExpVal')
+export const loadFundExpYoy = () => loadWithFallback('fundExpYoy')
+
+// 财政赤字
+export const loadDeficitTotal = () => loadWithFallback('deficitTotal')
+export const loadDeficitRatio = () => loadWithFallback('deficitRatio')
+export const loadCentralDef = () => loadWithFallback('centralDef')
+export const loadLocalDef = () => loadWithFallback('localDef')
+export const loadLocalSpecBond = () => loadWithFallback('localSpecBond')
+
+// 政府债余额
+export const loadGovDebtBal = () => loadWithFallback('govDebtBal')
+
+// 一站式:拉全部序列
+export async function loadAllSeries() {
+  const [
+    pubInc, pubIncYoy, pubExp, pubExpYoy,
+    fInc, fIncYoy, fExp, fExpYoy,
+    defTot, defR, cDef, lDef, lSpec,
+    govDebt,
+  ] = await Promise.all([
+    loadPubIncome(), loadPubIncomeYoy(), loadPubExp(), loadPubExpYoy(),
+    loadFundInc(), loadFundIncYoy(), loadFundExp(), loadFundExpYoy(),
+    loadDeficitTotal(), loadDeficitRatio(), loadCentralDef(), loadLocalDef(), loadLocalSpecBond(),
+    loadGovDebtBal(),
+  ])
+  return {
+    pubIncome: pubInc, pubIncomeYoy: pubIncYoy,
+    pubExp: pubExp, pubExpYoy: pubExpYoy,
+    fundInc: fInc, fundIncYoy: fIncYoy,
+    fundExp: fExp, fundExpYoy: fExpYoy,
+    deficitTotal: defTot, deficitRatio: defR,
+    centralDef: cDef, localDef: lDef, localSpecBond: lSpec,
+    govDebt: govDebt,
+  }
+}
+
+export async function loadLatestAll() {
+  const [
+    pubInc, pubIncYoy, pubExp, pubExpYoy,
+    fInc, fIncYoy, fExp, fExpYoy,
+    defTot, defR, cDef, lDef, lSpec,
+    govDebt,
+  ] = await Promise.all([
+    loadLatestWithFallback('pubIncomeVal'),
+    loadLatestWithFallback('pubIncomeYoy'),
+    loadLatestWithFallback('pubExpVal'),
+    loadLatestWithFallback('pubExpYoy'),
+    loadLatestWithFallback('fundIncVal'),
+    loadLatestWithFallback('fundIncYoy'),
+    loadLatestWithFallback('fundExpVal'),
+    loadLatestWithFallback('fundExpYoy'),
+    loadLatestWithFallback('deficitTotal'),
+    loadLatestWithFallback('deficitRatio'),
+    loadLatestWithFallback('centralDef'),
+    loadLatestWithFallback('localDef'),
+    loadLatestWithFallback('localSpecBond'),
+    loadLatestWithFallback('govDebtBal'),
+  ])
+  return {
+    pubIncome: pubInc, pubIncomeYoy: pubIncYoy,
+    pubExp: pubExp, pubExpYoy: pubExpYoy,
+    fundInc: fInc, fundIncYoy: fIncYoy,
+    fundExp: fExp, fundExpYoy: fExpYoy,
+    deficitTotal: defTot, deficitRatio: defR,
+    centralDef: cDef, localDef: lDef, localSpecBond: lSpec,
+    govDebt: govDebt,
+  }
+}
+
+export function latestOf(series) {
+  if (!series || !series.length) return null
+  const last = series[series.length - 1]
+  return { value: last[1], date: last[0] }
+}

+ 854 - 0
web-app/src/views/policy/fiscal/index.vue

@@ -0,0 +1,854 @@
+<template>
+  <div class="fiscal-page">
+    <header class="page-hd">
+      <div>
+        <div class="page-title">财政政策看板</div>
+        <div class="page-sub">政策面 · 财政政策 · 一般公共预算 / 政府性基金 / 财政赤字 / 政府债余额 · 数据源 财政部 · 中国政府网 · 中国人民银行</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" 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 一般公共预算收入 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§1.1 一般公共预算收入 · 累计值及增速</h2>
+        <span class="meta">月度 · 财政部</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最新 {{ formatPct(pubIncomeYoyLatest) }} · {{ formatDate(pubIncomeYoyMeta?.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">收入累计值 & 累计同比</span>
+            </div>
+            <div class="acts">
+              <span class="meta tag-r" v-if="pubIncomeYoyLatest">{{ pubIncomeYoyLatest.value >= 0 ? '累计正增长' : '累计负增长' }}</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="pubIncomeOpt" 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">{{ pubIncomeYoyMeta?.wind_code || '—' }}</span></div>
+              <div class="kv"><span class="k">数据源</span><span class="v">{{ pubIncomeYoyMeta?.source || '财政部' }}</span></div>
+              <div class="kv"><span class="k">频率</span><span class="v">{{ pubIncomeYoyMeta?.frequency || '月' }}</span></div>
+              <div class="kv"><span class="k">起始日期</span><span class="v">{{ pubIncomeYoyMeta?.data_start || '—' }}</span></div>
+              <div class="kv"><span class="k">最近观测</span><span class="v">{{ pubIncomeYoyMeta?.data_end || '—' }}</span></div>
+              <div class="kv"><span class="k">最新读数</span><span class="v strong">{{ formatPct(pubIncomeYoyLatest) }}</span></div>
+              <div class="kv"><span class="k">最新累计值</span><span class="v strong">{{ formatYi(pubIncomeLatest) }} 亿元</span></div>
+            </div>
+            <div class="note">
+              一般公共预算收入是中央和地方财政的主要收入来源,反映税收和非税收入的总量。{{ pubIncomeComment }}。
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §1.2 一般公共预算支出 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§1.2 一般公共预算支出 · 累计值及增速</h2>
+        <span class="meta">月度 · 财政部</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最新 {{ formatPct(pubExpYoyLatest) }} · {{ formatDate(pubExpYoyMeta?.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">支出累计值 & 累计同比</span>
+            </div>
+            <div class="acts">
+              <span class="meta tag-r" v-if="pubExpYoyLatest">{{ pubExpYoyLatest.value >= 0 ? '累计正增长' : '累计负增长' }}</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="pubExpOpt" 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">{{ pubExpYoyMeta?.wind_code || '—' }}</span></div>
+              <div class="kv"><span class="k">数据源</span><span class="v">{{ pubExpYoyMeta?.source || '财政部' }}</span></div>
+              <div class="kv"><span class="k">频率</span><span class="v">{{ pubExpYoyMeta?.frequency || '月' }}</span></div>
+              <div class="kv"><span class="k">起始日期</span><span class="v">{{ pubExpYoyMeta?.data_start || '—' }}</span></div>
+              <div class="kv"><span class="k">最近观测</span><span class="v">{{ pubExpYoyMeta?.data_end || '—' }}</span></div>
+              <div class="kv"><span class="k">最新读数</span><span class="v strong">{{ formatPct(pubExpYoyLatest) }}</span></div>
+              <div class="kv"><span class="k">最新累计值</span><span class="v strong">{{ formatYi(pubExpLatest) }} 亿元</span></div>
+            </div>
+            <div class="note">
+              一般公共预算支出反映政府提供公共服务、推进各项社会事业发展的资金投入力度。{{ pubExpComment }}。
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §1.3 收入 vs 支出 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§1.3 一般公共预算 · 收支对比</h2>
+        <span class="meta">月度 · 财政部</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">收入 {{ formatYi(pubIncomeLatest) }} · 支出 {{ formatYi(pubExpLatest) }} 亿元</span>
+      </div>
+      <div class="card-grid cols-1-1">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">1.3a</span>
+              <span class="title-text">收入 vs 支出 · 累计值</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="incVsExpOpt" height="300px" />
+          </div>
+        </div>
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">1.3b</span>
+              <span class="title-text">收支差 = 收入 − 支出</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="gapOpt" height="300px" />
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §1.4 政府性基金收入 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§1.4 政府性基金 · 收入累计值及增速</h2>
+        <span class="meta">月度 · 财政部</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最新 {{ formatPct(fundIncYoyLatest) }} · {{ formatDate(fundIncYoyMeta?.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">政府性基金收入 · 累计值 & 同比</span>
+            </div>
+            <div class="acts">
+              <span class="meta tag-r" v-if="fundIncYoyLatest">{{ fundIncYoyLatest.value >= 0 ? '累计正增长' : '累计负增长' }}</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="fundIncOpt" 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">{{ fundIncYoyMeta?.wind_code || '—' }}</span></div>
+              <div class="kv"><span class="k">数据源</span><span class="v">{{ fundIncYoyMeta?.source || '财政部' }}</span></div>
+              <div class="kv"><span class="k">频率</span><span class="v">{{ fundIncYoyMeta?.frequency || '月' }}</span></div>
+              <div class="kv"><span class="k">起始日期</span><span class="v">{{ fundIncYoyMeta?.data_start || '—' }}</span></div>
+              <div class="kv"><span class="k">最近观测</span><span class="v">{{ fundIncYoyMeta?.data_end || '—' }}</span></div>
+              <div class="kv"><span class="k">最新读数</span><span class="v strong">{{ formatPct(fundIncYoyLatest) }}</span></div>
+              <div class="kv"><span class="k">最新累计值</span><span class="v strong">{{ formatYi(fundIncLatest) }} 亿元</span></div>
+            </div>
+            <div class="note">
+              政府性基金收入主要来自国有土地使用权出让收入、专项收入等,与土地市场景气度密切相关。{{ fundIncComment }}。
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §1.5 政府性基金支出 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§1.5 政府性基金 · 支出累计值及增速</h2>
+        <span class="meta">月度 · 财政部</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最新 {{ formatPct(fundExpYoyLatest) }} · {{ formatDate(fundExpYoyMeta?.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.5</span>
+              <span class="title-text">政府性基金支出 · 累计值 & 同比</span>
+            </div>
+            <div class="acts">
+              <span class="meta tag-r" v-if="fundExpYoyLatest">{{ fundExpYoyLatest.value >= 0 ? '累计正增长' : '累计负增长' }}</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="fundExpOpt" 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">{{ fundExpYoyMeta?.wind_code || '—' }}</span></div>
+              <div class="kv"><span class="k">数据源</span><span class="v">{{ fundExpYoyMeta?.source || '财政部' }}</span></div>
+              <div class="kv"><span class="k">频率</span><span class="v">{{ fundExpYoyMeta?.frequency || '月' }}</span></div>
+              <div class="kv"><span class="k">起始日期</span><span class="v">{{ fundExpYoyMeta?.data_start || '—' }}</span></div>
+              <div class="kv"><span class="k">最近观测</span><span class="v">{{ fundExpYoyMeta?.data_end || '—' }}</span></div>
+              <div class="kv"><span class="k">最新读数</span><span class="v strong">{{ formatPct(fundExpYoyLatest) }}</span></div>
+              <div class="kv"><span class="k">最新累计值</span><span class="v strong">{{ formatYi(fundExpLatest) }} 亿元</span></div>
+            </div>
+            <div class="note">
+              政府性基金支出主要用于土地开发、专项建设和政府性基金收入对应的特定开支。{{ fundExpComment }}。
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §1.6 财政赤字和赤字率 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§1.6 财政赤字与赤字率 · 预期目标</h2>
+        <span class="meta">年度 · 中国政府网 · 财政部</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">{{ formatYi(deficitTotalLatest) }} 亿元 · 赤字率 {{ formatPct2(deficitRatioLatest) }}</span>
+      </div>
+      <div class="card-grid cols-1-1">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">1.6a</span>
+              <span class="title-text">财政赤字总额 · 年度目标</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="deficitTotalOpt" height="280px" />
+          </div>
+        </div>
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">1.6b</span>
+              <span class="title-text">财政赤字率 · 年度目标</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="deficitRatioOpt" height="280px" />
+          </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.6c</span>
+              <span class="title-text">中央 vs 地方 财政赤字</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="centralVsLocalOpt" height="280px" />
+          </div>
+        </div>
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">1.6d</span>
+              <span class="title-text">地方专项债额度</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="localSpecBondOpt" height="280px" />
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §1.7 政府债余额 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§1.7 社会融资规模存量 · 政府债券</h2>
+        <span class="meta">月度 · 中国人民银行</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最新 {{ formatYi(govDebtLatest) }} 万亿元 · {{ formatDate(govDebtMeta?.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.7</span>
+              <span class="title-text">政府债余额 · 万亿元</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="govDebtOpt" 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">{{ govDebtMeta?.wind_code || '—' }}</span></div>
+              <div class="kv"><span class="k">数据源</span><span class="v">{{ govDebtMeta?.source || '中国人民银行' }}</span></div>
+              <div class="kv"><span class="k">频率</span><span class="v">{{ govDebtMeta?.frequency || '月' }}</span></div>
+              <div class="kv"><span class="k">起始日期</span><span class="v">{{ govDebtMeta?.data_start || '—' }}</span></div>
+              <div class="kv"><span class="k">最近观测</span><span class="v">{{ govDebtMeta?.data_end || '—' }}</span></div>
+              <div class="kv"><span class="k">最新余额</span><span class="v strong">{{ formatYi(govDebtLatest) }} 万亿元</span></div>
+            </div>
+            <div class="note">
+              社会融资规模存量中的"政府债券"科目反映国债与地方政府债的余额规模,是观察财政扩张力度的关键指标。
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <footer class="footer">
+      数据源:财政部 · 中国政府网 · 中国人民银行 · {{ updatedAt }} · 财政政策看板 v1.0
+    </footer>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref as vRef } from 'vue'
+import Chart from '@/components/Chart.vue'
+import {
+  loadAllSeries, loadLatestAll, latestOf,
+} from './data'
+import {
+  monthlyAccumulatedOption, monthlyYoYOption,
+  incomeVsExpenseOption, fundIncVsExpOption,
+  yearlyBarOption, yearlyLineOption, monthlyLineOption, gapOption,
+} from './charts'
+
+const loading = vRef(false)
+const updatedAt = vRef('')
+
+const series = vRef({})
+const latestAll = vRef({})
+
+const pubIncomeSeries = vRef([])
+const pubIncomeYoySeries = vRef([])
+const pubExpSeries = vRef([])
+const pubExpYoySeries = vRef([])
+const fundIncSeries = vRef([])
+const fundIncYoySeries = vRef([])
+const fundExpSeries = vRef([])
+const fundExpYoySeries = vRef([])
+const deficitTotalSeries = vRef([])
+const deficitRatioSeries = vRef([])
+const centralDefSeries = vRef([])
+const localDefSeries = vRef([])
+const localSpecBondSeries = vRef([])
+const govDebtSeries = vRef([])
+
+const pubIncomeMeta = vRef(null)
+const pubIncomeYoyMeta = vRef(null)
+const pubExpMeta = vRef(null)
+const pubExpYoyMeta = vRef(null)
+const fundIncMeta = vRef(null)
+const fundIncYoyMeta = vRef(null)
+const fundExpMeta = vRef(null)
+const fundExpYoyMeta = vRef(null)
+const deficitTotalMeta = vRef(null)
+const deficitRatioMeta = vRef(null)
+const centralDefMeta = vRef(null)
+const localDefMeta = vRef(null)
+const localSpecBondMeta = vRef(null)
+const govDebtMeta = vRef(null)
+
+function formatNum(v) {
+  if (typeof v !== 'number') return v ?? '—'
+  if (Math.abs(v) >= 10000) return (v / 10000).toFixed(2)
+  return v.toFixed(2)
+}
+function formatYi(latest) {
+  if (!latest || typeof latest.value !== 'number') return '—'
+  return latest.value.toLocaleString(undefined, { maximumFractionDigits: 0 })
+}
+function formatPct(latest) {
+  if (!latest || typeof latest.value !== 'number') return '—'
+  return `${latest.value.toFixed(2)}%`
+}
+function formatPct2(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 [all, lat] = await Promise.all([loadAllSeries(), loadLatestAll()])
+    series.value = all
+    latestAll.value = lat
+
+    pubIncomeSeries.value = all.pubIncome.series
+    pubIncomeYoySeries.value = all.pubIncomeYoy.series
+    pubExpSeries.value = all.pubExp.series
+    pubExpYoySeries.value = all.pubExpYoy.series
+    fundIncSeries.value = all.fundInc.series
+    fundIncYoySeries.value = all.fundIncYoy.series
+    fundExpSeries.value = all.fundExp.series
+    fundExpYoySeries.value = all.fundExpYoy.series
+    deficitTotalSeries.value = all.deficitTotal.series
+    deficitRatioSeries.value = all.deficitRatio.series
+    centralDefSeries.value = all.centralDef.series
+    localDefSeries.value = all.localDef.series
+    localSpecBondSeries.value = all.localSpecBond.series
+    govDebtSeries.value = all.govDebt.series
+
+    pubIncomeMeta.value = all.pubIncome.meta
+    pubIncomeYoyMeta.value = all.pubIncomeYoy.meta
+    pubExpMeta.value = all.pubExp.meta
+    pubExpYoyMeta.value = all.pubExpYoy.meta
+    fundIncMeta.value = all.fundInc.meta
+    fundIncYoyMeta.value = all.fundIncYoy.meta
+    fundExpMeta.value = all.fundExp.meta
+    fundExpYoyMeta.value = all.fundExpYoy.meta
+    deficitTotalMeta.value = all.deficitTotal.meta
+    deficitRatioMeta.value = all.deficitRatio.meta
+    centralDefMeta.value = all.centralDef.meta
+    localDefMeta.value = all.localDef.meta
+    localSpecBondMeta.value = all.localSpecBond.meta
+    govDebtMeta.value = all.govDebt.meta
+
+    updatedAt.value = new Date().toISOString().slice(0, 19).replace('T', ' ')
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(reload)
+
+// ===== Latest =====
+const pubIncomeLatest = computed(() => latestOf(pubIncomeSeries.value))
+const pubIncomeYoyLatest = computed(() => latestOf(pubIncomeYoySeries.value))
+const pubExpLatest = computed(() => latestOf(pubExpSeries.value))
+const pubExpYoyLatest = computed(() => latestOf(pubExpYoySeries.value))
+const fundIncLatest = computed(() => latestOf(fundIncSeries.value))
+const fundIncYoyLatest = computed(() => latestOf(fundIncYoySeries.value))
+const fundExpLatest = computed(() => latestOf(fundExpSeries.value))
+const fundExpYoyLatest = computed(() => latestOf(fundExpYoySeries.value))
+const deficitTotalLatest = computed(() => latestOf(deficitTotalSeries.value))
+const deficitRatioLatest = computed(() => latestOf(deficitRatioSeries.value))
+const centralDefLatest = computed(() => latestOf(centralDefSeries.value))
+const localDefLatest = computed(() => latestOf(localDefSeries.value))
+const localSpecBondLatest = computed(() => latestOf(localSpecBondSeries.value))
+const govDebtLatest = computed(() => latestOf(govDebtSeries.value))
+
+// ===== KPI =====
+const kpis = computed(() => ([
+  { label: '公共预算收入 累计同比', icon: '💰', value: pubIncomeYoyLatest.value?.value, unit: '%', date: formatDate(pubIncomeYoyLatest.value?.date), tag: pubIncomeYoyLatest.value?.value >= 0 ? '税收回升' : '税收承压', sign: true },
+  { label: '公共预算支出 累计同比', icon: '🏛️', value: pubExpYoyLatest.value?.value, unit: '%', date: formatDate(pubExpYoyLatest.value?.date), tag: pubExpYoyLatest.value?.value >= 0 ? '支出扩张' : '支出放缓', sign: true },
+  { label: '政府性基金收入 累计同比', icon: '🏗️', value: fundIncYoyLatest.value?.value, unit: '%', date: formatDate(fundIncYoyLatest.value?.date), tag: fundIncYoyLatest.value?.value >= 0 ? '基金回正' : '基金承压', sign: true },
+  { label: '政府性基金支出 累计同比', icon: '🚧', value: fundExpYoyLatest.value?.value, unit: '%', date: formatDate(fundExpYoyLatest.value?.date), tag: fundExpYoyLatest.value?.value >= 0 ? '基金支出扩张' : '基金支出放缓', sign: true },
+  { label: '财政赤字 (预期目标)', icon: '📉', value: deficitTotalLatest.value?.value, unit: '亿元', date: formatDate(deficitTotalLatest.value?.date), tag: '年度', sign: true },
+  { label: '赤字率 (预期目标)', icon: '📊', value: deficitRatioLatest.value?.value, unit: '%', date: formatDate(deficitRatioLatest.value?.date), tag: '年度', sign: true },
+  { label: '地方专项债 (预期)', icon: '📦', value: localSpecBondLatest.value?.value, unit: '亿元', date: formatDate(localSpecBondLatest.value?.date), tag: '年度', sign: true },
+  { label: '政府债余额', icon: '🏦', value: govDebtLatest.value?.value, unit: '万亿元', date: formatDate(govDebtLatest.value?.date), tag: '存量', sign: true },
+]))
+
+// ===== 公共预算收入 =====
+const pubIncomeOpt = computed(() => monthlyAccumulatedOption(
+  pubIncomeSeries.value,
+  { title: '收入累计值', color: '#1552D0', unit: '亿元' },
+))
+
+const pubIncomeComment = computed(() => {
+  const v = pubIncomeYoyLatest.value?.value
+  if (typeof v !== 'number') return '收入增速持续观察'
+  if (v >= 6) return '收入增长动能较强'
+  if (v >= 2) return '收入增长温和'
+  if (v >= 0) return '收入增长偏弱'
+  return '收入端承压'
+})
+
+// ===== 公共预算支出 =====
+const pubExpOpt = computed(() => monthlyAccumulatedOption(
+  pubExpSeries.value,
+  { title: '支出累计值', color: '#E8912D', unit: '亿元' },
+))
+
+const pubExpComment = computed(() => {
+  const v = pubExpYoyLatest.value?.value
+  if (typeof v !== 'number') return '支出强度持续观察'
+  if (v >= 6) return '支出强度较高'
+  if (v >= 2) return '支出强度温和'
+  if (v >= 0) return '支出强度偏弱'
+  return '支出端收敛'
+})
+
+// ===== 收支对比 =====
+const incVsExpOpt = computed(() => incomeVsExpenseOption(
+  pubIncomeSeries.value,
+  pubExpSeries.value,
+))
+const gapOpt = computed(() => gapOption(
+  pubIncomeSeries.value,
+  pubExpSeries.value,
+  { title: '财政收支差', color: '#7C4DFF', unit: '亿元' },
+))
+
+// ===== 政府性基金 =====
+const fundIncOpt = computed(() => monthlyAccumulatedOption(
+  fundIncSeries.value,
+  { title: '基金收入累计值', color: '#7C4DFF', unit: '亿元' },
+))
+const fundIncComment = computed(() => {
+  const v = fundIncYoyLatest.value?.value
+  if (typeof v !== 'number') return '基金收入增速持续观察'
+  if (v >= 0) return '基金收入回正'
+  if (v >= -10) return '基金收入承压'
+  return '基金收入深度承压'
+})
+
+const fundExpOpt = computed(() => monthlyAccumulatedOption(
+  fundExpSeries.value,
+  { title: '基金支出累计值', color: '#1552D0', unit: '亿元' },
+))
+const fundExpComment = computed(() => {
+  const v = fundExpYoyLatest.value?.value
+  if (typeof v !== 'number') return '基金支出增速持续观察'
+  if (v >= 0) return '基金支出回正'
+  if (v >= -10) return '基金支出承压'
+  return '基金支出深度承压'
+})
+
+// ===== 财政赤字 =====
+const deficitTotalOpt = computed(() => yearlyBarOption(
+  deficitTotalSeries.value,
+  { title: '财政赤字', color: '#E13B3B', unit: '亿元' },
+))
+const deficitRatioOpt = computed(() => yearlyLineOption(
+  deficitRatioSeries.value,
+  { title: '财政赤字率', color: '#E13B3B', unit: '%' },
+))
+
+const centralVsLocalOpt = computed(() => {
+  const cats = centralDefSeries.value.map((x) => String(x[0]).slice(0, 4))
+  return {
+    tooltip: { backgroundColor: 'rgba(12,43,94,.94)', borderWidth: 0, textStyle: { color: '#fff', fontSize: 12 }, trigger: 'axis', axisPointer: { type: 'shadow' } },
+    legend: { data: ['中央赤字', '地方赤字'], top: 4, textStyle: { color: '#6B7A96', fontSize: 11 }, itemWidth: 14, itemHeight: 8 },
+    grid: { top: 32, left: 60, right: 24, bottom: 28 },
+    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: 'bar', stack: 'def', data: centralDefSeries.value.map((x) => x[1]), barWidth: 18, itemStyle: { color: '#E13B3B', borderRadius: [3, 3, 0, 0] } },
+      { name: '地方赤字', type: 'bar', stack: 'def', data: localDefSeries.value.map((x) => x[1]), barWidth: 18, itemStyle: { color: '#E8912D', borderRadius: [3, 3, 0, 0] } },
+    ],
+  }
+})
+
+const localSpecBondOpt = computed(() => yearlyBarOption(
+  localSpecBondSeries.value,
+  { title: '地方专项债', color: '#7C4DFF', unit: '亿元' },
+))
+
+// ===== 政府债 =====
+const govDebtOpt = computed(() => monthlyLineOption(
+  govDebtSeries.value,
+  { title: '政府债余额', color: '#1552D0', unit: '万亿元' },
+))
+</script>
+
+<style scoped>
+.fiscal-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-grid {
+  display: grid;
+  grid-template-columns: repeat(8, 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 .label {
+  font-size: 11.5px;
+  color: var(--mut, #6B7A96);
+  margin-bottom: 6px;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+.kpi-card .value {
+  font-size: 20px;
+  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; }
+
+.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 { color: var(--brand-d, #0E3A96) !important; font-weight: 600; }
+.meta { color: #98A5BD; font-size: 11.5px; }
+
+.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(4, 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>

+ 408 - 0
web-app/src/views/policy/industry/index.vue

@@ -0,0 +1,408 @@
+<template>
+  <div class="industry-page">
+    <header class="page-hd">
+      <div>
+        <div class="page-title">产业政策看板</div>
+        <div class="page-sub">政策面 · 产业政策 · 五年规划 · 行业战略方向 · 重点领域调控</div>
+      </div>
+      <div class="page-actions">
+        <button class="btn" @click="reload" :disabled="loading">
+          <span :class="['ic-refresh', { spinning: loading }]">↻</span> 刷新
+        </button>
+      </div>
+    </header>
+
+    <PolicySubnav
+      v-model="activeSection"
+      :items="subnav"
+      @select="onSubnavSelect"
+    />
+
+    <!-- 文档库 -->
+    <!-- <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>产业政策 · PDF 文档库</h2>
+        <span class="meta">五年规划纲要 · 行业调控政策 · 重点领域文件</span>
+      </div>
+      <PolicyDocumentLibrary :docs="industryDocs" @open="onOpenDoc" />
+    </section> -->
+
+    <!-- §4.1 十五五规划纲要 -->
+    <section id="4.1" class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§4.1 十五五规划纲要 · 摘要 + 历史文档</h2>
+        <span class="meta">顶层规划 · 全国人大</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最新 {{ planLatest.publishDate }} · 历史 {{ planHistory.length }} 份</span>
+      </div>
+      <PolicyLatestWithHistory
+        :title="planLatest.title"
+        icon="⊠"
+        :history="planHistory"
+        :latest-href="`/pdf-library#4.1`"
+        library-href="/pdf-library"
+        section-key="4.1"
+        :mark-first-active="true"
+      >
+        <template #latest>
+          <div class="date-banner">
+            <div class="date-side">
+              <div class="date-kicker">发布</div>
+              <div class="date-value">{{ planLatest.dateLabel }}</div>
+            </div>
+            <div class="date-divider"></div>
+            <div class="date-body">
+              <div class="date-title">{{ planLatest.banner.title }}</div>
+              <div class="date-note">{{ planLatest.banner.note }}</div>
+            </div>
+          </div>
+          <table class="policy-tbl">
+            <thead>
+              <tr>
+                <th>指标</th>
+                <th class="num">2026 基期</th>
+                <th class="num">2030 目标</th>
+                <th>方向</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="(row, i) in planLatest.kpiTable" :key="i">
+                <td class="sym">{{ row.indicator }}</td>
+                <td class="num">{{ row.base }}</td>
+                <td class="num">{{ row.target }}</td>
+                <td><span :class="['chip', `chip-${row.tone}`]">{{ row.tag }}</span></td>
+              </tr>
+            </tbody>
+          </table>
+        </template>
+      </PolicyLatestWithHistory>
+    </section>
+
+    <!-- 战略方向 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>重点战略方向</h2>
+        <span class="meta">{{ planStrategies.length }} 大方向 · 2026–2030</span>
+      </div>
+      <div class="strategy-grid">
+        <div
+          v-for="s in planStrategies"
+          :key="s.label"
+          :class="['strategy-card', `bg-${s.bg}`]"
+        >
+          <div class="strategy-label">{{ s.label }}</div>
+          <div class="strategy-text">{{ s.text }}</div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §4.2 地产政策 -->
+    <section id="4.2" class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§4.2 地产政策</h2>
+        <span class="meta">中央与地方调控 · 保交楼 · 白名单制度</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">文件采集中 · 敬请期待</span>
+      </div>
+      <div class="placeholder-card">
+        <div class="placeholder-icon">📂</div>
+        <div class="placeholder-title">地产政策文件 · 正在采集中</div>
+        <div class="placeholder-note">
+          涵盖 2024 年以来中央与地方主要地产调控文件、保交楼专项再贷款、白名单制度等
+        </div>
+        <span class="chip chip-gray">占位</span>
+      </div>
+    </section>
+
+    <footer class="footer">
+      数据源:全国人大 · 国家发改委 · 国务院 · {{ updatedAt }} · 产业政策看板 v1.0
+    </footer>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, ref as vRef } from 'vue'
+import { useRouter } from 'vue-router'
+import PolicyLatestWithHistory from '@/components/policy/PolicyLatestWithHistory.vue'
+import PolicyDocumentLibrary from '@/components/policy/PolicyDocumentLibrary.vue'
+import PolicySubnav from '@/components/policy/PolicySubnav.vue'
+import {
+  planLatest, planHistory, planStrategies,
+  policyDocumentLibrary,
+} from '../_shared/policy-documents'
+
+const router = useRouter()
+
+const loading = vRef(false)
+const updatedAt = vRef('')
+const activeSection = vRef('industry')
+
+const subnav = [
+  { key: 'fiscal',    label: '财政政策', to: '/policy/fiscal' },
+  { key: 'monetary',  label: '货币政策', to: '/policy/monetary' },
+  { key: 'meeting',   label: '重要会议', to: '/policy/meeting' },
+  { key: 'industry',  label: '产业政策', to: '/policy/industry' },
+  { key: 'library',   label: 'PDF 文档库', to: '/pdf-library' },
+]
+
+const industryDocs = policyDocumentLibrary.filter((d) =>
+  ['plan', 'real'].includes(d.key),
+)
+
+function onSubnavSelect() { /* handled by PolicySubnav via router.push */ }
+function onOpenDoc(d) {
+  if (d?.disabled) return
+  if (d?.href) router.push(d.href)
+}
+
+async function reload() {
+  loading.value = true
+  try {
+    updatedAt.value = new Date().toISOString().slice(0, 19).replace('T', ' ')
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(reload)
+</script>
+
+<style scoped>
+.industry-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: .15s;
+}
+.btn:hover { border-color: var(--brand, #1552D0); color: var(--brand-d, #0E3A96); }
+.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); } }
+
+.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; }
+
+/* ===== KPI 表格样式 ===== */
+.policy-tbl {
+  width: 100%;
+  border-collapse: collapse;
+}
+.policy-tbl th {
+  text-align: left;
+  padding: 9px 12px;
+  font-size: 11.5px;
+  color: var(--mut, #6B7A96);
+  font-weight: 500;
+  background: #FAFBFD;
+  border-bottom: 1px solid var(--line, #E4E9F2);
+}
+.policy-tbl th.num,
+.policy-tbl td.num { text-align: right; font-variant-numeric: tabular-nums; }
+.policy-tbl td {
+  padding: 10px 12px;
+  font-size: 12.5px;
+  border-bottom: 1px solid #F0F3F8;
+  font-variant-numeric: tabular-nums;
+}
+.policy-tbl tr:last-child td { border-bottom: none; }
+.policy-tbl tr:hover td { background: #F8FAFD; }
+.policy-tbl .sym { font-weight: 600; color: var(--ink, #1A2438); }
+
+.chip {
+  display: inline-block;
+  padding: 2px 8px;
+  border-radius: 10px;
+  font-size: 11px;
+  font-weight: 500;
+  font-variant-numeric: tabular-nums;
+}
+.chip-blue   { background: var(--brand-lt, #EAF1FF); color: var(--brand-d, #0E3A96); }
+.chip-green  { background: #E8F5EE; color: var(--down, #189A65); }
+.chip-yellow { background: #FFF6E5; color: #B25E00; }
+.chip-red    { background: #FCEAEA; color: var(--up, #E13B3B); }
+.chip-gray   { background: #EEF2F7; color: var(--mut, #6B7A96); }
+
+/* ===== 战略方向网格 ===== */
+.strategy-grid {
+  display: grid;
+  grid-template-columns: repeat(5, minmax(0, 1fr));
+  gap: 10px;
+}
+.strategy-card {
+  padding: 10px 12px;
+  border-radius: 6px;
+  border-left: 3px solid var(--brand, #1552D0);
+  background: var(--brand-lt, #EAF1FF);
+}
+.strategy-card.bg-brand { background: var(--brand-lt, #EAF1FF); border-color: var(--brand, #1552D0); }
+.strategy-card.bg-green { background: #E8F5EE; border-color: var(--down, #189A65); }
+.strategy-card.bg-warn  { background: #FFF6E5; border-color: var(--warn, #E8912D); }
+.strategy-card.bg-purple{ background: #F2EAFE; border-color: #8B5CF6; }
+.strategy-card.bg-red   { background: #FCEAEA; border-color: var(--up, #E13B3B); }
+
+.strategy-label {
+  font-size: 11px;
+  margin-bottom: 3px;
+  letter-spacing: .5px;
+  color: var(--brand-d, #0E3A96);
+}
+.strategy-card.bg-green .strategy-label { color: var(--down, #189A65); }
+.strategy-card.bg-warn .strategy-label  { color: #B25E00; }
+.strategy-card.bg-purple .strategy-label { color: #8B5CF6; }
+.strategy-card.bg-red .strategy-label    { color: var(--up, #E13B3B); }
+.strategy-text {
+  font-size: 12px;
+  color: var(--ink, #1A2438);
+  line-height: 1.5;
+}
+
+@media (max-width: 1280px) {
+  .strategy-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+@media (max-width: 980px) {
+  .strategy-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+
+/* ===== 战略方向 ===== */
+.date-banner {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  padding: 14px;
+  border-radius: 6px;
+  background: linear-gradient(135deg, #E8F0FE, #DBEAFE);
+  border-left: 4px solid var(--brand, #1552D0);
+  margin-bottom: 14px;
+}
+.date-side {
+  width: 80px;
+  text-align: center;
+  flex: none;
+}
+.date-kicker {
+  font-size: 11px;
+  letter-spacing: 2px;
+  color: var(--brand-d, #0E3A96);
+}
+.date-value {
+  font-size: 18px;
+  font-weight: 700;
+  color: var(--brand-d, #0E3A96);
+  margin-top: 4px;
+}
+.date-divider {
+  align-self: stretch;
+  width: 1px;
+  background: rgba(21, 82, 208, .3);
+  margin: 4px 0;
+}
+.date-body { flex: 1; min-width: 0; }
+.date-title {
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--ink, #1A2438);
+  margin-bottom: 4px;
+}
+.date-note {
+  font-size: 11.5px;
+  color: var(--mut, #6B7A96);
+  line-height: 1.55;
+}
+
+/* ===== 占位卡片 ===== */
+.placeholder-card {
+  background: #fff;
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 8px;
+  padding: 48px 16px;
+  text-align: center;
+  box-shadow: 0 1px 3px rgba(12, 43, 94, .04);
+}
+.placeholder-icon {
+  font-size: 48px;
+  opacity: .4;
+  margin-bottom: 14px;
+}
+.placeholder-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: var(--ink, #1A2438);
+  margin-bottom: 6px;
+}
+.placeholder-note {
+  font-size: 12.5px;
+  color: var(--mut, #6B7A96);
+  margin-bottom: 14px;
+}
+
+.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;
+}
+</style>

+ 366 - 0
web-app/src/views/policy/meeting/index.vue

@@ -0,0 +1,366 @@
+<template>
+  <div class="meeting-page">
+    <header class="page-hd">
+      <div>
+        <div class="page-title">重要会议看板</div>
+        <div class="page-sub">政策面 · 重要会议 · 中央经济工作会议 · 政府工作报告 · 政治局会议</div>
+      </div>
+      <div class="page-actions">
+        <button class="btn" @click="reload" :disabled="loading">
+          <span :class="['ic-refresh', { spinning: loading }]">↻</span> 刷新
+        </button>
+      </div>
+    </header>
+
+    <PolicySubnav
+      v-model="activeSection"
+      :items="subnav"
+      @select="onSubnavSelect"
+    />
+
+    <!-- 文档库 -->
+    <!-- <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>重要会议 · PDF 文档库</h2>
+        <span class="meta">中央经济工作会议 · 政府工作报告 · 政治局会议</span>
+      </div>
+      <PolicyDocumentLibrary :docs="meetingDocs" @open="onOpenDoc" />
+    </section> -->
+
+    <!-- §3.1 中央经济工作会议 -->
+    <section id="3.1" class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§3.1 中央经济工作会议 · 最新期摘要 + 历史文档</h2>
+        <span class="meta">年度 · 中共中央</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最新 {{ centralEconLatest.publishDate }} · 历史 {{ centralEconHistory.length }} 份(2020–2025)</span>
+      </div>
+      <PolicyLatestWithHistory
+        :title="centralEconLatest.title"
+        icon="⌧"
+        :history="centralEconHistory"
+        :latest-href="`/pdf-library#3.1`"
+        library-href="/pdf-library"
+        section-key="3.1"
+      >
+        <template #latest>
+          <div class="date-banner">
+            <div class="date-side">
+              <div class="date-kicker">会议时间</div>
+              <div class="date-value">{{ centralEconLatest.dateLabel }}</div>
+            </div>
+            <div class="date-divider"></div>
+            <div class="date-body">
+              <div class="date-title">{{ centralEconLatest.banner.title }}</div>
+              <div class="date-note">{{ centralEconLatest.banner.note }}</div>
+            </div>
+          </div>
+          <div class="para-list">
+            <p v-for="(p, i) in centralEconLatest.paragraphs" :key="i" v-html="p"></p>
+          </div>
+        </template>
+      </PolicyLatestWithHistory>
+    </section>
+
+    <!-- §3.2 政府工作报告 -->
+    <section id="3.2" class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§3.2 政府工作报告 · 最新期摘要 + 历史文档</h2>
+        <span class="meta">年度 · 国务院</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最新 {{ govWorkReportLatest.publishDate }} · 历史 {{ govWorkReportHistory.length }} 份(2021–2026)</span>
+      </div>
+      <PolicyLatestWithHistory
+        :title="govWorkReportLatest.title"
+        icon="⊞"
+        :history="govWorkReportHistory"
+        :latest-href="`/pdf-library#3.2`"
+        library-href="/pdf-library"
+        section-key="3.2"
+      >
+        <template #latest>
+          <div class="kpi-banner">
+            <div
+              v-for="(k, i) in govWorkReportLatest.kpis"
+              :key="k.label"
+              :class="['kpi-cell', { first: i === 0 }]"
+            >
+              <div class="kpi-label">{{ k.label }}</div>
+              <div :class="['kpi-value', `tone-${k.tone}`]">{{ k.value }}</div>
+            </div>
+          </div>
+          <div class="para-list">
+            <p v-for="(p, i) in govWorkReportLatest.paragraphs" :key="i" v-html="p"></p>
+          </div>
+        </template>
+      </PolicyLatestWithHistory>
+    </section>
+
+    <!-- §3.3 政治局会议 -->
+    <section id="3.3" class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§3.3 中央政治局会议 · 最新期摘要 + 历史文档</h2>
+        <span class="meta">季度 · 中共中央政治局</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最新 {{ politburoLatest.publishDate }} · 历史 {{ politburoHistory.length }} 份(2021-04–2026-07)</span>
+      </div>
+      <PolicyLatestWithHistory
+        :title="politburoLatest.title"
+        icon="⊟"
+        :history="politburoHistory"
+        :latest-href="`/pdf-library#3.3`"
+        library-href="/pdf-library"
+        section-key="3.3"
+      >
+        <template #latest>
+          <div class="date-banner">
+            <div class="date-side">
+              <div class="date-kicker">会议时间</div>
+              <div class="date-value">{{ politburoLatest.dateLabel }}</div>
+            </div>
+            <div class="date-divider"></div>
+            <div class="date-body">
+              <div class="date-title">{{ politburoLatest.banner.title }}</div>
+              <div class="date-note">{{ politburoLatest.banner.note }}</div>
+            </div>
+          </div>
+          <div class="para-list">
+            <p v-for="(p, i) in politburoLatest.paragraphs" :key="i" v-html="p"></p>
+          </div>
+        </template>
+      </PolicyLatestWithHistory>
+    </section>
+
+    <footer class="footer">
+      数据源:中共中央 · 国务院 · 中国政府网 · {{ updatedAt }} · 重要会议看板 v1.0
+    </footer>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, ref as vRef } from 'vue'
+import { useRouter } from 'vue-router'
+import PolicyLatestWithHistory from '@/components/policy/PolicyLatestWithHistory.vue'
+import PolicyDocumentLibrary from '@/components/policy/PolicyDocumentLibrary.vue'
+import PolicySubnav from '@/components/policy/PolicySubnav.vue'
+import {
+  centralEconLatest, centralEconHistory,
+  govWorkReportLatest, govWorkReportHistory,
+  politburoLatest, politburoHistory,
+  policyDocumentLibrary,
+} from '../_shared/policy-documents'
+
+const router = useRouter()
+
+const loading = vRef(false)
+const updatedAt = vRef('')
+const activeSection = vRef('meeting')
+
+const subnav = [
+  { key: 'fiscal',    label: '财政政策', to: '/policy/fiscal' },
+  { key: 'monetary',  label: '货币政策', to: '/policy/monetary' },
+  { key: 'meeting',   label: '重要会议', to: '/policy/meeting' },
+  { key: 'industry',  label: '产业政策', to: '/policy/industry' },
+  { key: 'library',   label: 'PDF 文档库', to: '/pdf-library' },
+]
+
+const meetingDocs = policyDocumentLibrary.filter((d) =>
+  ['cee', 'gwr', 'pbs'].includes(d.key),
+)
+
+function onSubnavSelect() { /* handled by PolicySubnav via router.push */ }
+function onOpenDoc(d) {
+  if (d?.disabled) return
+  if (d?.href) router.push(d.href)
+}
+
+async function reload() {
+  loading.value = true
+  try {
+    updatedAt.value = new Date().toISOString().slice(0, 19).replace('T', ' ')
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(reload)
+</script>
+
+<style scoped>
+.meeting-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: .15s;
+}
+.btn:hover { border-color: var(--brand, #1552D0); color: var(--brand-d, #0E3A96); }
+.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); } }
+
+.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; }
+
+.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;
+}
+
+/* ===== Latest card 内通用样式 ===== */
+.date-banner {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  padding: 14px;
+  border-radius: 6px;
+  background: linear-gradient(135deg, #E8F0FE, #DBEAFE);
+  border-left: 4px solid var(--brand, #1552D0);
+  margin-bottom: 14px;
+}
+.date-side {
+  width: 80px;
+  text-align: center;
+  flex: none;
+}
+.date-kicker {
+  font-size: 11px;
+  letter-spacing: 2px;
+  color: var(--brand-d, #0E3A96);
+}
+.date-value {
+  font-size: 18px;
+  font-weight: 700;
+  color: var(--brand-d, #0E3A96);
+  margin-top: 4px;
+}
+.date-divider {
+  align-self: stretch;
+  width: 1px;
+  background: rgba(21, 82, 208, .3);
+  margin: 4px 0;
+}
+.date-body { flex: 1; min-width: 0; }
+.date-title {
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--ink, #1A2438);
+  margin-bottom: 4px;
+}
+.date-note {
+  font-size: 11.5px;
+  color: var(--mut, #6B7A96);
+  line-height: 1.55;
+}
+
+.kpi-banner {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 14px;
+  padding: 14px;
+  border-radius: 6px;
+  background: linear-gradient(135deg, #E8F0FE, #DBEAFE);
+  border-left: 4px solid var(--brand, #1552D0);
+  margin-bottom: 14px;
+  text-align: center;
+}
+.kpi-cell {
+  min-width: 0;
+}
+.kpi-cell + .kpi-cell {
+  border-left: 1px solid rgba(21, 82, 208, .3);
+  padding-left: 14px;
+}
+.kpi-label {
+  font-size: 11px;
+  color: var(--brand-d, #0E3A96);
+  letter-spacing: .5px;
+}
+.kpi-value {
+  font-size: 18px;
+  font-weight: 700;
+  color: var(--brand-d, #0E3A96);
+  margin-top: 4px;
+  font-variant-numeric: tabular-nums;
+}
+.kpi-value.tone-brand { color: var(--brand-d, #0E3A96); }
+.kpi-value.tone-up    { color: var(--up, #E13B3B); }
+.kpi-value.tone-warn  { color: #B25E00; }
+.kpi-value.tone-green { color: var(--down, #189A65); }
+
+.para-list { font-size: 13px; line-height: 1.8; color: var(--ink, #1A2438); }
+.para-list p { margin-bottom: 8px; }
+.para-list p:last-child { margin-bottom: 0; }
+.para-list :deep(b) { color: var(--brand-d, #0E3A96); font-weight: 600; }
+.para-list :deep(.hl-up) { color: var(--up, #E13B3B); font-weight: 600; }
+.para-list :deep(.hl-dn) { color: var(--down, #189A65); font-weight: 600; }
+
+@media (max-width: 980px) {
+  .kpi-banner { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+</style>

+ 278 - 0
web-app/src/views/policy/monetary/charts.js

@@ -0,0 +1,278 @@
+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))
+
+const upGrad = (color) => ({
+  type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
+  colorStops: [{ offset: 0, color: '#3D7BE0' }, { offset: 1, color }],
+})
+const downGrad = (color) => ({
+  type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
+  colorStops: [{ offset: 0, color: '#4FCB9A' }, { offset: 1, color }],
+})
+
+// ============== 7天逆回购利率 (日频 · 折线) ==============
+export function repoRateOption(series, { color = '#E13B3B' } = {}) {
+  const cats = series.map((x) => String(x[0]).slice(5, 10))
+  const values = series.map((x) => x[1])
+  // 找出调降事件
+  const events = []
+  for (let i = 1; i < series.length; i++) {
+    if (series[i][1] !== series[i - 1][1]) {
+      events.push({ x: cats[i], v: values[i] })
+    }
+  }
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis' },
+    grid: { top: 24, left: 50, right: 18, bottom: 60 },
+    xAxis: {
+      type: 'category', data: cats,
+      ...axisStyle({ axisLabel: { color: C.mut, fontSize: 9 } }),
+    },
+    yAxis: {
+      type: 'value', name: '%', min: 0, max: 3,
+      ...axisStyle(),
+    },
+    series: [{
+      name: '逆回购 7天利率',
+      type: 'line',
+      step: 'end',
+      showSymbol: false,
+      data: values,
+      lineStyle: { color, width: 2 },
+      itemStyle: { color },
+      areaStyle: {
+        type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
+        colorStops: [{ offset: 0, color: 'rgba(225,59,59,.18)' }, { offset: 1, color: 'rgba(225,59,59,0)' }],
+      },
+      markPoint: events.length ? {
+        symbol: 'pin', symbolSize: 40,
+        data: events.map((e) => ({
+          name: '调降', coord: [e.x, e.v], value: e.v.toFixed(2),
+          itemStyle: { color: '#E13B3B' },
+        })),
+        label: { fontSize: 10, color: '#fff' },
+      } : undefined,
+    }],
+  }
+}
+
+// ============== 月度利率 / 水平(折线 + 区域) ==============
+export function monthlyLineOption(series, { title = '', color = C.brand, unit = '%', smooth = true } = {}) {
+  const cats = sliceDate(series)
+  const values = series.map((x) => x[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: unit, ...axisStyle() },
+    series: [{
+      name: title,
+      type: 'line',
+      smooth,
+      showSymbol: false,
+      symbol: 'circle',
+      symbolSize: 4,
+      data: values,
+      lineStyle: { color, width: 2.5 },
+      itemStyle: { color },
+      areaStyle: {
+        type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
+        colorStops: [{ offset: 0, color: color + '28' }, { offset: 1, color: color + '00' }],
+      },
+      label: { show: false },
+    }],
+  }
+}
+
+// ============== 月度余额(柱图) ==============
+export function monthlyBarOption(series, { title = '', color = C.brand, unit = '亿元' } = {}) {
+  const cats = sliceDate(series)
+  const values = series.map((x) => x[1])
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis', axisPointer: { type: 'shadow' } },
+    grid: { top: 18, left: 60, right: 24, 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: upGrad(color), borderRadius: [3, 3, 0, 0] },
+      label: { show: false },
+    }],
+  }
+}
+
+// ============== 社融 / M2 双线 ==============
+export function tsiM2Option(tsi, m2) {
+  const cats = sliceDate(tsi)
+  const tsiMap = new Map(tsi)
+  const m2Map = new Map(m2)
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis' },
+    legend: { data: ['社融存量同比', 'M2 同比'], top: 4, textStyle: { color: C.mut, fontSize: 11 }, itemWidth: 14, itemHeight: 8 },
+    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: '%', ...axisStyle() },
+    series: [
+      {
+        name: '社融存量同比',
+        type: 'line', smooth: true, showSymbol: false,
+        data: cats.map((_, i) => tsiMap.get(tsi[i][0])?.[1] ?? null),
+        lineStyle: { color: C.brand, width: 2.4 },
+        itemStyle: { color: C.brand },
+      },
+      {
+        name: 'M2 同比',
+        type: 'line', smooth: true, showSymbol: false,
+        data: cats.map((_, i) => m2Map.get(m2[i][0])?.[1] ?? null),
+        lineStyle: { color: C.warn, width: 2.4, type: 'dashed' },
+        itemStyle: { color: C.warn },
+      },
+    ],
+  }
+}
+
+// ============== 社融当月值 vs 近 3 月均值 ==============
+export function tsiMonthlyOption(tsi) {
+  const cats = sliceDate(tsi)
+  const values = tsi.map((x) => x[1])
+  // 3 月移动平均
+  const ma3 = values.map((_, i) => {
+    if (i < 2) return null
+    const slice = values.slice(i - 2, i + 1).filter((v) => typeof v === 'number')
+    if (slice.length < 3) return null
+    return Math.round(slice.reduce((a, b) => a + b, 0) / 3)
+  })
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis', axisPointer: { type: 'shadow' } },
+    legend: { data: ['社融当月值', '近 3 月均值'], top: 4, textStyle: { color: C.mut, fontSize: 11 }, itemWidth: 14, itemHeight: 8 },
+    grid: { top: 32, left: 60, right: 24, bottom: 28 },
+    xAxis: { type: 'category', data: cats, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10 } }) },
+    yAxis: { type: 'value', name: '亿元', ...axisStyle() },
+    series: [
+      {
+        name: '社融当月值',
+        type: 'bar',
+        data: values,
+        barWidth: 10,
+        itemStyle: {
+          color: (p) => (p.value >= 0 ? upGrad(C.brand) : downGrad(C.down)),
+          borderRadius: [3, 3, 0, 0],
+        },
+      },
+      {
+        name: '近 3 月均值',
+        type: 'line', smooth: true, showSymbol: false,
+        data: ma3,
+        lineStyle: { color: C.warn, width: 2, type: 'dashed' },
+        itemStyle: { color: C.warn },
+      },
+    ],
+  }
+}
+
+// ============== 买断式逆回购 多期限堆叠 ==============
+export function repoStackOption(repo6m, repo3m) {
+  const cats = sliceDate(repo6m)
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis', axisPointer: { type: 'shadow' } },
+    legend: { data: ['6 个月', '3 个月'], top: 4, textStyle: { color: C.mut, fontSize: 11 }, itemWidth: 14, itemHeight: 8 },
+    grid: { top: 32, left: 60, right: 24, bottom: 28 },
+    xAxis: { type: 'category', data: cats, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10 } }) },
+    yAxis: { type: 'value', name: '亿元', ...axisStyle() },
+    series: [
+      {
+        name: '6 个月',
+        type: 'bar', stack: 'repo',
+        data: repo6m.map((x) => x[1]),
+        barWidth: 12,
+        itemStyle: { color: upGrad(C.brand), borderRadius: [0, 0, 0, 0] },
+      },
+      {
+        name: '3 个月',
+        type: 'bar', stack: 'repo',
+        data: repo3m.map((x) => x[1]),
+        barWidth: 12,
+        itemStyle: { color: upGrad(C.warn), borderRadius: [3, 3, 0, 0] },
+      },
+    ],
+  }
+}
+
+// ============== MLF vs PSL 双线 ==============
+export function mlfPslOption(mlf, psl) {
+  const cats = sliceDate(mlf)
+  const mlfMap = new Map(mlf.map(([d, v]) => [String(d).slice(2, 7), v]))
+  const pslMap = new Map(psl.map(([d, v]) => [String(d).slice(2, 7), v]))
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis' },
+    legend: { data: ['MLF 余额', 'PSL 余额'], top: 4, textStyle: { color: C.mut, fontSize: 11 }, itemWidth: 14, itemHeight: 8 },
+    grid: { top: 32, left: 60, right: 24, bottom: 28 },
+    xAxis: { type: 'category', data: cats, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10 } }) },
+    yAxis: { type: 'value', name: '亿元', ...axisStyle() },
+    series: [
+      {
+        name: 'MLF 余额',
+        type: 'line', smooth: true, showSymbol: false,
+        data: cats.map((d) => mlfMap.get(d) ?? null),
+        lineStyle: { color: C.brand, width: 2.4 },
+        itemStyle: { color: C.brand },
+      },
+      {
+        name: 'PSL 余额',
+        type: 'line', smooth: true, showSymbol: false,
+        data: cats.map((d) => pslMap.get(d) ?? null),
+        lineStyle: { color: C.purple, width: 2.4, type: 'dashed' },
+        itemStyle: { color: C.purple },
+      },
+    ],
+  }
+}
+
+// ============== 国债净买入额(柱图) ==============
+export function bondBuyOption(series, { color = C.up } = {}) {
+  const cats = sliceDate(series)
+  const values = series.map((x) => x[1])
+  return {
+    tooltip: { ...tipOpt, trigger: 'axis', axisPointer: { type: 'shadow' } },
+    grid: { top: 18, left: 60, right: 24, bottom: 28 },
+    xAxis: { type: 'category', data: cats, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10 } }) },
+    yAxis: { type: 'value', name: '亿元', ...axisStyle() },
+    series: [{
+      name: '国债净买入',
+      type: 'bar',
+      data: values,
+      barWidth: 12,
+      itemStyle: { color: upGrad(color), borderRadius: [3, 3, 0, 0] },
+      label: { show: false },
+    }],
+  }
+}
+
+export function latestOf(series) {
+  if (!series || !series.length) return null
+  const last = series[series.length - 1]
+  return { value: last[1], date: last[0] }
+}

+ 404 - 0
web-app/src/views/policy/monetary/data.js

@@ -0,0 +1,404 @@
+// 货币政策 二级看板 — 数据加载层
+// 优先从后端 /api/indicators 拉取;失败时回落到本地 mock,保证页面可用。
+// 指标(与后端 政策面sqlite初始化.sql 一致):
+//   M0041371  逆回购利率:7天              (%, 日)
+//   M0061518  人民币存款准备金率:大型存款类金融机构  (%, 月)
+//   M5515072  MLF期末余额                (亿元, 月)
+//   M5528822  PSL期末余额                (亿元, 月)
+//   s4        买断式逆回购:6个月          (亿元, 月)
+//   s3        买断式逆回购:3个月          (亿元, 月)
+//   Y9767477  国债净买入额:1个月          (亿元, 月)
+//   M5206730  社会融资规模:当月值         (亿元, 月)
+//   M5525763  社会融资规模存量:同比       (%, 月)
+//   M0001385  M2:同比                    (%, 月)
+
+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',
+  purple: '#7C4DFF',
+}
+
+export const palette = C
+
+// 指标元数据缓存(按 category 拉一次,再用 wind_code 查 id)
+let monetaryCache = null
+async function fetchMeta() {
+  if (monetaryCache) return monetaryCache
+  const list = await listIndicators({ category: '货币政策' })
+  monetaryCache = list
+  return monetaryCache
+}
+
+function pickByCode(list, code) {
+  return (list || []).find((x) => x.wind_code === code) || null
+}
+
+function toSeries(rows) {
+  return (rows || []).map((r) => [r.obs_date, r.value])
+}
+
+// 月频取近 3 年;日频(逆回购)取近 2 年
+function defaultStart(indicator) {
+  const now = new Date()
+  const y = now.getFullYear()
+  const freq = indicator?.frequency || ''
+  if (freq === '日') return `${y - 2}-01-01`
+  return `${y - 3}-01-01`
+}
+
+async function loadValuesByCode(code, list, opts = {}) {
+  const meta = pickByCode(list, code)
+  if (!meta) return { meta: null, series: [], _code: code }
+  const payload = await getIndicatorValues(meta.id, {
+    start: opts.start || defaultStart(meta),
+    order: opts.order || 'asc',
+  })
+  return {
+    meta: payload.indicator || meta,
+    series: toSeries(payload.values),
+    _code: code,
+  }
+}
+
+async function loadLatestByCode(code, list) {
+  const meta = pickByCode(list, code)
+  if (!meta) return { meta: null, latest: null, _code: code }
+  const payload = await getIndicatorLatest(meta.id)
+  return {
+    meta: payload.indicator || meta,
+    latest: payload.latest ? { date: payload.latest.obs_date, value: payload.latest.value } : null,
+    _code: code,
+  }
+}
+
+// ============== Mock 兜底数据 ==============
+function mockMonthly(base, jitter, start = '2023-01-31', months = 30, op = 'pct') {
+  const arr = []
+  let v = base
+  const startDate = new Date(start)
+  for (let i = 0; i < months; i++) {
+    const d = new Date(startDate.getFullYear(), startDate.getMonth() + i, 1)
+    const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0)
+    const y = lastDay.getFullYear()
+    const m = String(lastDay.getMonth() + 1).padStart(2, '0')
+    const dd = String(lastDay.getDate()).padStart(2, '0')
+    if (op === 'pct') {
+      v = +(v + (Math.sin(i * 0.8 + base) * jitter) - 0.02).toFixed(2)
+    } else if (op === 'level') {
+      // 阶梯式:偶有下调
+      v = +(v + (Math.sin(i * 0.6 + base) * jitter) - 0.1).toFixed(2)
+    } else {
+      v = +(v * (1 + Math.sin(i * 0.5 + base) * 0.05 + 0.01)).toFixed(0)
+    }
+    arr.push([`${y}-${m}-${dd}`, v])
+  }
+  return arr
+}
+
+function mockDailyReverseRepo() {
+  // 7天逆回购利率:阶段性下调
+  const arr = []
+  const events = [
+    { date: '2023-01-01', v: 2.00 },
+    { date: '2023-06-13', v: 1.90 },
+    { date: '2023-08-15', v: 1.80 },
+    { date: '2024-07-22', v: 1.70 },
+    { date: '2024-09-27', v: 1.50 },
+    { date: '2025-05-07', v: 1.40 },
+  ]
+  const start = new Date('2023-01-01')
+  const end = new Date('2026-09-22')
+  for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
+    // 找到最近的生效事件
+    let cv = 2.00
+    for (const ev of events) {
+      if (new Date(ev.date) <= d) cv = ev.v
+    }
+    if (d.getDay() === 0 || d.getDay() === 6) continue
+    const y = d.getFullYear()
+    const m = String(d.getMonth() + 1).padStart(2, '0')
+    const dd = String(d.getDate()).padStart(2, '0')
+    arr.push([`${y}-${m}-${dd}`, cv])
+  }
+  return arr
+}
+
+const mock = {
+  repoRate:    { name: '逆回购利率:7天',                unit: '%',     source: '中国人民银行', code: 'M0041371', series: mockDailyReverseRepo() },
+  rrr:         { name: '存款准备金率:大型存款类金融机构', unit: '%',    source: '中国人民银行', code: 'M0061518', series: mockMonthly(10.5, 0.1, '2023-01-31', 30, 'level') },
+  mlf:         { name: 'MLF期末余额',                    unit: '亿元', source: '中国人民银行', code: 'M5515072', series: mockMonthly(40000, 500, '2023-01-31', 30, 'abs') },
+  psl:         { name: 'PSL期末余额',                    unit: '亿元', source: '中国人民银行', code: 'M5528822', series: mockMonthly(28000, 500, '2023-01-31', 30, 'abs') },
+  repo6m:      { name: '买断式逆回购数量:6个月',          unit: '亿元', source: '中国人民银行', code: 's4',       series: mockMonthly(5000, 800, '2024-09-30', 24, 'abs') },
+  repo3m:      { name: '买断式逆回购数量:3个月',          unit: '亿元', source: '中国人民银行', code: 's3',       series: mockMonthly(4000, 800, '2024-10-31', 24, 'abs') },
+  bondBuy:     { name: '国债净买入额:1个月',              unit: '亿元', source: 'Wind',         code: 'Y9767477', series: mockMonthly(800, 300, '2024-07-31', 26, 'abs') },
+  tsiMonth:    { name: '社会融资规模:当月值',             unit: '亿元', source: '中国人民银行', code: 'M5206730', series: mockMonthly(30000, 5000, '2023-01-31', 30, 'abs') },
+  tsiYoy:      { name: '社会融资规模存量:同比',           unit: '%',    source: '中国人民银行', code: 'M5525763', series: mockMonthly(8.5, 0.3, '2023-01-31', 30, 'pct') },
+  m2:          { name: 'M2:同比',                        unit: '%',    source: '中国人民银行', code: 'M0001385', series: mockMonthly(8.5, 0.4, '2023-01-31', 30, 'pct') },
+}
+
+// 修正存款准备金率(逐步下降)
+mock.rrr.series = [
+  ['2023-01-31', 11.50], ['2023-03-31', 10.50], ['2023-09-30', 10.50], ['2023-12-31', 10.50],
+  ['2024-01-31', 10.50], ['2024-02-29', 10.00], ['2024-09-30', 9.50], ['2024-12-31', 9.00],
+  ['2025-01-31', 9.00], ['2025-04-30', 9.00], ['2025-05-31', 9.00], ['2025-09-30', 8.50],
+  ['2026-01-31', 8.50], ['2026-04-30', 8.00], ['2026-07-31', 7.50], ['2026-08-31', 7.50],
+]
+// MLF 余额
+mock.mlf.series = [
+  ['2023-01-31', 45000], ['2023-03-31', 51000], ['2023-06-30', 51900], ['2023-09-30', 51800],
+  ['2023-12-31', 50600], ['2024-01-31', 47200], ['2024-03-31', 42900], ['2024-06-30', 39900],
+  ['2024-09-30', 36800], ['2024-12-31', 34500], ['2025-01-31', 32500], ['2025-03-31', 30400],
+  ['2025-06-30', 29500], ['2025-09-30', 28800], ['2025-12-31', 28500], ['2026-01-31', 28000],
+  ['2026-03-31', 27600], ['2026-05-31', 27400], ['2026-06-30', 27300], ['2026-07-31', 27200],
+  ['2026-08-31', 27100],
+]
+// PSL 余额
+mock.psl.series = [
+  ['2023-01-31', 31500], ['2023-06-30', 30200], ['2023-12-31', 29200],
+  ['2024-03-31', 28700], ['2024-06-30', 28500], ['2024-09-30', 28300],
+  ['2024-12-31', 28200], ['2025-03-31', 28100], ['2025-06-30', 28000],
+  ['2025-09-30', 27900], ['2025-12-31', 27800], ['2026-03-31', 27600],
+  ['2026-06-30', 27400], ['2026-08-31', 27300],
+]
+// 买断式逆回购(6个月/3个月)
+mock.repo6m.series = [
+  ['2024-10-31', 5000], ['2024-11-30', 8000], ['2024-12-31', 11000],
+  ['2025-01-31', 14000], ['2025-02-28', 16500], ['2025-03-31', 18500],
+  ['2025-04-30', 19000], ['2025-05-31', 19500], ['2025-06-30', 19000],
+  ['2025-07-31', 18000], ['2025-08-31', 17500], ['2025-09-30', 17000],
+  ['2025-10-31', 16500], ['2025-11-30', 16000], ['2025-12-31', 15500],
+  ['2026-01-31', 15000], ['2026-02-28', 14800], ['2026-03-31', 14500],
+  ['2026-04-30', 14200], ['2026-05-31', 14000], ['2026-06-30', 13800],
+  ['2026-07-31', 13600], ['2026-08-31', 13500], ['2026-09-30', 13400],
+]
+mock.repo3m.series = [
+  ['2024-11-30', 3000], ['2024-12-31', 5500],
+  ['2025-01-31', 8000], ['2025-02-28', 10500], ['2025-03-31', 12500],
+  ['2025-04-30', 13000], ['2025-05-31', 13200], ['2025-06-30', 13000],
+  ['2025-07-31', 12500], ['2025-08-31', 12200], ['2025-09-30', 12000],
+  ['2025-10-31', 11800], ['2025-11-30', 11500], ['2025-12-31', 11300],
+  ['2026-01-31', 11000], ['2026-02-28', 10800], ['2026-03-31', 10500],
+  ['2026-04-30', 10200], ['2026-05-31', 10000], ['2026-06-30', 9800],
+  ['2026-07-31', 9600], ['2026-08-31', 9500], ['2026-09-30', 9400],
+]
+// 国债净买入额
+mock.bondBuy.series = [
+  ['2024-07-31', 800], ['2024-08-31', 1200], ['2024-09-30', 1500],
+  ['2024-10-31', 1800], ['2024-11-30', 2200], ['2024-12-31', 2500],
+  ['2025-01-31', 2800], ['2025-02-28', 3000], ['2025-03-31', 3200],
+  ['2025-04-30', 3000], ['2025-05-31', 2800], ['2025-06-30', 2500],
+  ['2025-07-31', 2300], ['2025-08-31', 2100], ['2025-09-30', 2000],
+  ['2025-10-31', 1900], ['2025-11-30', 1800], ['2025-12-31', 1700],
+  ['2026-01-31', 1600], ['2026-02-28', 1500], ['2026-03-31', 1400],
+  ['2026-04-30', 1300], ['2026-05-31', 1200], ['2026-06-30', 1100],
+  ['2026-07-31', 1000], ['2026-08-31', 900],
+]
+// 社融当月值
+mock.tsiMonth.series = [
+  ['2023-01-31', 59845], ['2023-02-28', 31600], ['2023-03-31', 53800],
+  ['2023-04-30', 12200], ['2023-05-31', 15600], ['2023-06-30', 42200],
+  ['2023-07-31', 5350], ['2023-08-31', 31200], ['2023-09-30', 41200],
+  ['2023-10-31', 18400], ['2023-11-30', 34500], ['2023-12-31', 19400],
+  ['2024-01-31', 65260], ['2024-02-29', 15000], ['2024-03-31', 48300],
+  ['2024-04-30', -2000], ['2024-05-31', 8200], ['2024-06-30', 32900],
+  ['2024-07-31', -7700], ['2024-08-31', 30200], ['2024-09-30', 37600],
+  ['2024-10-31', 14800], ['2024-11-30', 28250], ['2024-12-31', 18500],
+  ['2025-01-31', 70500], ['2025-02-28', 22000], ['2025-03-31', 58600],
+  ['2025-04-30', 2800], ['2025-05-31', 11200], ['2025-06-30', 42000],
+  ['2025-07-31', -3000], ['2025-08-31', 33800], ['2025-09-30', 41000],
+  ['2025-10-31', 18200], ['2025-11-30', 32000], ['2025-12-31', 22000],
+  ['2026-01-31', 72300], ['2026-02-28', 23500], ['2026-03-31', 59500],
+  ['2026-04-30', 3200], ['2026-05-31', 12500], ['2026-06-30', 43500],
+  ['2026-07-31', -2500], ['2026-08-31', 35100],
+]
+// 社融存量同比
+mock.tsiYoy.series = [
+  ['2023-01-31', 9.4], ['2023-03-31', 10.0], ['2023-06-30', 9.0],
+  ['2023-09-30', 9.0], ['2023-12-31', 9.5],
+  ['2024-03-31', 8.7], ['2024-06-30', 8.1], ['2024-09-30', 8.0],
+  ['2024-12-31', 8.0], ['2025-03-31', 8.4], ['2025-06-30', 8.7],
+  ['2025-09-30', 8.7], ['2025-12-31', 8.5],
+  ['2026-03-31', 8.6], ['2026-06-30', 8.8], ['2026-08-31', 8.9],
+]
+// M2 同比
+mock.m2.series = [
+  ['2023-01-31', 12.6], ['2023-03-31', 12.7], ['2023-06-30', 11.3],
+  ['2023-09-30', 10.3], ['2023-12-31', 9.7],
+  ['2024-03-31', 8.3], ['2024-06-30', 6.2], ['2024-09-30', 6.8],
+  ['2024-12-31', 7.3], ['2025-03-31', 7.0], ['2025-06-30', 6.7],
+  ['2025-09-30', 8.2], ['2025-12-31', 8.5],
+  ['2026-03-31', 8.6], ['2026-06-30', 8.7], ['2026-08-31', 8.8],
+]
+
+function mockSeries(key) {
+  return mock[key]?.series || []
+}
+function mockMeta(key) {
+  const m = mock[key]
+  const freq = key === 'repoRate' ? '日' : '月'
+  return {
+    id: null,
+    category: '货币政策',
+    name: m?.name || '',
+    unit: m?.unit || '',
+    frequency: freq,
+    wind_code: m?.code || '—',
+    source: m?.source || '中国人民银行',
+    data_start: m?.series?.[0]?.[0] || '',
+    data_end: m?.series?.[m.series.length - 1]?.[0] || '',
+  }
+}
+
+// ============== 加载函数(带兜底) ==============
+async function loadWithFallback(key) {
+  try {
+    const list = await fetchMeta()
+    const m = mock[key]
+    const res = await loadValuesByCode(m.code, list)
+    if (res.meta && res.series.length) return res
+    return { meta: res.meta || mockMeta(key), series: mockSeries(key) }
+  } catch (err) {
+    console.warn(`[monetary] ${key} fallback to mock:`, err)
+    return { meta: mockMeta(key), series: mockSeries(key) }
+  }
+}
+
+async function loadLatestWithFallback(key) {
+  try {
+    const list = await fetchMeta()
+    const m = mock[key]
+    const res = await loadLatestByCode(m.code, list)
+    if (res.latest) return res
+    const series = mockSeries(key)
+    const last = series[series.length - 1]
+    return {
+      meta: res.meta || mockMeta(key),
+      latest: last ? { date: last[0], value: last[1] } : null,
+    }
+  } catch (err) {
+    console.warn(`[monetary] latest ${key} fallback:`, err)
+    const series = mockSeries(key)
+    const last = series[series.length - 1]
+    return {
+      meta: mockMeta(key),
+      latest: last ? { date: last[0], value: last[1] } : null,
+    }
+  }
+}
+
+export const loadRepoRate = () => loadWithFallback('repoRate')
+export const loadRrr = () => loadWithFallback('rrr')
+export const loadMlf = () => loadWithFallback('mlf')
+export const loadPsl = () => loadWithFallback('psl')
+export const loadRepo6m = () => loadWithFallback('repo6m')
+export const loadRepo3m = () => loadWithFallback('repo3m')
+export const loadBondBuy = () => loadWithFallback('bondBuy')
+export const loadTsiMonth = () => loadWithFallback('tsiMonth')
+export const loadTsiYoy = () => loadWithFallback('tsiYoy')
+export const loadM2 = () => loadWithFallback('m2')
+
+export async function loadAllSeries() {
+  const [
+    repoRate, rrr, mlf, psl,
+    r6m, r3m, bondBuy,
+    tsiMonth, tsiYoy, m2,
+  ] = await Promise.all([
+    loadRepoRate(), loadRrr(), loadMlf(), loadPsl(),
+    loadRepo6m(), loadRepo3m(), loadBondBuy(),
+    loadTsiMonth(), loadTsiYoy(), loadM2(),
+  ])
+  return {
+    repoRate, rrr, mlf, psl,
+    repo6m: r6m, repo3m: r3m, bondBuy,
+    tsiMonth, tsiYoy, m2,
+  }
+}
+
+export async function loadLatestAll() {
+  const [
+    repoRate, rrr, mlf, psl,
+    r6m, r3m, bondBuy,
+    tsiMonth, tsiYoy, m2,
+  ] = await Promise.all([
+    loadLatestWithFallback('repoRate'),
+    loadLatestWithFallback('rrr'),
+    loadLatestWithFallback('mlf'),
+    loadLatestWithFallback('psl'),
+    loadLatestWithFallback('repo6m'),
+    loadLatestWithFallback('repo3m'),
+    loadLatestWithFallback('bondBuy'),
+    loadLatestWithFallback('tsiMonth'),
+    loadLatestWithFallback('tsiYoy'),
+    loadLatestWithFallback('m2'),
+  ])
+  return {
+    repoRate, rrr, mlf, psl,
+    repo6m: r6m, repo3m: r3m, bondBuy,
+    tsiMonth, tsiYoy, m2,
+  }
+}
+
+export function latestOf(series) {
+  if (!series || !series.length) return null
+  const last = series[series.length - 1]
+  return { value: last[1], date: last[0] }
+}
+
+// 央行货政报告与例会:返回最近期次(仅用于卡片展示)
+export const monetaryPolicyReports = [
+  {
+    title: '2025年第三季度 中国货币政策执行报告',
+    date: '2025-11-10',
+    summary: '稳健的货币政策要灵活适度,发挥总量与结构双重功能。保持流动性合理充裕,引导信贷均衡投放。',
+  },
+  {
+    title: '2025年第二季度 中国货币政策执行报告',
+    date: '2025-08-15',
+    summary: '加大货币政策调控强度,支持经济回升向好。优化资金供给结构,降低实体经济融资成本。',
+  },
+  {
+    title: '2025年第一季度 中国货币政策执行报告',
+    date: '2025-05-12',
+    summary: '择机降准降息,加大对实体经济支持力度。',
+  },
+  {
+    title: '2024年第四季度 中国货币政策执行报告',
+    date: '2025-02-13',
+    summary: '实施适度宽松的货币政策,加大逆周期调节力度。',
+  },
+]
+
+export const monetaryPolicyMinutes = [
+  {
+    title: '中国人民银行货币政策委员会 2025年第四季度例会',
+    date: '2025-12-26',
+    summary: '继续实施适度宽松的货币政策,加大逆周期和跨周期调节力度。',
+  },
+  {
+    title: '中国人民银行货币政策委员会 2025年第三季度例会',
+    date: '2025-09-25',
+    summary: '增强政策前瞻性、灵活性、针对性,进一步深化利率和汇率市场化改革。',
+  },
+  {
+    title: '中国人民银行货币政策委员会 2025年第二季度例会',
+    date: '2025-06-25',
+    summary: '加强逆周期调节,发挥好货币政策工具的总量与结构双重功能。',
+  },
+  {
+    title: '中国人民银行货币政策委员会 2025年第一季度例会',
+    date: '2025-03-21',
+    summary: '推动金融总量稳定增长,加强货币政策与财政政策协同配合。',
+  },
+]

+ 1064 - 0
web-app/src/views/policy/monetary/index.vue

@@ -0,0 +1,1064 @@
+<template>
+  <div class="monetary-page">
+    <header class="page-hd">
+      <div>
+        <div class="page-title">货币政策看板</div>
+        <div class="page-sub">政策面 · 货币政策 · 量价工具 / 社融 M2 / 央行货政报告 · 数据源 中国人民银行 · Wind</div>
+      </div>
+      <div class="page-actions">
+        <button class="btn" @click="reload" :disabled="loading">
+          <span :class="['ic-refresh', { spinning: loading }]">↻</span> 刷新
+        </button>
+      </div>
+    </header>
+
+    <PolicySubnav
+      v-model="activeSection"
+      :items="subnav"
+      @select="onSubnavSelect"
+    />
+
+    <!-- KPI 概览条 -->
+    <section class="kpi-grid">
+      <div class="kpi-card" 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>
+
+    <!-- §2.1 政策利率 · 7天逆回购 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§2.1 政策利率 · 7天逆回购利率</h2>
+        <span class="meta">日频 · 中国人民银行</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最新 {{ formatPct2(repoRateLatest) }} · {{ formatDate(repoRateMeta?.data_end) }}</span>
+      </div>
+      <div class="card-grid cols-2-1">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">2.1</span>
+              <span class="title-text">7 天逆回购利率 · 阶梯走势</span>
+            </div>
+            <div class="acts">
+              <span class="meta tag-r" v-if="repoRateLatest">当前水平 {{ formatPct2(repoRateLatest) }}</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="repoRateOpt" 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">{{ repoRateMeta?.wind_code || '—' }}</span></div>
+              <div class="kv"><span class="k">数据源</span><span class="v">{{ repoRateMeta?.source || '中国人民银行' }}</span></div>
+              <div class="kv"><span class="k">频率</span><span class="v">{{ repoRateMeta?.frequency || '日' }}</span></div>
+              <div class="kv"><span class="k">起始日期</span><span class="v">{{ repoRateMeta?.data_start || '—' }}</span></div>
+              <div class="kv"><span class="k">最近观测</span><span class="v">{{ repoRateMeta?.data_end || '—' }}</span></div>
+              <div class="kv"><span class="k">最新读数</span><span class="v strong">{{ formatPct2(repoRateLatest) }}</span></div>
+            </div>
+            <div class="note">
+              7 天逆回购利率是央行公开市场操作的核心政策利率,构成我国利率走廊上限的关键参考。{{ repoRateComment }}。
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §2.2 存款准备金率 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§2.2 存款准备金率 · 大型存款类金融机构</h2>
+        <span class="meta">月度 · 中国人民银行</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最新 {{ formatPct2(rrrLatest) }} · {{ formatDate(rrrMeta?.data_end) }}</span>
+      </div>
+      <div class="card-grid cols-2-1">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">2.2</span>
+              <span class="title-text">存款准备金率 · 阶梯下调</span>
+            </div>
+            <div class="acts">
+              <span class="meta tag-r" v-if="rrrLatest">已较 2023 年累计下调 {{ rrrCutBp }}bp</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="rrrOpt" 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">{{ rrrMeta?.wind_code || '—' }}</span></div>
+              <div class="kv"><span class="k">数据源</span><span class="v">{{ rrrMeta?.source || '中国人民银行' }}</span></div>
+              <div class="kv"><span class="k">频率</span><span class="v">{{ rrrMeta?.frequency || '月' }}</span></div>
+              <div class="kv"><span class="k">起始日期</span><span class="v">{{ rrrMeta?.data_start || '—' }}</span></div>
+              <div class="kv"><span class="k">最近观测</span><span class="v">{{ rrrMeta?.data_end || '—' }}</span></div>
+              <div class="kv"><span class="k">最新读数</span><span class="v strong">{{ formatPct2(rrrLatest) }}</span></div>
+            </div>
+            <div class="note">
+              存款准备金率是央行调节银行体系流动性总量的核心工具之一,下调可释放长期资金支持信贷投放。{{ rrrComment }}。
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §2.3 + §2.4 MLF / PSL 余额 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§2.3–2.4 中长期流动性工具 · MLF / PSL 余额</h2>
+        <span class="meta">月度 · 中国人民银行</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">MLF {{ formatYi(mlfLatest) }} 亿元 · PSL {{ formatYi(pslLatest) }} 亿元</span>
+      </div>
+      <div class="card-grid cols-2-1">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">2.3+4</span>
+              <span class="title-text">MLF 与 PSL 余额</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="mlfPslOpt" 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">MLF 编码</span><span class="v">{{ mlfMeta?.wind_code || '—' }}</span></div>
+              <div class="kv"><span class="k">MLF 最新</span><span class="v strong">{{ formatYi(mlfLatest) }} 亿元</span></div>
+              <div class="kv"><span class="k">PSL 编码</span><span class="v">{{ pslMeta?.wind_code || '—' }}</span></div>
+              <div class="kv"><span class="k">PSL 最新</span><span class="v strong">{{ formatYi(pslLatest) }} 亿元</span></div>
+              <div class="kv"><span class="k">合计</span><span class="v strong">{{ formatYi(mlfPslSum) }} 亿元</span></div>
+            </div>
+            <div class="note">
+              MLF 余额反映中期借贷便利存量,PSL 主要支持政策性银行信贷投放。两者合计反映中长期流动性投放总规模。{{ mlfPslComment }}。
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §2.5 买断式逆回购 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§2.5 买断式逆回购 · 月度净投放规模</h2>
+        <span class="meta">月度 · 中国人民银行</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">6m {{ formatYi(repo6mLatest) }} · 3m {{ formatYi(repo3mLatest) }} 亿元</span>
+      </div>
+      <div class="card-grid cols-2-1">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">2.5</span>
+              <span class="title-text">买断式逆回购 · 6m + 3m 堆叠</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="repoStackOpt" 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">6m 编码</span><span class="v">{{ repo6mMeta?.wind_code || '—' }}</span></div>
+              <div class="kv"><span class="k">6m 最新</span><span class="v strong">{{ formatYi(repo6mLatest) }} 亿元</span></div>
+              <div class="kv"><span class="k">3m 编码</span><span class="v">{{ repo3mMeta?.wind_code || '—' }}</span></div>
+              <div class="kv"><span class="k">3m 最新</span><span class="v strong">{{ formatYi(repo3mLatest) }} 亿元</span></div>
+              <div class="kv"><span class="k">合计</span><span class="v strong">{{ formatYi(repoTotal) }} 亿元</span></div>
+            </div>
+            <div class="note">
+              买断式逆回购是 2024 年新增的中长期流动性投放工具,央行通过 6 个月 / 3 个月期限搭配,丰富投放渠道。{{ repoComment }}。
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §2.6 国债净买入 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§2.6 国债买卖 · 月度净买入规模</h2>
+        <span class="meta">月度 · Wind</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最新 {{ formatYi(bondBuyLatest) }} 亿元 · {{ formatDate(bondBuyMeta?.data_end) }}</span>
+      </div>
+      <div class="card-grid cols-2-1">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">2.6</span>
+              <span class="title-text">国债月度净买入额</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="bondBuyOpt" 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">{{ bondBuyMeta?.wind_code || '—' }}</span></div>
+              <div class="kv"><span class="k">数据源</span><span class="v">{{ bondBuyMeta?.source || 'Wind' }}</span></div>
+              <div class="kv"><span class="k">频率</span><span class="v">{{ bondBuyMeta?.frequency || '月' }}</span></div>
+              <div class="kv"><span class="k">起始日期</span><span class="v">{{ bondBuyMeta?.data_start || '—' }}</span></div>
+              <div class="kv"><span class="k">最近观测</span><span class="v">{{ bondBuyMeta?.data_end || '—' }}</span></div>
+              <div class="kv"><span class="k">最新读数</span><span class="v strong">{{ formatYi(bondBuyLatest) }} 亿元</span></div>
+            </div>
+            <div class="note">
+              央行于 2024 年 8 月起开展国债买卖操作,作为流动性管理工具与货币政策框架的有益补充。
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §2.7 + §2.8 社融 + M2 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§2.7–2.8 社会融资规模 & M2</h2>
+        <span class="meta">月度 · 中国人民银行</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">社融当月 {{ formatYi(tsiMonthLatest) }} 亿元 · 存量同比 {{ formatPct2(tsiYoyLatest) }}</span>
+      </div>
+      <div class="card-grid cols-1-1">
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">2.7</span>
+              <span class="title-text">社融月度新增 & 近 3 月均值</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="tsiMonthlyOpt" height="300px" />
+          </div>
+        </div>
+        <div class="card">
+          <div class="card-head">
+            <div class="title">
+              <span class="ic">2.8</span>
+              <span class="title-text">社融存量同比 vs M2 同比</span>
+            </div>
+          </div>
+          <div class="card-body">
+            <Chart :option="tsiM2Opt" height="300px" />
+            <div class="legend-row">
+              <span class="dot-c brand"></span><span>社融存量同比</span>
+              <span class="dot-c warn"></span><span>M2 同比</span>
+              <span class="dot-c faint"></span><span>M2 − 社融 = 货币活化程度</span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- §2.9 央行货政报告 · 最新摘要 + 历史文档 -->
+    <section id="2.9" class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§2.9 央行货币政策执行报告 · 最新期摘要 + 历史文档</h2>
+        <span class="meta">季度 · 中国人民银行</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最新 {{ monetaryReportLatest.publishDate }} · 历史 {{ monetaryReportHistory.length }} 份(2017Q1–2026Q2)</span>
+      </div>
+      <PolicyLatestWithHistory
+        :title="monetaryReportLatest.title"
+        icon="⌘"
+        :history="monetaryReportHistory"
+        :latest-href="`/pdf-library#2.9`"
+        library-href="/pdf-library"
+        section-key="2.9"
+      >
+        <template #latest>
+          <div class="hl-grid">
+            <div
+              v-for="h in monetaryReportLatest.highlights"
+              :key="h.label"
+              :class="['hl-card', `tone-${h.tone}`]"
+            >
+              <div class="hl-label">{{ h.label }}</div>
+              <div class="hl-text">{{ h.text }}</div>
+            </div>
+          </div>
+          <table class="policy-tbl">
+            <thead>
+              <tr><th>章节</th><th>核心表述</th><th>关键变化</th></tr>
+            </thead>
+            <tbody>
+              <tr v-for="(c, i) in monetaryReportLatest.changes" :key="i">
+                <td class="sym">{{ c.chapter }}</td>
+                <td>{{ c.content }}</td>
+                <td><span :class="['chip', `chip-${c.tone}`]">{{ c.tag }}</span></td>
+              </tr>
+            </tbody>
+          </table>
+        </template>
+      </PolicyLatestWithHistory>
+    </section>
+
+    <!-- §2.10 央行货政例会 · 最新摘要 + 历史文档 -->
+    <section id="2.10" class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>§2.10 央行货币政策委员会例会 · 最新期摘要 + 历史文档</h2>
+        <span class="meta">季度 · 中国人民银行</span>
+        <span class="meta-spacer"></span>
+        <span class="meta">最新 {{ monetaryMinuteLatest.publishDate }} · 历史 {{ monetaryMinuteHistory.length }} 份</span>
+      </div>
+      <PolicyLatestWithHistory
+        :title="monetaryMinuteLatest.title"
+        icon="⌥"
+        :history="monetaryMinuteHistory"
+        :latest-href="`/pdf-library#2.10`"
+        library-href="/pdf-library"
+        section-key="2.10"
+      >
+        <template #latest>
+          <div :class="['stance-banner', `tone-${monetaryMinuteLatest.stanceTone}`]">
+            <div class="stance-side">
+              <div class="stance-kicker">政策立场</div>
+              <div class="stance-value">{{ monetaryMinuteLatest.stance }}</div>
+            </div>
+            <div class="stance-divider"></div>
+            <div class="stance-body">
+              <div class="stance-title">{{ monetaryMinuteLatest.stanceSummary }}</div>
+              <div class="stance-note">{{ monetaryMinuteLatest.stanceNote }}</div>
+            </div>
+          </div>
+          <div class="para-list">
+            <p v-for="(p, i) in monetaryMinuteLatest.paragraphs" :key="i" v-html="p"></p>
+          </div>
+          <div class="tag-row">
+            <span
+              v-for="t in monetaryMinuteLatest.tags"
+              :key="t.label"
+              :class="['chip', `chip-${t.tone}`]"
+            >{{ t.label }}</span>
+          </div>
+        </template>
+      </PolicyLatestWithHistory>
+    </section>
+
+    <!-- 文档库 · 政策面 PDF 文档库入口 -->
+    <section class="section">
+      <div class="section-hd">
+        <span class="dot"></span>
+        <h2>政策面 · PDF 文档库</h2>
+        <span class="meta">央行货政报告 · 货政例会 · 重要会议通稿 · 十五五规划 · 地产政策</span>
+      </div>
+      <PolicyDocumentLibrary :docs="policyDocumentLibrary" @open="onOpenDoc" />
+    </section>
+
+    <footer class="footer">
+      数据源:中国人民银行 · Wind · {{ updatedAt }} · 货币政策看板 v1.0
+    </footer>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref as vRef } from 'vue'
+import { useRouter } from 'vue-router'
+import Chart from '@/components/Chart.vue'
+import PolicyLatestWithHistory from '@/components/policy/PolicyLatestWithHistory.vue'
+import PolicyDocumentLibrary from '@/components/policy/PolicyDocumentLibrary.vue'
+import PolicySubnav from '@/components/policy/PolicySubnav.vue'
+import {
+  loadAllSeries, loadLatestAll, latestOf,
+} from './data'
+import {
+  monetaryReportLatest, monetaryReportHistory,
+  monetaryMinuteLatest, monetaryMinuteHistory,
+  policyDocumentLibrary,
+} from '../_shared/policy-documents'
+import {
+  repoRateOption, monthlyLineOption, monthlyBarOption,
+  tsiMonthlyOption, tsiM2Option, repoStackOption,
+  mlfPslOption, bondBuyOption,
+} from './charts'
+
+const router = useRouter()
+function onOpenDoc(d) {
+  if (d?.disabled) return
+  if (d?.href) router.push(d.href)
+}
+
+const activeSection = vRef('monetary')
+const subnav = [
+  { key: 'fiscal',    label: '财政政策', to: '/policy/fiscal' },
+  { key: 'monetary',  label: '货币政策', to: '/policy/monetary' },
+  { key: 'meeting',   label: '重要会议', to: '/policy/meeting' },
+  { key: 'industry',  label: '产业政策', to: '/policy/industry' },
+  { key: 'library',   label: 'PDF 文档库', to: '/pdf-library' },
+]
+function onSubnavSelect() { /* navigation handled inside PolicySubnav */ }
+
+const loading = vRef(false)
+const updatedAt = vRef('')
+
+const repoRateSeries = vRef([])
+const rrrSeries = vRef([])
+const mlfSeries = vRef([])
+const pslSeries = vRef([])
+const repo6mSeries = vRef([])
+const repo3mSeries = vRef([])
+const bondBuySeries = vRef([])
+const tsiMonthSeries = vRef([])
+const tsiYoySeries = vRef([])
+const m2Series = vRef([])
+
+const repoRateMeta = vRef(null)
+const rrrMeta = vRef(null)
+const mlfMeta = vRef(null)
+const pslMeta = vRef(null)
+const repo6mMeta = vRef(null)
+const repo3mMeta = vRef(null)
+const bondBuyMeta = vRef(null)
+const tsiMonthMeta = vRef(null)
+const tsiYoyMeta = vRef(null)
+const m2Meta = vRef(null)
+
+function formatNum(v) {
+  if (typeof v !== 'number') return v ?? '—'
+  return v.toFixed(2)
+}
+function formatYi(latest) {
+  if (!latest || typeof latest.value !== 'number') return '—'
+  return latest.value.toLocaleString(undefined, { maximumFractionDigits: 0 })
+}
+function formatPct(latest) {
+  if (!latest || typeof latest.value !== 'number') return '—'
+  return `${latest.value.toFixed(2)}%`
+}
+function formatPct2(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 [all] = await Promise.all([loadAllSeries()])
+    repoRateSeries.value = all.repoRate.series
+    rrrSeries.value = all.rrr.series
+    mlfSeries.value = all.mlf.series
+    pslSeries.value = all.psl.series
+    repo6mSeries.value = all.repo6m.series
+    repo3mSeries.value = all.repo3m.series
+    bondBuySeries.value = all.bondBuy.series
+    tsiMonthSeries.value = all.tsiMonth.series
+    tsiYoySeries.value = all.tsiYoy.series
+    m2Series.value = all.m2.series
+
+    repoRateMeta.value = all.repoRate.meta
+    rrrMeta.value = all.rrr.meta
+    mlfMeta.value = all.mlf.meta
+    pslMeta.value = all.psl.meta
+    repo6mMeta.value = all.repo6m.meta
+    repo3mMeta.value = all.repo3m.meta
+    bondBuyMeta.value = all.bondBuy.meta
+    tsiMonthMeta.value = all.tsiMonth.meta
+    tsiYoyMeta.value = all.tsiYoy.meta
+    m2Meta.value = all.m2.meta
+
+    updatedAt.value = new Date().toISOString().slice(0, 19).replace('T', ' ')
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(reload)
+
+const repoRateLatest = computed(() => latestOf(repoRateSeries.value))
+const rrrLatest = computed(() => latestOf(rrrSeries.value))
+const mlfLatest = computed(() => latestOf(mlfSeries.value))
+const pslLatest = computed(() => latestOf(pslSeries.value))
+const repo6mLatest = computed(() => latestOf(repo6mSeries.value))
+const repo3mLatest = computed(() => latestOf(repo3mSeries.value))
+const bondBuyLatest = computed(() => latestOf(bondBuySeries.value))
+const tsiMonthLatest = computed(() => latestOf(tsiMonthSeries.value))
+const tsiYoyLatest = computed(() => latestOf(tsiYoySeries.value))
+const m2Latest = computed(() => latestOf(m2Series.value))
+
+const rrrCutBp = computed(() => {
+  if (!rrrSeries.value.length || rrrLatest.value == null) return null
+  const first = rrrSeries.value[0][1]
+  const last = rrrLatest.value.value
+  if (typeof first !== 'number' || typeof last !== 'number') return null
+  return Math.round((first - last) * 100)
+})
+
+const mlfPslSum = computed(() => {
+  if (mlfLatest.value?.value == null || pslLatest.value?.value == null) return null
+  return mlfLatest.value.value + pslLatest.value.value
+})
+const repoTotal = computed(() => {
+  if (repo6mLatest.value?.value == null || repo3mLatest.value?.value == null) return null
+  return repo6mLatest.value.value + repo3mLatest.value.value
+})
+
+const kpis = computed(() => ([
+  { label: '7天逆回购利率', icon: '🏦', value: repoRateLatest.value?.value, unit: '%', date: formatDate(repoRateLatest.value?.date), tag: '当前政策利率', sign: false },
+  { label: '存款准备金率', icon: '🛡️', value: rrrLatest.value?.value, unit: '%', date: formatDate(rrrLatest.value?.date), tag: '大型机构', sign: false },
+  { label: 'MLF 余额', icon: '💵', value: mlfLatest.value?.value, unit: '亿元', date: formatDate(mlfLatest.value?.date), tag: '中期投放', sign: true },
+  { label: 'PSL 余额', icon: '🧱', value: pslLatest.value?.value, unit: '亿元', date: formatDate(pslLatest.value?.date), tag: '政策性投放', sign: true },
+  { label: '买断式逆回购 (合计)', icon: '🔁', value: repoTotal.value, unit: '亿元', date: formatDate(repo6mLatest.value?.date), tag: '6m+3m', sign: true },
+  { label: '国债月度净买入', icon: '📥', value: bondBuyLatest.value?.value, unit: '亿元', date: formatDate(bondBuyLatest.value?.date), tag: '央行买债', sign: true },
+  { label: '社融当月新增', icon: '🏗️', value: tsiMonthLatest.value?.value, unit: '亿元', date: formatDate(tsiMonthLatest.value?.date), tag: '总信用', sign: true },
+  { label: 'M2 同比', icon: '💱', value: m2Latest.value?.value, unit: '%', date: formatDate(m2Latest.value?.date), tag: '广义货币', sign: true },
+]))
+
+// ===== Charts =====
+const repoRateOpt = computed(() => repoRateOption(repoRateSeries.value))
+
+const repoRateComment = computed(() => {
+  const v = repoRateLatest.value?.value
+  if (typeof v !== 'number') return '当前政策利率持续观察'
+  if (v <= 1.4) return '当前处于近年低位,宽松基调延续'
+  if (v <= 1.7) return '当前处于温和水平'
+  if (v <= 2.0) return '处于历史中性区间'
+  return '处于相对偏高水平'
+})
+
+const rrrOpt = computed(() => monthlyLineOption(rrrSeries.value, {
+  title: '存款准备金率',
+  color: '#E8912D',
+  unit: '%',
+  smooth: false,
+}))
+
+const rrrComment = computed(() => {
+  const v = rrrLatest.value?.value
+  if (typeof v !== 'number') return '准备金率水平持续观察'
+  if (v <= 8.0) return '处于近年低位,结构性宽松延续'
+  if (v <= 9.5) return '处于温和水平'
+  return '处于中性偏紧区间'
+})
+
+const mlfPslOpt = computed(() => mlfPslOption(mlfSeries.value, pslSeries.value))
+const mlfPslComment = computed(() => {
+  if (mlfLatest.value == null) return '中长期流动性投放持续观察'
+  const arr = mlfSeries.value
+  if (arr.length < 2) return '中长期流动性投放持续观察'
+  const first = arr[0][1]
+  const last = arr[arr.length - 1][1]
+  if (typeof first !== 'number' || typeof last !== 'number') return '中长期流动性投放持续观察'
+  if (last < first) return '余额较前期回落,存量逐步压降'
+  if (last > first) return '余额较前期回升'
+  return '余额相对稳定'
+})
+
+const repoStackOpt = computed(() => repoStackOption(repo6mSeries.value, repo3mSeries.value))
+const repoComment = computed(() => {
+  if (repoTotal.value == null) return '买断式逆回购规模持续观察'
+  if (repoTotal.value >= 20000) return '买断式逆回购存量较高,中长期投放充裕'
+  if (repoTotal.value >= 10000) return '买断式逆回购存量适中'
+  return '买断式逆回购存量偏低'
+})
+
+const bondBuyOpt = computed(() => bondBuyOption(bondBuySeries.value))
+const tsiMonthlyOpt = computed(() => tsiMonthlyOption(tsiMonthSeries.value))
+const tsiM2Opt = computed(() => tsiM2Option(tsiYoySeries.value, m2Series.value))
+</script>
+
+<style scoped>
+.monetary-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-grid {
+  display: grid;
+  grid-template-columns: repeat(8, 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 .label {
+  font-size: 11.5px;
+  color: var(--mut, #6B7A96);
+  margin-bottom: 6px;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+.kpi-card .value {
+  font-size: 20px;
+  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-2-2 { grid-template-columns: 1fr 1fr; }
+.card-grid.cols-1 { grid-template-columns: 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; }
+
+.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 { color: var(--brand-d, #0E3A96) !important; font-weight: 600; }
+.meta { color: #98A5BD; font-size: 11.5px; }
+
+/* Report cards */
+.report-card {
+  background: #fff;
+  border: 1px solid var(--line, #E4E9F2);
+  border-radius: 8px;
+  padding: 14px 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  transition: 0.15s;
+}
+.report-card:hover { box-shadow: 0 1px 3px rgba(16, 32, 64, 0.06); border-color: #D1D8E5; }
+.report-card .report-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.report-card .badge {
+  display: inline-block;
+  font-size: 10.5px;
+  font-weight: 600;
+  padding: 3px 8px;
+  border-radius: 3px;
+  background: #EAF1FF;
+  color: #0E3A96;
+  letter-spacing: 0.2px;
+}
+.report-card .badge.alt {
+  background: #FFF1E0;
+  color: #B25E00;
+}
+.report-card .r-date {
+  font-size: 11.5px;
+  color: #98A5BD;
+  font-variant-numeric: tabular-nums;
+}
+.report-card .report-title {
+  font-size: 13.5px;
+  font-weight: 600;
+  color: var(--ink, #1A2438);
+  line-height: 1.5;
+}
+.report-card .report-summary {
+  font-size: 12px;
+  line-height: 1.65;
+  color: var(--mut, #6B7A96);
+}
+
+/* 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;
+}
+
+/* ===== §2.9 / §2.10 / §3.x 富文本摘要样式 ===== */
+.hl-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 12px;
+  margin-bottom: 14px;
+}
+.hl-card {
+  padding: 12px 14px;
+  border-radius: 6px;
+  border-left: 3px solid var(--brand, #1552D0);
+  background: var(--brand-lt, #EAF1FF);
+}
+.hl-card.tone-brand { background: var(--brand-lt, #EAF1FF); border-color: var(--brand, #1552D0); }
+.hl-card.tone-green { background: #E8F5EE; border-color: var(--down, #189A65); }
+.hl-card.tone-warn  { background: #FFF6E5; border-color: var(--warn, #E8912D); }
+.hl-card.tone-up    { background: #FCEAEA; border-color: var(--up, #E13B3B); }
+.hl-card .hl-label {
+  font-size: 11px;
+  margin-bottom: 4px;
+  letter-spacing: .5px;
+  color: var(--brand-d, #0E3A96);
+}
+.hl-card.tone-green .hl-label { color: var(--down, #189A65); }
+.hl-card.tone-warn .hl-label { color: var(--warn, #B25E00); }
+.hl-card.tone-up .hl-label { color: var(--up, #E13B3B); }
+.hl-card .hl-text {
+  font-size: 13px;
+  font-weight: 600;
+  line-height: 1.5;
+  color: var(--ink, #1A2438);
+}
+
+.policy-tbl {
+  width: 100%;
+  border-collapse: collapse;
+}
+.policy-tbl th {
+  text-align: left;
+  padding: 9px 12px;
+  font-size: 11.5px;
+  color: var(--mut, #6B7A96);
+  font-weight: 500;
+  background: #FAFBFD;
+  border-bottom: 1px solid var(--line, #E4E9F2);
+}
+.policy-tbl td {
+  padding: 10px 12px;
+  font-size: 12.5px;
+  border-bottom: 1px solid #F0F3F8;
+  font-variant-numeric: tabular-nums;
+}
+.policy-tbl tr:last-child td { border-bottom: none; }
+.policy-tbl tr:hover td { background: #F8FAFD; }
+.policy-tbl .sym { font-weight: 600; color: var(--ink, #1A2438); }
+
+.chip {
+  display: inline-block;
+  padding: 2px 8px;
+  border-radius: 10px;
+  font-size: 11px;
+  font-weight: 500;
+  font-variant-numeric: tabular-nums;
+  border: 0;
+}
+.chip-blue   { background: var(--brand-lt, #EAF1FF); color: var(--brand-d, #0E3A96); }
+.chip-green  { background: #E8F5EE; color: var(--down, #189A65); }
+.chip-yellow { background: #FFF6E5; color: #B25E00; }
+.chip-red    { background: #FCEAEA; color: var(--up, #E13B3B); }
+.chip-gray   { background: #EEF2F7; color: var(--mut, #6B7A96); }
+.chip-warn   { background: #FFF6E5; color: #B25E00; }
+
+.stance-banner {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  padding: 14px;
+  border-radius: 6px;
+  border-left: 3px solid var(--brand, #1552D0);
+  background: var(--brand-lt, #EAF1FF);
+  margin-bottom: 14px;
+}
+.stance-banner.tone-brand { background: var(--brand-lt, #EAF1FF); border-color: var(--brand, #1552D0); }
+.stance-banner.tone-warn  { background: #FFF6E5; border-color: var(--warn, #E8912D); }
+.stance-banner.tone-up    { background: #FCEAEA; border-color: var(--up, #E13B3B); }
+.stance-side {
+  width: 80px;
+  text-align: center;
+  flex: none;
+}
+.stance-kicker {
+  font-size: 11px;
+  letter-spacing: 2px;
+  color: var(--brand-d, #0E3A96);
+}
+.stance-banner.tone-warn .stance-kicker { color: #B25E00; }
+.stance-banner.tone-up .stance-kicker { color: var(--up, #E13B3B); }
+.stance-value {
+  font-size: 22px;
+  font-weight: 700;
+  color: var(--brand-d, #0E3A96);
+  margin-top: 4px;
+}
+.stance-banner.tone-warn .stance-value { color: #B25E00; }
+.stance-banner.tone-up .stance-value { color: var(--up, #E13B3B); }
+.stance-divider {
+  align-self: stretch;
+  width: 1px;
+  background: rgba(21, 82, 208, .3);
+  margin: 4px 0;
+}
+.stance-banner.tone-warn .stance-divider { background: rgba(233, 145, 45, .35); }
+.stance-banner.tone-up .stance-divider { background: rgba(225, 59, 59, .35); }
+.stance-body { flex: 1; min-width: 0; }
+.stance-title {
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--ink, #1A2438);
+  margin-bottom: 4px;
+}
+.stance-note {
+  font-size: 11.5px;
+  color: var(--mut, #6B7A96);
+  line-height: 1.55;
+}
+
+.para-list { font-size: 13px; line-height: 1.8; color: var(--ink, #1A2438); }
+.para-list p { margin-bottom: 8px; }
+.para-list p:last-child { margin-bottom: 0; }
+.para-list :deep(b) { color: var(--brand-d, #0E3A96); font-weight: 600; }
+.para-list :deep(.hl-up) { color: var(--up, #E13B3B); font-weight: 600; }
+.para-list :deep(.hl-dn) { color: var(--down, #189A65); font-weight: 600; }
+
+.tag-row {
+  margin-top: 12px;
+  display: flex;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+
+@media (max-width: 1280px) {
+  .kpi-grid { grid-template-columns: repeat(4, 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-2 { grid-template-columns: 1fr; }
+  .hl-grid { grid-template-columns: 1fr; }
+}
+</style>