R68 A1-A6:Portal 首页信息架构与对齐优化
这个提交包含在:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 已通过;最终状态核对后提交。"
|
||||||
|
在新工单中引用
屏蔽一个用户