fix(dashboard): 修复普通用户落地页与待办角色过滤

- 新增 router/access.ts 统一准入与落地页(工作台→业务数据→回款),
  守卫/登录回跳/菜单/面包屑复用;普通用户不再被无限重定向
- 待办按角色映射在途审批节点(财务/领导/admin),admin 不代办但可查全部
- 预警排除已终止业务;WarningBlock.total 为全量命中数;回款趋势改日期范围
- 工作台加载请求序号防竞态;KPI 口径说明补经理分成/调整单
- 新增 role_home_flow e2e,更新 system_management 普通用户断言

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-20 09:45:03 +08:00
1 parent ebb3a92f25
commit 39a3fa7796
16 files changed
+590 -59

No files matched your search

+3
View File
@@ -67,7 +67,10 @@ export interface WarningBlock {
enabled: boolean
receipt_overdue_days: number
approval_timeout_days: number
/** 当前返回的条目(受展示上限约束) */
items: WarningItem[]
/** 全部规则命中总数;徽标计数用它,不能用 items.length */
total: number
receipt_overdue_total: number
approval_timeout_total: number
sync_available: boolean
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppMenu from '@/components/layout/AppMenu.vue'
import { firstAccessiblePath } from '@/router/access'
import { useUserStore } from '@/store/user'
/** 基础布局:左侧品牌导航(窄屏收为抽屉)+ 顶栏(面包屑/用户/退出)+ 内容区。 */
@@ -13,11 +14,14 @@ const userStore = useUserStore()
const drawerVisible = ref(false)
/** 「首页」按角色解析:普通用户没有工作台权限,固定链到业务数据页 */
const homePath = computed(() => firstAccessiblePath(router, userStore.roles))
/** 面包屑:首页 + 分组(如有)+ 当前页标题(首页自身不重复) */
const breadcrumbs = computed<string[]>(() => {
const items: string[] = []
if (route.meta.group) items.push(route.meta.group)
if (route.meta.title && route.path !== '/dashboard') items.push(route.meta.title)
if (route.meta.title && route.path !== homePath.value) items.push(route.meta.title)
return items
})
@@ -66,7 +70,7 @@ async function handleLogout(): Promise<void> {
</svg>
</button>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: homePath }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item">
{{ item }}
</el-breadcrumb-item>
+3 -9
View File
@@ -1,8 +1,8 @@
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { canAccessRoute, isMenuRecord } from '@/router/access'
import { useUserStore } from '@/store/user'
import { ROLE_ADMIN } from '@/utils/constants'
/** 菜单节点:从路由表派生,避免菜单标题与页面标题两处维护漂移。 */
export interface MenuNode {
@@ -30,19 +30,13 @@ export function useAppMenu() {
const router = useRouter()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN))
const userRoles = computed(() => userStore.roles)
const menuNodes = computed<MenuNode[]>(() =>
router
.getRoutes()
.filter((record) => Boolean(record.meta.title) && record.meta.menu !== false)
.filter((record) => !record.meta.requiresAdmin || isAdmin.value)
// 职能页面(审批等):任一列入角色命中即可见(admin 须显式列入集合)
.filter(
(record) =>
!record.meta.allowedRoles || record.meta.allowedRoles.some((role) => userRoles.value.includes(role)),
)
// 准入规则与路由守卫、落地页解析共用,避免三处漂移
.filter((record) => isMenuRecord(record) && canAccessRoute(record, userRoles.value))
.map((record) => ({
path: record.path,
title: record.meta.title ?? '',
+62
View File
@@ -0,0 +1,62 @@
import type { RouteRecordNormalized, Router } from 'vue-router'
import { ROLE_ADMIN } from '@/utils/constants'
/**
* 路由准入与落地页解析(菜单、守卫、登录回跳共用一份规则)。
*
* 之前守卫两处兜底硬编码 `/dashboard`,工作台收紧权限后普通用户会被
* 反复重定向到同一受限页(无限循环)。集中在这里后,任何角色的落地页
* 都必须先通过同一准入判定。
*/
/** 可作为菜单项/落地页:有标题、未被 menu:false 排除、非免登录页 */
export function isMenuRecord(record: RouteRecordNormalized): boolean {
return Boolean(record.meta.title) && record.meta.menu !== false && !record.meta.public
}
/** 当前角色是否可访问该路由(与守卫、菜单过滤同源) */
export function canAccessRoute(record: RouteRecordNormalized, roles: string[]): boolean {
if (record.meta.public) return false
if (record.meta.requiresAdmin && !roles.includes(ROLE_ADMIN)) return false
if (
record.meta.allowedRoles &&
!record.meta.allowedRoles.some((role) => roles.includes(role))
) {
return false
}
return true
}
/**
* 落地页候选(按优先级):工作台 → 业务数据 → 回款。
* 普通用户没有工作台权限时落到业务数据页,而不是被菜单 order 带到
* 基础数据管理页;候选都不可访问时再按菜单顺序兜底。
*/
const HOME_CANDIDATES = ['/dashboard', '/business/businesses', '/business/receipts']
function matchedRecord(router: Router, path: string): RouteRecordNormalized | undefined {
const matched = router.resolve(path).matched
return matched[matched.length - 1]
}
/** 当前角色可访问的默认首页;无任何可访问页时回登录页(防循环) */
export function firstAccessiblePath(router: Router, roles: string[]): string {
for (const path of HOME_CANDIDATES) {
const record = matchedRecord(router, path)
if (record && isMenuRecord(record) && canAccessRoute(record, roles)) return path
}
const fallback = router
.getRoutes()
.filter(isMenuRecord)
.filter((record) => canAccessRoute(record, roles))
.sort((a, b) => (a.meta.order ?? 99) - (b.meta.order ?? 99))
return fallback[0]?.path ?? '/login'
}
/** 登录回跳目标是否可信且可访问(防开放重定向与回跳受限页) */
export function canAccessPath(router: Router, path: string, roles: string[]): boolean {
if (!path.startsWith('/') || path.startsWith('//')) return false
const record = matchedRecord(router, path)
return Boolean(record && isMenuRecord(record) && canAccessRoute(record, roles))
}
+8 -4
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { ElMessage } from 'element-plus'
import { firstAccessiblePath } from '@/router/access'
import { useUserStore } from '@/store/user'
import { ROLE_ADMIN } from '@/utils/constants'
@@ -265,14 +266,17 @@ router.beforeEach(async (to) => {
}
if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN)) {
ElMessage.error('需要管理员权限')
return { path: '/dashboard' }
const home = firstAccessiblePath(router, userStore.roles)
// 根路径/catch-all 的重定向不提示;用户手输受限 URL 才提示
if (!to.redirectedFrom) ElMessage.error('需要管理员权限')
return home === to.path ? { path: '/login' } : { path: home }
}
// 职能页面(审批等):任一角色命中即可进;菜单隐藏之外的第二道防线
if (to.meta.allowedRoles && !to.meta.allowedRoles.some((role) => userStore.roles.includes(role))) {
ElMessage.error('无权访问该页面')
return { path: '/dashboard' }
const home = firstAccessiblePath(router, userStore.roles)
if (!to.redirectedFrom) ElMessage.error('无权访问该页面')
return home === to.path ? { path: '/login' } : { path: home }
}
return true
+15 -4
View File
@@ -42,6 +42,8 @@ const ORDER_TYPE_LABELS: Record<number, string> = {
const month = ref(currentMonth())
const data = ref<DashboardData | null>(null)
const loading = ref(false)
/** 快速切换月份时,只允许最新请求写入数据与 loading */
let loadSeq = 0
const perfChartRef = ref<HTMLDivElement | null>(null)
const receiptChartRef = ref<HTMLDivElement | null>(null)
@@ -54,15 +56,18 @@ function currentMonth(): string {
}
async function load(): Promise<void> {
const seq = ++loadSeq
loading.value = true
try {
data.value = await dashboardApi.getDashboard({ month: month.value })
const result = await dashboardApi.getDashboard({ month: month.value })
if (seq !== loadSeq) return // 已有更新的请求,丢弃过期响应
data.value = result
await nextTick()
renderCharts()
} catch (error) {
handledError(error)
if (seq === loadSeq) handledError(error)
} finally {
loading.value = false
if (seq === loadSeq) loading.value = false
}
}
@@ -294,7 +299,7 @@ function formatTime(value: string): string {
<div class="bento-card b-2x2">
<div class="b-eyebrow">
风险预警
<span class="b-count">{{ data?.warnings.items.length ?? 0 }}</span>
<span class="b-count">{{ data?.warnings.total ?? 0 }}</span>
</div>
<div v-if="data && !data.warnings.enabled" class="b-empty-mini">
预警已关闭(参数 warning.enabled=0)
@@ -322,6 +327,12 @@ function formatTime(value: string): string {
暂无预警(回款逾期 {{ data?.warnings.receipt_overdue_days ?? 90 }} 天 /
审批超时 {{ data?.warnings.approval_timeout_days ?? 3 }} 天)
</div>
<div
v-if="data && data.warnings.total > data.warnings.items.length"
class="b-empty-mini"
>
仅显示前 {{ data.warnings.items.length }} 条,共 {{ data.warnings.total }} 条
</div>
<div class="b-eyebrow b-eyebrow-gap">同步异常预警</div>
<div class="b-empty-mini">
{{ data?.warnings.sync_available ? '已接入' : '同步模块未启用(待 T+ 对接)' }}
+9 -1
View File
@@ -4,6 +4,7 @@ import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getCaptcha } from '@/api/auth'
import { canAccessPath, firstAccessiblePath } from '@/router/access'
import { useUserStore } from '@/store/user'
const router = useRouter()
@@ -87,8 +88,15 @@ async function handleSubmit(): Promise<void> {
captcha_id: captchaId.value,
captcha_code: form.captcha_code.trim(),
})
// 先拉角色再解析落地页:普通用户不能回跳到受限的工作台
await userStore.fetchUserInfo()
ElMessage.success('登录成功')
await router.replace((route.query.redirect as string) || '/dashboard')
const redirect = route.query.redirect
const target =
typeof redirect === 'string' && canAccessPath(router, redirect, userStore.roles)
? redirect
: firstAccessiblePath(router, userStore.roles)
await router.replace(target)
} catch {
// **必须刷新验证码**:后端用 GETDEL 消费验证码,无论何种失败它都已失效。
// 不刷新的话,用户第二次提交必然报「验证码错误」,且极难自查原因。