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:
1 parent
ebb3a92f25
commit
39a3fa7796
16 files changed
+590
-59
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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+ 对接)' }}
|
||||
|
||||
@@ -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 消费验证码,无论何种失败它都已失效。
|
||||
// 不刷新的话,用户第二次提交必然报「验证码错误」,且极难自查原因。
|
||||
|
||||
Reference in new issue
Block a user