R64 Fix:Portal 渲染崩溃修复
这个提交包含在:
+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 {
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 可用口令未执行,需由持有账号的人补做一次浏览器确认。"
|
||||||
|
在新工单中引用
屏蔽一个用户