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 { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -19,6 +19,7 @@ import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, isManagerRole } from "../auth/permissions"; import { hasAnyPermission, isManagerRole } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar"; import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { ErrorBoundary } from "../components/common/ErrorBoundary";
import { EmptyStateCard } from "../components/common/EmptyStateCard"; import { EmptyStateCard } from "../components/common/EmptyStateCard";
import { WelcomeDialog } from "../components/onboarding/WelcomeDialog"; import { WelcomeDialog } from "../components/onboarding/WelcomeDialog";
import { MetricCard } from "../components/patterns/MetricCard"; import { MetricCard } from "../components/patterns/MetricCard";
@@ -170,6 +171,7 @@ function formatWorkMinutes(value: number | null | undefined) {
} }
function buildTodoAggregateItems(payload: PortalHomePayload) { function buildTodoAggregateItems(payload: PortalHomePayload) {
const cards = Array.isArray(payload.cards) ? payload.cards : [];
const items: TodoAggregateItem[] = []; const items: TodoAggregateItem[] = [];
if (payload.pendingTasks.count > 0) { if (payload.pendingTasks.count > 0) {
items.push({ items.push({
@@ -189,7 +191,7 @@ function buildTodoAggregateItems(payload: PortalHomePayload) {
icon: "check_circle", icon: "check_circle",
}); });
} }
for (const card of payload.cards) { for (const card of cards) {
if (!card.severity || (card.severity !== "warning" && card.severity !== "high")) { if (!card.severity || (card.severity !== "warning" && card.severity !== "high")) {
continue; continue;
} }
@@ -376,6 +378,8 @@ function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] })
} }
function AnnouncementsPanel({ announcements }: { announcements: PortalHomePayload["announcements"] }) { function AnnouncementsPanel({ announcements }: { announcements: PortalHomePayload["announcements"] }) {
const safeAnnouncements = Array.isArray(announcements) ? announcements : [];
return ( return (
<SectionPanel <SectionPanel
title="公告" title="公告"
@@ -383,7 +387,7 @@ function AnnouncementsPanel({ announcements }: { announcements: PortalHomePayloa
actions={<Link to="/announcements" className="text-sm font-medium text-primary hover:underline">查看全部</Link>} actions={<Link to="/announcements" className="text-sm font-medium text-primary hover:underline">查看全部</Link>}
> >
<div className="space-y-3"> <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 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="font-medium text-zinc-900">{item.title}</div>
<div className="mt-2 text-sm leading-6 text-zinc-600">{item.summary}</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"] }) { function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] }) {
const safeItems = Array.isArray(items) ? items : [];
return ( return (
<SectionPanel title="常用入口" description="只保留高频入口,避免把所有模块堆成超市。"> <SectionPanel title="常用入口" description="只保留高频入口,避免把所有模块堆成超市。">
<div className="grid gap-3 sm:grid-cols-2"> <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"> <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} {item.title}
</Link> </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({ function GettingStartedCard({
items, items,
completedCount, completedCount,
@@ -506,7 +526,7 @@ function GettingStartedCard({
completedCount: number; completedCount: number;
onDismiss: () => void; onDismiss: () => void;
}) { }) {
const progressPercent = Math.round((completedCount / items.length) * 100); const progressPercent = items.length ? Math.round((completedCount / items.length) * 100) : 0;
return ( return (
<SectionPanel <SectionPanel
@@ -660,10 +680,12 @@ function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
} }
function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) { function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) {
const safeItems = Array.isArray(items) ? items : [];
return ( return (
<SectionPanel title="团队全员状态" description="同时看人在不在岗、有没有请假,以及今天是否已打卡。"> <SectionPanel title="团队全员状态" description="同时看人在不在岗、有没有请假,以及今天是否已打卡。">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> <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); const badge = resolveTeamStatusBadge(item);
return ( return (
<div key={item.employeeId} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4"> <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[] }) { function RecentActivitiesPanel({ items }: { items: PortalRecentActivity[] }) {
const safeItems = Array.isArray(items) ? items : [];
return ( return (
<SectionPanel title="近期动态流" description="把最近发生的报销、请假、考勤和资产动作放在同一条时间线上。"> <SectionPanel title="近期动态流" description="把最近发生的报销、请假、考勤和资产动作放在同一条时间线上。">
<div className="space-y-3"> <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"> <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"> <div className="flex items-start gap-3">
<EmployeeAvatar name={item.actorName} size="sm" /> <EmployeeAvatar name={item.actorName} size="sm" />
@@ -722,6 +746,38 @@ export function PortalHomePage() {
setShowGettingStarted(shouldShowGettingStartedCard()); 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({ const attendanceClockInMutation = useMutation({
mutationFn: () => clockInAttendance(), mutationFn: () => clockInAttendance(),
onSuccess: async (response) => { onSuccess: async (response) => {
@@ -770,10 +826,16 @@ export function PortalHomePage() {
} }
const payload = portalQuery.data; 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 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 primaryCardKeySet = primaryCards.map((card) => card.key);
const secondaryCards = compactCards(payload.cards, primaryCardKeySet); const secondaryCards = compactCards(cards, primaryCardKeySet);
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error); const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
const attendanceToday = attendanceTodayQuery.data ?? null; const attendanceToday = attendanceTodayQuery.data ?? null;
const clockAction = resolveClockAction(attendanceToday, { const clockAction = resolveClockAction(attendanceToday, {
@@ -782,38 +844,7 @@ export function PortalHomePage() {
featureDisabled: attendanceFeatureDisabled, featureDisabled: attendanceFeatureDisabled,
pending: attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending, pending: attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending,
}); });
const todoAggregateItems = buildTodoAggregateItems(payload); const todoAggregateItems = buildTodoAggregateItems({ ...payload, cards });
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;
function handleCloseWelcome() { function handleCloseWelcome() {
clearWelcomeDialogFlag(); clearWelcomeDialogFlag();
@@ -830,13 +861,13 @@ export function PortalHomePage() {
key: "expense", key: "expense",
href: "/reimbursements", href: "/reimbursements",
label: "我的报销", label: "我的报销",
value: `${payload.myApplications.find((item) => item.domain === "reimbursement")?.count ?? 0} 条`, value: `${applicationItems.find((item) => item.domain === "reimbursement")?.count ?? 0} 条`,
}, },
{ {
key: "leave", key: "leave",
href: "/leave-requests", href: "/leave-requests",
label: "我的请假", label: "我的请假",
value: `${payload.myApplications.find((item) => item.domain === "leave")?.count ?? 0} 条`, value: `${applicationItems.find((item) => item.domain === "leave")?.count ?? 0} 条`,
}, },
{ {
key: "assets", key: "assets",
@@ -922,7 +953,9 @@ export function PortalHomePage() {
return ( return (
<PageScaffold> <PageScaffold>
<WelcomeDialog open={showWelcome} onClose={handleCloseWelcome} /> <ErrorBoundary fallback={null}>
<WelcomeDialog open={showWelcome} onClose={handleCloseWelcome} />
</ErrorBoundary>
<WorkbenchHero <WorkbenchHero
eyebrow={isManagerView ? "管理工作台" : "个人工作台"} eyebrow={isManagerView ? "管理工作台" : "个人工作台"}
@@ -960,11 +993,20 @@ export function PortalHomePage() {
</div> </div>
{showGettingStarted ? ( {showGettingStarted ? (
<GettingStartedCard <ErrorBoundary
items={gettingStartedItems} fallback={(
completedCount={gettingStartedCompletedCount} <PortalSectionFallback
onDismiss={handleDismissGettingStarted} title="快速上手"
/> description="先完成基础资料和入口熟悉,再进入请假、通知等日常动作。"
/>
)}
>
<GettingStartedCard
items={gettingStartedItems}
completedCount={gettingStartedCompletedCount}
onDismiss={handleDismissGettingStarted}
/>
</ErrorBoundary>
) : null} ) : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
@@ -973,19 +1015,37 @@ export function PortalHomePage() {
</div> </div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr),minmax(0,0.85fr)]"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr),minmax(0,0.85fr)]">
<TeamStatusBoardPanel items={payload.teamStatusBoard} /> <ErrorBoundary
<RecentActivitiesPanel items={payload.recentActivities} /> fallback={(
<PortalSectionFallback
title="团队全员状态"
description="同时看人在不在岗、有没有请假,以及今天是否已打卡。"
/>
)}
>
<TeamStatusBoardPanel items={teamStatusBoard} />
</ErrorBoundary>
<ErrorBoundary
fallback={(
<PortalSectionFallback
title="近期动态流"
description="把最近发生的报销、请假、考勤和资产动作放在同一条时间线上。"
/>
)}
>
<RecentActivitiesPanel items={recentActivities} />
</ErrorBoundary>
</div> </div>
{isManagerView ? ( {isManagerView ? (
<> <>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(0,0.95fr)]"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(0,0.95fr)]">
<SummaryPanel title="今日管理重点" items={managerSummaryItems} /> <SummaryPanel title="今日管理重点" items={managerSummaryItems} />
<QuickLinksPanel items={payload.quickLinks} /> <QuickLinksPanel items={quickLinkItems} />
</div> </div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,0.95fr),minmax(0,1.05fr)]"> <div className="grid gap-6 xl:grid-cols-[minmax(0,0.95fr),minmax(0,1.05fr)]">
<AnnouncementsPanel announcements={payload.announcements} /> <AnnouncementsPanel announcements={announcementItems} />
<SecondaryCardsPanel <SecondaryCardsPanel
title="项目跟进与组织协同" title="项目跟进与组织协同"
description="把项目、组织和风险类信息下移到第二阅读层。" description="把项目、组织和风险类信息下移到第二阅读层。"
@@ -999,12 +1059,12 @@ export function PortalHomePage() {
<> <>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),minmax(0,0.9fr)]"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),minmax(0,0.9fr)]">
<SummaryPanel title="我的申请与资产" items={employeeSummaryItems} /> <SummaryPanel title="我的申请与资产" items={employeeSummaryItems} />
<AnnouncementsPanel announcements={payload.announcements} /> <AnnouncementsPanel announcements={announcementItems} />
</div> </div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
<RecentVisitsPanel /> <RecentVisitsPanel />
<QuickLinksPanel items={payload.quickLinks} /> <QuickLinksPanel items={quickLinkItems} />
</div> </div>
</> </>
)} )}
+11 -8
查看文件
@@ -3,7 +3,7 @@ import { canUseWindow, isEnterpriseMessengerContainer, matchesMedia } from "./li
const MOBILE_APPROVAL_TASK_PATH = /^\/workflow\/tasks\/([^/]+)$/i; const MOBILE_APPROVAL_TASK_PATH = /^\/workflow\/tasks\/([^/]+)$/i;
const OFFLINE_ALLOWED_PATHS = ["/mobile/approvals"]; 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() { export function registerPwaIfEnabled() {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
@@ -63,12 +63,15 @@ export async function clearPwaRuntimeCaches() {
return; return;
} }
await Promise.all( const cacheNames = await window.caches.keys();
RUNTIME_CACHE_NAMES.map(async (cacheName) => { const cachesToDelete = cacheNames.filter((cacheName) =>
try { CLEARABLE_CACHE_PREFIXES.some((prefix) => cacheName.startsWith(prefix)),
await window.caches.delete(cacheName);
} catch {
}
}),
); );
await Promise.all(cachesToDelete.map(async (cacheName) => {
try {
await window.caches.delete(cacheName);
} catch {
}
}));
} }
+3
查看文件
@@ -117,9 +117,12 @@ export default defineConfig({
], ],
}, },
workbox: { workbox: {
cleanupOutdatedCaches: true,
clientsClaim: true,
navigateFallback: "/index.html", navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api\//], navigateFallbackDenylist: [/^\/api\//],
globPatterns: ["**/*.{js,css,html,ico,png,svg,webmanifest}"], globPatterns: ["**/*.{js,css,html,ico,png,svg,webmanifest}"],
skipWaiting: true,
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: ({ request, url }) => urlPattern: ({ request, url }) =>
@@ -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 [email protected] '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 可用口令未执行,需由持有账号的人补做一次浏览器确认。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 更新接管调整后重新部署。
3 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 功能不变。
4 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 策略。
5 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 配置与注册行为,不改后端静态托管逻辑,除非验证显示仍有必要。
6 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 未提供可用管理员口令而未执行。
7 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 [email protected] '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 可用口令未执行,需由持有账号的人补做一次浏览器确认。