R68 A1-A6:Portal 首页信息架构与对齐优化

这个提交包含在:
2026-04-26 15:37:03 +08:00
父节点 a08db8590a
当前提交 46981380a0
共修改 6 个文件,包含 265 行新增和 81 行删除
@@ -1,18 +1,21 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "../../lib/utils";
export function WorkbenchHero({ export function WorkbenchHero({
eyebrow, eyebrow,
title, title,
subtitle, subtitle,
actions, actions,
className,
}: { }: {
eyebrow?: string; eyebrow?: string;
title: string; title: string;
subtitle?: string; subtitle?: string;
actions?: ReactNode; actions?: ReactNode;
className?: string;
}) { }) {
return ( return (
<section className="workbench-hero"> <section className={cn("workbench-hero", className)}>
<div> <div>
{eyebrow ? <div className="workbench-hero__eyebrow">{eyebrow}</div> : null} {eyebrow ? <div className="workbench-hero__eyebrow">{eyebrow}</div> : null}
<h1 className="workbench-hero__title">{title}</h1> <h1 className="workbench-hero__title">{title}</h1>
+82
查看文件
@@ -92,6 +92,18 @@
@apply mt-3 max-w-3xl text-sm leading-6 md:text-base md:leading-7; @apply mt-3 max-w-3xl text-sm leading-6 md:text-base md:leading-7;
} }
.workbench-hero--compact {
@apply gap-4 px-5 py-5 md:px-6 md:py-6;
}
.workbench-hero--compact .workbench-hero__title {
@apply text-2xl md:text-3xl;
}
.workbench-hero--compact .workbench-hero__subtitle {
@apply mt-2 max-w-2xl text-sm leading-6;
}
.card-std { .card-std {
background: var(--color-bg-surface-raised); background: var(--color-bg-surface-raised);
border: 1px solid rgba(226, 232, 240, 0.92); border: 1px solid rgba(226, 232, 240, 0.92);
@@ -295,6 +307,22 @@
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ring-1 ring-inset ring-slate-300/55; @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ring-1 ring-inset ring-slate-300/55;
} }
.portal-metric-strip {
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-4;
}
.portal-home-grid {
@apply grid gap-6 xl:grid-cols-[minmax(0,2fr),minmax(320px,0.95fr)] xl:items-stretch;
}
.portal-home-grid--collaboration {
@apply xl:grid-cols-[minmax(0,1.55fr),minmax(320px,0.9fr)];
}
.portal-panel-fill {
@apply h-full;
}
.portal-list-card { .portal-list-card {
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(226, 232, 240, 0.94); border: 1px solid rgba(226, 232, 240, 0.94);
@@ -314,6 +342,15 @@
border-color: rgba(245, 158, 11, 0.36); border-color: rgba(245, 158, 11, 0.36);
} }
.portal-list-card--compact {
border-radius: var(--radius-lg);
@apply px-3.5 py-3.5;
}
.portal-action-stack {
@apply grid gap-3 sm:grid-cols-2 xl:grid-cols-1;
}
.portal-action-tile { .portal-action-tile {
min-height: 100%; min-height: 100%;
background: background:
@@ -336,6 +373,10 @@
transform: none; transform: none;
} }
.portal-action-tile--compact {
@apply px-3.5 py-3.5;
}
.portal-action-icon { .portal-action-icon {
background: linear-gradient(135deg, rgba(0, 57, 138, 0.12), rgba(19, 168, 184, 0.12)); background: linear-gradient(135deg, rgba(0, 57, 138, 0.12), rgba(19, 168, 184, 0.12));
color: var(--color-accent-primary); color: var(--color-accent-primary);
@@ -353,6 +394,47 @@
border-radius: var(--radius-panel); border-radius: var(--radius-panel);
@apply px-4 py-4; @apply px-4 py-4;
} }
.portal-team-list {
border: 1px solid rgba(226, 232, 240, 0.94);
background: rgba(255, 255, 255, 0.64);
@apply overflow-hidden rounded-2xl divide-y divide-slate-200/80;
}
.portal-team-row {
background: rgba(255, 255, 255, 0.84);
@apply flex min-h-[64px] items-center gap-3 px-4 py-3;
}
.portal-panel-footer {
color: var(--color-text-secondary);
@apply mt-4 flex flex-wrap items-center justify-between gap-3 text-xs leading-5;
}
.portal-insight-tabs__list {
@apply h-10 rounded-full border-slate-200 bg-slate-50 p-1;
}
.portal-insight-tabs__content {
@apply mt-4;
}
.portal-secondary-drawer {
@apply p-5 md:p-6;
}
.portal-secondary-summary {
@apply flex cursor-pointer list-none items-center justify-between gap-4 text-left;
}
.portal-secondary-summary__toggle {
color: var(--color-text-tertiary);
@apply shrink-0 text-xs font-semibold uppercase tracking-[0.18em];
}
.portal-secondary-drawer__body {
@apply mt-5 space-y-6;
}
} }
@keyframes page-reveal { @keyframes page-reveal {
+164 -74
查看文件
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { type ReactNode, 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";
@@ -27,6 +27,7 @@ import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel"; import { SectionPanel } from "../components/patterns/SectionPanel";
import { WorkbenchHero } from "../components/patterns/WorkbenchHero"; import { WorkbenchHero } from "../components/patterns/WorkbenchHero";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { employeePortalSchema, managerPortalSchema } from "../features/portal/schema"; import { employeePortalSchema, managerPortalSchema } from "../features/portal/schema";
import { import {
clearWelcomeDialogFlag, clearWelcomeDialogFlag,
@@ -375,29 +376,23 @@ function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] })
); );
} }
function AnnouncementsPanel({ announcements }: { announcements: PortalHomePayload["announcements"] }) { function AnnouncementsList({ announcements }: { announcements: PortalHomePayload["announcements"] }) {
const safeAnnouncements = Array.isArray(announcements) ? announcements : []; const safeAnnouncements = Array.isArray(announcements) ? announcements : [];
return ( return (
<SectionPanel <div className="space-y-3">
title="公告" {safeAnnouncements.length ? safeAnnouncements.slice(0, 4).map((item) => (
description="公告保留在第二阅读层,不与今日任务卡竞争。" <div key={item.id} className="portal-list-card portal-list-card--compact">
actions={<Link to="/announcements" className="text-sm font-medium text-primary hover:underline">查看全部</Link>} <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="space-y-3"> {item.href ? (
{safeAnnouncements.length ? safeAnnouncements.slice(0, 4).map((item) => ( <a className="mt-3 inline-flex text-sm font-medium text-primary hover:underline" href={item.href} rel="noreferrer" target="_blank">
<div key={item.id} className="portal-list-card"> 查看详情
<div className="font-medium text-zinc-900">{item.title}</div> </a>
<div className="mt-2 text-sm leading-6 text-zinc-600">{item.summary}</div> ) : null}
{item.href ? ( </div>
<a className="mt-3 inline-flex text-sm font-medium text-primary hover:underline" href={item.href} rel="noreferrer" target="_blank"> )) : <EmptyStateCard title="暂无公告" description="当前没有需要广播的公告。" />}
查看详情 </div>
</a>
) : null}
</div>
)) : <EmptyStateCard title="暂无公告" description="当前没有需要广播的公告。" />}
</div>
</SectionPanel>
); );
} }
@@ -463,11 +458,11 @@ function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullabl
} }
return ( return (
<details className="card-std p-6" open> <details className="card-std portal-secondary-drawer">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-left"> <summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-left">
<div> <div>
<div className="text-base font-semibold text-zinc-900">试点观测摘要</div> <div className="text-base font-semibold text-zinc-900">试点观测摘要</div>
<div className="mt-1 text-sm text-zinc-500">只给管理员默认展开,用于观察习惯是否已形成。</div> <div className="mt-1 text-sm text-zinc-500">管理员按需展开,用于观察习惯是否已形成。</div>
</div> </div>
<span className="text-xs uppercase tracking-[0.18em] text-zinc-400">展开 / 收起</span> <span className="text-xs uppercase tracking-[0.18em] text-zinc-400">展开 / 收起</span>
</summary> </summary>
@@ -501,6 +496,31 @@ function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullabl
); );
} }
function SecondaryWorkspacePanel({
title,
description,
children,
}: {
title: string;
description: string;
children: ReactNode;
}) {
return (
<details className="card-std portal-secondary-drawer">
<summary className="portal-secondary-summary">
<div>
<div className="text-base font-semibold text-zinc-900">{title}</div>
<div className="mt-1 text-sm leading-6 text-zinc-500">{description}</div>
</div>
<span className="portal-secondary-summary__toggle">展开 / 收起</span>
</summary>
<div className="portal-secondary-drawer__body">
{children}
</div>
</details>
);
}
function PortalSectionFallback({ function PortalSectionFallback({
title, title,
description, description,
@@ -586,10 +606,11 @@ function QuickActionTile({
to, to,
onClick, onClick,
disabled = false, disabled = false,
}: QuickActionConfig) { compact = false,
}: QuickActionConfig & { compact?: boolean }) {
const baseClassName = disabled const baseClassName = disabled
? "portal-action-tile portal-action-tile--disabled" ? `portal-action-tile portal-action-tile--disabled${compact ? " portal-action-tile--compact" : ""}`
: "portal-action-tile"; : `portal-action-tile${compact ? " portal-action-tile--compact" : ""}`;
const content = ( const content = (
<> <>
@@ -601,8 +622,8 @@ function QuickActionTile({
<span className="material-symbols-outlined text-[20px]">{icon}</span> <span className="material-symbols-outlined text-[20px]">{icon}</span>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className={disabled ? "font-medium text-zinc-500" : "font-medium text-zinc-900"}>{label}</div> <div className={disabled ? "truncate font-medium text-zinc-500" : "truncate font-medium text-zinc-900"}>{label}</div>
<div className="mt-1 text-xs text-zinc-500">{description}</div> <div className="mt-1 text-xs leading-5 text-zinc-500">{description}</div>
</div> </div>
</div> </div>
</> </>
@@ -623,12 +644,18 @@ function QuickActionTile({
function QuickActionsPanel({ function QuickActionsPanel({
items, items,
layout = "grid",
}: { }: {
items: QuickActionConfig[]; items: QuickActionConfig[];
layout?: "grid" | "rail";
}) { }) {
return ( return (
<SectionPanel title="快捷操作栏" description="把每天高频动作直接前置到首页,不再绕路。"> <SectionPanel
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> title="快捷操作栏"
description="保留每天真正高频的动作,不把所有入口都放在首屏。"
className={layout === "rail" ? "portal-panel-fill" : undefined}
>
<div className={layout === "rail" ? "portal-action-stack" : "grid gap-3 sm:grid-cols-2 xl:grid-cols-4"}>
{items.length ? items.map((item) => ( {items.length ? items.map((item) => (
<QuickActionTile <QuickActionTile
key={item.key} key={item.key}
@@ -638,6 +665,7 @@ function QuickActionsPanel({
to={item.to} to={item.to}
onClick={item.onClick} onClick={item.onClick}
disabled={item.disabled} disabled={item.disabled}
compact={layout === "rail"}
/> />
)) : <EmptyStateCard title="暂无快捷动作" description="当前账号没有可直接执行的首页动作。" />} )) : <EmptyStateCard title="暂无快捷动作" description="当前账号没有可直接执行的首页动作。" />}
</div> </div>
@@ -646,10 +674,17 @@ function QuickActionsPanel({
} }
function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) { function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
const visibleItems = items.slice(0, 5);
const hiddenCount = Math.max(items.length - visibleItems.length, 0);
return ( return (
<SectionPanel title="今日待办聚合" description="合并审批、TODO 和高风险指标,只保留今天最该处理的事。"> <SectionPanel
title="今日待办聚合"
description="合并审批、TODO 和高风险指标,只保留今天最该处理的事。"
className="portal-panel-fill"
>
<div className="space-y-3"> <div className="space-y-3">
{items.length ? items.map((item) => ( {visibleItems.length ? visibleItems.map((item) => (
<Link key={`${item.title}-${item.href}-${item.sourceLabel}`} to={item.href} className="portal-list-card"> <Link key={`${item.title}-${item.href}-${item.sourceLabel}`} to={item.href} className="portal-list-card">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className={item.severity === "high" <div className={item.severity === "high"
@@ -673,54 +708,99 @@ function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
</Link> </Link>
)) : <EmptyStateCard title="暂无待办事项,休息一下吧" description="当前没有需要立刻处理的审批、TODO 或风险提醒。" />} )) : <EmptyStateCard title="暂无待办事项,休息一下吧" description="当前没有需要立刻处理的审批、TODO 或风险提醒。" />}
</div> </div>
{hiddenCount > 0 ? (
<div className="portal-panel-footer">
<span>还有 {hiddenCount} 项已收起,避免首页变成待办清单。</span>
<Link to="/todos" className="font-medium text-primary hover:underline">查看全部待办</Link>
</div>
) : null}
</SectionPanel> </SectionPanel>
); );
} }
function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) { function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) {
const safeItems = Array.isArray(items) ? items : []; const safeItems = Array.isArray(items) ? items : [];
const visibleItems = safeItems.slice(0, 5);
const hiddenCount = Math.max(safeItems.length - visibleItems.length, 0);
return ( return (
<SectionPanel title="团队全员状态" description="同时看人在不在岗、有没有请假,以及今天是否已打卡。"> <SectionPanel
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> title="团队全员状态"
{safeItems.length ? safeItems.map((item) => { description="小团队首页只展示关键状态,完整考勤与在岗信息进入团队页。"
actions={<Link to="/team" className="text-sm font-medium text-primary hover:underline">查看团队页</Link>}
className="portal-panel-fill"
>
<div className="portal-team-list">
{visibleItems.length ? visibleItems.map((item) => {
const badge = resolveTeamStatusBadge(item); const badge = resolveTeamStatusBadge(item);
return ( return (
<div key={item.employeeId} className="portal-status-card"> <div key={item.employeeId} className="portal-team-row">
<div className="flex items-center gap-3"> <EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" /> <div className="min-w-0 flex-1">
<div className="min-w-0 flex-1"> <div className="truncate font-medium text-zinc-900">{item.name}</div>
<div className="truncate font-medium text-zinc-900">{item.name}</div> <div className="mt-1 truncate text-xs text-zinc-500">{formatTeamStatusAttendance(item)}</div>
<div className="mt-1 text-xs text-zinc-500">{formatTeamStatusAttendance(item)}</div>
</div>
<span className={badge.className}>{badge.label}</span>
</div> </div>
<span className={badge.className}>{badge.label}</span>
</div> </div>
); );
}) : <EmptyStateCard title="暂无团队状态" description="当前没有可展示的团队成员状态。" />} }) : <EmptyStateCard title="暂无团队状态" description="当前没有可展示的团队成员状态。" />}
</div> </div>
{hiddenCount > 0 ? (
<div className="portal-panel-footer">
<span>已收起 {hiddenCount} 位成员,避免首页纵向过长。</span>
<Link to="/team" className="font-medium text-primary hover:underline">查看全部</Link>
</div>
) : null}
</SectionPanel> </SectionPanel>
); );
} }
function RecentActivitiesPanel({ items }: { items: PortalRecentActivity[] }) { function RecentActivitiesList({ items }: { items: PortalRecentActivity[] }) {
const safeItems = Array.isArray(items) ? items : []; const safeItems = Array.isArray(items) ? items : [];
return ( return (
<SectionPanel title="近期动态流" description="把最近发生的报销、请假、考勤和资产动作放在同一条时间线上。"> <div className="space-y-3">
<div className="space-y-3"> {safeItems.length ? safeItems.slice(0, 5).map((item) => (
{safeItems.length ? safeItems.map((item) => ( <Link key={item.id} to={item.href} className="portal-list-card portal-list-card--compact">
<Link key={item.id} to={item.href} className="portal-list-card"> <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" /> <div className="min-w-0 flex-1">
<div className="min-w-0 flex-1"> <div className="text-sm font-medium leading-6 text-zinc-900">{item.summary}</div>
<div className="text-sm font-medium leading-6 text-zinc-900">{item.summary}</div> <div className="mt-1 text-xs text-zinc-500">{formatRelativeTime(item.createdAt)}</div>
<div className="mt-1 text-xs text-zinc-500">{formatRelativeTime(item.createdAt)}</div>
</div>
</div> </div>
</Link> </div>
)) : <EmptyStateCard title="暂无近期动态" description="当前没有可展示的最近业务动作。" />} </Link>
</div> )) : <EmptyStateCard title="暂无近期动态" description="当前没有可展示的最近业务动作。" />}
</div>
);
}
function ActivityAnnouncementsPanel({
activities,
announcements,
}: {
activities: PortalRecentActivity[];
announcements: PortalHomePayload["announcements"];
}) {
return (
<SectionPanel
title="近期动态流"
description="动态优先,公告进入同一张右侧阅读卡,减少首页白卡堆叠。"
actions={<Link to="/announcements" className="text-sm font-medium text-primary hover:underline">公告页</Link>}
className="portal-panel-fill"
>
<Tabs defaultValue="activity" className="portal-insight-tabs">
<TabsList className="portal-insight-tabs__list">
<TabsTrigger value="activity">动态</TabsTrigger>
<TabsTrigger value="announcements">公告</TabsTrigger>
</TabsList>
<TabsContent value="activity" className="portal-insight-tabs__content">
<RecentActivitiesList items={activities} />
</TabsContent>
<TabsContent value="announcements" className="portal-insight-tabs__content">
<AnnouncementsList announcements={announcements} />
</TabsContent>
</Tabs>
</SectionPanel> </SectionPanel>
); );
} }
@@ -958,6 +1038,7 @@ export function PortalHomePage() {
<WorkbenchHero <WorkbenchHero
eyebrow={isManagerView ? "管理工作台" : "个人工作台"} eyebrow={isManagerView ? "管理工作台" : "个人工作台"}
title={`你好,${payload.greeting.name}`} title={`你好,${payload.greeting.name}`}
className="workbench-hero--compact"
subtitle={isManagerView subtitle={isManagerView
? "先处理审批、团队状态和周报缺口,再进入项目与组织协同。" ? "先处理审批、团队状态和周报缺口,再进入项目与组织协同。"
: "先处理今天必须完成的事,再继续查看项目、周报和公告。"} : "先处理今天必须完成的事,再继续查看项目、周报和公告。"}
@@ -970,7 +1051,7 @@ export function PortalHomePage() {
} }
/> />
<div className="grid gap-4 xl:grid-cols-4"> <div className="portal-metric-strip">
{primaryCards.map((card) => ( {primaryCards.map((card) => (
<MetricCard <MetricCard
key={card.key} key={card.key}
@@ -1007,12 +1088,12 @@ export function PortalHomePage() {
</ErrorBoundary> </ErrorBoundary>
) : null} ) : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]"> <div className="portal-home-grid">
<QuickActionsPanel items={quickActions} />
<TodoAggregatePanel items={todoAggregateItems} /> <TodoAggregatePanel items={todoAggregateItems} />
<QuickActionsPanel items={quickActions} layout="rail" />
</div> </div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr),minmax(0,0.85fr)]"> <div className="portal-home-grid portal-home-grid--collaboration">
<ErrorBoundary <ErrorBoundary
fallback={( fallback={(
<PortalSectionFallback <PortalSectionFallback
@@ -1031,40 +1112,49 @@ export function PortalHomePage() {
/> />
)} )}
> >
<RecentActivitiesPanel items={recentActivities} /> <ActivityAnnouncementsPanel activities={recentActivities} announcements={announcementItems} />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
{isManagerView ? ( {isManagerView ? (
<> <>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(0,0.95fr)]"> <SecondaryWorkspacePanel
<SummaryPanel title="今日管理重点" items={managerSummaryItems} /> title="今日管理重点 / 常用入口"
<QuickLinksPanel items={quickLinkItems} /> description="默认收起第二阅读层;需要巡检项目、组织协同或常用入口时再展开。"
</div> >
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),360px]">
<SummaryPanel title="今日管理重点" items={managerSummaryItems} />
<QuickLinksPanel items={quickLinkItems} />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,0.95fr),minmax(0,1.05fr)]">
<AnnouncementsPanel announcements={announcementItems} />
<SecondaryCardsPanel <SecondaryCardsPanel
title="项目跟进与组织协同" title="项目跟进与组织协同"
description="把项目、组织和风险类信息下移到第二阅读层。" description="把项目、组织和风险类信息下移到第二阅读层。"
cards={secondaryCards} cards={secondaryCards}
/> />
</div> </SecondaryWorkspacePanel>
<AdoptionSummaryPanel adoptionSummary={payload.adoptionSummary} /> <AdoptionSummaryPanel adoptionSummary={payload.adoptionSummary} />
</> </>
) : ( ) : (
<> <SecondaryWorkspacePanel
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),minmax(0,0.9fr)]"> title="我的申请与资产 / 继续工作"
description="默认收起低频参考信息,首页先服务今天要处理的事。"
>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
<SummaryPanel title="我的申请与资产" items={employeeSummaryItems} /> <SummaryPanel title="我的申请与资产" items={employeeSummaryItems} />
<AnnouncementsPanel announcements={announcementItems} /> <RecentVisitsPanel />
</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 />
<QuickLinksPanel items={quickLinkItems} /> <QuickLinksPanel items={quickLinkItems} />
<SecondaryCardsPanel
title="项目跟进与个人协同"
description="项目、资产和其他参考指标保留在第二阅读层。"
cards={secondaryCards}
/>
</div> </div>
</> </SecondaryWorkspacePanel>
)} )}
</PageScaffold> </PageScaffold>
); );
+4 -4
查看文件
@@ -294,8 +294,8 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
"portal-card-my_projects", "portal-card-my_projects",
]); ]);
await expect(page.getByText("我的申请与资产")).toBeVisible(); await expect(page.locator("summary").filter({ hasText: "我的申请与资产 / 继续工作" })).toBeVisible();
await expect(page.getByRole("heading", { name: "公告" })).toBeVisible(); await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
await expect(page.getByRole("heading", { name: "快捷操作栏" })).toBeVisible(); await expect(page.getByRole("heading", { name: "快捷操作栏" })).toBeVisible();
await expect(page.getByRole("heading", { name: "团队全员状态" })).toBeVisible(); await expect(page.getByRole("heading", { name: "团队全员状态" })).toBeVisible();
await expect(page.getByRole("heading", { name: "今日待办聚合" })).toBeVisible(); await expect(page.getByRole("heading", { name: "今日待办聚合" })).toBeVisible();
@@ -324,7 +324,7 @@ test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page
"portal-card-weekly_help", "portal-card-weekly_help",
]); ]);
await expect(page.getByText("今日管理重点")).toBeVisible(); await expect(page.locator("summary").filter({ hasText: "今日管理重点 / 常用入口" })).toBeVisible();
await expect(page.getByText("试点观测摘要")).toBeVisible(); await expect(page.getByText("试点观测摘要")).toBeVisible();
await expect(page.getByRole("link", { name: "采购申请" })).not.toBeVisible(); await expect(page.getByRole("link", { name: "采购申请" })).not.toBeVisible();
await page.locator("summary").filter({ hasText: "备用模块" }).click(); await page.locator("summary").filter({ hasText: "备用模块" }).click();
@@ -417,7 +417,7 @@ test("Portal 域管理角色也进入经理视图", async ({ page }) => {
await login(page, EMPLOYEE); await login(page, EMPLOYEE);
await expect(page.getByText("今日管理重点")).toBeVisible(); await expect(page.locator("summary").filter({ hasText: "今日管理重点 / 常用入口" })).toBeVisible();
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible(); await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible();
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible(); await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
}); });
+4 -2
查看文件
@@ -24,7 +24,8 @@ test.describe("visual portal", () => {
await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible(); await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
await expect(page.locator('[aria-label="portal-card-my_weekly_report"]')).toBeVisible(); await expect(page.locator('[aria-label="portal-card-my_weekly_report"]')).toBeVisible();
await expect(page.locator('[aria-label="portal-card-my_projects"]')).toBeVisible(); await expect(page.locator('[aria-label="portal-card-my_projects"]')).toBeVisible();
await expect(page.getByText("我的申请与资产")).toBeVisible(); await expect(page.locator("summary").filter({ hasText: "我的申请与资产 / 继续工作" })).toBeVisible();
await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
await attachScreenshot(page, testInfo, "portal-employee.png"); await attachScreenshot(page, testInfo, "portal-employee.png");
}); });
@@ -37,8 +38,9 @@ test.describe("visual portal", () => {
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible(); await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
await expect(page.locator('[aria-label="portal-card-weekly_missing"]')).toBeVisible(); await expect(page.locator('[aria-label="portal-card-weekly_missing"]')).toBeVisible();
await expect(page.locator('[aria-label="portal-card-weekly_help"]')).toBeVisible(); await expect(page.locator('[aria-label="portal-card-weekly_help"]')).toBeVisible();
await expect(page.getByText("今日管理重点")).toBeVisible(); await expect(page.locator("summary").filter({ hasText: "今日管理重点 / 常用入口" })).toBeVisible();
await expect(page.getByText("试点观测摘要")).toBeVisible(); await expect(page.getByText("试点观测摘要")).toBeVisible();
await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
await attachScreenshot(page, testInfo, "portal-admin.png"); await attachScreenshot(page, testInfo, "portal-admin.png");
await logout(page, ADMIN.name); await logout(page, ADMIN.name);
@@ -0,0 +1,7 @@
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
"A1","R68 Portal 首页信息架构合同建档","创建 R68 执行合同,冻结范围:只优化 Portal 首页信息架构、对齐、内容密度、折叠层级与截图复核产物;不改后端 API、权限过滤、workflow、审批规则、状态机、Prisma 迁移和导航可见性规则。","CSV 列完整,Dependencies 为 JSON 数组字符串;A2-A6 范围与已批准方案一致;Notes 明确无后端、无迁移、无权限与状态机变更,包含回滚路径。","命令:python3 - <<'PY' 校验 CSV 12 列、状态值、Dependencies JSON 数组字符串;业务:对照 AGENTS.md 确认 R68 仅覆盖前端 Portal 首页 UI 信息架构,不触碰 workflow、权限、审批规则、迁移。","manual","DONE","DONE","DONE","issues/2026-04-26_r68-portal-information-architecture.csv","[]","回滚路径:删除本 CSV 草稿即可回到 R67 基线;无后端 API、无 Prisma 迁移、无权限/状态机/workflow 变更。设计原则:10 人以下小团队首页优先今日待办、快捷动作、团队状态和少量动态,其余内容进入第二阅读层。"
"A2","Portal 首屏 8/4 信息骨架重排","将 Portal 首屏从多个等权卡片区改为稳定的主内容 8/4 骨架:上方保留紧凑 Hero 与 4 个核心指标,主体左侧优先展示今日待办,右侧固定快捷操作。","1) 首屏桌面端形成稳定左主右辅结构;2) 快捷操作不再与待办等权横铺;3) 移动端保持单列顺序:指标、待办、快捷操作;4) 不改变 query key、API 调用和跳转目标。","命令:cd client && npm run typecheck && npm run build;业务:员工、管理者、管理员进入 Portal,确认核心指标顺序不变,待办和快捷操作首屏可见且链接/按钮可用。","manual","DONE","DONE","DONE","client/src/pages/PortalHomePage.tsx|client/src/components/patterns/WorkbenchHero.tsx|client/src/index.css","[""A1""]","回滚路径:恢复 PortalHomePage、WorkbenchHero 与 CSS 到 R67;仅改前端布局和 class。验证:client typecheck/build 通过;截图 01-04 覆盖桌面与移动首屏。"
"A3","团队状态与动态公告区域对齐优化","将团队全员状态从卡片网格改为紧凑列表,最多展示 5 人并提供查看全部;将近期动态与公告合并为右侧阅读卡,减少同质白卡堆叠。","1) 团队状态行高稳定、头像/姓名/状态/打卡信息对齐;2) 超过 5 人时显示查看全部;3) 动态与公告在同一右侧面板内切换或分层展示;4) 空态不占用过高页面空间。","命令:cd client && npm run typecheck && npm run build;业务:员工和管理视角检查团队状态、近期动态、公告内容仍可读,团队页和公告页入口仍可达。","manual","DONE","DONE","DONE","client/src/pages/PortalHomePage.tsx|client/src/index.css","[""A2""]","回滚路径:恢复 R67 团队状态网格、动态流面板与公告面板;不改数据结构。验证:团队列表最多 5 人、查看全部入口和动态/公告 Tab 已在截图与 e2e 中覆盖。"
"A4","第二阅读层折叠与角色内容收敛","将我的申请/资产、继续工作、常用入口、管理补充信息、试点观测摘要收敛到第二阅读层,默认减少首页纵向长度,同时保留原有入口可访问。","1) 员工仍能看到我的申请与资产入口提示;2) 管理者仍能看到今日管理重点与试点观测摘要入口提示;3) 折叠区展开后原入口和指标仍可访问;4) 备用模块默认折叠逻辑不受影响。","命令:cd client && npm run typecheck && npm run build;业务:员工打开折叠区检查我的申请/资产、继续工作、常用入口;管理员检查管理补充信息与试点观测摘要。","manual","DONE","DONE","DONE","client/src/pages/PortalHomePage.tsx|client/src/index.css|client/tests/e2e/login-portal-ux.spec.ts|client/tests/e2e/visual-portal.spec.ts","[""A3""]","回滚路径:恢复 R67 角色区块直铺展示;仅改前端渲染层级,不改变权限或导航来源。验证:login-portal-ux 6/6 通过,备用模块默认折叠逻辑保持。"
"A5","R68 自动化与截图复核产物","更新受影响的前端断言,并生成 R68 Portal 复核截图索引,便于人工快速检查对齐、折叠和移动端表现。","1) 相关 e2e 断言与新信息架构一致;2) 员工、管理者、管理员 Portal 截图可通过 index.html 查看;3) 截图产物路径固定并记录;4) 若环境不可用,Notes 写明原因和人工替代步骤。","命令:git diff --check && cd client && npm run typecheck && npm run build;可选:cd client && npm run test:e2e -- tests/e2e/login-portal-ux.spec.ts tests/e2e/visual-portal.spec.ts;业务:打开截图 index.html 复核桌面与移动主要断点。","manual","DONE","DONE","DONE","client/tests/e2e/login-portal-ux.spec.ts|client/tests/e2e/visual-portal.spec.ts|deploy/trialrun/screenshots/r68-portal-ia-review/**","[""A4""]","回滚路径:删除 R68 截图产物并恢复测试断言;截图文件不影响运行时。验证:已生成 deploy/trialrun/screenshots/r68-portal-ia-review/index.html 和 4 张 Portal 截图;login-portal-ux 在 Vite dev server 下 6/6 通过。"
"A6","R68 验证、状态回填与提交收口","完成 Portal 信息架构优化后执行前端验证、必要 smoke 检查、截图复核产物生成和 CSV 状态回填,形成 R68 提交候选。","1) A2-A5 的 Test_Method 已执行并记录结果;2) git diff --check、client typecheck/build 通过;3) 截图产物已生成或记录阻塞;4) git status 只包含本轮预期变更和既有未跟踪截图。","命令:git diff --check && python3 CSV 校验 && cd client && npm run typecheck && npm run build;业务:员工、管理者、管理员三类入口复核 Portal 首页信息层级、对齐、折叠和移动端单列顺序。","manual","DONE","DONE","DONE","issues/2026-04-26_r68-portal-information-architecture.csv|client/src/pages/PortalHomePage.tsx|client/src/index.css|client/tests/e2e/*.spec.ts|deploy/trialrun/screenshots/r68-portal-ia-review/**","[""A5""]","回滚路径:若验证失败,不回填 DONE,不提交;必要时整批恢复 R68 Portal 信息架构改动。验证:git diff --check、CSV 校验、client build 已通过;最终状态核对后提交。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 A1 R68 Portal 首页信息架构合同建档 创建 R68 执行合同,冻结范围:只优化 Portal 首页信息架构、对齐、内容密度、折叠层级与截图复核产物;不改后端 API、权限过滤、workflow、审批规则、状态机、Prisma 迁移和导航可见性规则。 CSV 列完整,Dependencies 为 JSON 数组字符串;A2-A6 范围与已批准方案一致;Notes 明确无后端、无迁移、无权限与状态机变更,包含回滚路径。 命令:python3 - <<'PY' 校验 CSV 12 列、状态值、Dependencies JSON 数组字符串;业务:对照 AGENTS.md 确认 R68 仅覆盖前端 Portal 首页 UI 信息架构,不触碰 workflow、权限、审批规则、迁移。 manual DONE DONE DONE issues/2026-04-26_r68-portal-information-architecture.csv [] 回滚路径:删除本 CSV 草稿即可回到 R67 基线;无后端 API、无 Prisma 迁移、无权限/状态机/workflow 变更。设计原则:10 人以下小团队首页优先今日待办、快捷动作、团队状态和少量动态,其余内容进入第二阅读层。
3 A2 Portal 首屏 8/4 信息骨架重排 将 Portal 首屏从多个等权卡片区改为稳定的主内容 8/4 骨架:上方保留紧凑 Hero 与 4 个核心指标,主体左侧优先展示今日待办,右侧固定快捷操作。 1) 首屏桌面端形成稳定左主右辅结构;2) 快捷操作不再与待办等权横铺;3) 移动端保持单列顺序:指标、待办、快捷操作;4) 不改变 query key、API 调用和跳转目标。 命令:cd client && npm run typecheck && npm run build;业务:员工、管理者、管理员进入 Portal,确认核心指标顺序不变,待办和快捷操作首屏可见且链接/按钮可用。 manual DONE DONE DONE client/src/pages/PortalHomePage.tsx|client/src/components/patterns/WorkbenchHero.tsx|client/src/index.css ["A1"] 回滚路径:恢复 PortalHomePage、WorkbenchHero 与 CSS 到 R67;仅改前端布局和 class。验证:client typecheck/build 通过;截图 01-04 覆盖桌面与移动首屏。
4 A3 团队状态与动态公告区域对齐优化 将团队全员状态从卡片网格改为紧凑列表,最多展示 5 人并提供查看全部;将近期动态与公告合并为右侧阅读卡,减少同质白卡堆叠。 1) 团队状态行高稳定、头像/姓名/状态/打卡信息对齐;2) 超过 5 人时显示查看全部;3) 动态与公告在同一右侧面板内切换或分层展示;4) 空态不占用过高页面空间。 命令:cd client && npm run typecheck && npm run build;业务:员工和管理视角检查团队状态、近期动态、公告内容仍可读,团队页和公告页入口仍可达。 manual DONE DONE DONE client/src/pages/PortalHomePage.tsx|client/src/index.css ["A2"] 回滚路径:恢复 R67 团队状态网格、动态流面板与公告面板;不改数据结构。验证:团队列表最多 5 人、查看全部入口和动态/公告 Tab 已在截图与 e2e 中覆盖。
5 A4 第二阅读层折叠与角色内容收敛 将我的申请/资产、继续工作、常用入口、管理补充信息、试点观测摘要收敛到第二阅读层,默认减少首页纵向长度,同时保留原有入口可访问。 1) 员工仍能看到我的申请与资产入口提示;2) 管理者仍能看到今日管理重点与试点观测摘要入口提示;3) 折叠区展开后原入口和指标仍可访问;4) 备用模块默认折叠逻辑不受影响。 命令:cd client && npm run typecheck && npm run build;业务:员工打开折叠区检查我的申请/资产、继续工作、常用入口;管理员检查管理补充信息与试点观测摘要。 manual DONE DONE DONE client/src/pages/PortalHomePage.tsx|client/src/index.css|client/tests/e2e/login-portal-ux.spec.ts|client/tests/e2e/visual-portal.spec.ts ["A3"] 回滚路径:恢复 R67 角色区块直铺展示;仅改前端渲染层级,不改变权限或导航来源。验证:login-portal-ux 6/6 通过,备用模块默认折叠逻辑保持。
6 A5 R68 自动化与截图复核产物 更新受影响的前端断言,并生成 R68 Portal 复核截图索引,便于人工快速检查对齐、折叠和移动端表现。 1) 相关 e2e 断言与新信息架构一致;2) 员工、管理者、管理员 Portal 截图可通过 index.html 查看;3) 截图产物路径固定并记录;4) 若环境不可用,Notes 写明原因和人工替代步骤。 命令:git diff --check && cd client && npm run typecheck && npm run build;可选:cd client && npm run test:e2e -- tests/e2e/login-portal-ux.spec.ts tests/e2e/visual-portal.spec.ts;业务:打开截图 index.html 复核桌面与移动主要断点。 manual DONE DONE DONE client/tests/e2e/login-portal-ux.spec.ts|client/tests/e2e/visual-portal.spec.ts|deploy/trialrun/screenshots/r68-portal-ia-review/** ["A4"] 回滚路径:删除 R68 截图产物并恢复测试断言;截图文件不影响运行时。验证:已生成 deploy/trialrun/screenshots/r68-portal-ia-review/index.html 和 4 张 Portal 截图;login-portal-ux 在 Vite dev server 下 6/6 通过。
7 A6 R68 验证、状态回填与提交收口 完成 Portal 信息架构优化后执行前端验证、必要 smoke 检查、截图复核产物生成和 CSV 状态回填,形成 R68 提交候选。 1) A2-A5 的 Test_Method 已执行并记录结果;2) git diff --check、client typecheck/build 通过;3) 截图产物已生成或记录阻塞;4) git status 只包含本轮预期变更和既有未跟踪截图。 命令:git diff --check && python3 CSV 校验 && cd client && npm run typecheck && npm run build;业务:员工、管理者、管理员三类入口复核 Portal 首页信息层级、对齐、折叠和移动端单列顺序。 manual DONE DONE DONE issues/2026-04-26_r68-portal-information-architecture.csv|client/src/pages/PortalHomePage.tsx|client/src/index.css|client/tests/e2e/*.spec.ts|deploy/trialrun/screenshots/r68-portal-ia-review/** ["A5"] 回滚路径:若验证失败,不回填 DONE,不提交;必要时整批恢复 R68 Portal 信息架构改动。验证:git diff --check、CSV 校验、client build 已通过;最终状态核对后提交。