fix: login landing from menu_access, silent optional Layout 403

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-06-28 23:59:33 +08:00
parent 9e7dba32d2
commit 12506c5d32
7 changed files with 253 additions and 35 deletions

View File

@@ -4,7 +4,8 @@ import request from '@/utils/request'
import {
ensureMenuAccess,
getMenuAccess,
getFirstVisiblePath,
getDefaultLandingPath,
getEffectiveVisiblePaths,
isMenuSessionFresh,
JITUAN_API,
} from '@/utils/club-context'
@@ -17,10 +18,13 @@ const routes = [
{
path: '/',
component: Layout,
redirect: () => getFirstVisiblePath() || '/order/platform',
redirect: () => getDefaultLandingPath() || '/welcome',
children: [
{
path: 'welcome',
component: () => import('@/views/Welcome.vue'),
meta: { title: '首页' }
},
// ========== 板块与财务 ==========
{
path: 'finance/bankuai',
@@ -352,18 +356,31 @@ router.beforeEach(async (to, from, next) => {
}
const access = getMenuAccess()
if (!isMenuSessionFresh() || !access?.visible_paths?.length) {
if (!isMenuSessionFresh()) {
next()
return
}
if (to.path === '/welcome') {
next()
return
}
if (!getEffectiveVisiblePaths(access).length) {
if (to.path !== '/welcome') {
next('/welcome')
return
}
next()
return
}
const path = to.path
if (!isRouteAllowed(path, access.visible_paths)) {
const fallback = getFirstVisiblePath()
if (fallback && path !== fallback) {
next(fallback)
return
}
if (!isRouteAllowed(path, getEffectiveVisiblePaths(access))) {
const fallback = getDefaultLandingPath(access) || '/welcome'
next(fallback)
return
}
next()
})

View File

@@ -3,7 +3,7 @@ const CLUB_SCOPE_KEY = 'admin_club_scope';
const CLUB_CONTEXT_KEY = 'admin_club_context';
const MENU_ACCESS_KEY = 'kefu_menu_access';
const MENU_SESSION_KEY = 'kefu_menu_session_v';
const MENU_SESSION_VERSION = '8';
const MENU_SESSION_VERSION = '11';
const DEFAULT_CLUB_ID = 'xq';
let menuAccessInflight = null;
@@ -82,24 +82,145 @@ export function isMenuSessionFresh() {
export function shouldShowAllMenus(access) {
if (!isMenuSessionFresh()) return true;
if (!access) return true;
if (access.menu_ready === false) return true;
if (!Array.isArray(access.visible_page_ids) || !Array.isArray(access.visible_paths)) return true;
return false;
if (Array.isArray(access.visible_page_ids) && access.visible_page_ids.length > 0) {
return false;
}
if (Array.isArray(access.visible_paths) && access.visible_paths.length > 0) {
return false;
}
if (Array.isArray(access.permission_codes) && access.permission_codes.length > 0) {
return false;
}
return true;
}
/** ??? kefu_menu_definitions ???visible_paths ???? permission_codes ?? */
const KEFU_MENU_ROWS = [
{ page_id: 'finance.bankuai', parent_id: 'finance', path: '/finance/bankuai', sort_order: 11, perm_codes: ['bankuai'] },
{ page_id: 'finance.data', parent_id: 'finance', path: '/finance/data', sort_order: 12, perm_codes: ['caiwu'] },
{ page_id: 'assessment.examiner', parent_id: 'assessment', path: '/assessment/examiner', sort_order: 21, perm_codes: ['kaohepeizhi'] },
{ page_id: 'assessment.config', parent_id: 'assessment', path: '/assessment/config', sort_order: 22, perm_codes: ['kaohepeizhi'] },
{ page_id: 'assessment.record', parent_id: 'assessment', path: '/assessment/record', sort_order: 23, perm_codes: ['kaohepeizhi'] },
{ page_id: 'order.platform', parent_id: 'order', path: '/order/platform', sort_order: 31, perm_codes: ['002ab'] },
{ page_id: 'order.merchant', parent_id: 'order', path: '/order/merchant', sort_order: 32, perm_codes: ['002ac'] },
{ page_id: 'cross-platform.order', parent_id: 'cross-platform', path: '/cross-order', sort_order: 41, perm_codes: ['003aa'] },
{ page_id: 'cross-platform.settings', parent_id: 'cross-platform', path: '/cross-settings', sort_order: 42, perm_codes: ['003aa'] },
{ page_id: 'cross-platform.data', parent_id: 'cross-platform', path: '/cross-data', sort_order: 43, perm_codes: ['003aa'] },
{ page_id: 'cross-platform.pre', parent_id: 'cross-platform', path: '/cross-pre', sort_order: 44, perm_codes: ['003aa'] },
{ page_id: 'user.player', parent_id: 'user', path: '/user/player', sort_order: 51, perm_codes: ['001aa', '001bb', '001cc', '001dd', '001ee', '001ff', '001gg'] },
{ page_id: 'user.guanli', parent_id: 'user', path: '/user/guanli', sort_order: 52, perm_codes: ['4400a', '4400b', '4400c', '4400d', '4400e', '4400f'] },
{ page_id: 'user.shangjia', parent_id: 'user', path: '/user/shangjia', sort_order: 53, perm_codes: ['1100a', '1100b'] },
{ page_id: 'user.zuzhang', parent_id: 'user', path: '/user/zuzhang', sort_order: 54, perm_codes: ['6600a', '6600b', '6600c', '6600d', '6600e'] },
{ page_id: 'user.identity-tag', parent_id: 'user', path: '/user/identity-tag', sort_order: 55, perm_codes: ['8080a', 'sfbq666'] },
{ page_id: 'withdraw.audit', parent_id: 'withdraw', path: '/withdraw/audit', sort_order: 61, perm_codes: ['005bb'] },
{ page_id: 'withdraw.data', parent_id: 'withdraw', path: '/withdraw/data', sort_order: 63, perm_codes: ['005bb'] },
{ page_id: 'withdraw.settings', parent_id: 'withdraw', path: '/withdraw/settings', sort_order: 64, perm_codes: ['5500a', '5500b', '5500c'] },
{ page_id: 'punishment', parent_id: '', path: '/punishment', sort_order: 80, perm_codes: ['005aa', '66693a', '66693b', '66693c', '66694c', '99933abs'] },
{ page_id: 'product.list', parent_id: 'product', path: '/product/list', sort_order: 91, perm_codes: ['2200a'] },
{ page_id: 'product.type-zone', parent_id: 'product', path: '/product/type-zone', sort_order: 92, perm_codes: ['7007a'] },
{ page_id: 'product.data', parent_id: 'product', path: '/product/data', sort_order: 93, perm_codes: ['2200a'] },
{ page_id: 'member.list', parent_id: 'member', path: '/member/list', sort_order: 101, perm_codes: ['3300a'] },
{ page_id: 'member.data', parent_id: 'member', path: '/member/data', sort_order: 102, perm_codes: ['huiyuancaiwu'] },
{ page_id: 'miniapp.carousel', parent_id: 'miniapp', path: '/miniapp/carousel', sort_order: 111, perm_codes: ['8080a'] },
{ page_id: 'miniapp.assets', parent_id: 'miniapp', path: '/miniapp/assets', sort_order: 112, perm_codes: ['8080a'] },
{ page_id: 'miniapp.popup', parent_id: 'miniapp', path: '/miniapp/popup-notice', sort_order: 113, perm_codes: ['8080a'] },
{ page_id: 'miniapp.leixing', parent_id: 'miniapp', path: '/miniapp/leixing', sort_order: 115, perm_codes: ['8080a'] },
{ page_id: 'miniapp.kaohe-tags', parent_id: 'miniapp', path: '/miniapp/kaohe-tags', sort_order: 116, perm_codes: ['8080a'] },
{ page_id: 'miniapp.dashou-exam', parent_id: 'miniapp', path: '/miniapp/dashou-exam', sort_order: 117, perm_codes: ['dsks666', '8080a'] },
{ page_id: 'shop.list', parent_id: 'shop', path: '/shop/list', sort_order: 121, perm_codes: ['999a', '999b', '999c', '999d', '999e'] },
{ page_id: 'shop.withdraw', parent_id: 'shop', path: '/shop/withdraw-apply', sort_order: 122, perm_codes: ['999a', '999b', '999c', '999d', '999e'] },
{ page_id: 'shop.products', parent_id: 'shop', path: '/shop/products', sort_order: 123, perm_codes: ['999a', '999b', '999c', '999d', '999e'] },
{ page_id: 'chat.agent', parent_id: '', path: '/chat/agent', sort_order: 130, perm_codes: ['abca1', 'baac2', 'cabc3', 'cb3a2', 'bcaa4'] },
];
const KEFU_MENU_PARENT_IDS = {
finance: ['finance.bankuai', 'finance.data'],
assessment: ['assessment.examiner', 'assessment.config', 'assessment.record'],
order: ['order.platform', 'order.merchant'],
'cross-platform': ['cross-platform.order', 'cross-platform.settings', 'cross-platform.data', 'cross-platform.pre'],
user: ['user.player', 'user.guanli', 'user.shangjia', 'user.zuzhang', 'user.identity-tag'],
withdraw: ['withdraw.audit', 'withdraw.data', 'withdraw.settings'],
product: ['product.list', 'product.type-zone', 'product.data'],
member: ['member.list', 'member.data'],
miniapp: ['miniapp.carousel', 'miniapp.assets', 'miniapp.popup', 'miniapp.leixing', 'miniapp.kaohe-tags', 'miniapp.dashou-exam', 'miniapp.withdraw-settings'],
shop: ['shop.list', 'shop.withdraw', 'shop.products'],
};
function pathsFromPermissionCodes(codes) {
if (!codes?.length) return [];
const seen = new Set();
const paths = [];
for (const row of KEFU_MENU_ROWS) {
if (!row.path) continue;
const perms = row.perm_codes || [];
if (!perms.some((p) => codes.includes(p))) continue;
if (seen.has(row.path)) continue;
seen.add(row.path);
paths.push(row.path);
}
return paths;
}
function pageAllowedByCodes(pageId, codes) {
const row = KEFU_MENU_ROWS.find((r) => r.page_id === pageId);
if (row?.perm_codes?.some((p) => codes.includes(p))) return true;
const children = KEFU_MENU_PARENT_IDS[pageId];
if (!children) return false;
return children.some((childId) => pageAllowedByCodes(childId, codes));
}
export function getEffectiveVisiblePaths(access = null) {
const a = access || getMenuAccess();
const paths = a?.visible_paths;
if (Array.isArray(paths) && paths.length > 0) {
return paths;
}
return pathsFromPermissionCodes(a?.permission_codes || []);
}
export function canShowMenuPage(pageId) {
const access = getMenuAccess();
if (shouldShowAllMenus(access)) return true;
if (access.visible_page_ids.length === 0) return false;
return access.visible_page_ids.includes(pageId);
const ids = access?.visible_page_ids;
if (Array.isArray(ids) && ids.length > 0) {
if (ids.includes(pageId)) return true;
const children = KEFU_MENU_PARENT_IDS[pageId];
return children ? children.some((id) => ids.includes(id)) : false;
}
const codes = access?.permission_codes || [];
if (!codes.length) return false;
return pageAllowedByCodes(pageId, codes);
}
export function getFirstVisiblePath() {
const access = getMenuAccess();
if (!access || !Array.isArray(access.visible_paths) || access.visible_paths.length === 0) {
return null;
const paths = getEffectiveVisiblePaths();
return paths.length > 0 ? paths[0] : null;
}
export function getDefaultLandingPath(access = null) {
const paths = getEffectiveVisiblePaths(access);
return paths.length > 0 ? paths[0] : null;
}
/** menu_ready=false: server seed missing; empty codes: role not bound */
export function formatNoMenuAccessMessage(access) {
const codes = access?.permission_codes || [];
const roles = access?.role_names || [];
const uid = access?.yonghuid || '';
const idHint = uid ? ` UserID=${uid}.` : '';
if (access?.menu_ready === false && access?.menu_source === 'db') {
return `Menu table empty.${idHint} Server: python manage.py migrate jituan && python manage.py seed_kefu_menu`;
}
return access.visible_paths[0];
if (!codes.length) {
if (roles.length) {
return `No permissions.${idHint} Roles: [${roles.join(', ')}]. Add perm kaohepeizhi to these roles in Role Mgmt.`;
}
return `No role on account.${idHint} Admin Users -> open THIS user (check UserID) -> Add Role -> Save -> re-login.`;
}
if (!getEffectiveVisiblePaths(access).length) {
return `Perms: [${codes.join(', ')}].${idHint} No menu path from server.`;
}
return `Perms: [${codes.join(', ')}].${idHint} Contact admin.`;
}
export function canSwitchClubByMenu() {

View File

@@ -39,7 +39,9 @@ service.interceptors.response.use(
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
} else if (status === 403) {
ElMessage.error('没有权限')
if (!error.config?.skipErrorToast) {
ElMessage.error('没有权限')
}
} else {
ElMessage.error(error.response.data?.message || '请求失败')
}

View File

@@ -256,18 +256,13 @@ import {
setMenuAccess,
getMenuAccess,
canSwitchClubByMenu,
shouldShowAllMenus,
ensureMenuAccess,
canShowMenuPage,
} from '@/utils/club-context'
const menuAccessState = ref(getMenuAccess())
const canShow = (pageId) => {
const access = menuAccessState.value ?? getMenuAccess()
if (shouldShowAllMenus(access)) return true
if (access.visible_page_ids.length === 0) return false
return access.visible_page_ids.includes(pageId)
}
const canShow = (pageId) => canShowMenuPage(pageId)
const { proxy } = getCurrentInstance()
const route = useRoute()
@@ -327,7 +322,7 @@ const loadMenuAccess = async () => {
const loadClubContext = async () => {
try {
const res = await request.get(JITUAN_API.meContext)
const res = await request.get(JITUAN_API.meContext, { skipErrorToast: true })
if (res.code === 0 && res.data) {
mergeServerClubContext(res.data)
clubOptions.value = res.data.clubs || []
@@ -386,7 +381,7 @@ const fetchStats = async () => {
try {
const res = await request.post('/yonghu/kfjrhqzl', {
phone: userPhone.value
}, { signal: abortController.signal })
}, { signal: abortController.signal, skipErrorToast: true })
if (res.code === 0) {
stats.value = res.data
}
@@ -427,7 +422,7 @@ const fetchKefuPermission = async () => {
try {
const res = await request.post('/houtai/kfhqltqx', {
phone: userPhone.value
})
}, { skipErrorToast: true })
if (res.code === 0) {
const { permissions, goeasy_appkey } = res.data
kefuPermissions.value = permissions || []

View File

@@ -65,7 +65,7 @@ import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock, Key } from '@element-plus/icons-vue'
import axios from 'axios' // 直接使用 axios不经过封装的 request
import { JITUAN_API, setAdminClubContext, setMenuAccess } from '@/utils/club-context'
import { JITUAN_API, getDefaultLandingPath, setAdminClubContext, setMenuAccess } from '@/utils/club-context'
const router = useRouter()
const loading = ref(false)
@@ -123,9 +123,10 @@ const handleLogin = async () => {
} else if (res.data.menu_access) {
setMenuAccess(res.data.menu_access)
}
const ma = res.data.menu_access || {}
const landing = getDefaultLandingPath(ma)
ElMessage.success('登录成功')
const firstPath = res.data.menu_access?.visible_paths?.[0]
router.push(firstPath || '/order/platform')
router.push(landing || '/welcome')
} else if (res.code === 4) {
// 账号被封禁
ElMessage.error('账号已被封禁')

75
src/views/Welcome.vue Normal file
View File

@@ -0,0 +1,75 @@
<template>
<div class="welcome-page">
<el-result icon="warning" title="暂无可用功能菜单">
<template #sub-title>
<p v-if="yonghuid">用户ID{{ yonghuid }}</p>
<p v-if="roleNames.length">已绑角色{{ roleNames.join('') }}角色内可能未含功能权限码</p>
<p v-else>该账号未绑定任何角色</p>
<p class="hint">请超管在后台用户中为该用户ID绑定角色角色内需含对应权限考核=kaohepeizhi</p>
</template>
<template #extra>
<el-button type="primary" @click="goTry">刷新权限</el-button>
<el-button @click="logout">退出登录</el-button>
</template>
</el-result>
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import {
clearMenuAccess,
getDefaultLandingPath,
getMenuAccess,
JITUAN_API,
setMenuAccess,
} from '@/utils/club-context'
const router = useRouter()
const access = computed(() => getMenuAccess() || {})
const yonghuid = computed(() => access.value.yonghuid || '')
const roleNames = computed(() => access.value.role_names || [])
const goTry = async () => {
try {
const res = await request.get(JITUAN_API.menuAccess)
if (res.code === 0 && res.data) {
setMenuAccess(res.data)
const landing = getDefaultLandingPath(res.data)
if (landing) {
ElMessage.success('权限已生效')
router.replace(landing)
return
}
}
ElMessage.warning('仍无可用菜单,请确认角色与权限已绑定')
} catch {
ElMessage.error('刷新失败')
}
}
const logout = () => {
localStorage.removeItem('token')
clearMenuAccess()
router.replace('/login')
}
onMounted(() => {
const landing = getDefaultLandingPath()
if (landing) router.replace(landing)
})
</script>
<style scoped>
.welcome-page {
padding: 48px 24px;
}
.hint {
margin-top: 8px;
color: #909399;
font-size: 14px;
}
</style>

View File

@@ -51,6 +51,7 @@
<!-- 用户表格 -->
<el-table :data="userList" v-loading="loading" border class="user-table">
<el-table-column prop="phone" label="账号" width="140" />
<el-table-column prop="yonghuid" label="用户ID" width="100" />
<el-table-column prop="nicheng" label="昵称" width="150" />
<el-table-column label="角色" min-width="180">
<template #default="{ row }">
@@ -103,6 +104,9 @@
<div class="info-section">
<div class="section-title">基本信息</div>
<el-form :model="editForm" label-width="100px">
<el-form-item label="用户ID">
<span>{{ currentUser?.yonghuid }}</span>
</el-form-item>
<el-form-item label="账号">
<span>{{ currentUser?.phone }}</span>
</el-form-item>
@@ -386,6 +390,7 @@ const saveUserInfo = async () => {
const payload = {
username,
action: 'update_user_info',
yonghuid: currentUser.value.yonghuid,
phone: currentUser.value.phone,
nicheng: editForm.value.nicheng,
status: editForm.value.status
@@ -426,6 +431,7 @@ const confirmAddUserRoles = async () => {
const res = await request.post('/houtai/xghtyhsj', {
username,
action: 'add_user_roles',
yonghuid: currentUser.value.yonghuid,
phone: currentUser.value.phone,
role_codes: selectedRoleCodes.value
})
@@ -452,6 +458,7 @@ const removeUserRole = async (role) => {
const res = await request.post('/houtai/xghtyhsj', {
username,
action: 'remove_user_role',
yonghuid: currentUser.value.yonghuid,
phone: currentUser.value.phone,
role_code: role.role_code
})