From bc6e4955b3dd4f23ba4c7ae6b01069cc4e044c86 Mon Sep 17 00:00:00 2001 From: shunli Date: Wed, 15 Apr 2026 20:58:18 +0800 Subject: [PATCH] =?UTF-8?q?R64=20Fix=EF=BC=9APortal=20=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E5=B4=A9=E6=BA=83=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/PortalHomePage.tsx | 170 ++++++++++++------ client/src/pwa.ts | 19 +- client/vite.config.ts | 3 + ...6-04-15_20-39-58-r64-portal-render-fix.csv | 7 + 4 files changed, 136 insertions(+), 63 deletions(-) create mode 100644 issues/2026-04-15_20-39-58-r64-portal-render-fix.csv diff --git a/client/src/pages/PortalHomePage.tsx b/client/src/pages/PortalHomePage.tsx index c73d1fc4..774aadba 100644 --- a/client/src/pages/PortalHomePage.tsx +++ b/client/src/pages/PortalHomePage.tsx @@ -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 ( 查看全部} >
- {announcements.length ? announcements.slice(0, 4).map((item) => ( + {safeAnnouncements.length ? safeAnnouncements.slice(0, 4).map((item) => (
{item.title}
{item.summary}
@@ -417,10 +421,12 @@ function RecentVisitsPanel() { } function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] }) { + const safeItems = Array.isArray(items) ? items : []; + return (
- {items.length ? items.map((item) => ( + {safeItems.length ? safeItems.map((item) => ( {item.title} @@ -497,6 +503,20 @@ function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullabl ); } +function PortalSectionFallback({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( + + + + ); +} + 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 (
- {items.length ? items.map((item) => { + {safeItems.length ? safeItems.map((item) => { const badge = resolveTeamStatusBadge(item); return (
@@ -684,10 +706,12 @@ function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) { } function RecentActivitiesPanel({ items }: { items: PortalRecentActivity[] }) { + const safeItems = Array.isArray(items) ? items : []; + return (
- {items.length ? items.map((item) => ( + {safeItems.length ? safeItems.map((item) => (
@@ -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(() => ([ - { - 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 ( - + + + {showGettingStarted ? ( - + + )} + > + + ) : null}
@@ -973,19 +1015,37 @@ export function PortalHomePage() {
- - + + )} + > + + + + )} + > + +
{isManagerView ? ( <>
- +
- +
- +
- +
)} diff --git a/client/src/pwa.ts b/client/src/pwa.ts index 5ff37c07..dc59c832 100644 --- a/client/src/pwa.ts +++ b/client/src/pwa.ts @@ -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 { + } + })); } diff --git a/client/vite.config.ts b/client/vite.config.ts index f2382c1d..74a5ba36 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -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 }) => diff --git a/issues/2026-04-15_20-39-58-r64-portal-render-fix.csv b/issues/2026-04-15_20-39-58-r64-portal-render-fix.csv new file mode 100644 index 00000000..15ea3279 --- /dev/null +++ b/issues/2026-04-15_20-39-58-r64-portal-render-fix.csv @@ -0,0 +1,7 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +"A1","执行合同建档与范围冻结","创建并校验 Portal 首页渲染崩溃修复执行合同,冻结本轮范围为 Portal 渲染崩溃修复、局部错误隔离、PWA 更新接管硬化与部署回归,不新增 Prisma 迁移和 npm 依赖。","1) 新建 issues/2026-04-15_20-39-58-r64-portal-render-fix.csv;2) 仅包含 A1-A6 六项;3) Dependencies 使用 JSON 数组字符串;4) Notes 明确无 Prisma 迁移、无新依赖、回滚为移除本轮 Portal 与 PWA 修复代码。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-15_20-39-58-r64-portal-render-fix.csv。业务:执行合同可作为本轮唯一实施与回归依据。","manual","DONE","DONE","DONE","issues/2026-04-15_20-39-58-r64-portal-render-fix.csv","[]","无 Prisma 迁移,无新依赖。回滚路径:移除 PortalHomePage 防御性修复与 PWA 更新接管调整后重新部署。" +"A2","Portal Hook 顺序崩溃修复","修复 PortalHomePage 在 loading/error 早返回后才调用 useMemo 导致的 React #310 渲染崩溃,并收紧 onboarding 完成度读取为空时的安全访问。","1) PortalHomePage 在 loading、error、success 三种渲染路径下 Hook 调用顺序一致;2) me 缺失 profileCompleteness 时不会因读取 score 崩溃;3) /portal 首次加载不再触发 React #310。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:登录后进入 /portal,首屏可正常渲染,不出现页面渲染出错。","manual","DONE","DONE","DONE","client/src/pages/PortalHomePage.tsx","[""A1""]","优先修复 Hook 顺序错误;保持 WelcomeDialog 与 GettingStartedCard 功能不变。" +"A3","Portal 新增区块局部错误隔离","为 WelcomeDialog、GettingStartedCard、TeamStatusBoardPanel、RecentActivitiesPanel 增加局部 ErrorBoundary 与 payload fallback,避免单块渲染异常拖垮整个 Portal 页面。","1) WelcomeDialog、GettingStartedCard、TeamStatusBoardPanel、RecentActivitiesPanel 任一块异常时仅局部降级;2) teamStatusBoard 与 recentActivities 在缺失或非数组时使用空数组 fallback;3) Portal 其余区域保持可用。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:模拟新员工 onboarding 或 Portal 数据缺失时,首页主体仍可渲染。","manual","DONE","DONE","DONE","client/src/pages/PortalHomePage.tsx|client/src/components/common/ErrorBoundary.tsx|client/src/components/onboarding/WelcomeDialog.tsx|client/src/api/portal.ts","[""A1"",""A2""]","局部边界仅用于隔离新增区块,不改全局 ErrorBoundary 策略。" +"A4","PWA 更新接管与 409 资源冲突硬化","检查并加固 Vite PWA 与注册逻辑,优先通过 cleanupOutdatedCaches、skipWaiting、clientsClaim 等配置降低旧 precache 与新 chunk hash 冲突导致的 409 资源问题。","1) PWA 配置不新增依赖;2) 新 Service Worker 发布后可更快接管页面;3) 不影响现有离线与运行时缓存主逻辑;4) client build 通过。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:部署后刷新页面,不应因旧缓存 manifest 长时间滞留导致持续资源冲突。","manual","DONE","DONE","DONE","client/vite.config.ts|client/src/pwa.ts","[""A1"",""A2""]","仅调整现有 vite-plugin-pwa 配置与注册行为,不改后端静态托管逻辑,除非验证显示仍有必要。" +"A5","验证与回归","执行 Portal 路由静态检查、前端 typecheck/build,并完成 Portal 首页手工回归,确认首屏渲染、onboarding、局部面板和 PWA 构建均无回归。","1) server/src/routes/portal.js 语法检查通过;2) client typecheck 与 build 通过;3) /portal 首屏可访问;4) WelcomeDialog、GettingStartedCard、团队状态板和最近动态渲染正常。","命令:1) export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/portal.js;2) cd ../client && npm run typecheck && npm run build。业务:登录后进入 /portal,检查 Portal 主区块与 onboarding 提示正常。","manual","DONE","DONE","DONE","server/src/routes/portal.js|client/src/pages/PortalHomePage.tsx|client/vite.config.ts|client/src/pwa.ts","[""A2"",""A3"",""A4""]","命令检查全部通过。额外尝试的本地登录型 E2E 受本地数据库 schema 漂移阻塞:auth 登录更新 employees.avatar_path 时命中 P2022。远端真实登录烟测因 trialrun 未提供可用管理员口令而未执行。" +"A6","部署、QA 收口与提交","完成 trialrun 部署、远端 /ready 校验、Portal 页面冒烟,并在验证通过后回填 CSV 状态;若有代码改动则提交修复 commit。","1) bash deploy/trialrun/deploy.sh 执行成功;2) 远端 /ready 返回 ok;3) CSV 三个状态列按实测结果回填;4) 若有代码改动则 git commit 为 R64 Fix:Portal 渲染崩溃修复。","命令:1) export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && bash deploy/trialrun/deploy.sh;2) ssh deploy@118.89.77.218 'curl -fsS http://127.0.0.1:3000/ready'。业务:部署后访问 trialrun /portal,不再出现页面渲染出错。","manual","DONE","DONE","DONE","issues/2026-04-15_20-39-58-r64-portal-render-fix.csv|client/src/pages/PortalHomePage.tsx|client/vite.config.ts|client/src/pwa.ts|deploy/trialrun/deploy.sh","[""A5""]","部署已完成,远端 /ready 返回 ok。真实登录烟测因缺少 trialrun 可用口令未执行,需由持有账号的人补做一次浏览器确认。"