From 46981380a049839ad10452ccdfe0ba28da95fa4b Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 26 Apr 2026 15:37:03 +0800 Subject: [PATCH] =?UTF-8?q?R68=20A1-A6=EF=BC=9APortal=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E6=9E=B6=E6=9E=84=E4=B8=8E=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/patterns/WorkbenchHero.tsx | 5 +- client/src/index.css | 82 ++++++ client/src/pages/PortalHomePage.tsx | 238 ++++++++++++------ client/tests/e2e/login-portal-ux.spec.ts | 8 +- client/tests/e2e/visual-portal.spec.ts | 6 +- ...26_r68-portal-information-architecture.csv | 7 + 6 files changed, 265 insertions(+), 81 deletions(-) create mode 100644 issues/2026-04-26_r68-portal-information-architecture.csv diff --git a/client/src/components/patterns/WorkbenchHero.tsx b/client/src/components/patterns/WorkbenchHero.tsx index 12b132ea..4cc036ce 100644 --- a/client/src/components/patterns/WorkbenchHero.tsx +++ b/client/src/components/patterns/WorkbenchHero.tsx @@ -1,18 +1,21 @@ import type { ReactNode } from "react"; +import { cn } from "../../lib/utils"; export function WorkbenchHero({ eyebrow, title, subtitle, actions, + className, }: { eyebrow?: string; title: string; subtitle?: string; actions?: ReactNode; + className?: string; }) { return ( -
+
{eyebrow ?
{eyebrow}
: null}

{title}

diff --git a/client/src/index.css b/client/src/index.css index 91abbbf4..3ada0ce0 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -92,6 +92,18 @@ @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 { background: var(--color-bg-surface-raised); 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; } + .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 { background: rgba(255, 255, 255, 0.92); border: 1px solid rgba(226, 232, 240, 0.94); @@ -314,6 +342,15 @@ 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 { min-height: 100%; background: @@ -336,6 +373,10 @@ transform: none; } + .portal-action-tile--compact { + @apply px-3.5 py-3.5; + } + .portal-action-icon { background: linear-gradient(135deg, rgba(0, 57, 138, 0.12), rgba(19, 168, 184, 0.12)); color: var(--color-accent-primary); @@ -353,6 +394,47 @@ border-radius: var(--radius-panel); @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 { diff --git a/client/src/pages/PortalHomePage.tsx b/client/src/pages/PortalHomePage.tsx index 04d8b26f..d23de2e9 100644 --- a/client/src/pages/PortalHomePage.tsx +++ b/client/src/pages/PortalHomePage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -27,6 +27,7 @@ import { PageScaffold } from "../components/patterns/PageScaffold"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { WorkbenchHero } from "../components/patterns/WorkbenchHero"; import { Button } from "../components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"; import { employeePortalSchema, managerPortalSchema } from "../features/portal/schema"; import { 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 : []; return ( - 查看全部} - > -
- {safeAnnouncements.length ? safeAnnouncements.slice(0, 4).map((item) => ( -
-
{item.title}
-
{item.summary}
- {item.href ? ( - - 查看详情 - - ) : null} -
- )) : } -
-
+
+ {safeAnnouncements.length ? safeAnnouncements.slice(0, 4).map((item) => ( +
+
{item.title}
+
{item.summary}
+ {item.href ? ( + + 查看详情 + + ) : null} +
+ )) : } +
); } @@ -463,11 +458,11 @@ function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullabl } return ( -
+
试点观测摘要
-
只给管理员默认展开,用于观察习惯是否已形成。
+
管理员按需展开,用于观察习惯是否已形成。
展开 / 收起
@@ -501,6 +496,31 @@ function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullabl ); } +function SecondaryWorkspacePanel({ + title, + description, + children, +}: { + title: string; + description: string; + children: ReactNode; +}) { + return ( +
+ +
+
{title}
+
{description}
+
+ 展开 / 收起 +
+
+ {children} +
+
+ ); +} + function PortalSectionFallback({ title, description, @@ -586,10 +606,11 @@ function QuickActionTile({ to, onClick, disabled = false, -}: QuickActionConfig) { + compact = false, +}: QuickActionConfig & { compact?: boolean }) { const baseClassName = disabled - ? "portal-action-tile portal-action-tile--disabled" - : "portal-action-tile"; + ? `portal-action-tile portal-action-tile--disabled${compact ? " portal-action-tile--compact" : ""}` + : `portal-action-tile${compact ? " portal-action-tile--compact" : ""}`; const content = ( <> @@ -601,8 +622,8 @@ function QuickActionTile({ {icon}
-
{label}
-
{description}
+
{label}
+
{description}
@@ -623,12 +644,18 @@ function QuickActionTile({ function QuickActionsPanel({ items, + layout = "grid", }: { items: QuickActionConfig[]; + layout?: "grid" | "rail"; }) { return ( - -
+ +
{items.length ? items.map((item) => ( )) : }
@@ -646,10 +674,17 @@ function QuickActionsPanel({ } function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) { + const visibleItems = items.slice(0, 5); + const hiddenCount = Math.max(items.length - visibleItems.length, 0); + return ( - +
- {items.length ? items.map((item) => ( + {visibleItems.length ? visibleItems.map((item) => (
)) : }
+ {hiddenCount > 0 ? ( +
+ 还有 {hiddenCount} 项已收起,避免首页变成待办清单。 + 查看全部待办 +
+ ) : null} ); } function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) { const safeItems = Array.isArray(items) ? items : []; + const visibleItems = safeItems.slice(0, 5); + const hiddenCount = Math.max(safeItems.length - visibleItems.length, 0); return ( - -
- {safeItems.length ? safeItems.map((item) => { + 查看团队页} + className="portal-panel-fill" + > +
+ {visibleItems.length ? visibleItems.map((item) => { const badge = resolveTeamStatusBadge(item); return ( -
-
- -
-
{item.name}
-
{formatTeamStatusAttendance(item)}
-
- {badge.label} +
+ +
+
{item.name}
+
{formatTeamStatusAttendance(item)}
+ {badge.label}
); }) : }
+ {hiddenCount > 0 ? ( +
+ 已收起 {hiddenCount} 位成员,避免首页纵向过长。 + 查看全部 +
+ ) : null} ); } -function RecentActivitiesPanel({ items }: { items: PortalRecentActivity[] }) { +function RecentActivitiesList({ items }: { items: PortalRecentActivity[] }) { const safeItems = Array.isArray(items) ? items : []; return ( - -
- {safeItems.length ? safeItems.map((item) => ( - -
- -
-
{item.summary}
-
{formatRelativeTime(item.createdAt)}
-
+
+ {safeItems.length ? safeItems.slice(0, 5).map((item) => ( + +
+ +
+
{item.summary}
+
{formatRelativeTime(item.createdAt)}
- - )) : } -
+
+ + )) : } +
+ ); +} + +function ActivityAnnouncementsPanel({ + activities, + announcements, +}: { + activities: PortalRecentActivity[]; + announcements: PortalHomePayload["announcements"]; +}) { + return ( + 公告页} + className="portal-panel-fill" + > + + + 动态 + 公告 + + + + + + + + ); } @@ -958,6 +1038,7 @@ export function PortalHomePage() { -
+
{primaryCards.map((card) => ( ) : null} -
- +
+
-
+
)} > - +
{isManagerView ? ( <> -
- - -
+ +
+ + +
-
- -
+
) : ( - <> -
+ +
- +
- +
- +
)} ); diff --git a/client/tests/e2e/login-portal-ux.spec.ts b/client/tests/e2e/login-portal-ux.spec.ts index bb5d56e0..78a555d4 100644 --- a/client/tests/e2e/login-portal-ux.spec.ts +++ b/client/tests/e2e/login-portal-ux.spec.ts @@ -294,8 +294,8 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => { "portal-card-my_projects", ]); - await expect(page.getByText("我的申请与资产")).toBeVisible(); - await expect(page.getByRole("heading", { name: "公告" })).toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "我的申请与资产 / 继续工作" })).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(); @@ -324,7 +324,7 @@ test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page "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.getByRole("link", { name: "采购申请" })).not.toBeVisible(); await page.locator("summary").filter({ hasText: "备用模块" }).click(); @@ -417,7 +417,7 @@ test("Portal 域管理角色也进入经理视图", async ({ page }) => { 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-team_presence"]')).toBeVisible(); }); diff --git a/client/tests/e2e/visual-portal.spec.ts b/client/tests/e2e/visual-portal.spec.ts index ad9e0513..62e48cfc 100644 --- a/client/tests/e2e/visual-portal.spec.ts +++ b/client/tests/e2e/visual-portal.spec.ts @@ -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-my_weekly_report"]')).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"); }); @@ -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-weekly_missing"]')).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.getByRole("tab", { name: "公告" })).toBeVisible(); await attachScreenshot(page, testInfo, "portal-admin.png"); await logout(page, ADMIN.name); diff --git a/issues/2026-04-26_r68-portal-information-architecture.csv b/issues/2026-04-26_r68-portal-information-architecture.csv new file mode 100644 index 00000000..f4c9909d --- /dev/null +++ b/issues/2026-04-26_r68-portal-information-architecture.csv @@ -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 已通过;最终状态核对后提交。"