feat(bx-mobile): render home entries from portal-filtered menu catalog

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-09 16:08:09 +08:00
1 parent a0eebfdbd3
commit 8eb0ad14e7
1 file changed
+54 -132
+54 -132
View File
@@ -18,7 +18,7 @@
</div>
<!-- Todo card(置于功能菜单上方) -->
<div class="home-panels" style="margin-top:12px;display:flex;flex-direction:column;gap:10px">
<div class="home-panels" style="margin-top: 12px; display: flex; flex-direction: column; gap: 10px">
<div class="todo-card">
<div v-if="todoItems.length" class="todo-head todo-head--badge-only">
<span class="badge">{{ todoItems.length }}</span>
@@ -29,7 +29,13 @@
<div class="name">{{ item.title }}</div>
<div class="meta">{{ item.subText }} · {{ item.timeText }}</div>
</div>
<button type="button" class="btn btn-primary btn-sm" @click="router.push(item.primaryRoute)">去处理</button>
<button
type="button"
class="btn btn-primary btn-sm"
@click="router.push(item.primaryRoute)"
>
去处理
</button>
</div>
</template>
<div v-else class="empty-state empty-state--embed">
@@ -39,27 +45,27 @@
</div>
</div>
<!-- Menu groups(无子项则不展示该分组;「首页」不展示一级标题,其子项仍显示) -->
<!-- 门户 menuCode 过滤后的入口目录 -->
<div v-for="group in groupsWithItems" :key="group.id">
<div v-if="(group.menuName || '').trim() !== '首页'" class="section-title">{{ group.menuName }}</div>
<div class="section-title">{{ group.menuName }}</div>
<div class="menu-grid">
<div
v-for="item in getItems(group)"
:key="item.id"
v-for="item in group.items"
:key="item.menuCode"
class="menu-item"
@click="navigateTo(item.menuCode)"
@click="navigateTo(item.path)"
>
<div
class="icon"
:style="{
background: getColor(item.menuCode) + '18',
color: getColor(item.menuCode),
background: item.color + '18',
color: item.color,
}"
>
<van-icon :name="getIcon(item.menuCode)" size="22" />
<van-icon :name="item.icon" size="22" />
</div>
<span v-if="hasDot(item.menuCode)" class="msg-dot"></span>
<div class="txt">{{ menuDisplayName(item) }}</div>
<div class="txt">{{ item.title }}</div>
</div>
</div>
</div>
@@ -70,7 +76,7 @@
</div>
</div>
<div style="height: 20px;"></div>
<div style="height: 20px"></div>
</div>
</template>
@@ -79,13 +85,13 @@ import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { PRIMARY_CONFIRM_BUTTON_COLOR } from '@/constants/dialog'
import { useAuthStore, type MenuItem } from '@/stores/auth'
import { useAuthStore } from '@/stores/auth'
import type { BxMenuEntry } from '@/constants/bxMenuCatalog'
import { advanceApi, reimburseApi } from '@/api'
const router = useRouter()
const auth = useAuthStore()
/* ───── Reactive Stats ───── */
const stats = ref({
submittedThisMonth: 0,
pendingCount: 0,
@@ -97,7 +103,7 @@ const stats = ref({
const pendingApprovalCounts = ref<Record<string, number>>({})
interface TodoItem {
id: number
id: string
title: string
subText: string
statusText: string
@@ -111,13 +117,12 @@ interface TodoItem {
const todoItems = ref<TodoItem[]>([])
onMounted(async () => {
if (!auth.menuList.length) {
await auth.fetchMenus()
if (!auth.user) {
await auth.fetchUser()
}
await fetchHomeData()
})
/* ───── Fetch & Compute Stats ───── */
function pagedRows(res: any): any[] {
const d = res?.data
if (!d) return []
@@ -138,11 +143,15 @@ async function fetchHomeData() {
const approvedCount =
Number(r.approvedCount ?? 0) + Number(a.approvedPassCount ?? 0) + Number(a.issuedCount ?? 0)
const totalCount = Number(r.totalCount ?? 0) + Number(a.totalCount ?? 0)
const totalAmount = 0
stats.value = { submittedThisMonth, pendingCount, approvedCount, totalAmount, totalCount }
stats.value = {
submittedThisMonth,
pendingCount,
approvedCount,
totalAmount: 0,
totalCount,
}
// Fetch pending approvals for todo area & red dots
const [pendingAdvanceRes, pendingReimburseRes] = await Promise.all([
advanceApi.pendingApproval({ page: 1, pageSize: 5 }).catch(() => null),
reimburseApi.pendingApproval({ page: 1, pageSize: 5 }).catch(() => null),
@@ -154,24 +163,22 @@ async function fetchHomeData() {
const pendingReimburseTotal = (pendingReimburseRes as any)?.data?.total || 0
pendingApprovalCounts.value = {
'advance-approve': pendingAdvanceTotal,
'reimburse-approve': pendingReimburseTotal,
todo: pendingAdvanceTotal + pendingReimburseTotal,
mine: pendingCount,
'bx:advanceApprove:list': pendingAdvanceTotal,
'bx:reimburseApprove:list': pendingReimburseTotal,
'bx:viewTodo:list': pendingAdvanceTotal + pendingReimburseTotal,
}
// Build todo items
const items: TodoItem[] = []
for (const item of pendingAdvanceItems) {
items.push({
id: item.id,
id: String(item.id),
title: item.title || '备用金单待审批',
subText: [item.title, item.applicantName, item.departmentName].filter(Boolean).join(' · '),
statusText: '待处理',
statusVariant: 'warning',
amountText: `金额 ¥${Number(item.amount || 0).toFixed(2)}`,
timeText: formatTime(item.createdAt),
timeText: formatTime(item.createTime || item.createdAt),
primaryRoute: '/approval',
secondaryRoute: '/approval',
})
@@ -179,13 +186,13 @@ async function fetchHomeData() {
for (const item of pendingReimburseItems) {
items.push({
id: item.id,
id: String(item.id),
title: item.title || '报销申请待审批',
subText: [item.title, item.applicantName, item.departmentName].filter(Boolean).join(' · '),
statusText: '待处理',
statusVariant: 'warning',
amountText: `金额 ¥${Number(item.amount || 0).toFixed(2)}`,
timeText: formatTime(item.createdAt),
timeText: formatTime(item.createTime || item.createdAt),
primaryRoute: '/approval',
secondaryRoute: '/approval',
})
@@ -194,7 +201,7 @@ async function fetchHomeData() {
items.sort((a, b) => b.timeText.localeCompare(a.timeText))
todoItems.value = items.slice(0, 5)
} catch {
// Keep default empty state
/* keep empty */
}
}
@@ -211,7 +218,6 @@ function formatTime(dateStr: string): string {
return `${d.getMonth() + 1}/${d.getDate()} ${hh}:${mm}`
}
/* ───── Computed for Template ───── */
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 6) return '凌晨好'
@@ -221,109 +227,26 @@ const greeting = computed(() => {
return '晚上好'
})
/* ───── Menu Logic ───── */
function getItems(group: MenuItem): MenuItem[] {
if (!group.children?.length) return []
const result: MenuItem[] = []
for (const child of group.children) {
if (child.children?.length) {
result.push(...child.children.filter(c => c.menuCode))
} else if (child.menuCode) {
result.push(child)
}
const groupsWithItems = computed(() => {
const map = new Map<string, BxMenuEntry[]>()
for (const e of auth.visibleEntries) {
const list = map.get(e.group) || []
list.push(e)
map.set(e.group, list)
}
return result
}
return [...map.entries()].map(([menuName, items]) => ({
id: menuName,
menuName,
items,
}))
})
/** 仅包含有可展示子菜单卡片的一级分组 */
const groupsWithItems = computed(() => (auth.menuList || []).filter((g) => getItems(g).length > 0))
/** 与列表顶栏命名统一;后端菜单名可能是旧文案(如「我的报销」)时覆盖展示 */
const MOBILE_MENU_DISPLAY: Record<string, string> = {
'reimburse-apply': '报销申请',
'advance-issue': '备用金\n发放管理',
payment: '发放/冲抵\n支付管理',
}
function menuDisplayName(item: MenuItem): string {
const code = item.menuCode || ''
return MOBILE_MENU_DISPLAY[code] ?? item.menuName
}
const ROUTE_MAP: Record<string, string> = {
'advance-apply': '/advance/list',
'advance-approve': '/advance/approve',
'advance-issue': '/advance/issue',
'reimburse-apply': '/reimburse/list',
'reimburse-approve': '/reimburse/approve',
payment: '/payment',
mine: '/mine',
todo: '/approval',
all: '/all',
'user-manage': '/admin/users',
'role-manage': '/admin/roles',
'menu-manage': '/admin/menus',
project: '/admin/expenses',
'wf-advance': '/admin/wf-advance',
'wf-reimburse': '/admin/wf-reimburse',
demo: '/advance/list',
}
function navigateTo(code: string | null) {
if (!code) return
const path = ROUTE_MAP[code]
function navigateTo(path: string) {
if (path) router.push(path)
}
const ICON_MAP: Record<string, string> = {
'advance-apply': 'cash-back-record',
'advance-approve': 'checked',
'advance-issue': 'paid',
'reimburse-apply': 'description',
'reimburse-approve': 'todo-list-o',
payment: 'balance-pay',
mine: 'records',
todo: 'clock-o',
all: 'list-switch',
'user-manage': 'manager-o',
'role-manage': 'shield-o',
'menu-manage': 'apps-o',
project: 'label-o',
'wf-advance': 'exchange',
'wf-reimburse': 'exchange',
demo: 'smile-o',
}
function getIcon(code: string | null): string {
return (code && ICON_MAP[code]) || 'bookmark-o'
}
const COLOR_MAP: Record<string, string> = {
'advance-apply': '#10b981',
'advance-approve': '#3b82f6',
'advance-issue': '#8b5cf6',
'reimburse-apply': '#f59e0b',
'reimburse-approve': '#ef4444',
payment: '#06b6d4',
mine: '#6366f1',
todo: '#f97316',
all: '#64748b',
'user-manage': '#3b82f6',
'role-manage': '#8b5cf6',
'menu-manage': '#10b981',
project: '#f59e0b',
'wf-advance': '#06b6d4',
'wf-reimburse': '#ec4899',
demo: '#94a3b8',
}
function getColor(code: string | null): string {
return (code && COLOR_MAP[code]) || '#64748b'
}
function hasDot(code: string | null): boolean {
if (!code) return false
return (pendingApprovalCounts.value[code] || 0) > 0
function hasDot(menuCode: string): boolean {
return (pendingApprovalCounts.value[menuCode] || 0) > 0
}
async function handleLogout() {
@@ -333,7 +256,7 @@ async function handleLogout() {
message: '确定退出登录?',
confirmButtonColor: PRIMARY_CONFIRM_BUTTON_COLOR,
})
auth.logout()
await auth.logout()
router.push('/login')
} catch {
/* cancelled */
@@ -347,4 +270,3 @@ async function handleLogout() {
color: var(--text-muted);
}
</style>