zlwu 5 months ago
parent
commit
62b156fa9f

+ 58 - 0
bx/bx-backend/sql/add_home_dashboard_menu.sql

@@ -0,0 +1,58 @@
+-- 首页:根级一级菜单 menu_code=dashboard(id=90),无二级子菜单
+-- 兼容旧版「90 目录 + 91 子菜单」;可重复执行
+
+START TRANSACTION;
+
+-- 下线旧首页目录(仅类型为目录且无编码的 id=90)
+UPDATE bx_menu SET del_flag = 1, update_time = NOW()
+WHERE id = 90 AND parent_id = 0 AND menu_type = 1 AND (menu_code IS NULL OR menu_code = '');
+
+-- 旧子菜单 91 先提升为根级(便于后续把权限迁到 90)
+UPDATE bx_menu SET
+  parent_id = 0,
+  menu_name = '首页',
+  menu_code = 'dashboard',
+  menu_type = 2,
+  path = '/home',
+  sort_order = 0,
+  del_flag = 0,
+  update_time = NOW()
+WHERE id = 91 AND menu_code = 'dashboard';
+
+-- 统一主键为 90:插入或覆盖为根级首页
+INSERT INTO bx_menu (
+  id, parent_id, menu_name, menu_code, menu_type, path, icon, sort_order, visible, status,
+  remark, create_by, create_time, update_by, update_time, del_flag
+) VALUES (
+  90, 0, '首页', 'dashboard', 2, '/home', NULL, 0, 1, 1,
+  NULL, 'system', NOW(), 'system', NOW(), 0
+)
+ON DUPLICATE KEY UPDATE
+  parent_id = VALUES(parent_id),
+  menu_name = VALUES(menu_name),
+  menu_code = VALUES(menu_code),
+  menu_type = VALUES(menu_type),
+  path = VALUES(path),
+  sort_order = VALUES(sort_order),
+  del_flag = 0,
+  status = 1,
+  update_time = NOW();
+
+-- 原只授权了 91 的角色,补授权 90
+INSERT IGNORE INTO bx_menu_role (menu_id, role_id, create_time)
+SELECT 90, role_id, NOW() FROM bx_menu_role WHERE menu_id = 91;
+
+-- 下线重复的 91
+UPDATE bx_menu SET del_flag = 1, update_time = NOW() WHERE id = 91;
+DELETE FROM bx_menu_role WHERE menu_id = 91;
+
+-- 已删除的旧目录 90 的无效角色关联
+DELETE FROM bx_menu_role WHERE menu_id = 90 AND NOT EXISTS (
+  SELECT 1 FROM bx_menu m WHERE m.id = 90 AND m.del_flag = 0
+);
+
+-- 所有角色具备首页(若尚未配置)
+INSERT IGNORE INTO bx_menu_role (menu_id, role_id, create_time)
+SELECT 90, r.id, NOW() FROM bx_role r WHERE r.del_flag = 0;
+
+COMMIT;

BIN
bx/bx-web/public/1.png


BIN
bx/bx-web/public/2.png


+ 4 - 0
bx/bx-web/src/mock/data.ts

@@ -11,6 +11,7 @@ export interface MenuItem {
 }
 
 export const menuItems: MenuItem[] = [
+  { id: 'dashboard', title: '首页', group: '首页' },
   // 基础管理
   { id: 'user-manage', title: '用户管理', group: '基础管理' },
   { id: 'role-manage', title: '角色管理', group: '基础管理' },
@@ -98,6 +99,9 @@ export interface MenuRow {
 }
 
 export const menuRows: MenuRow[] = [
+  {
+    id: 90, parentId: 0, menuName: '首页', menuCode: 'dashboard', menuType: '菜单', path: '/home', sortOrder: 0, status: '启用'
+  },
   {
     id: 100, parentId: 0, menuName: '基础管理', menuCode: '', menuType: '目录', path: '', sortOrder: 1, status: '启用',
     children: [

+ 4 - 0
bx/bx-web/src/shell/openMenuTabKey.ts

@@ -0,0 +1,4 @@
+import type { InjectionKey } from 'vue'
+
+/** MainLayout 提供:子页面(如首页快捷入口)打开侧栏菜单对应页签 */
+export const openMenuTabKey: InjectionKey<(menuCode: string) => void> = Symbol('openMenuTab')

+ 44 - 3
bx/bx-web/src/styles/index.css

@@ -322,6 +322,15 @@ body {
   color: var(--primary-dark);
 }
 
+/* 根级单菜单(无二级,如首页) */
+.menu-link.menu-link--l1-root {
+  margin: 0 0 4px;
+  padding: 8px 12px;
+  font-size: 12px;
+  font-weight: 700;
+  letter-spacing: 0.02em;
+}
+
 .main {
   flex: 1;
   min-width: 0;
@@ -620,13 +629,45 @@ body {
   font-size: 13px;
 }
 
+/* 各页顶部统计卡:单行不换行,窄屏卡片等分变窄(原 grid auto-fit 会换行) */
 .stats {
-  display: grid;
-  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
-  gap: 12px;
+  display: flex;
+  flex-direction: row;
+  flex-wrap: nowrap;
+  align-items: stretch;
+  width: 100%;
+  min-width: 0;
+  gap: clamp(6px, 1.2vw, 12px);
   margin-bottom: 16px;
 }
 
+.stats > .stat {
+  flex: 1 1 0;
+  min-width: 0;
+  max-width: 100%;
+  box-sizing: border-box;
+  padding: clamp(8px, 1.8vw, 12px) clamp(6px, 1.2vw, 14px);
+  overflow: hidden;
+}
+
+.stats > .stat .k {
+  margin: 0 0 6px;
+  font-size: clamp(11px, 2.4vw, 14px);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.stats > .stat .v {
+  margin: 0;
+  font-size: clamp(13px, 3.8vw, 22px);
+  font-weight: 700;
+  color: var(--primary-dark);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .stat {
   background: #fff;
   border: 1px solid var(--border);

+ 28 - 3
bx/bx-web/src/views/MainLayout.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import type { Component } from 'vue'
-import { computed, onMounted, ref } from 'vue'
+import { computed, onMounted, provide, ref } from 'vue'
 import { useRouter } from 'vue-router'
 import {
   Bell,
@@ -15,6 +15,7 @@ import {
   Fold,
   FolderOpened,
   Histogram,
+  HomeFilled,
   Key,
   List,
   Management,
@@ -29,11 +30,13 @@ import {
   Wallet,
 } from '@element-plus/icons-vue'
 import { useAuthStore, type MenuItem } from '../stores/auth'
+import { openMenuTabKey } from '../shell/openMenuTabKey'
 import logoGif from '../assets/logo.gif'
 
 const router = useRouter()
 
 const titleMap: Record<string, string> = {
+  dashboard: '首页',
   demo: '业务界面demo',
   'user-manage': '用户管理',
   'role-manage': '角色管理',
@@ -68,9 +71,11 @@ import PaymentPage from './pages/PaymentPage.vue'
 import MinePage from './pages/MinePage.vue'
 import TodoPage from './pages/TodoPage.vue'
 import AllPage from './pages/AllPage.vue'
+import HomePage from './pages/HomePage.vue'
 
 /** 侧栏菜单项图标(按 menuCode;后端新增菜单可走 resolveMenuIcon 关键词兜底) */
 const menuIconMap: Record<string, Component> = {
+  dashboard: HomeFilled,
   demo: Monitor,
   'user-manage': User,
   'role-manage': Key,
@@ -110,6 +115,7 @@ function resolveMenuIcon(menuCode: string | null | undefined, menuName?: string
 function resolveLevel1Icon(menuCode: string | null | undefined, menuName?: string | null): Component {
   if (menuCode && menuIconMap[menuCode]) return menuIconMap[menuCode]
   const name = menuName || ''
+  if (/^首页$/.test(name)) return HomeFilled
   if (/基础管理/.test(name)) return Management
   if (/基础配置|系统配置/.test(name)) return Setting
   if (name === '我的' || name === '我的申请') return User
@@ -124,6 +130,7 @@ function resolveLevel1Icon(menuCode: string | null | undefined, menuName?: strin
 }
 
 const pageComponentMap: Record<string, any> = {
+  dashboard: HomePage,
   demo: DemoPage,
   'user-manage': UserManagePage,
   'role-manage': RoleManagePage,
@@ -180,7 +187,7 @@ const currentPage = computed(() => pageComponentMap[activeTab.value] ?? null)
 
 function findFirstLeaf(items: MenuItem[]): string {
   for (const item of items) {
-    if (item.menuCode) return item.menuCode
+    if (item.menuCode && !(item.children && item.children.length)) return item.menuCode
     if (item.children?.length) {
       const found = findFirstLeaf(item.children)
       if (found) return found
@@ -189,11 +196,18 @@ function findFirstLeaf(items: MenuItem[]): string {
   return ''
 }
 
+/** 根级即叶子菜单(如首页),无二级展开 */
+function isRootLeafMenu(group: MenuItem): boolean {
+  return Boolean(group.menuCode && !(group.children && group.children.length))
+}
+
 function openMenuTab(tabId: string) {
   if (!openTabs.value.includes(tabId)) openTabs.value.push(tabId)
   activeTab.value = tabId
 }
 
+provide(openMenuTabKey, openMenuTab)
+
 function removeTab(tabId: string) {
   if (openTabs.value.length === 1) return
   const idx = openTabs.value.indexOf(tabId)
@@ -265,7 +279,18 @@ onMounted(async () => {
       <el-scrollbar>
         <div class="nav-tree">
           <template v-for="group in auth.menuList" :key="group.id">
-            <details class="nav-details nav-details--l1" open>
+            <a
+              v-if="isRootLeafMenu(group)"
+              class="menu-link menu-link--l1-root"
+              :class="{ active: activeTab === group.menuCode }"
+              @click.prevent="group.menuCode && openMenuTab(group.menuCode)"
+            >
+              <el-icon class="menu-link-ico">
+                <component :is="resolveMenuIcon(group.menuCode, group.menuName)" />
+              </el-icon>
+              <span class="menu-link-text">{{ group.menuName }}</span>
+            </a>
+            <details v-else class="nav-details nav-details--l1" open>
               <summary class="nav-summary nav-summary--l1">
                 <span class="nav-summary-main">
                   <el-icon class="nav-summary-ico">

+ 502 - 0
bx/bx-web/src/views/pages/HomePage.vue

@@ -0,0 +1,502 @@
+<script setup lang="ts">
+import { inject, onMounted, reactive, ref } from 'vue'
+import { advanceApi, reimburseApi } from '../../api'
+import { statusLabel, statusTone, hasWorkflowNode } from '../../utils/format'
+import { useServerPaged } from '../../hooks/useServerPaged'
+import BaseTag from '../../components/base/BaseTag.vue'
+import BaseOpButton from '../../components/base/BaseOpButton.vue'
+import { openMenuTabKey } from '../../shell/openMenuTabKey'
+
+const openMenuTab = inject(openMenuTabKey, () => {})
+
+const loadingMap = reactive({
+  stats: false,
+})
+
+const advanceStats = ref({
+  totalCount: 0,
+  pendingCount: 0,
+  approvedPassCount: 0,
+  rejectedCount: 0,
+  issuedCount: 0,
+  issuedAmount: 0,
+  monthApplyAmount: 0,
+  monthApplyCount: 0,
+  pendingIssueCount: 0,
+  offsetTotal: 0,
+})
+
+const reimburseStats = ref({
+  totalCount: 0,
+  pendingCount: 0,
+  approvedCount: 0,
+  rejectedCount: 0,
+  approvedAmount: 0,
+  monthApplyAmount: 0,
+  monthApplyCount: 0,
+})
+
+const listTab = ref<'advance' | 'reimburse'>('advance')
+
+const {
+  rows: advanceRows,
+  total: advanceTotal,
+  currentPage: advanceCurrentPage,
+  pageSize: advancePageSize,
+  loading: advanceLoading,
+  rangeText: advanceRangeText,
+  refresh: refreshAdvanceList,
+  onSizeChange: onAdvanceSizeChange,
+} = useServerPaged(async (params) => {
+  const res: any = await advanceApi.my(params)
+  const d = res.data || {}
+  return { rows: d.rows ?? [], total: Number(d.total ?? 0) }
+})
+
+const {
+  rows: reimburseRows,
+  total: reimburseTotal,
+  currentPage: reimburseCurrentPage,
+  pageSize: reimbursePageSize,
+  loading: reimburseLoading,
+  rangeText: reimburseRangeText,
+  refresh: refreshReimburseList,
+  onSizeChange: onReimburseSizeChange,
+} = useServerPaged(async (params) => {
+  const res: any = await reimburseApi.my(params)
+  const d = res.data || {}
+  return { rows: d.rows ?? [], total: Number(d.total ?? 0) }
+})
+
+async function loadStats() {
+  loadingMap.stats = true
+  try {
+    const [aRes, rRes]: any[] = await Promise.all([advanceApi.myStats(), reimburseApi.myStats()])
+    const a = aRes.data || {}
+    const r = rRes.data || {}
+    advanceStats.value = {
+      totalCount: Number(a.totalCount ?? 0),
+      pendingCount: Number(a.pendingCount ?? 0),
+      approvedPassCount: Number(a.approvedPassCount ?? 0),
+      rejectedCount: Number(a.rejectedCount ?? 0),
+      issuedCount: Number(a.issuedCount ?? 0),
+      issuedAmount: Number(a.issuedAmount ?? 0),
+      monthApplyAmount: Number(a.monthApplyAmount ?? 0),
+      monthApplyCount: Number(a.monthApplyCount ?? 0),
+      pendingIssueCount: Number(a.pendingIssueCount ?? 0),
+      offsetTotal: Number(a.offsetTotal ?? 0),
+    }
+    reimburseStats.value = {
+      totalCount: Number(r.totalCount ?? 0),
+      pendingCount: Number(r.pendingCount ?? 0),
+      approvedCount: Number(r.approvedCount ?? 0),
+      rejectedCount: Number(r.rejectedCount ?? 0),
+      approvedAmount: Number(r.approvedAmount ?? 0),
+      monthApplyAmount: Number(r.monthApplyAmount ?? 0),
+      monthApplyCount: Number(r.monthApplyCount ?? 0),
+    }
+  } finally {
+    loadingMap.stats = false
+  }
+}
+
+function money(n: number) {
+  return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+}
+
+type TagTone = 'pending' | 'processing' | 'done' | 'warning' | 'error' | 'info'
+
+function elTagTone(s: unknown): TagTone {
+  return statusTone(s) as TagTone
+}
+
+function goReimburseApply() {
+  openMenuTab('reimburse-apply')
+}
+
+function goAdvanceApply() {
+  openMenuTab('advance-apply')
+}
+
+onMounted(() => {
+  loadStats()
+  refreshAdvanceList()
+  refreshReimburseList()
+})
+</script>
+
+<template>
+  <div class="home-page">
+    <div class="page-head">
+      <h1>首页</h1>
+      <p class="desc">个人备用金与报销概况、快捷发起与近期申请单。</p>
+    </div>
+
+    <div class="home-quick-row">
+      <button type="button" class="home-quick-btn home-quick-btn--reim" @click="goReimburseApply">
+        <span class="home-quick-bg" style="background-image: url(/2.png)" />
+        <span class="home-quick-inner">
+          <span class="home-quick-label">新增报销申请</span>
+        </span>
+      </button>
+      <button type="button" class="home-quick-btn home-quick-btn--adv" @click="goAdvanceApply">
+        <span class="home-quick-bg" style="background-image: url(/1.png)" />
+        <span class="home-quick-inner">
+          <span class="home-quick-label">新增备用金申请</span>
+        </span>
+      </button>
+    </div>
+
+    <div v-loading="loadingMap.stats" class="home-stats-row">
+      <section class="home-stat-panel home-stat-panel--advance">
+        <h2 class="home-stat-title">备用金申请</h2>
+        <div class="home-stat-grid-wrap">
+        <div class="home-stat-grid">
+          <div class="home-stat-cell">
+            <span class="k">申请总数</span>
+            <span class="v">{{ advanceStats.totalCount }}</span>
+          </div>
+          <div class="home-stat-cell">
+            <span class="k">审批中</span>
+            <span class="v">{{ advanceStats.pendingCount }}</span>
+          </div>
+          <div class="home-stat-cell">
+            <span class="k">已通过</span>
+            <span class="v">{{ advanceStats.approvedPassCount }}</span>
+          </div>
+          <div class="home-stat-cell">
+            <span class="k">已驳回</span>
+            <span class="v">{{ advanceStats.rejectedCount }}</span>
+          </div>
+          <div class="home-stat-cell">
+            <span class="k">已发放笔数</span>
+            <span class="v">{{ advanceStats.issuedCount }}</span>
+          </div>
+          <div class="home-stat-cell">
+            <span class="k">待发放</span>
+            <span class="v">{{ advanceStats.pendingIssueCount }}</span>
+          </div>
+          <div class="home-stat-cell">
+            <span class="k">已发放金额(元)</span>
+            <span class="v">{{ money(advanceStats.issuedAmount) }}</span>
+          </div>
+          <div class="home-stat-cell">
+            <span class="k">本月申请金额 / 笔数</span>
+            <span class="v">{{ money(advanceStats.monthApplyAmount) }} / {{ advanceStats.monthApplyCount }}</span>
+          </div>
+          <div class="home-stat-cell">
+            <span class="k">冲抵合计(元)</span>
+            <span class="v">{{ money(advanceStats.offsetTotal) }}</span>
+          </div>
+        </div>
+        </div>
+      </section>
+
+      <section class="home-stat-panel home-stat-panel--reimburse">
+        <h2 class="home-stat-title">报销申请</h2>
+        <div class="home-stat-grid-wrap">
+        <div class="home-stat-grid">
+          <div class="home-stat-cell">
+            <span class="k">申请总数</span>
+            <span class="v">{{ reimburseStats.totalCount }}</span>
+          </div>
+          <div class="home-stat-cell">
+            <span class="k">审批中</span>
+            <span class="v">{{ reimburseStats.pendingCount }}</span>
+          </div>
+          <div class="home-stat-cell">
+            <span class="k">已通过</span>
+            <span class="v">{{ reimburseStats.approvedCount }}</span>
+          </div>
+          <div class="home-stat-cell">
+            <span class="k">已驳回</span>
+            <span class="v">{{ reimburseStats.rejectedCount }}</span>
+          </div>
+          <div class="home-stat-cell">
+            <span class="k">已通过金额(元)</span>
+            <span class="v">{{ money(reimburseStats.approvedAmount) }}</span>
+          </div>
+          <div class="home-stat-cell">
+            <span class="k">本月申请金额 / 笔数</span>
+            <span class="v">{{ money(reimburseStats.monthApplyAmount) }} / {{ reimburseStats.monthApplyCount }}</span>
+          </div>
+        </div>
+        </div>
+      </section>
+    </div>
+
+    <div class="panel home-list-panel">
+      <el-tabs v-model="listTab" class="home-tabs">
+        <el-tab-pane label="我的备用金申请" name="advance">
+          <div class="table-scroll" v-loading="advanceLoading">
+            <el-table :data="advanceRows" stripe empty-text="暂无数据">
+              <el-table-column type="index" label="序号" width="70" />
+              <el-table-column prop="applyCode" label="申请单号" min-width="140" />
+              <el-table-column prop="reason" label="事由" min-width="140" show-overflow-tooltip />
+              <el-table-column prop="amount" label="金额(元)" width="120" class-name="col-money" />
+              <el-table-column prop="status" label="状态" width="110">
+                <template #default="{ row }">
+                  <BaseTag :tone="elTagTone(row.status)">{{ statusLabel(row.status) }}</BaseTag>
+                </template>
+              </el-table-column>
+              <el-table-column prop="currentNode" label="当前节点" min-width="120">
+                <template #default="{ row }">
+                  <BaseTag v-if="hasWorkflowNode(row.currentNode)" :tone="elTagTone(row.currentNode)">
+                    {{ statusLabel(row.currentNode) }}
+                  </BaseTag>
+                  <span v-else class="node-current-empty">-</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="applyTime" label="申请时间" min-width="160" />
+              <el-table-column label="操作" width="120" fixed="right" class-name="col-actions">
+                <template #default>
+                  <BaseOpButton primary @click="openMenuTab('advance-apply')">去办理</BaseOpButton>
+                </template>
+              </el-table-column>
+            </el-table>
+          </div>
+          <div class="list-pager">
+            <span class="list-range">{{ advanceRangeText }}</span>
+            <div class="list-pager-right">
+              <el-pagination
+                v-model:current-page="advanceCurrentPage"
+                v-model:page-size="advancePageSize"
+                :total="advanceTotal"
+                layout="sizes, prev, pager, next"
+                :page-sizes="[10, 20, 50]"
+                prev-text="上一页"
+                next-text="下一页"
+                @size-change="onAdvanceSizeChange"
+              />
+              <span class="pager-size-suffix">条</span>
+            </div>
+          </div>
+        </el-tab-pane>
+
+        <el-tab-pane label="我的报销申请" name="reimburse">
+          <div class="table-scroll" v-loading="reimburseLoading">
+            <el-table :data="reimburseRows" stripe empty-text="暂无数据">
+              <el-table-column type="index" label="序号" width="70" />
+              <el-table-column prop="applyCode" label="申请单号" min-width="140" />
+              <el-table-column prop="title" label="标题" min-width="160" show-overflow-tooltip />
+              <el-table-column prop="expenseName" label="费用项目" min-width="120" show-overflow-tooltip />
+              <el-table-column prop="amount" label="金额(元)" width="120" class-name="col-money" />
+              <el-table-column prop="status" label="状态" width="110">
+                <template #default="{ row }">
+                  <BaseTag :tone="elTagTone(row.status)">{{ statusLabel(row.status) }}</BaseTag>
+                </template>
+              </el-table-column>
+              <el-table-column prop="currentNode" label="当前节点" min-width="120">
+                <template #default="{ row }">
+                  <BaseTag v-if="hasWorkflowNode(row.currentNode)" :tone="elTagTone(row.currentNode)">
+                    {{ statusLabel(row.currentNode) }}
+                  </BaseTag>
+                  <span v-else class="node-current-empty">-</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="applyTime" label="申请时间" min-width="160" />
+              <el-table-column label="操作" width="120" fixed="right" class-name="col-actions">
+                <template #default>
+                  <BaseOpButton primary @click="openMenuTab('reimburse-apply')">去办理</BaseOpButton>
+                </template>
+              </el-table-column>
+            </el-table>
+          </div>
+          <div class="list-pager">
+            <span class="list-range">{{ reimburseRangeText }}</span>
+            <div class="list-pager-right">
+              <el-pagination
+                v-model:current-page="reimburseCurrentPage"
+                v-model:page-size="reimbursePageSize"
+                :total="reimburseTotal"
+                layout="sizes, prev, pager, next"
+                :page-sizes="[10, 20, 50]"
+                prev-text="上一页"
+                next-text="下一页"
+                @size-change="onReimburseSizeChange"
+              />
+              <span class="pager-size-suffix">条</span>
+            </div>
+          </div>
+        </el-tab-pane>
+      </el-tabs>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.home-page {
+  padding-bottom: 24px;
+}
+
+.home-stats-row {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 16px;
+  margin-bottom: 20px;
+}
+
+@media (max-width: 1100px) {
+  .home-stats-row {
+    grid-template-columns: 1fr;
+  }
+}
+
+.home-stat-panel {
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  box-shadow: var(--shadow);
+  padding: 16px 18px;
+}
+
+.home-stat-title {
+  margin: 0 0 12px;
+  font-size: 15px;
+  font-weight: 600;
+  color: var(--text);
+}
+
+.home-stat-grid-wrap {
+  min-width: 0;
+  overflow-x: auto;
+  -webkit-overflow-scrolling: touch;
+}
+
+/* 备用金:9 项固定两行(5 + 4) */
+.home-stat-panel--advance .home-stat-grid {
+  display: grid;
+  grid-template-columns: repeat(5, minmax(72px, 1fr));
+  grid-template-rows: repeat(2, auto);
+  gap: 8px 10px;
+}
+
+/* 报销:6 项固定两行(3 + 3) */
+.home-stat-panel--reimburse .home-stat-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(80px, 1fr));
+  grid-template-rows: repeat(2, auto);
+  gap: 8px 10px;
+}
+
+.home-stat-cell {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  min-width: 0;
+  padding: 8px 10px;
+  background: var(--table-head);
+  border-radius: var(--radius-md);
+  border: 1px solid var(--el-border-color-light);
+}
+
+.home-stat-panel--advance .home-stat-title {
+  color: var(--primary-dark);
+}
+
+.home-stat-panel--reimburse .home-stat-title {
+  color: #0369a1;
+}
+
+.home-stat-cell .k {
+  font-size: 12px;
+  line-height: 1.3;
+  color: var(--text-muted);
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+  overflow: hidden;
+  word-break: break-word;
+}
+
+.home-stat-cell .v {
+  font-size: 17px;
+  font-weight: 600;
+  color: var(--text);
+  line-height: 1.25;
+  word-break: break-all;
+}
+
+.home-quick-row {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 16px;
+  margin-bottom: 20px;
+}
+
+@media (max-width: 560px) {
+  .home-quick-row {
+    grid-template-columns: 1fr;
+  }
+}
+
+.home-quick-btn {
+  position: relative;
+  display: block;
+  width: 100%;
+  min-height: 140px;
+  padding: 0;
+  border: none;
+  border-radius: var(--radius);
+  overflow: hidden;
+  cursor: pointer;
+  text-align: left;
+  box-shadow: var(--shadow);
+}
+
+.home-quick-btn:disabled {
+  opacity: 0.75;
+  cursor: wait;
+}
+
+.home-quick-bg {
+  position: absolute;
+  inset: 0;
+  background-size: cover;
+  background-position: center right;
+  transition: transform 0.25s ease;
+}
+
+.home-quick-btn:hover .home-quick-bg {
+  transform: scale(1.02);
+}
+
+.home-quick-inner {
+  position: relative;
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  min-height: 140px;
+  padding: 20px 22px;
+  background: transparent;
+}
+
+.home-quick-label {
+  font-size: 22px;
+  font-weight: 700;
+  letter-spacing: 0.02em;
+  color: #fff;
+}
+
+@media (max-width: 560px) {
+  .home-quick-label {
+    font-size: 18px;
+  }
+}
+
+.home-list-panel {
+  padding: 8px 16px 16px;
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  box-shadow: var(--shadow);
+}
+
+.home-tabs :deep(.el-tabs__header) {
+  margin-bottom: 12px;
+}
+
+.home-tabs :deep(.el-tabs__item) {
+  font-size: 14px;
+}
+</style>

+ 6 - 0
bx/报销业务/bx-menu-role-init.sql

@@ -21,6 +21,7 @@ SET CHARACTER SET utf8mb4;
 --   320  待办(一级目录)        312  备用金审批     322  报销审批
 --   330  发放/冲抵(一级目录)   313  备用金发放管理  323  支付管理(发放/冲抵)
 --   400  视图中心(目录)        401  我的申请单     402  待我审批      403  全部申请单
+--   90   首页(一级菜单 menu_code=dashboard)
 -- ============================================================
 
 -- 先清理旧数据
@@ -30,6 +31,8 @@ DELETE FROM bx_menu_role;
 -- 1. ADMIN(管理员)— 拥有全部菜单
 -- ============================================================
 INSERT INTO bx_menu_role (menu_id, role_id) VALUES
+-- 首页
+(90, 1),
 -- 基础管理
 (100, 1), (101, 1), (102, 1), (103, 1),
 -- 基础配置
@@ -45,6 +48,7 @@ INSERT INTO bx_menu_role (menu_id, role_id) VALUES
 -- 2. EMPLOYEE(员工)— 可发起备用金/报销申请 + 查看自己的申请单
 -- ============================================================
 INSERT INTO bx_menu_role (menu_id, role_id) VALUES
+(90, 2),
 (310, 2), (311, 2), (321, 2),
 (400, 2), (401, 2);
 
@@ -52,6 +56,7 @@ INSERT INTO bx_menu_role (menu_id, role_id) VALUES
 -- 3. FINANCE(财务人员)— 审批 + 发放 + 支付处理 + 费用项目管理
 -- ============================================================
 INSERT INTO bx_menu_role (menu_id, role_id) VALUES
+(90, 3),
 (200, 3), (201, 3),
 (320, 3), (312, 3), (322, 3),
 (330, 3), (313, 3), (323, 3),
@@ -61,5 +66,6 @@ INSERT INTO bx_menu_role (menu_id, role_id) VALUES
 -- 4. GENERAL_MANAGER(总经理)— 审批 + 查看全部申请单
 -- ============================================================
 INSERT INTO bx_menu_role (menu_id, role_id) VALUES
+(90, 4),
 (320, 4), (312, 4), (322, 4),
 (400, 4), (401, 4), (402, 4), (403, 4);

+ 8 - 0
bx/报销业务/bx-schema-reset.sql

@@ -330,6 +330,10 @@ INSERT INTO bx_role (role_name, role_code, sort_order, remark) VALUES
 ('总经理', 'GENERAL_MANAGER', 4, '查看全部申请单');
 
 -- 初始菜单(三级结构)
+-- L1:首页(工作台,一级菜单无子级)
+INSERT INTO bx_menu (id, parent_id, menu_name, menu_code, menu_type, path, sort_order) VALUES
+(90, 0, '首页', 'dashboard', 2, '/home', 0);
+
 -- L1:基础管理
 INSERT INTO bx_menu (id, parent_id, menu_name, menu_code, menu_type, path, sort_order) VALUES
 (100, 0, '基础管理', NULL, 1, NULL, 1),
@@ -442,6 +446,7 @@ SELECT r.id, u.id FROM bx_role r, bx_user u WHERE r.role_code = 'ADMIN' AND u.us
 
 -- ADMIN(管理员)— 拥有全部菜单
 INSERT INTO bx_menu_role (menu_id, role_id) VALUES
+(90, 1),
 (100, 1), (101, 1), (102, 1), (103, 1),
 (200, 1), (201, 1), (202, 1), (203, 1),
 (310, 1), (311, 1), (321, 1),
@@ -451,11 +456,13 @@ INSERT INTO bx_menu_role (menu_id, role_id) VALUES
 
 -- EMPLOYEE(员工)— 备用金/报销申请 + 我的申请单
 INSERT INTO bx_menu_role (menu_id, role_id) VALUES
+(90, 2),
 (310, 2), (311, 2), (321, 2),
 (400, 2), (401, 2);
 
 -- FINANCE(财务人员)— 审批/发放/支付 + 费用项目管理
 INSERT INTO bx_menu_role (menu_id, role_id) VALUES
+(90, 3),
 (200, 3), (201, 3),
 (320, 3), (312, 3), (322, 3),
 (330, 3), (313, 3), (323, 3),
@@ -463,5 +470,6 @@ INSERT INTO bx_menu_role (menu_id, role_id) VALUES
 
 -- GENERAL_MANAGER(总经理)— 审批 + 全部申请单视图
 INSERT INTO bx_menu_role (menu_id, role_id) VALUES
+(90, 4),
 (320, 4), (312, 4), (322, 4),
 (400, 4), (401, 4), (402, 4), (403, 4);

+ 4 - 0
bx/报销业务/bx-schema.sql

@@ -305,6 +305,10 @@ INSERT INTO bx_role (role_name, role_code, sort_order, remark) VALUES
 ('总经理', 'GENERAL_MANAGER', 4, '查看全部申请单');
 
 -- 初始菜单(三级结构)
+-- L1:首页(工作台,一级菜单)
+INSERT INTO bx_menu (id, parent_id, menu_name, menu_code, menu_type, path, sort_order) VALUES
+(90, 0, '首页', 'dashboard', 2, '/home', 0);
+
 -- L1:基础管理
 INSERT INTO bx_menu (id, parent_id, menu_name, menu_code, menu_type, path, sort_order) VALUES
 (100, 0, '基础管理', NULL, 1, NULL, 1),