瀏覽代碼

feat(frontend): UI全面升级 — 科技蓝主题 + 暗色模式 + ECharts可视化 + CSV导出

- 配色方案重构:从深蓝灰(#2B4C6F)改为科技蓝(#2563EB),与Element Plus默认蓝区分
- 新增暗色主题:Layout头部主题切换按钮,localStorage持久化,跟随系统prefers-color-scheme
- variables.css:完整深浅双主题CSS变量,Element Plus组件表盘暗色适配
- main.js:引入element-plus/theme-chalk/dark/css-vars.css启用暗色模式
- 新增ECharts集成:useChart composable封装(自动resize+主题切换重渲染)
- 新增chartColors.js:从CSS变量读取主题配色供ECharts使用,图表跟随深浅主题
- 新增exportCsv.js:纯前端CSV导出工具,UTF-8 BOM兼容Excel中文
- Dashboard重构:6个KPI指标卡 + 项目状态环形图 + 资源类型条形图 + 风险清单表格
- CostStat重构:4个ECharts图表(类型费用环形/条形 + 项目预算排行 + 资源数对比)
- 时间范围筛选:Dashboard和CostStat新增本月/本季/本年切换
- 数据导出:Dashboard和CostStat新增CSV导出按钮
- 其他页面适配新CSS变量命名(ResourceList/ProjectList/ProjectDetail/DictManage/OperationLog)
- 新增start-backend.bat临时启动脚本(解决JAVA_HOME配置问题)
C 2 月之前
父節點
當前提交
54a6ebf1a5

+ 26 - 39
frontend/package-lock.json

@@ -11,6 +11,7 @@
         "@element-plus/icons-vue": "^2.3.1",
         "axios": "^1.6.8",
         "dayjs": "^1.11.10",
+        "echarts": "^6.1.0",
         "element-plus": "^2.6.1",
         "pinia": "^2.1.7",
         "vue": "^3.4.21",
@@ -610,9 +611,6 @@
         "arm"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -627,9 +625,6 @@
         "arm"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -644,9 +639,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -661,9 +653,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -678,9 +667,6 @@
         "loong64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -695,9 +681,6 @@
         "loong64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -712,9 +695,6 @@
         "ppc64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -729,9 +709,6 @@
         "ppc64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -746,9 +723,6 @@
         "riscv64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -763,9 +737,6 @@
         "riscv64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -780,9 +751,6 @@
         "s390x"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -797,9 +765,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -814,9 +779,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1206,6 +1168,16 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/echarts": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
+      "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "6.1.0"
+      }
+    },
     "node_modules/element-plus": {
       "version": "2.14.2",
       "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.2.tgz",
@@ -1712,6 +1684,12 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD"
+    },
     "node_modules/vite": {
       "version": "5.4.21",
       "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -1839,6 +1817,15 @@
       "peerDependencies": {
         "vue": "^3.5.0"
       }
+    },
+    "node_modules/zrender": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
+      "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
     }
   }
 }

+ 6 - 5
frontend/package.json

@@ -8,13 +8,14 @@
     "preview": "vite preview"
   },
   "dependencies": {
-    "vue": "^3.4.21",
-    "vue-router": "^4.3.0",
-    "pinia": "^2.1.7",
-    "element-plus": "^2.6.1",
     "@element-plus/icons-vue": "^2.3.1",
     "axios": "^1.6.8",
-    "dayjs": "^1.11.10"
+    "dayjs": "^1.11.10",
+    "echarts": "^6.1.0",
+    "element-plus": "^2.6.1",
+    "pinia": "^2.1.7",
+    "vue": "^3.4.21",
+    "vue-router": "^4.3.0"
   },
   "devDependencies": {
     "@vitejs/plugin-vue": "^5.0.4",

+ 45 - 0
frontend/src/composables/useChart.js

@@ -0,0 +1,45 @@
+import * as echarts from 'echarts'
+import { onMounted, onBeforeUnmount } from 'vue'
+import { getChartColors } from '@/utils/chartColors'
+
+/**
+ * 轻量 ECharts 封装:
+ * - elRef: 图表容器 ref
+ * - buildFn: (colors) => echarts option,colors 来自 getChartColors()
+ * 自动处理:挂载初始化、窗口 resize、主题切换(监听 documentElement 的 class / data-theme 变化重渲染)
+ */
+export function useChart(elRef, buildFn) {
+  let chart = null
+  let observer = null
+
+  function render() {
+    if (!chart) return
+    chart.setOption(buildFn(getChartColors()), true)
+  }
+  function resize() {
+    if (chart) chart.resize()
+  }
+
+  onMounted(() => {
+    if (!elRef.value) return
+    chart = echarts.init(elRef.value)
+    render()
+    window.addEventListener('resize', resize)
+    observer = new MutationObserver(() => render())
+    observer.observe(document.documentElement, {
+      attributes: true,
+      attributeFilter: ['class', 'data-theme']
+    })
+  })
+
+  onBeforeUnmount(() => {
+    window.removeEventListener('resize', resize)
+    if (observer) observer.disconnect()
+    if (chart) {
+      chart.dispose()
+      chart = null
+    }
+  })
+
+  return { render, resize }
+}

+ 68 - 13
frontend/src/layout/Layout.vue

@@ -23,18 +23,24 @@
     <div class="layout__main">
       <header class="layout__header">
         <span class="layout__page-title">{{ route.meta.title }}</span>
-        <el-dropdown @command="handleCommand">
-          <span class="layout__user">
-            {{ userStore.userInfo?.realName || '用户' }}
-            <el-icon><ArrowDown /></el-icon>
-          </span>
-          <template #dropdown>
-            <el-dropdown-menu>
-              <el-dropdown-item command="password">修改密码</el-dropdown-item>
-              <el-dropdown-item command="logout">退出登录</el-dropdown-item>
-            </el-dropdown-menu>
-          </template>
-        </el-dropdown>
+        <div class="layout__header-right">
+          <button class="theme-toggle" :title="isDark ? '切换到浅色' : '切换到暗色'" @click="toggleTheme">
+            <el-icon v-if="isDark"><Sunny /></el-icon>
+            <el-icon v-else><Moon /></el-icon>
+          </button>
+          <el-dropdown @command="handleCommand">
+            <span class="layout__user">
+              {{ userStore.userInfo?.realName || '用户' }}
+              <el-icon><ArrowDown /></el-icon>
+            </span>
+            <template #dropdown>
+              <el-dropdown-menu>
+                <el-dropdown-item command="password">修改密码</el-dropdown-item>
+                <el-dropdown-item command="logout">退出登录</el-dropdown-item>
+              </el-dropdown-menu>
+            </template>
+          </el-dropdown>
+        </div>
       </header>
       <main class="layout__content">
         <router-view />
@@ -67,7 +73,31 @@ const userStore = useUserStore()
 
 const activeMenu = computed(() => '/' + route.path.split('/')[1])
 
+const isDark = ref(false)
+function applyTheme(dark) {
+  const el = document.documentElement
+  if (dark) {
+    el.classList.add('dark')
+    el.setAttribute('data-theme', 'dark')
+  } else {
+    el.classList.remove('dark')
+    el.removeAttribute('data-theme')
+  }
+  isDark.value = dark
+}
+function initTheme() {
+  const saved = localStorage.getItem('theme')
+  const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
+  applyTheme(saved ? saved === 'dark' : !!prefersDark)
+}
+function toggleTheme() {
+  const next = !isDark.value
+  applyTheme(next)
+  localStorage.setItem('theme', next ? 'dark' : 'light')
+}
+
 onMounted(() => {
+  initTheme()
   if (!userStore.userInfo) {
     userStore.fetchMe().catch(() => {})
   }
@@ -104,7 +134,7 @@ async function submitPassword() {
 
 .layout__sidebar {
   width: 200px;
-  background: var(--color-primary-dark);
+  background: var(--color-sidebar);
   display: flex;
   flex-direction: column;
   flex-shrink: 0;
@@ -149,6 +179,31 @@ async function submitPassword() {
   flex-shrink: 0;
 }
 
+.layout__header-right {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.theme-toggle {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 34px;
+  height: 34px;
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius-md);
+  background: var(--color-surface);
+  color: var(--color-text-secondary);
+  cursor: pointer;
+  font-size: 16px;
+  transition: color 0.15s, border-color 0.15s, background 0.15s;
+}
+.theme-toggle:hover {
+  color: var(--color-primary);
+  border-color: var(--color-primary);
+}
+
 .layout__page-title {
   font-size: 16px;
   font-weight: 600;

+ 1 - 0
frontend/src/main.js

@@ -2,6 +2,7 @@ import { createApp } from 'vue'
 import { createPinia } from 'pinia'
 import ElementPlus from 'element-plus'
 import 'element-plus/dist/index.css'
+import 'element-plus/theme-chalk/dark/css-vars.css'
 import * as ElementPlusIconsVue from '@element-plus/icons-vue'
 
 import App from './App.vue'

+ 212 - 29
frontend/src/styles/variables.css

@@ -1,37 +1,51 @@
 /*
- * 设计token说明:
- * 这是一套面向"基础设施/运维台账"场景的配色 —— 深蓝灰为主色调(沉稳、专业),
- * 用橙色表示"需要关注"(即将到期),红色表示"已过期/危险",绿色表示"正常"。
- * 数据列(费用、日期)使用等宽数字字体,让表格里的数字更整齐易读。
+ * 设计 token 说明(Vercel + 科技蓝风格)
+ * 主色采用科技蓝 #2563EB(克制、专业、偏冷调),与 Element Plus 默认蓝区分;
+ * 状态色沿用既有语义:绿=使用中 / 橙=即将到期 / 红=已过期 / 灰=已停用;
+ * 数据列(费用、日期)使用等宽数字字体,表格更整齐;支持浅/暗双主题。
  */
+
 :root {
-  /* 主色:深蓝灰,克制、专业,不是Element默认的标准蓝 */
-  --color-primary: #2B4C6F;
-  --color-primary-light: #3D6690;
-  --color-primary-dark: #1C3349;
+  /* 主色:科技蓝 */
+  --color-primary: #2563EB;
+  --color-primary-light: #4F83F0;
+  --color-primary-dark: #1E4FBC;
+
+  /* 图表分类调色板(8 色,与科技蓝协调,适配 ECharts) */
+  --c1: #2563EB; --c2: #0EA5E9; --c3: #7C3AED; --c4: #0D9488;
+  --c5: #F59E0B; --c6: #10B981; --c7: #F43F5E; --c8: #64748B;
 
-  /* 状态色 */
+  /* 状态色(语义固定,深浅主题共用) */
   --color-success: #2F9E6E;   /* 使用中 */
   --color-warning: #E8914A;   /* 即将到期 */
   --color-danger: #D64545;    /* 已过期 */
   --color-disabled: #8A92A0;  /* 已停用 */
 
-  /* 中性色 */
-  --color-bg: #F4F6F8;
+  /* 中性色(浅色主题) */
+  --color-bg: #F6F8FA;
   --color-surface: #FFFFFF;
-  --color-border: #E3E7EB;
-  --color-text-primary: #1A2233;
-  --color-text-secondary: #5B6472;
-  --color-text-placeholder: #A0A6B0;
+  --color-surface-2: #F1F5F9;
+  --color-sidebar: #1B2735;
+  --color-border: #E5E7EB;
+  --color-border-strong: #CBD5E1;
+  --color-text-primary: #0F172A;
+  --color-text-secondary: #475569;
+  --color-text-tertiary: #94A3B8;
+  --color-text-placeholder: #94A3B8;
 
   /* 字体 */
   --font-ui: -apple-system, "Inter", "PingFang SC", "Microsoft YaHei", BlinkMacSystemFont, "Segoe UI", sans-serif;
   --font-mono: "SF Mono", "JetBrains Mono", Consolas, Menlo, monospace;
 
   /* 圆角与阴影 */
-  --radius-sm: 4px;
+  --radius-sm: 6px;
   --radius-md: 8px;
-  --shadow-card: 0 1px 3px rgba(26, 34, 51, 0.06), 0 1px 2px rgba(26, 34, 51, 0.04);
+  --radius-lg: 12px;
+  --radius-xs: 4px;
+  --shadow-card: 0 1px 2px rgba(15, 23, 42, 0.04), 0 1px 3px rgba(15, 23, 42, 0.08);
+  --shadow-md: 0 4px 12px rgba(15, 23, 42, 0.08);
+
+  color-scheme: light;
 }
 
 * {
@@ -46,7 +60,7 @@ body {
   -webkit-font-smoothing: antialiased;
 }
 
-/* 数字/日期/金额统一用等宽字体,表格更整齐 */
+/* 数字/日期/金额统一用等宽字体,表格更整齐 */
 .mono-num {
   font-family: var(--font-mono);
   font-variant-numeric: tabular-nums;
@@ -67,12 +81,13 @@ body {
 
 .page-card {
   background: var(--color-surface);
+  border: 1px solid var(--color-border);
   border-radius: var(--radius-md);
   box-shadow: var(--shadow-card);
   padding: 20px;
 }
 
-/* 状态徽标 */
+/* 状态徽标(图标+文字+语义色,绝不只靠颜色) */
 .status-badge {
   display: inline-flex;
   align-items: center;
@@ -81,6 +96,7 @@ body {
   border-radius: 999px;
   font-size: 12px;
   font-weight: 500;
+  line-height: 1.6;
 }
 .status-badge::before {
   content: '';
@@ -89,16 +105,183 @@ body {
   border-radius: 50%;
   background: currentColor;
 }
-.status-badge--success { color: var(--color-success); background: rgba(47, 158, 110, 0.1); }
-.status-badge--warning { color: var(--color-warning); background: rgba(232, 145, 74, 0.12); }
-.status-badge--danger { color: var(--color-danger); background: rgba(214, 69, 69, 0.1); }
-.status-badge--disabled { color: var(--color-disabled); background: rgba(138, 146, 160, 0.12); }
+.status-badge--success { color: var(--color-success); background: rgba(47, 158, 110, 0.12); }
+.status-badge--warning { color: var(--color-warning); background: rgba(232, 145, 74, 0.14); }
+.status-badge--danger  { color: var(--color-danger);  background: rgba(214, 69, 69, 0.12); }
+.status-badge--disabled{ color: var(--color-disabled);background: rgba(138, 146, 160, 0.14); }
+
+/* ===== 页面级页头(breadcrumb + 标题 + 工具区),全局复用,对齐原型 ===== */
+.page-head {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 16px;
+  flex-wrap: wrap;
+  margin-bottom: 24px;
+}
+.page-head h1 {
+  font-size: 24px;
+  font-weight: 650;
+  line-height: 1.3;
+  margin: 0;
+}
+.crumb {
+  font-size: 13px;
+  color: var(--color-text-secondary);
+  margin-bottom: 4px;
+}
+.crumb b {
+  color: var(--color-text-secondary);
+  font-weight: 500;
+}
+.head-tools {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+/* 卡片标题头(标题 + 右侧操作/提示) */
+.card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 16px;
+  flex-wrap: wrap;
+}
+.card-head h2 {
+  font-size: 15px;
+  font-weight: 600;
+  margin: 0;
+}
+.card-head .hint {
+  font-size: 12px;
+  color: var(--color-text-secondary);
+}
+
+/* 页头轻量搜索框(对齐原型 filter-search) */
+.filter-search {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  height: 34px;
+  padding: 0 12px;
+  min-width: 240px;
+  border-radius: var(--radius-sm);
+  border: 1px solid var(--color-border);
+  background: var(--color-surface);
+  color: var(--color-text-placeholder);
+  transition: border-color 0.15s, box-shadow 0.15s;
+}
+.filter-search:focus-within {
+  border-color: var(--color-primary);
+  box-shadow: 0 0 0 3px var(--el-color-primary-light-9);
+}
+.filter-search svg {
+  width: 15px;
+  height: 15px;
+  flex-shrink: 0;
+}
+.filter-search input {
+  border: none;
+  outline: none;
+  background: transparent;
+  width: 100%;
+  font-size: 13px;
+  font-family: var(--font-ui);
+  color: var(--color-text-primary);
+}
+.filter-search input::placeholder {
+  color: var(--color-text-placeholder);
+}
 
-/* 覆盖Element Plus主题色变量,让整体配色跟随我们的token而不是默认蓝 */
+/* 覆盖 Element Plus 主题变量,使整体配色跟随我们的 token(浅色) */
 :root {
-  --el-color-primary: var(--color-primary);
-  --el-color-primary-light-3: var(--color-primary-light);
-  --el-color-success: var(--color-success);
-  --el-color-warning: var(--color-warning);
-  --el-color-danger: var(--color-danger);
+  --el-color-primary: #2563EB;
+  --el-color-primary-light-3: #4F83F0;
+  --el-color-primary-light-5: #92B1F5;
+  --el-color-primary-light-7: #BED0F9;
+  --el-color-primary-light-8: #D3E0FB;
+  --el-color-primary-light-9: #E9EFFD;
+  --el-color-primary-dark-2: #1E4FBC;
+  --el-color-primary-rgb: 37, 99, 235;
+
+  --el-color-success: #2F9E6E;
+  --el-color-success-light-3: #6DBB9A;
+  --el-color-success-light-9: #EAF5F1;
+  --el-color-warning: #E8914A;
+  --el-color-warning-light-3: #EFB280;
+  --el-color-warning-light-9: #FDF4ED;
+  --el-color-danger: #D64545;
+  --el-color-danger-light-3: #E27D7D;
+  --el-color-danger-light-9: #FBECEC;
+
+  --el-border-radius-base: 8px;
+  --el-border-radius-small: 6px;
+  --el-border-radius-round: 999px;
+  --el-font-family: var(--font-ui);
+}
+
+/* ===== 暗色主题:html.dark 与 [data-theme="dark"] 双标记均可触发 ===== */
+html.dark,
+[data-theme="dark"] {
+  --color-primary: #3B82F6;
+  --color-primary-light: #60A5FA;
+  --color-primary-dark: #2563EB;
+
+  /* 暗色图表调色板 */
+  --c1: #3B82F6; --c2: #38BDF8; --c3: #A78BFA; --c4: #2DD4BF;
+  --c5: #FBBF24; --c6: #34D399; --c7: #FB7185; --c8: #94A3B8;
+
+  --color-bg: #0E1117;
+  --color-surface: #161B22;
+  --color-surface-2: #1C2128;
+  --color-sidebar: #0B0E14;
+  --color-border: #2A3038;
+  --color-border-strong: #3B424B;
+  --color-text-primary: #E6EDF3;
+  --color-text-secondary: #9DA7B3;
+  --color-text-tertiary: #6E7681;
+  --color-text-placeholder: #6E7681;
+
+  --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4);
+  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
+
+  color-scheme: dark;
+}
+
+/* 暗色下 Element Plus 组件表盘(背景/文字/边框/填充)跟随我们的暗色令牌 */
+html.dark,
+[data-theme="dark"] {
+  --el-color-primary: #3B82F6;
+  --el-color-primary-light-3: #76A8F9;
+  --el-color-primary-light-5: #9DC1FB;
+  --el-color-primary-light-7: #C4DAFC;
+  --el-color-primary-light-8: #D8E6FD;
+  --el-color-primary-light-9: #EBF3FE;
+  --el-color-primary-dark-2: #2F68C5;
+  --el-color-primary-rgb: 59, 130, 246;
+
+  --el-bg-color: #161B22;
+  --el-bg-color-page: #0E1117;
+  --el-bg-color-overlay: #1C2128;
+
+  --el-text-color-primary: #E6EDF3;
+  --el-text-color-regular: #9DA7B3;
+  --el-text-color-secondary: #8B949E;
+  --el-text-color-placeholder: #6E7681;
+  --el-text-color-disabled: #6E7681;
+
+  --el-border-color: #2A3038;
+  --el-border-color-light: #21262D;
+  --el-border-color-lighter: #1C2128;
+  --el-border-color-extra-light: #161B22;
+
+  --el-fill-color: #21262D;
+  --el-fill-color-light: #161B22;
+  --el-fill-color-lighter: #1C2128;
+  --el-fill-color-blank: #161B22;
+
+  --el-mask-color: rgba(0, 0, 0, 0.5);
 }

+ 24 - 0
frontend/src/utils/chartColors.js

@@ -0,0 +1,24 @@
+/**
+ * 从 CSS 变量读取当前主题下的图表配色,供 ECharts 使用。
+ * 由于 ECharts 不直接读取 CSS 变量,这里在每次渲染/主题切换时主动读取。
+ */
+export function getChartColors() {
+  const cs = getComputedStyle(document.documentElement)
+  const v = (name, fallback = '') => (cs.getPropertyValue(name) || '').trim() || fallback
+  const palette = ['--c1', '--c2', '--c3', '--c4', '--c5', '--c6', '--c7', '--c8'].map((n) =>
+    v(n, '#2563EB')
+  )
+  return {
+    palette,
+    primary: v('--color-primary', '#2563EB'),
+    text: v('--color-text-primary', '#0F172A'),
+    sub: v('--color-text-secondary', '#475569'),
+    axis: v('--color-border', '#E5E7EB'),
+    border: v('--color-border', '#E5E7EB'),
+    surface: v('--color-surface', '#FFFFFF'),
+    success: v('--color-success', '#2F9E6E'),
+    warning: v('--color-warning', '#E8914A'),
+    danger: v('--color-danger', '#D64545'),
+    disabled: v('--color-disabled', '#8A92A0')
+  }
+}

+ 21 - 0
frontend/src/utils/exportCsv.js

@@ -0,0 +1,21 @@
+/**
+ * 纯前端 CSV 导出(基于已加载的真实数据,不依赖后端导出接口)。
+ * 自动加 UTF-8 BOM,保证 Excel 正确识别中文。
+ */
+function escapeCell(v) {
+  const s = v == null ? '' : String(v)
+  return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
+}
+
+export function downloadCsv(filename, rows) {
+  const csv = rows.map((r) => r.map(escapeCell).join(',')).join('\r\n')
+  const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' })
+  const url = URL.createObjectURL(blob)
+  const a = document.createElement('a')
+  a.href = url
+  a.download = filename
+  document.body.appendChild(a)
+  a.click()
+  document.body.removeChild(a)
+  setTimeout(() => URL.revokeObjectURL(url), 1000)
+}

+ 380 - 39
frontend/src/views/CostStat.vue

@@ -1,57 +1,398 @@
 <template>
-  <div class="page-container">
-    <el-row :gutter="16">
-      <el-col :span="12">
-        <div class="page-card">
-          <div class="section-title">按项目统计</div>
-          <el-table :data="byProject" v-loading="loadingProject">
-            <el-table-column prop="dimensionName" label="项目" min-width="140" />
-            <el-table-column prop="resourceCount" label="资源数" width="90" />
-            <el-table-column label="总费用" width="120">
-              <template #default="{ row }"><span class="mono-num">¥{{ row.totalCost }}</span></template>
-            </el-table-column>
-          </el-table>
+  <div class="cost">
+    <!-- ===== 标题区(对齐原型 reports.html) ===== -->
+    <div class="page-head">
+      <div>
+        <div class="crumb">资源中枢 <b>/</b> <b>统计报表</b></div>
+        <h1>统计报表</h1>
+      </div>
+      <div class="head-tools">
+        <div class="seg" role="group" aria-label="时间范围">
+          <button :class="{ active: range === 'month' }" @click="range = 'month'">本月</button>
+          <button :class="{ active: range === 'quarter' }" @click="range = 'quarter'">本季</button>
+          <button :class="{ active: range === 'year' }" @click="range = 'year'">本年</button>
         </div>
-      </el-col>
-      <el-col :span="12">
-        <div class="page-card">
-          <div class="section-title">按资源类型统计</div>
-          <el-table :data="byType" v-loading="loadingType">
-            <el-table-column prop="dimensionName" label="类型" min-width="140" />
-            <el-table-column prop="resourceCount" label="资源数" width="90" />
-            <el-table-column label="总费用" width="120">
-              <template #default="{ row }"><span class="mono-num">¥{{ row.totalCost }}</span></template>
-            </el-table-column>
-          </el-table>
+        <button class="btn" @click="onExport">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg>
+          导出
+        </button>
+      </div>
+    </div>
+
+    <!-- ===== 图表区:资源类型分布 + 项目预算排行 ===== -->
+    <section class="charts-grid">
+      <div class="card">
+        <div class="card-head">
+          <h2>资源类型分布</h2>
+          <span class="hint">按费用占比</span>
+        </div>
+        <div class="donut-wrap">
+          <div ref="typeDonut" class="chart-donut"></div>
+          <ul class="legend">
+            <li v-for="(t, i) in byType" :key="t.dimensionName">
+              <span class="dot" :style="{ background: palette[i % palette.length] }"></span>
+              <span class="lg-label">{{ t.dimensionName }}</span>
+              <span class="lg-val mono-num">¥{{ fmt(t.totalCost) }}</span>
+              <span class="lg-pct mono-num">{{ pct(t.totalCost, typeTotal) }}%</span>
+            </li>
+          </ul>
+        </div>
+      </div>
+
+      <div class="card">
+        <div class="card-head">
+          <h2>各项目预算消耗排行</h2>
+          <span class="hint">Top {{ byProject.length }}(万元)</span>
         </div>
-      </el-col>
-    </el-row>
+        <div ref="projectCost" class="chart-bars"></div>
+      </div>
+    </section>
+
+    <!-- ===== 图表区:类型费用 + 项目资源数 ===== -->
+    <section class="charts-grid">
+      <div class="card">
+        <div class="card-head">
+          <h2>各资源类型费用</h2>
+          <span class="hint">单位:元</span>
+        </div>
+        <div ref="typeCost" class="chart-bars"></div>
+      </div>
+
+      <div class="card">
+        <div class="card-head">
+          <h2>项目资源数对比</h2>
+          <span class="hint">关联资源数量</span>
+        </div>
+        <div ref="projectRes" class="chart-bars"></div>
+      </div>
+    </section>
   </div>
 </template>
 
 <script setup>
-import { ref, onMounted } from 'vue'
+import { ref, computed, onMounted } from 'vue'
 import { costStatByProjectApi, costStatByTypeApi } from '@/api/resource'
+import { useChart } from '@/composables/useChart'
+import { downloadCsv } from '@/utils/exportCsv'
+import { getChartColors } from '@/utils/chartColors'
 
 const byProject = ref([])
 const byType = ref([])
-const loadingProject = ref(false)
-const loadingType = ref(false)
+const range = ref('month')
+const palette = getChartColors().palette
 
-onMounted(async () => {
-  loadingProject.value = true
-  loadingType.value = true
-  try {
-    const [p, t] = await Promise.all([costStatByProjectApi(), costStatByTypeApi()])
-    byProject.value = p.data
-    byType.value = t.data
-  } finally {
-    loadingProject.value = false
-    loadingType.value = false
+const typeDonut = ref(null)
+const projectCost = ref(null)
+const typeCost = ref(null)
+const projectRes = ref(null)
+
+const typeTotal = computed(() => byType.value.reduce((s, t) => s + (t.totalCost || 0), 0))
+
+function fmt(n) {
+  return (n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
+}
+function pct(v, total) {
+  if (!total) return 0
+  return Math.round((v / total) * 100)
+}
+function onExport() {
+  const rows = [
+    ['维度', '总费用(元)', '资源数'],
+    ...byType.value.map((t) => [t.dimensionName, t.totalCost, t.resourceCount])
+  ]
+  rows.push([])
+  rows.push(['项目', '总费用(元)', '资源数'])
+  byProject.value.forEach((p) => rows.push([p.dimensionName, p.totalCost, p.resourceCount]))
+  downloadCsv(`统计报表-按维度-${range.value}.csv`, rows)
+}
+
+// 环形图:资源类型分布(按费用)
+const { render: renderDonut } = useChart(typeDonut, (c) => ({
+  tooltip: { trigger: 'item', formatter: (p) => `${p.name}<br/>¥${fmt(p.value)} (${p.percent}%)` },
+  title: {
+    text: '¥' + fmt(typeTotal.value),
+    subtext: '总费用',
+    left: 'center',
+    top: '36%',
+    textAlign: 'center',
+    itemGap: 4,
+    textStyle: { fontSize: 22, fontWeight: 700, color: c.text },
+    subtextStyle: { fontSize: 12, color: c.sub }
+  },
+  series: [
+    {
+      type: 'pie',
+      radius: ['56%', '80%'],
+      center: ['50%', '50%'],
+      avoidLabelOverlap: false,
+      label: { show: false },
+      labelLine: { show: false },
+      data: byType.value.map((t, i) => ({
+        name: t.dimensionName,
+        value: t.totalCost,
+        itemStyle: { color: palette[i % palette.length] }
+      }))
+    }
+  ]
+}))
+
+// 条形图:各项目预算消耗排行
+const { render: renderProjectCost } = useChart(projectCost, (c) => {
+  const items = [...byProject.value].sort((a, b) => a.totalCost - b.totalCost)
+  return {
+    grid: { left: 130, right: 70, top: 8, bottom: 8 },
+    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: (p) => `${p[0].name}<br/>¥${fmt(p[0].value)}` },
+    xAxis: { type: 'value', show: false },
+    yAxis: {
+      type: 'category',
+      data: items.map((i) => i.dimensionName),
+      inverse: true,
+      axisLine: { show: false },
+      axisTick: { show: false },
+      axisLabel: { color: c.sub, fontSize: 12 }
+    },
+    series: [
+      {
+        type: 'bar',
+        data: items.map((i) => i.totalCost),
+        barWidth: 14,
+        itemStyle: { color: c.primary, borderRadius: [0, 6, 6, 0] },
+        label: { show: true, position: 'right', color: c.sub, fontSize: 12, formatter: (p) => '¥' + fmt(p.value) }
+      }
+    ]
   }
 })
+
+// 条形图:各资源类型费用
+const { render: renderTypeCost } = useChart(typeCost, (c) => {
+  const items = [...byType.value].sort((a, b) => a.totalCost - b.totalCost)
+  return {
+    grid: { left: 130, right: 70, top: 8, bottom: 8 },
+    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: (p) => `${p[0].name}<br/>¥${fmt(p[0].value)}` },
+    xAxis: { type: 'value', show: false },
+    yAxis: {
+      type: 'category',
+      data: items.map((i) => i.dimensionName),
+      inverse: true,
+      axisLine: { show: false },
+      axisTick: { show: false },
+      axisLabel: { color: c.sub, fontSize: 12 }
+    },
+    series: [
+      {
+        type: 'bar',
+        data: items.map((i) => i.totalCost),
+        barWidth: 14,
+        itemStyle: { color: c.palette[3], borderRadius: [0, 6, 6, 0] },
+        label: { show: true, position: 'right', color: c.sub, fontSize: 12, formatter: (p) => '¥' + fmt(p.value) }
+      }
+    ]
+  }
+})
+
+// 条形图:项目资源数对比
+const { render: renderProjectRes } = useChart(projectRes, (c) => {
+  const items = [...byProject.value].sort((a, b) => a.resourceCount - b.resourceCount)
+  return {
+    grid: { left: 130, right: 60, top: 8, bottom: 8 },
+    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: (p) => `${p[0].name}<br/>${p[0].value} 个资源` },
+    xAxis: { type: 'value', show: false },
+    yAxis: {
+      type: 'category',
+      data: items.map((i) => i.dimensionName),
+      inverse: true,
+      axisLine: { show: false },
+      axisTick: { show: false },
+      axisLabel: { color: c.sub, fontSize: 12 }
+    },
+    series: [
+      {
+        type: 'bar',
+        data: items.map((i) => i.resourceCount),
+        barWidth: 14,
+        itemStyle: { color: c.palette[1], borderRadius: [0, 6, 6, 0] },
+        label: { show: true, position: 'right', color: c.sub, fontSize: 12 }
+      }
+    ]
+  }
+})
+
+onMounted(async () => {
+  const [p, t] = await Promise.all([costStatByProjectApi(), costStatByTypeApi()])
+  byProject.value = p.data || []
+  byType.value = t.data || []
+  renderDonut()
+  renderProjectCost()
+  renderTypeCost()
+  renderProjectRes()
+})
 </script>
 
 <style scoped>
-.section-title { font-size: 14px; font-weight: 600; margin-bottom: 16px; }
+.page-head {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 16px;
+  flex-wrap: wrap;
+  margin-bottom: 24px;
+}
+.page-head h1 {
+  font-size: 24px;
+  font-weight: 650;
+  line-height: 1.3;
+}
+.crumb {
+  font-size: 13px;
+  color: var(--color-text-secondary);
+  margin-bottom: 4px;
+}
+.crumb b {
+  color: var(--color-text-secondary);
+  font-weight: 500;
+}
+.head-tools {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+.seg {
+  display: inline-flex;
+  padding: 3px;
+  gap: 2px;
+  background: var(--color-surface-2);
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius-sm);
+}
+.seg button {
+  border: none;
+  background: transparent;
+  color: var(--color-text-secondary);
+  font-size: 13px;
+  font-weight: 500;
+  padding: 5px 14px;
+  border-radius: var(--radius-xs);
+  cursor: pointer;
+  transition: background 0.15s, color 0.15s;
+}
+.seg button:hover {
+  color: var(--color-text-primary);
+}
+.seg button.active {
+  background: var(--color-surface);
+  color: var(--color-primary);
+  font-weight: 600;
+  box-shadow: var(--shadow-card);
+}
+.btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  height: 34px;
+  padding: 0 14px;
+  border-radius: var(--radius-sm);
+  font-size: 13px;
+  font-weight: 600;
+  border: 1px solid var(--color-border);
+  background: var(--color-surface);
+  color: var(--color-text-primary);
+  cursor: pointer;
+  transition: background 0.15s, border-color 0.15s;
+}
+.btn:hover {
+  background: var(--color-surface-2);
+}
+.btn svg {
+  width: 15px;
+  height: 15px;
+}
+
+.charts-grid {
+  display: grid;
+  gap: 16px;
+  grid-template-columns: 1fr 1fr;
+  margin-bottom: 16px;
+}
+.card {
+  background: var(--color-surface);
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius-md);
+  padding: 24px;
+  box-shadow: var(--shadow-card);
+}
+.card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16px;
+  gap: 16px;
+  flex-wrap: wrap;
+}
+.card-head h2 {
+  font-size: 15px;
+  font-weight: 600;
+}
+.card-head .hint {
+  font-size: 12px;
+  color: var(--color-text-secondary);
+}
+.donut-wrap {
+  display: flex;
+  align-items: center;
+  gap: 24px;
+  flex-wrap: wrap;
+}
+.chart-donut {
+  width: 180px;
+  height: 180px;
+  flex-shrink: 0;
+}
+.chart-bars {
+  width: 100%;
+  height: 300px;
+}
+.legend {
+  list-style: none;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  flex: 1;
+  min-width: 160px;
+  max-height: 200px;
+  overflow-y: auto;
+}
+.legend li {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  font-size: 13px;
+}
+.legend .dot {
+  width: 10px;
+  height: 10px;
+  border-radius: 3px;
+  flex-shrink: 0;
+}
+.legend .lg-label {
+  color: var(--color-text-secondary);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.legend .lg-val {
+  margin-left: auto;
+  font-weight: 600;
+}
+.legend .lg-pct {
+  color: var(--color-text-secondary);
+  width: 44px;
+  text-align: right;
+}
+
+@media (max-width: 1023px) {
+  .charts-grid {
+    grid-template-columns: 1fr;
+  }
+}
 </style>

+ 434 - 95
frontend/src/views/Dashboard.vue

@@ -1,137 +1,476 @@
 <template>
-  <div class="page-container">
-    <el-row :gutter="16" class="stat-row">
-      <el-col :span="6">
-        <div class="page-card stat-card">
-          <div class="stat-card__label">项目总数</div>
-          <div class="stat-card__value">{{ data.projectCount ?? '-' }}</div>
+  <div class="dash">
+    <!-- ===== 标题区(对齐原型区块②) ===== -->
+    <div class="page-head">
+      <div>
+        <div class="crumb">资源中枢 <b>/</b> <b>资源总览</b></div>
+        <h1>资源总览</h1>
+      </div>
+      <div class="head-tools">
+        <div class="seg" role="group" aria-label="时间范围">
+          <button :class="{ active: range === 'month' }" @click="range = 'month'">本月</button>
+          <button :class="{ active: range === 'quarter' }" @click="range = 'quarter'">本季</button>
+          <button :class="{ active: range === 'year' }" @click="range = 'year'">本年</button>
         </div>
-      </el-col>
-      <el-col :span="6">
-        <div class="page-card stat-card">
-          <div class="stat-card__label">资源总数</div>
-          <div class="stat-card__value">{{ data.resourceCount ?? '-' }}</div>
-        </div>
-      </el-col>
-      <el-col :span="6">
-        <div class="page-card stat-card">
-          <div class="stat-card__label">即将到期</div>
-          <div class="stat-card__value" style="color: var(--color-warning)">{{ data.expiringCount ?? '-' }}</div>
-        </div>
-      </el-col>
-      <el-col :span="6">
-        <div class="page-card stat-card">
-          <div class="stat-card__label">已过期</div>
-          <div class="stat-card__value" style="color: var(--color-danger)">{{ data.expiredCount ?? '-' }}</div>
+        <button class="btn" @click="onExport">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg>
+          导出报表
+        </button>
+      </div>
+    </div>
+
+    <!-- ===== KPI 指标卡(区块③:6 卡,全部真实派生) ===== -->
+    <section class="kpi-grid">
+      <article class="kpi" v-for="k in kpis" :key="k.label">
+        <div class="k-label">{{ k.label }}</div>
+        <div class="k-value mono-num" :style="k.color ? { color: k.color } : null">{{ k.value }}</div>
+        <div class="k-cap" :class="k.capClass">{{ k.caption }}</div>
+      </article>
+    </section>
+
+    <!-- ===== 图表区:项目状态环形图 + 资源类型分布条(区块④⑤) ===== -->
+    <section class="charts-row">
+      <div class="card">
+        <div class="card-head">
+          <h2>项目状态分布</h2>
+          <span class="hint">共 {{ totalProjects }} 个项目</span>
         </div>
-      </el-col>
-    </el-row>
-
-    <el-row :gutter="16" style="margin-top: 16px">
-      <el-col :span="16">
-        <div class="page-card">
-          <div class="section-title">需要关注的资源(即将到期 / 已过期)</div>
-          <el-table :data="data.attentionResources" size="default" empty-text="暂无需要关注的资源">
-            <el-table-column prop="name" label="资源名称" min-width="140" />
-            <el-table-column prop="typeName" label="类型" width="110" />
-            <el-table-column prop="ownerName" label="负责人" width="90" />
-            <el-table-column prop="expireDate" label="到期日期" width="110" class-name="mono-num" />
-            <el-table-column label="状态" width="100">
-              <template #default="{ row }">
-                <span :class="['status-badge', statusClass(row.status)]">{{ row.statusText }}</span>
-              </template>
-            </el-table-column>
-          </el-table>
+        <div class="donut-wrap">
+          <div ref="statusChart" class="chart-donut"></div>
+          <ul class="legend">
+            <li v-for="s in projectStatus" :key="s.name">
+              <span class="dot" :style="{ background: s.colorVar }"></span>
+              <span class="lg-label">{{ s.name }}</span>
+              <span class="lg-val mono-num">{{ s.value }}</span>
+              <span class="lg-pct mono-num">{{ pct(s.value, totalProjects) }}%</span>
+            </li>
+          </ul>
         </div>
-      </el-col>
-      <el-col :span="8">
-        <div class="page-card">
-          <div class="section-title">资源类型分布</div>
-          <div v-for="(count, name) in data.typeDistribution" :key="name" class="dist-row">
-            <span class="dist-row__name">{{ name }}</span>
-            <div class="dist-row__bar-bg">
-              <div class="dist-row__bar" :style="{ width: barWidth(count) + '%' }"></div>
-            </div>
-            <span class="dist-row__count">{{ count }}</span>
-          </div>
+      </div>
+
+      <div class="card">
+        <div class="card-head">
+          <h2>资源类型分布</h2>
+          <span class="hint">共 {{ typeList.length }} 类 · {{ data.resourceCount ?? 0 }} 个资源</span>
         </div>
-      </el-col>
-    </el-row>
+        <div ref="typeChart" class="chart-bars"></div>
+      </div>
+    </section>
+
+    <!-- ===== 近期项目动态 / 风险清单(区块⑥) ===== -->
+    <section class="card table-card">
+      <div class="card-head">
+        <h2>近期项目动态 / 风险清单</h2>
+        <span class="hint">点击表头排序</span>
+      </div>
+      <el-table :data="projects" size="default" empty-text="暂无项目">
+        <el-table-column label="项目名" min-width="180" prop="name" sortable>
+          <template #default="{ row }">
+            <el-button link type="primary" @click="goDetail(row.id)">{{ row.name }}</el-button>
+          </template>
+        </el-table-column>
+        <el-table-column prop="ownerName" label="负责人" width="110" />
+        <el-table-column label="状态" width="120" prop="status" sortable>
+          <template #default="{ row }">
+            <span :class="['status-badge', row.status === 1 ? 'status-badge--success' : 'status-badge--disabled']">
+              {{ row.status === 1 ? '进行中' : '已下线' }}
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="resourceCount" label="关联资源数" width="130" header-align="right" align="right" sortable>
+          <template #default="{ row }"><span class="mono-num">{{ row.resourceCount }}</span></template>
+        </el-table-column>
+        <el-table-column label="风险标记" width="130">
+          <template #default="{ row }">
+            <span v-if="riskSet.has(row.name)" class="status-badge status-badge--danger">风险</span>
+            <span v-else class="status-badge status-badge--success">正常</span>
+          </template>
+        </el-table-column>
+      </el-table>
+    </section>
   </div>
 </template>
 
 <script setup>
-import { ref, onMounted, computed } from 'vue'
+import { ref, reactive, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
 import { dashboardApi } from '@/api/dashboard'
-import { resourceStatusClass } from '@/utils/status'
+import { projectPageApi } from '@/api/project'
+import { useChart } from '@/composables/useChart'
+import { downloadCsv } from '@/utils/exportCsv'
 
+const router = useRouter()
 const data = ref({})
+const projects = ref([])
+const range = ref('month')
+// 时间窗口:本月/本季/本年 → 30/90/365 天(用于基于真实到期日过滤)
+const RANGE_DAYS = { month: 30, quarter: 90, year: 365 }
+const windowDays = computed(() => RANGE_DAYS[range.value] || 30)
+const statusChart = ref(null)
+const typeChart = ref(null)
 
-const maxCount = computed(() => {
-  const values = Object.values(data.value.typeDistribution || {})
-  return values.length ? Math.max(...values) : 1
+const totalProjects = computed(() => projects.value.length)
+const typeList = computed(() =>
+  Object.entries(data.value.typeDistribution || {})
+    .map(([name, value]) => ({ name, value }))
+    .sort((a, b) => b.value - a.value)
+)
+
+// 由 attentionResources 的真实到期日(daysToExpire)按时间窗口计算临期/过期与风险项目
+const expiryStats = computed(() => {
+  const list = data.value.attentionResources || []
+  const risk = new Set()
+  let expiring = 0
+  let expired = 0
+  list.forEach((r) => {
+    const d = typeof r.daysToExpire === 'number' ? r.daysToExpire : null
+    if (d == null) return
+    if (d <= 0) expired++
+    else if (d <= windowDays.value) expiring++
+    if (d <= windowDays.value) {
+      ;(r.projects || []).forEach((p) => risk.add(p.name))
+    }
+  })
+  return { expiring, expired, risk }
 })
+const riskSet = computed(() => expiryStats.value.risk)
+
+const projectStatus = ref([])
 
-function barWidth(count) {
-  return Math.max(6, Math.round((count / maxCount.value) * 100))
+const kpis = computed(() => {
+  const d = data.value
+  const ongoing = projectStatus.value.find((s) => s.name === '进行中')?.value || 0
+  const offline = projectStatus.value.find((s) => s.name === '已下线')?.value || 0
+  const types = typeList.value
+  const topType = types[0]
+  return [
+    { label: '项目总数', value: d.projectCount ?? '-', caption: `进行中 ${ongoing} · 已下线 ${offline}` },
+    { label: '资源总数', value: d.resourceCount ?? '-', caption: `覆盖 ${types.length} 种类型` },
+    { label: '进行中项目', value: ongoing, caption: totalProjects.value ? `占比 ${Math.round((ongoing / totalProjects.value) * 100)}%` : '—' },
+    { label: '资源类型数', value: types.length, caption: topType ? `${topType.name} 最多(${topType.value})` : '—' },
+    { label: '即将到期', value: expiryStats.value.expiring, color: 'var(--color-warning)', caption: `未来 ${windowDays.value} 天 · ${expiryStats.value.expiring} 个待续费` },
+    { label: '已过期', value: expiryStats.value.expired, color: 'var(--color-danger)', caption: `近 ${windowDays.value} 天 · ${expiryStats.value.expired} 个已过期` }
+  ]
+})
+
+function pct(v, total) {
+  if (!total) return 0
+  return Math.round((v / total) * 100)
 }
 
-function statusClass(status) {
-  return resourceStatusClass(status)
+function goDetail(id) {
+  router.push(`/project/${id}`)
 }
 
+function onExport() {
+  const rows = [['项目名', '负责人', '状态', '关联资源数', '风险标记']]
+  projects.value.forEach((p) => {
+    rows.push([
+      p.name,
+      p.ownerName || '-',
+      p.status === 1 ? '进行中' : '已下线',
+      p.resourceCount,
+      riskSet.value.has(p.name) ? '风险' : '正常'
+    ])
+  })
+  downloadCsv(`资源总览-项目清单-${range.value}.csv`, rows)
+}
+
+// —— 环形图:项目状态分布 ——
+const { render: renderStatus } = useChart(statusChart, (c) => ({
+  tooltip: { trigger: 'item', formatter: '{b}: {c} 个 ({d}%)' },
+  title: {
+    text: String(totalProjects.value),
+    subtext: '个项目',
+    left: 'center',
+    top: '38%',
+    textAlign: 'center',
+    itemGap: 4,
+    textStyle: { fontSize: 28, fontWeight: 700, color: c.text },
+    subtextStyle: { fontSize: 12, color: c.sub }
+  },
+  series: [
+    {
+      type: 'pie',
+      radius: ['58%', '80%'],
+      center: ['50%', '50%'],
+      avoidLabelOverlap: false,
+      label: { show: false },
+      labelLine: { show: false },
+      data: projectStatus.value.map((s) => ({
+        name: s.name,
+        value: s.value,
+        itemStyle: { color: c.palette[s.cidx] }
+      }))
+    }
+  ]
+}))
+
+// —— 条形图:资源类型分布 ——
+const { render: renderType } = useChart(typeChart, (c) => {
+  const items = typeList.value
+  return {
+    grid: { left: 96, right: 48, top: 8, bottom: 8 },
+    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: '{b}: {c} 个' },
+    xAxis: { type: 'value', show: false },
+    yAxis: {
+      type: 'category',
+      data: items.map((i) => i.name),
+      inverse: true,
+      axisLine: { show: false },
+      axisTick: { show: false },
+      axisLabel: { color: c.sub, fontSize: 12 }
+    },
+    series: [
+      {
+        type: 'bar',
+        data: items.map((i) => i.value),
+        barWidth: 12,
+        itemStyle: { color: c.primary, borderRadius: [0, 6, 6, 0] },
+        label: { show: true, position: 'right', color: c.sub, fontSize: 12, fontFamily: 'var(--font-mono)' }
+      }
+    ]
+  }
+})
+
 onMounted(async () => {
-  const res = await dashboardApi()
-  data.value = res.data
+  const [dash, proj] = await Promise.all([dashboardApi(), projectPageApi({ pageNum: 1, pageSize: 100 })])
+  data.value = dash.data
+  const list = (proj.data.records || []).slice()
+  projects.value = list
+
+  // 项目状态聚合(真实)
+  const ongoing = list.filter((p) => p.status === 1).length
+  const offline = list.filter((p) => p.status === 0).length
+  projectStatus.value = [
+    { name: '进行中', value: ongoing, colorVar: 'var(--c1)', cidx: 0 },
+    { name: '已下线', value: offline, colorVar: 'var(--c8)', cidx: 7 }
+  ]
+
+  // 数据就绪后刷新图表
+  renderStatus()
+  renderType()
 })
 </script>
 
 <style scoped>
-.stat-card__label {
+.page-head {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 16px;
+  flex-wrap: wrap;
+  margin-bottom: 24px;
+}
+.page-head h1 {
+  font-size: 24px;
+  font-weight: 650;
+  line-height: 1.3;
+}
+.crumb {
   font-size: 13px;
   color: var(--color-text-secondary);
-  margin-bottom: 8px;
+  margin-bottom: 4px;
 }
-.stat-card__value {
-  font-family: var(--font-mono);
-  font-size: 28px;
-  font-weight: 600;
+.crumb b {
+  color: var(--color-text-secondary);
+  font-weight: 500;
+}
+.head-tools {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+/* 时间范围分段控件 */
+.seg {
+  display: inline-flex;
+  padding: 3px;
+  gap: 2px;
+  background: var(--color-surface-2);
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius-sm);
+}
+.seg button {
+  border: none;
+  background: transparent;
+  color: var(--color-text-secondary);
+  font-size: 13px;
+  font-weight: 500;
+  padding: 5px 14px;
+  border-radius: var(--radius-xs);
+  cursor: pointer;
+  transition: background 0.15s, color 0.15s;
+}
+.seg button:hover {
   color: var(--color-text-primary);
 }
-.section-title {
-  font-size: 14px;
+.seg button.active {
+  background: var(--color-surface);
+  color: var(--color-primary);
   font-weight: 600;
-  margin-bottom: 16px;
+  box-shadow: var(--shadow-card);
+}
+
+.btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  height: 34px;
+  padding: 0 14px;
+  border-radius: var(--radius-sm);
+  font-size: 13px;
+  font-weight: 600;
+  border: 1px solid var(--color-border);
+  background: var(--color-surface);
   color: var(--color-text-primary);
+  cursor: pointer;
+  transition: background 0.15s, border-color 0.15s;
+}
+.btn:hover {
+  background: var(--color-surface-2);
+}
+.btn svg {
+  width: 15px;
+  height: 15px;
+}
+
+/* KPI 卡 */
+.kpi-grid {
+  display: grid;
+  gap: 16px;
+  grid-template-columns: repeat(6, 1fr);
+  margin-bottom: 24px;
+}
+.kpi {
+  background: var(--color-surface);
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius-md);
+  padding: 20px;
+  box-shadow: var(--shadow-card);
+  transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
 }
-.dist-row {
+.kpi:hover {
+  transform: translateY(-2px);
+  border-color: var(--color-border-strong);
+  box-shadow: var(--shadow-md);
+}
+.k-label {
+  font-size: 12px;
+  font-weight: 500;
+  color: var(--color-text-secondary);
+  letter-spacing: 0.3px;
+}
+.k-value {
+  font-size: 30px;
+  font-weight: 700;
+  line-height: 1.1;
+  margin-top: 8px;
+}
+.k-cap {
+  margin-top: 8px;
+  font-size: 12px;
+  color: var(--color-text-secondary);
+}
+
+/* 图表区 */
+.charts-row {
+  display: grid;
+  gap: 16px;
+  grid-template-columns: 1fr 1fr;
+  margin-bottom: 24px;
+}
+.card {
+  background: var(--color-surface);
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius-md);
+  padding: 24px;
+  box-shadow: var(--shadow-card);
+}
+.card-head {
   display: flex;
   align-items: center;
-  gap: 10px;
-  margin-bottom: 12px;
-  font-size: 13px;
+  justify-content: space-between;
+  margin-bottom: 16px;
+}
+.card-head h2 {
+  font-size: 15px;
+  font-weight: 600;
 }
-.dist-row__name {
-  width: 80px;
+.card-head .hint {
+  font-size: 12px;
   color: var(--color-text-secondary);
+}
+
+.donut-wrap {
+  display: flex;
+  align-items: center;
+  gap: 24px;
+  flex-wrap: wrap;
+}
+.chart-donut {
+  width: 180px;
+  height: 180px;
   flex-shrink: 0;
 }
-.dist-row__bar-bg {
+.chart-bars {
+  width: 100%;
+  height: 300px;
+}
+.legend {
+  list-style: none;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
   flex: 1;
-  height: 8px;
-  background: var(--color-bg);
-  border-radius: 4px;
-  overflow: hidden;
-}
-.dist-row__bar {
-  height: 100%;
-  background: var(--color-primary-light);
-  border-radius: 4px;
-}
-.dist-row__count {
-  width: 28px;
-  text-align: right;
-  font-family: var(--font-mono);
+  min-width: 160px;
+}
+.legend li {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  font-size: 13px;
+}
+.legend .dot {
+  width: 10px;
+  height: 10px;
+  border-radius: 3px;
+  flex-shrink: 0;
+}
+.legend .lg-label {
   color: var(--color-text-secondary);
 }
+.legend .lg-val {
+  margin-left: auto;
+  font-weight: 600;
+}
+.legend .lg-pct {
+  color: var(--color-text-secondary);
+  width: 44px;
+  text-align: right;
+}
+.table-card {
+  margin-bottom: 24px;
+}
+
+/* 响应式 */
+@media (max-width: 1279px) {
+  .kpi-grid {
+    grid-template-columns: repeat(3, 1fr);
+  }
+}
+@media (max-width: 1023px) {
+  .charts-row {
+    grid-template-columns: 1fr;
+  }
+  .kpi-grid {
+    grid-template-columns: repeat(2, 1fr);
+  }
+}
+@media (max-width: 639px) {
+  .kpi-grid {
+    grid-template-columns: 1fr;
+  }
+}
 </style>

+ 1 - 1
frontend/src/views/dict/DictManage.vue

@@ -9,7 +9,7 @@
           </div>
           <el-table :data="typeList" v-loading="typeLoading">
             <el-table-column prop="name" label="名称" min-width="160" />
-            <el-table-column prop="sort" label="排序" width="80" />
+            <el-table-column prop="sort" label="排序" width="80" header-align="right" align="right" />
             <el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip />
             <el-table-column label="操作" width="140">
               <template #default="{ row }">

+ 23 - 5
frontend/src/views/project/ProjectDetail.vue

@@ -1,7 +1,21 @@
 <template>
   <div class="page-container">
+    <div class="page-head">
+      <div>
+        <div class="crumb">资源中枢 <b>/</b> 项目管理 <b>/</b> <b>{{ detail.name }}</b></div>
+        <h1>{{ detail.name }}</h1>
+      </div>
+      <div class="head-tools">
+        <el-button link type="primary" @click="goBack">
+          <el-icon><ArrowLeft /></el-icon>
+          <span>返回</span>
+        </el-button>
+      </div>
+    </div>
+
     <div class="page-card" style="margin-bottom: 16px">
-      <el-descriptions :title="detail.name" :column="3" border>
+      <div class="card-head"><h2>基本信息</h2></div>
+      <el-descriptions :column="3" border>
         <el-descriptions-item label="负责人">{{ detail.ownerName }}</el-descriptions-item>
         <el-descriptions-item label="状态">
           <span :class="['status-badge', detail.status === 1 ? 'status-badge--success' : 'status-badge--disabled']">
@@ -14,8 +28,8 @@
     </div>
 
     <div class="page-card">
-      <div class="page-toolbar">
-        <span class="section-title">关联资源</span>
+      <div class="card-head">
+        <h2>关联资源</h2>
         <el-button type="primary" @click="relateDialogVisible = true">+ 关联资源</el-button>
       </div>
 
@@ -59,13 +73,14 @@
 
 <script setup>
 import { ref, reactive, onMounted, watch, computed } from 'vue'
-import { useRoute } from 'vue-router'
+import { useRoute, useRouter } from 'vue-router'
 import { ElMessage } from 'element-plus'
 import { projectDetailApi } from '@/api/project'
 import { resourcePageApi, resourceRelateApi, resourceUnrelateApi } from '@/api/resource'
 import { resourceStatusClass } from '@/utils/status'
 
 const route = useRoute()
+const router = useRouter()
 const detail = reactive({})
 const allResources = ref([])
 
@@ -81,6 +96,10 @@ function statusClass(status) {
   return resourceStatusClass(status)
 }
 
+function goBack() {
+  router.push('/project')
+}
+
 async function loadDetail() {
   const res = await projectDetailApi(route.params.id)
   Object.assign(detail, res.data)
@@ -121,6 +140,5 @@ watch(() => route.params.id, () => {
 </script>
 
 <style scoped>
-.section-title { font-size: 14px; font-weight: 600; }
 .muted-tip { color: var(--color-text-placeholder); font-size: 12px; margin-bottom: 10px; }
 </style>

+ 26 - 10
frontend/src/views/project/ProjectList.vue

@@ -1,10 +1,28 @@
 <template>
   <div class="page-container">
+    <div class="page-head">
+      <div>
+        <div class="crumb">资源中枢 <b>/</b> <b>项目管理</b></div>
+        <h1>项目管理</h1>
+      </div>
+      <div class="head-tools">
+        <span class="filter-search">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
+          <input v-model="query.name" placeholder="搜索项目名 / 负责人…" @keyup.enter="handleSearch" />
+        </span>
+        <el-button type="primary" @click="openCreate">+ 新增项目</el-button>
+      </div>
+    </div>
+
     <div class="page-card">
-      <el-form :model="query" inline>
-        <el-form-item label="项目名称">
-          <el-input v-model="query.name" placeholder="项目名称" clearable @keyup.enter="handleSearch" />
-        </el-form-item>
+      <div class="card-head">
+        <div>
+          <h2>项目列表</h2>
+          <span class="hint">共 {{ total }} 条</span>
+        </div>
+      </div>
+
+      <el-form :model="query" inline class="filter-form">
         <el-form-item label="状态">
           <el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
             <el-option label="进行中" :value="1" />
@@ -17,11 +35,6 @@
         </el-form-item>
       </el-form>
 
-      <div class="page-toolbar">
-        <span class="muted-tip">共 {{ total }} 条</span>
-        <el-button type="primary" @click="openCreate">+ 新增项目</el-button>
-      </div>
-
       <el-table :data="list" v-loading="loading">
         <el-table-column label="项目名称" min-width="160">
           <template #default="{ row }">
@@ -29,7 +42,7 @@
           </template>
         </el-table-column>
         <el-table-column prop="ownerName" label="负责人" width="100" />
-        <el-table-column prop="resourceCount" label="关联资源数" width="110" />
+        <el-table-column prop="resourceCount" label="关联资源数" width="110" header-align="right" align="right" />
         <el-table-column label="状态" width="100">
           <template #default="{ row }">
             <span :class="['status-badge', row.status === 1 ? 'status-badge--success' : 'status-badge--disabled']">
@@ -164,5 +177,8 @@ onMounted(async () => {
 </script>
 
 <style scoped>
+.filter-form {
+  margin: 4px 0 16px;
+}
 .muted-tip { color: var(--color-text-placeholder); font-size: 12px; }
 </style>

+ 23 - 12
frontend/src/views/resource/ResourceList.vue

@@ -1,10 +1,29 @@
 <template>
   <div class="page-container">
+    <div class="page-head">
+      <div>
+        <div class="crumb">资源中枢 <b>/</b> <b>资源台账</b></div>
+        <h1>资源台账</h1>
+      </div>
+      <div class="head-tools">
+        <span class="filter-search">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
+          <input v-model="query.name" placeholder="搜索资源名称 / 关联项目…" @keyup.enter="handleSearch" />
+        </span>
+        <el-button type="primary" @click="openCreate">+ 新增资源</el-button>
+      </div>
+    </div>
+
     <div class="page-card">
+      <div class="card-head">
+        <div>
+          <h2>资源列表</h2>
+          <span class="hint">共 {{ total }} 条</span>
+        </div>
+        <el-button @click="handleExport">导出Excel</el-button>
+      </div>
+
       <el-form :model="query" inline class="filter-form">
-        <el-form-item label="名称">
-          <el-input v-model="query.name" placeholder="资源名称" clearable @keyup.enter="handleSearch" />
-        </el-form-item>
         <el-form-item label="类型">
           <el-select v-model="query.typeId" placeholder="全部" clearable style="width: 140px">
             <el-option v-for="t in typeOptions" :key="t.id" :label="t.name" :value="t.id" />
@@ -29,14 +48,6 @@
         </el-form-item>
       </el-form>
 
-      <div class="page-toolbar">
-        <span class="muted-tip">共 {{ total }} 条</span>
-        <div>
-          <el-button @click="handleExport">导出Excel</el-button>
-          <el-button type="primary" @click="openCreate">+ 新增资源</el-button>
-        </div>
-      </div>
-
       <el-table :data="list" v-loading="loading">
         <el-table-column prop="name" label="资源名称" min-width="150" />
         <el-table-column prop="typeName" label="类型" width="110" />
@@ -168,7 +179,7 @@ onMounted(async () => {
 
 <style scoped>
 .filter-form {
-  margin-bottom: 4px;
+  margin: 4px 0 16px;
 }
 .muted-tip {
   color: var(--color-text-placeholder);

+ 1 - 43
frontend/src/views/system/OperationLog.vue

@@ -25,7 +25,7 @@
       </el-form>
 
       <el-table :data="list" v-loading="loading">
-        <el-table-column prop="createTime" label="时间" width="180" class-name="mono-num" />
+        <el-table-column prop="createTime" label="时间" width="180" class-name="mono-num" header-align="right" align="right" />
         <el-table-column prop="module" label="模块" width="100" />
         <el-table-column prop="action" label="操作" width="100" />
         <el-table-column prop="content" label="详情" min-width="240" show-overflow-tooltip />
@@ -90,45 +90,3 @@ onMounted(loadList)
   margin-bottom: 4px;
 }
 </style>
-<template>
-  <div class="page-container">
-    <div class="page-card">
-      <el-table :data="list" v-loading="loading">
-        <el-table-column prop="createTime" label="时间" width="180" class-name="mono-num" />
-        <el-table-column prop="module" label="模块" width="100" />
-        <el-table-column prop="action" label="操作" width="100" />
-        <el-table-column prop="content" label="详情" min-width="240" show-overflow-tooltip />
-      </el-table>
-
-      <el-pagination
-        style="margin-top: 16px; justify-content: flex-end"
-        v-model:current-page="pageNum"
-        v-model:page-size="pageSize"
-        :total="total"
-        layout="total, prev, pager, next"
-        @current-change="loadList"
-      />
-    </div>
-  </div>
-</template>
-
-<script setup>
-import { ref, onMounted } from 'vue'
-import { operationLogPageApi } from '@/api/log'
-
-const list = ref([])
-const total = ref(0)
-const loading = ref(false)
-const pageNum = ref(1)
-const pageSize = ref(15)
-
-async function loadList() {
-  loading.value = true
-  const res = await operationLogPageApi({ pageNum: pageNum.value, pageSize: pageSize.value })
-  list.value = res.data.records
-  total.value = res.data.total
-  loading.value = false
-}
-
-onMounted(loadList)
-</script>

+ 4 - 0
start-backend.bat

@@ -0,0 +1,4 @@
+@echo off
+set JAVA_HOME=D:\Program Files\Java\jdk-17
+cd /d d:\demo\resource-platform\backend
+mvn spring-boot:run