R64 Fix:Portal 渲染崩溃修复

这个提交包含在:
2026-04-15 20:58:18 +08:00
父节点 7ac0de660c
当前提交 bc6e4955b3
共修改 4 个文件,包含 136 行新增和 63 行删除
+115 -55
查看文件
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -19,6 +19,7 @@ import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, isManagerRole } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { ErrorBoundary } from "../components/common/ErrorBoundary";
import { EmptyStateCard } from "../components/common/EmptyStateCard";
import { WelcomeDialog } from "../components/onboarding/WelcomeDialog";
import { MetricCard } from "../components/patterns/MetricCard";
@@ -170,6 +171,7 @@ function formatWorkMinutes(value: number | null | undefined) {
}
function buildTodoAggregateItems(payload: PortalHomePayload) {
const cards = Array.isArray(payload.cards) ? payload.cards : [];
const items: TodoAggregateItem[] = [];
if (payload.pendingTasks.count > 0) {
items.push({
@@ -189,7 +191,7 @@ function buildTodoAggregateItems(payload: PortalHomePayload) {
icon: "check_circle",
});
}
for (const card of payload.cards) {
for (const card of cards) {
if (!card.severity || (card.severity !== "warning" && card.severity !== "high")) {
continue;
}
@@ -376,6 +378,8 @@ function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] })
}
function AnnouncementsPanel({ announcements }: { announcements: PortalHomePayload["announcements"] }) {
const safeAnnouncements = Array.isArray(announcements) ? announcements : [];
return (
<SectionPanel
title="公告"
@@ -383,7 +387,7 @@ function AnnouncementsPanel({ announcements }: { announcements: PortalHomePayloa
actions={<Link to="/announcements" className="text-sm font-medium text-primary hover:underline">查看全部</Link>}
>
<div className="space-y-3">
{announcements.length ? announcements.slice(0, 4).map((item) => (
{safeAnnouncements.length ? safeAnnouncements.slice(0, 4).map((item) => (
<div key={item.id} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="mt-2 text-sm leading-6 text-zinc-600">{item.summary}</div>
@@ -417,10 +421,12 @@ function RecentVisitsPanel() {
}
function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] }) {
const safeItems = Array.isArray(items) ? items : [];
return (
<SectionPanel title="常用入口" description="只保留高频入口,避免把所有模块堆成超市。">
<div className="grid gap-3 sm:grid-cols-2">
{items.length ? items.map((item) => (
{safeItems.length ? safeItems.map((item) => (
<Link key={item.key} to={item.href} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm font-medium text-zinc-700 hover:bg-zinc-50">
{item.title}
</Link>
@@ -497,6 +503,20 @@ function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullabl
);
}
function PortalSectionFallback({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<SectionPanel title={title} description={description}>
<EmptyStateCard title={`${title}暂时不可用`} description="该区域渲染失败,请刷新页面后重试。" />
</SectionPanel>
);
}
function GettingStartedCard({
items,
completedCount,
@@ -506,7 +526,7 @@ function GettingStartedCard({
completedCount: number;
onDismiss: () => void;
}) {
const progressPercent = Math.round((completedCount / items.length) * 100);
const progressPercent = items.length ? Math.round((completedCount / items.length) * 100) : 0;
return (
<SectionPanel
@@ -660,10 +680,12 @@ function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
}
function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) {
const safeItems = Array.isArray(items) ? items : [];
return (
<SectionPanel title="团队全员状态" description="同时看人在不在岗、有没有请假,以及今天是否已打卡。">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{items.length ? items.map((item) => {
{safeItems.length ? safeItems.map((item) => {
const badge = resolveTeamStatusBadge(item);
return (
<div key={item.employeeId} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
@@ -684,10 +706,12 @@ function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) {
}
function RecentActivitiesPanel({ items }: { items: PortalRecentActivity[] }) {
const safeItems = Array.isArray(items) ? items : [];
return (
<SectionPanel title="近期动态流" description="把最近发生的报销、请假、考勤和资产动作放在同一条时间线上。">
<div className="space-y-3">
{items.length ? items.map((item) => (
{safeItems.length ? safeItems.map((item) => (
<Link key={item.id} to={item.href} className="block rounded-2xl border border-zinc-200 bg-white px-4 py-4 hover:bg-zinc-50">
<div className="flex items-start gap-3">
<EmployeeAvatar name={item.actorName} size="sm" />
@@ -722,6 +746,38 @@ export function PortalHomePage() {
setShowGettingStarted(shouldShowGettingStartedCard());
}, []);
const gettingStartedItems: GettingStartedChecklistItem[] = [
{
key: "profile",
title: "完善个人资料",
description: "补充紧急联系人和银行账户",
href: "/settings/profile",
completed: (me?.profileCompleteness?.score ?? 0) >= 50,
},
{
key: "notification-preferences",
title: "设置通知偏好",
description: "选择是否接收外部渠道推送",
href: "/settings/profile",
completed: true,
},
{
key: "leave-flow",
title: "了解请假流程",
description: "查看假期类型和余额",
href: "/leave-requests",
completed: false,
},
{
key: "notifications",
title: "查看通知中心",
description: "了解审批和业务消息",
href: "/notifications",
completed: false,
},
];
const gettingStartedCompletedCount = gettingStartedItems.filter((item) => item.completed).length;
const attendanceClockInMutation = useMutation({
mutationFn: () => clockInAttendance(),
onSuccess: async (response) => {
@@ -770,10 +826,16 @@ export function PortalHomePage() {
}
const payload = portalQuery.data;
const cards = Array.isArray(payload.cards) ? payload.cards : [];
const applicationItems = Array.isArray(payload.myApplications) ? payload.myApplications : [];
const announcementItems = Array.isArray(payload.announcements) ? payload.announcements : [];
const quickLinkItems = Array.isArray(payload.quickLinks) ? payload.quickLinks : [];
const recentActivities = Array.isArray(payload.recentActivities) ? payload.recentActivities : [];
const teamStatusBoard = Array.isArray(payload.teamStatusBoard) ? payload.teamStatusBoard : [];
const portalSchema = isManagerView ? managerPortalSchema : employeePortalSchema;
const primaryCards = pickCards(payload.cards, portalSchema.heroCards);
const primaryCards = pickCards(cards, portalSchema.heroCards);
const primaryCardKeySet = primaryCards.map((card) => card.key);
const secondaryCards = compactCards(payload.cards, primaryCardKeySet);
const secondaryCards = compactCards(cards, primaryCardKeySet);
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
const attendanceToday = attendanceTodayQuery.data ?? null;
const clockAction = resolveClockAction(attendanceToday, {
@@ -782,38 +844,7 @@ export function PortalHomePage() {
featureDisabled: attendanceFeatureDisabled,
pending: attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending,
});
const todoAggregateItems = buildTodoAggregateItems(payload);
const gettingStartedItems = useMemo<GettingStartedChecklistItem[]>(() => ([
{
key: "profile",
title: "完善个人资料",
description: "补充紧急联系人和银行账户",
href: "/settings/profile",
completed: (me?.profileCompleteness.score ?? 0) >= 50,
},
{
key: "notification-preferences",
title: "设置通知偏好",
description: "选择是否接收外部渠道推送",
href: "/settings/profile",
completed: true,
},
{
key: "leave-flow",
title: "了解请假流程",
description: "查看假期类型和余额",
href: "/leave-requests",
completed: false,
},
{
key: "notifications",
title: "查看通知中心",
description: "了解审批和业务消息",
href: "/notifications",
completed: false,
},
]), [me?.profileCompleteness.score]);
const gettingStartedCompletedCount = gettingStartedItems.filter((item) => item.completed).length;
const todoAggregateItems = buildTodoAggregateItems({ ...payload, cards });
function handleCloseWelcome() {
clearWelcomeDialogFlag();
@@ -830,13 +861,13 @@ export function PortalHomePage() {
key: "expense",
href: "/reimbursements",
label: "我的报销",
value: `${payload.myApplications.find((item) => item.domain === "reimbursement")?.count ?? 0} 条`,
value: `${applicationItems.find((item) => item.domain === "reimbursement")?.count ?? 0} 条`,
},
{
key: "leave",
href: "/leave-requests",
label: "我的请假",
value: `${payload.myApplications.find((item) => item.domain === "leave")?.count ?? 0} 条`,
value: `${applicationItems.find((item) => item.domain === "leave")?.count ?? 0} 条`,
},
{
key: "assets",
@@ -922,7 +953,9 @@ export function PortalHomePage() {
return (
<PageScaffold>
<WelcomeDialog open={showWelcome} onClose={handleCloseWelcome} />
<ErrorBoundary fallback={null}>
<WelcomeDialog open={showWelcome} onClose={handleCloseWelcome} />
</ErrorBoundary>
<WorkbenchHero
eyebrow={isManagerView ? "管理工作台" : "个人工作台"}
@@ -960,11 +993,20 @@ export function PortalHomePage() {
</div>
{showGettingStarted ? (
<GettingStartedCard
items={gettingStartedItems}
completedCount={gettingStartedCompletedCount}
onDismiss={handleDismissGettingStarted}
/>
<ErrorBoundary
fallback={(
<PortalSectionFallback
title="快速上手"
description="先完成基础资料和入口熟悉,再进入请假、通知等日常动作。"
/>
)}
>
<GettingStartedCard
items={gettingStartedItems}
completedCount={gettingStartedCompletedCount}
onDismiss={handleDismissGettingStarted}
/>
</ErrorBoundary>
) : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
@@ -973,19 +1015,37 @@ export function PortalHomePage() {
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr),minmax(0,0.85fr)]">
<TeamStatusBoardPanel items={payload.teamStatusBoard} />
<RecentActivitiesPanel items={payload.recentActivities} />
<ErrorBoundary
fallback={(
<PortalSectionFallback
title="团队全员状态"
description="同时看人在不在岗、有没有请假,以及今天是否已打卡。"
/>
)}
>
<TeamStatusBoardPanel items={teamStatusBoard} />
</ErrorBoundary>
<ErrorBoundary
fallback={(
<PortalSectionFallback
title="近期动态流"
description="把最近发生的报销、请假、考勤和资产动作放在同一条时间线上。"
/>
)}
>
<RecentActivitiesPanel items={recentActivities} />
</ErrorBoundary>
</div>
{isManagerView ? (
<>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(0,0.95fr)]">
<SummaryPanel title="今日管理重点" items={managerSummaryItems} />
<QuickLinksPanel items={payload.quickLinks} />
<QuickLinksPanel items={quickLinkItems} />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,0.95fr),minmax(0,1.05fr)]">
<AnnouncementsPanel announcements={payload.announcements} />
<AnnouncementsPanel announcements={announcementItems} />
<SecondaryCardsPanel
title="项目跟进与组织协同"
description="把项目、组织和风险类信息下移到第二阅读层。"
@@ -999,12 +1059,12 @@ export function PortalHomePage() {
<>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),minmax(0,0.9fr)]">
<SummaryPanel title="我的申请与资产" items={employeeSummaryItems} />
<AnnouncementsPanel announcements={payload.announcements} />
<AnnouncementsPanel announcements={announcementItems} />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
<RecentVisitsPanel />
<QuickLinksPanel items={payload.quickLinks} />
<QuickLinksPanel items={quickLinkItems} />
</div>
</>
)}
+11 -8
查看文件
@@ -3,7 +3,7 @@ import { canUseWindow, isEnterpriseMessengerContainer, matchesMedia } from "./li
const MOBILE_APPROVAL_TASK_PATH = /^\/workflow\/tasks\/([^/]+)$/i;
const OFFLINE_ALLOWED_PATHS = ["/mobile/approvals"];
const RUNTIME_CACHE_NAMES = ["app-api-runtime"];
const CLEARABLE_CACHE_PREFIXES = ["app-api-runtime", "app-static-runtime", "workbox-precache"];
export function registerPwaIfEnabled() {
if (import.meta.env.DEV) {
@@ -63,12 +63,15 @@ export async function clearPwaRuntimeCaches() {
return;
}
await Promise.all(
RUNTIME_CACHE_NAMES.map(async (cacheName) => {
try {
await window.caches.delete(cacheName);
} catch {
}
}),
const cacheNames = await window.caches.keys();
const cachesToDelete = cacheNames.filter((cacheName) =>
CLEARABLE_CACHE_PREFIXES.some((prefix) => cacheName.startsWith(prefix)),
);
await Promise.all(cachesToDelete.map(async (cacheName) => {
try {
await window.caches.delete(cacheName);
} catch {
}
}));
}
+3
查看文件
@@ -117,9 +117,12 @@ export default defineConfig({
],
},
workbox: {
cleanupOutdatedCaches: true,
clientsClaim: true,
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api\//],
globPatterns: ["**/*.{js,css,html,ico,png,svg,webmanifest}"],
skipWaiting: true,
runtimeCaching: [
{
urlPattern: ({ request, url }) =>