From 7ac0de660ca84bfaa196a519cb15fc1a8fbbcce3 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 14 Apr 2026 16:45:10 +0800 Subject: [PATCH] =?UTF-8?q?R64=20A1-A6=EF=BC=9A=E6=96=B0=E5=91=98=E5=B7=A5?= =?UTF-8?q?=E5=85=A5=E9=97=A8=E5=BC=95=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/types.ts | 9 ++ client/src/auth/AuthContext.tsx | 35 ++++- .../components/onboarding/WelcomeDialog.tsx | 106 +++++++++++++ client/src/lib/onboardingGuide.ts | 48 ++++++ client/src/lib/testIds.ts | 6 + client/src/pages/ChangePasswordPage.tsx | 3 +- client/src/pages/PortalHomePage.tsx | 140 +++++++++++++++++- client/tests/e2e/helpers/auth.ts | 7 + client/tests/e2e/login-portal-ux.spec.ts | 31 ++++ issues/2026-04-14_r64-onboarding-guide.csv | 7 + server/src/routes/me.js | 24 +++ server/tests/unit/routes/me.test.js | 42 ++++++ 12 files changed, 453 insertions(+), 5 deletions(-) create mode 100644 client/src/components/onboarding/WelcomeDialog.tsx create mode 100644 client/src/lib/onboardingGuide.ts create mode 100644 issues/2026-04-14_r64-onboarding-guide.csv diff --git a/client/src/api/types.ts b/client/src/api/types.ts index ab83873d..99593375 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -23,6 +23,14 @@ export type LoginResponse = { mustChangePassword?: boolean; }; +export type ProfileCompleteness = { + hasEmergencyContact: boolean; + hasBankAccount: boolean; + hasAvatar: boolean; + hasIdNumber: boolean; + score: number; +}; + export type MeResponse = { id: string; employeeNo: string; @@ -34,6 +42,7 @@ export type MeResponse = { status: string; notificationPushEnabled: boolean; roleCodes: string[]; + profileCompleteness: ProfileCompleteness; wecomBinding?: { externalUserId: string; createdAt: string | null; diff --git a/client/src/auth/AuthContext.tsx b/client/src/auth/AuthContext.tsx index a178a42d..e7c92ecd 100644 --- a/client/src/auth/AuthContext.tsx +++ b/client/src/auth/AuthContext.tsx @@ -9,7 +9,7 @@ import { useState, } from "react"; import { configureHttpAuth } from "../api/http"; -import type { AuthState, LoginResponse, MeResponse } from "../api/types"; +import type { AuthState, LoginResponse, MeResponse, ProfileCompleteness } from "../api/types"; import { changePassword as changePasswordApi } from "../api/auth"; import { clearPwaRuntimeCaches } from "../pwa"; import { @@ -53,6 +53,13 @@ const OFFLINE_AUTH_SNAPSHOT_VERSION = 1; const OFFLINE_AUTH_SNAPSHOT_TTL_MS = 24 * 60 * 60 * 1000; const PROACTIVE_REFRESH_RATIO = 0.8; const MIN_REFRESH_DELAY_MS = 1000; +const DEFAULT_PROFILE_COMPLETENESS: ProfileCompleteness = { + hasEmergencyContact: false, + hasBankAccount: false, + hasAvatar: false, + hasIdNumber: false, + score: 0, +}; function isWindowAvailable() { return typeof window !== "undefined"; @@ -69,6 +76,28 @@ function clearSessionHint() { document.cookie = "has_session=; path=/; max-age=0"; } +function normalizeProfileCompleteness(value: unknown): ProfileCompleteness { + if (!value || typeof value !== "object") { + return DEFAULT_PROFILE_COMPLETENESS; + } + + const payload = value as Partial; + return { + hasEmergencyContact: Boolean(payload.hasEmergencyContact), + hasBankAccount: Boolean(payload.hasBankAccount), + hasAvatar: Boolean(payload.hasAvatar), + hasIdNumber: Boolean(payload.hasIdNumber), + score: typeof payload.score === "number" && Number.isFinite(payload.score) ? payload.score : 0, + }; +} + +function normalizeMeResponse(value: MeResponse | Partial) { + return { + ...value, + profileCompleteness: normalizeProfileCompleteness(value.profileCompleteness), + } as MeResponse; +} + function readMustChangePasswordFromStorage() { return isWindowAvailable() && window.localStorage.getItem(MUST_CHANGE_PASSWORD_STORAGE_KEY) === "1"; } @@ -110,7 +139,7 @@ function readOfflineAuthSnapshot() { return { version: OFFLINE_AUTH_SNAPSHOT_VERSION, - me: parsed.me, + me: normalizeMeResponse(parsed.me as Partial), permissions: parsed.permissions.map((item) => String(item)), savedAt: savedAt.toISOString(), } satisfies OfflineAuthSnapshot; @@ -127,7 +156,7 @@ function writeOfflineAuthSnapshot(me: MeResponse, permissions: string[]) { const snapshot: OfflineAuthSnapshot = { version: OFFLINE_AUTH_SNAPSHOT_VERSION, - me, + me: normalizeMeResponse(me), permissions, savedAt: new Date().toISOString(), }; diff --git a/client/src/components/onboarding/WelcomeDialog.tsx b/client/src/components/onboarding/WelcomeDialog.tsx new file mode 100644 index 00000000..0458e128 --- /dev/null +++ b/client/src/components/onboarding/WelcomeDialog.tsx @@ -0,0 +1,106 @@ +import { Link } from "react-router-dom"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../ui/dialog"; + +type WelcomeLinkItem = { + icon: string; + label: string; + description: string; + to: string; +}; + +const WELCOME_LINKS: WelcomeLinkItem[] = [ + { + icon: "event_available", + label: "请假申请", + description: "提交和查看请假流程", + to: "/leave-requests", + }, + { + icon: "request_quote", + label: "报销提交", + description: "进入报销工作台创建单据", + to: "/reimbursements", + }, + { + icon: "schedule", + label: "考勤打卡", + description: "查看今日状态并完成打卡", + to: "/attendance", + }, + { + icon: "manage_accounts", + label: "个人设置", + description: "补充资料并维护通知偏好", + to: "/settings/profile", + }, + { + icon: "notifications", + label: "通知中心", + description: "查看审批和业务消息", + to: "/notifications", + }, +]; + +export function WelcomeDialog({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + return ( + !nextOpen && onClose()}> + + + 欢迎加入海音达工作台! + + 你的账号已就绪。以下是常用功能入口,点击即可开始使用: + + + +
+ {WELCOME_LINKS.map((item) => ( + +
+
+ {item.icon} +
+
+
{item.label}
+
{item.description}
+
+
+ + ))} +
+ +
+ 你可以随时在左侧菜单找到所有功能模块。 +
+ + + + +
+
+ ); +} diff --git a/client/src/lib/onboardingGuide.ts b/client/src/lib/onboardingGuide.ts new file mode 100644 index 00000000..637880c4 --- /dev/null +++ b/client/src/lib/onboardingGuide.ts @@ -0,0 +1,48 @@ +export const ONBOARDING_WELCOME_STORAGE_KEY = "hyb_oa_show_welcome"; +export const ONBOARDING_ACTIVE_STORAGE_KEY = "hyb_oa_onboarding_active"; +export const ONBOARDING_DISMISSED_STORAGE_KEY = "hyb_oa_getting_started_dismissed"; + +function canUseLocalStorage() { + return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; +} + +export function markOnboardingEntryReady() { + if (!canUseLocalStorage()) { + return; + } + + window.localStorage.setItem(ONBOARDING_WELCOME_STORAGE_KEY, "1"); + window.localStorage.setItem(ONBOARDING_ACTIVE_STORAGE_KEY, "1"); + window.localStorage.removeItem(ONBOARDING_DISMISSED_STORAGE_KEY); +} + +export function shouldShowWelcomeDialog() { + return canUseLocalStorage() && window.localStorage.getItem(ONBOARDING_WELCOME_STORAGE_KEY) === "1"; +} + +export function clearWelcomeDialogFlag() { + if (!canUseLocalStorage()) { + return; + } + + window.localStorage.removeItem(ONBOARDING_WELCOME_STORAGE_KEY); +} + +export function shouldShowGettingStartedCard() { + if (!canUseLocalStorage()) { + return false; + } + + return ( + window.localStorage.getItem(ONBOARDING_ACTIVE_STORAGE_KEY) === "1" && + window.localStorage.getItem(ONBOARDING_DISMISSED_STORAGE_KEY) !== "1" + ); +} + +export function dismissGettingStartedCard() { + if (!canUseLocalStorage()) { + return; + } + + window.localStorage.setItem(ONBOARDING_DISMISSED_STORAGE_KEY, "1"); +} diff --git a/client/src/lib/testIds.ts b/client/src/lib/testIds.ts index 5d01f364..9c169faa 100644 --- a/client/src/lib/testIds.ts +++ b/client/src/lib/testIds.ts @@ -7,6 +7,12 @@ export const TEST_IDS = { topbar: { profileTrigger: "topbar-profile-trigger", }, + onboarding: { + welcomeDialog: "onboarding-welcome-dialog", + welcomeClose: "onboarding-welcome-close", + gettingStartedCard: "onboarding-getting-started-card", + gettingStartedDismiss: "onboarding-getting-started-dismiss", + }, workflow: { tasksTable: "workflow-tasks-table", taskRow: "workflow-task-row", diff --git a/client/src/pages/ChangePasswordPage.tsx b/client/src/pages/ChangePasswordPage.tsx index e6876b93..450ac711 100644 --- a/client/src/pages/ChangePasswordPage.tsx +++ b/client/src/pages/ChangePasswordPage.tsx @@ -6,6 +6,7 @@ import { useAuth } from "../auth/AuthContext"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; +import { markOnboardingEntryReady } from "../lib/onboardingGuide"; const PASSWORD_REQUIREMENTS = [ "至少 12 位", @@ -35,6 +36,7 @@ export function ChangePasswordPage() { setSubmitting(true); try { await changePassword(oldPassword, newPassword); + markOnboardingEntryReady(); navigate("/portal", { replace: true }); } catch (err) { if (isApiError(err)) { @@ -127,4 +129,3 @@ export function ChangePasswordPage() { ); } - diff --git a/client/src/pages/PortalHomePage.tsx b/client/src/pages/PortalHomePage.tsx index d26bd648..c73d1fc4 100644 --- a/client/src/pages/PortalHomePage.tsx +++ b/client/src/pages/PortalHomePage.tsx @@ -1,3 +1,4 @@ +import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -19,12 +20,19 @@ import { useAuth } from "../auth/AuthContext"; import { hasAnyPermission, isManagerRole } from "../auth/permissions"; import { EmployeeAvatar } from "../components/common/EmployeeAvatar"; import { EmptyStateCard } from "../components/common/EmptyStateCard"; +import { WelcomeDialog } from "../components/onboarding/WelcomeDialog"; import { MetricCard } from "../components/patterns/MetricCard"; 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 { employeePortalSchema, managerPortalSchema } from "../features/portal/schema"; +import { + clearWelcomeDialogFlag, + dismissGettingStartedCard, + shouldShowGettingStartedCard, + shouldShowWelcomeDialog, +} from "../lib/onboardingGuide"; import { readRecentVisits } from "../lib/recentVisits"; type SummaryItem = { @@ -54,6 +62,14 @@ type QuickActionConfig = { disabled?: boolean; }; +type GettingStartedChecklistItem = { + key: string; + title: string; + description: string; + href: string; + completed: boolean; +}; + const LEAVE_PORTAL_PERMISSIONS = ["leave.view_own", "leave.view_all", "leave.create", "leave.submit"]; const EXPENSE_PORTAL_PERMISSIONS = ["expense.view_own", "expense.view_all", "expense.create", "expense.proxy_manage"]; const TODO_SEVERITY_RANK = { @@ -481,6 +497,70 @@ function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullabl ); } +function GettingStartedCard({ + items, + completedCount, + onDismiss, +}: { + items: GettingStartedChecklistItem[]; + completedCount: number; + onDismiss: () => void; +}) { + const progressPercent = Math.round((completedCount / items.length) * 100); + + return ( + + 关闭清单 + + )} + className="shadow-sm" + > +
+ {items.map((item) => ( + + + + {item.completed ? "check_circle" : "radio_button_unchecked"} + + +
+
{item.title}
+
{item.description}
+
+ arrow_forward + + ))} + +
+
+ 完成进度 + {completedCount}/{items.length} +
+
+
+
+
+
+ + ); +} + function QuickActionTile({ icon, label, @@ -625,7 +705,9 @@ function RecentActivitiesPanel({ items }: { items: PortalRecentActivity[] }) { export function PortalHomePage() { const queryClient = useQueryClient(); - const { permissions } = useAuth(); + const { me, permissions } = useAuth(); + const [showWelcome, setShowWelcome] = useState(false); + const [showGettingStarted, setShowGettingStarted] = useState(false); const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() }); const attendanceTodayQuery = useQuery({ queryKey: ["portal-home", "attendance", "today"], @@ -635,6 +717,11 @@ export function PortalHomePage() { const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]); const isManagerView = isManagerRole(permissions); + useEffect(() => { + setShowWelcome(shouldShowWelcomeDialog()); + setShowGettingStarted(shouldShowGettingStartedCard()); + }, []); + const attendanceClockInMutation = useMutation({ mutationFn: () => clockInAttendance(), onSuccess: async (response) => { @@ -696,6 +783,47 @@ export function PortalHomePage() { pending: attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending, }); const todoAggregateItems = buildTodoAggregateItems(payload); + const gettingStartedItems = useMemo(() => ([ + { + 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() { + clearWelcomeDialogFlag(); + setShowWelcome(false); + } + + function handleDismissGettingStarted() { + dismissGettingStartedCard(); + setShowGettingStarted(false); + } const employeeSummaryItems = ([ { @@ -794,6 +922,8 @@ export function PortalHomePage() { return ( + + + {showGettingStarted ? ( + + ) : null} +
diff --git a/client/tests/e2e/helpers/auth.ts b/client/tests/e2e/helpers/auth.ts index 1496f198..d150f38c 100644 --- a/client/tests/e2e/helpers/auth.ts +++ b/client/tests/e2e/helpers/auth.ts @@ -47,6 +47,13 @@ export type MeResponse = { email: string; roleCodes: string[]; permissions: string[]; + profileCompleteness?: { + hasEmergencyContact: boolean; + hasBankAccount: boolean; + hasAvatar: boolean; + hasIdNumber: boolean; + score: number; + }; }; export type AuthenticatedApiClient = { diff --git a/client/tests/e2e/login-portal-ux.spec.ts b/client/tests/e2e/login-portal-ux.spec.ts index fbf0b4b6..25918ce4 100644 --- a/client/tests/e2e/login-portal-ux.spec.ts +++ b/client/tests/e2e/login-portal-ux.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { EMPLOYEE, login } from "./helpers/auth"; +import { TEST_IDS } from "./helpers/testIds"; const ADMIN = { identifier: "admin@hyinda.com", password: "AdminPassword1234", name: "Admin" }; @@ -25,7 +26,15 @@ function mockMe(page: import("@playwright/test").Page, roleCodes: string[], name departmentId: "dept-1", directManagerId: null, status: "active", + notificationPushEnabled: true, roleCodes, + profileCompleteness: { + hasEmergencyContact: true, + hasBankAccount: true, + hasAvatar: false, + hasIdNumber: false, + score: 50, + }, wecomBinding: null, })), }); @@ -258,6 +267,28 @@ test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page await expect(page.getByRole("link", { name: "采购申请" })).toBeVisible(); }); +test("Portal 新员工引导先显示欢迎弹窗,再显示快速上手清单", async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem("hyb_oa_show_welcome", "1"); + window.localStorage.setItem("hyb_oa_onboarding_active", "1"); + window.localStorage.removeItem("hyb_oa_getting_started_dismissed"); + }); + + await mockNavigation(page, "employee"); + await mockPortalHome(page, "employee"); + await mockAttendanceToday(page); + await mockMe(page, ["employee"], "Employee"); + + await login(page, EMPLOYEE); + + await expect(page.getByTestId(TEST_IDS.onboarding.welcomeDialog)).toBeVisible(); + await page.getByTestId(TEST_IDS.onboarding.welcomeClose).click(); + await expect(page.getByTestId(TEST_IDS.onboarding.welcomeDialog)).not.toBeVisible(); + await expect(page.getByTestId(TEST_IDS.onboarding.gettingStartedCard)).toBeVisible(); + await page.getByTestId(TEST_IDS.onboarding.gettingStartedDismiss).click(); + await expect(page.getByTestId(TEST_IDS.onboarding.gettingStartedCard)).not.toBeVisible(); +}); + test("Portal 域管理角色也进入经理视图", async ({ page }) => { await mockNavigation(page, "manager"); await mockPortalHome(page, "manager"); diff --git a/issues/2026-04-14_r64-onboarding-guide.csv b/issues/2026-04-14_r64-onboarding-guide.csv new file mode 100644 index 00000000..9150e54d --- /dev/null +++ b/issues/2026-04-14_r64-onboarding-guide.csv @@ -0,0 +1,7 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +"A1","R64 执行合同建档与范围冻结","创建并校验 R64 新员工入门引导执行合同 CSV,冻结范围:1) /api/me 返回 profileCompleteness;2) 改密成功后首次进入 /portal 弹出 WelcomeDialog;3) Portal 首页新增仅新员工路径可见的 GettingStartedCard;4) localStorage 持久化引导状态。不新增 Prisma 迁移,不新增 npm 依赖。","issues/2026-04-14_r64-onboarding-guide.csv 列完整、Dependencies 为 JSON 数组字符串、A2-A6 范围与当前方案一致,且 Notes 明确无迁移、无新依赖、localStorage 持久化与回滚方式。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-14_r64-onboarding-guide.csv。检查:1) 12 列完整;2) A2-A6 依赖链合理;3) Notes 明确无迁移、无新依赖、localStorage 持久化与回滚路径。","manual","DONE","DONE","DONE","issues/2026-04-14_r64-onboarding-guide.csv","[]","回滚路径:删除本次 CSV 不影响业务。" +"A2","/api/me 增加 profileCompleteness","在 server/src/routes/me.js 的 GET /api/me 响应中新增 profileCompleteness,对紧急联系人、银行账户、头像、身份证号四项做布尔校验并按 25 分制计算 score;同步扩展前端 MeResponse 类型。","1) /api/me 返回 profileCompleteness.hasEmergencyContact/hasBankAccount/hasAvatar/hasIdNumber/score;2) score 固定为 0|25|50|75|100;3) 原有 notificationPushEnabled、roleCodes、wecomBinding 响应不回归;4) me 单测覆盖空资料与部分完成资料场景。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/me.js && npm test -- tests/unit/routes/me.test.js。业务:1) 四项都为空时 score=0;2) 已填两项时 score=50;3) 原有 /api/me 字段保持可用。","manual","DONE","DONE","DONE","server/src/routes/me.js|server/tests/unit/routes/me.test.js|client/src/api/types.ts|client/src/auth/AuthContext.tsx","[""A1""]","回滚路径:移除 profileCompleteness 响应字段、类型定义与兼容逻辑。无 Prisma 迁移,无新依赖。" +"A3","改密后 WelcomeDialog 欢迎引导","新增 WelcomeDialog,并在 ChangePasswordPage 改密成功后写入 hyb_oa_show_welcome 与 hyb_oa_onboarding_active;PortalHomePage 读取欢迎 flag 后弹出欢迎对话框,关闭或点击入口后清除欢迎 flag。","1) 改密成功后 navigate('/portal') 前写入 hyb_oa_show_welcome=1 与 hyb_oa_onboarding_active=1;2) /portal 首次进入显示 WelcomeDialog;3) 点击入口卡片会关闭弹窗并跳转正确页面;4) 点击“开始使用”、遮罩关闭或 ESC 关闭都会清除 hyb_oa_show_welcome;5) 不修改登录流程和改密校验逻辑。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 改密成功后首次进入 /portal 弹出欢迎对话框;2) 关闭后不重复显示;3) 入口跳转到请假/报销/考勤/个人设置/通知中心。","manual","DONE","DONE","DONE","client/src/pages/ChangePasswordPage.tsx|client/src/components/onboarding/WelcomeDialog.tsx|client/src/pages/PortalHomePage.tsx","[""A1"",""A2""]","回滚路径:移除 WelcomeDialog 组件与改密后写入欢迎 flag 的逻辑。引导状态仅保存在 localStorage。" +"A4","Portal 首页入门清单卡片","在 PortalHomePage 的 Hero 下方、QuickActionsPanel 上方新增 GettingStartedCard,仅在 hyb_oa_onboarding_active=1 且 hyb_oa_getting_started_dismissed != 1 时显示,使用 /api/me 返回的 profileCompleteness 与静态规则计算完成进度。","1) GettingStartedCard 只在新员工 onboarding 本地路径显示;2) “完善个人资料”在 profileCompleteness.score >= 50 时显示已完成;3) 进度条和已完成数量按 4 项规则正确显示;4) 点击“关闭清单”后写入 hyb_oa_getting_started_dismissed=1 并隐藏;5) 现有 Hero、快捷操作、公告等 Portal 区块不回归。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 预置 onboarding flag 后打开 /portal 可见“快速上手”;2) 关闭欢迎弹窗后同次 onboarding 流仍可见清单;3) 关闭清单后刷新不再显示。","manual","DONE","DONE","DONE","client/src/pages/PortalHomePage.tsx|client/src/components/onboarding/WelcomeDialog.tsx|client/src/api/types.ts","[""A1"",""A2"",""A3""]","回滚路径:移除 GettingStartedCard、本地 onboarding 标记读取与进度显示逻辑。引导状态仅保存在 localStorage。" +"A5","测试与回归验证","执行 R64 后端 me 路由检查、前端 typecheck/build,并扩展现有 Portal E2E mock 与 onboarding 场景,确认 profileCompleteness、WelcomeDialog 和 GettingStartedCard 无回归。","1) /api/me 返回 profileCompleteness;2) server/tests/unit/routes/me.test.js 通过;3) client typecheck/build 通过;4) login-portal-ux 相关 mock 可承接 profileCompleteness;5) onboarding 场景可验证欢迎弹窗与清单显示/关闭逻辑。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/me.js && npm test -- tests/unit/routes/me.test.js && cd ../client && npm run typecheck && npm run build。业务:1) 预置 hyb_oa_show_welcome=1 与 hyb_oa_onboarding_active=1 打开 /portal,可见欢迎弹窗;2) 关闭欢迎后可见快速上手;3) 点击关闭清单后卡片消失。","manual","DONE","DONE","DONE","server/src/routes/me.js|server/tests/unit/routes/me.test.js|client/src/api/types.ts|client/src/auth/AuthContext.tsx|client/src/pages/ChangePasswordPage.tsx|client/src/components/onboarding/WelcomeDialog.tsx|client/src/pages/PortalHomePage.tsx|client/tests/e2e/login-portal-ux.spec.ts","[""A2"",""A3"",""A4""]","回滚路径:若 post-check 或 onboarding 场景失败,暂停提交并回退对应 R64 代码。无 Prisma 迁移,无新依赖。" +"A6","QA、状态回填与提交收口","在所有校验通过后回填 R64 CSV 状态,确认 /api/me、WelcomeDialog、GettingStartedCard 与 Portal 首页均符合预期,并完成 git commit 收口。","1) issues/2026-04-14_r64-onboarding-guide.csv 的 Dev_Status、Review1_Status、Regression_Status 按实测结果回填;2) 所有验证命令通过;3) git commit 成功,提交信息为 R64 A1-A6:新员工入门引导。","命令:1) python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-14_r64-onboarding-guide.csv;2) export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/me.js && npm test -- tests/unit/routes/me.test.js && cd ../client && npm run typecheck && npm run build;3) git status 确认仅包含 R64 相关变更。业务:核对改密后欢迎弹窗、Portal 入门清单和 /api/me 完成度字段最终行为与方案一致。","manual","DONE","DONE","DONE","issues/2026-04-14_r64-onboarding-guide.csv|server/src/routes/me.js|server/tests/unit/routes/me.test.js|client/src/api/types.ts|client/src/auth/AuthContext.tsx|client/src/pages/ChangePasswordPage.tsx|client/src/components/onboarding/WelcomeDialog.tsx|client/src/pages/PortalHomePage.tsx|client/tests/e2e/login-portal-ux.spec.ts","[""A5""]","回滚路径:若验证失败则不提交,先修复后再回填状态。commit message:R64 A1-A6:新员工入门引导。无 Prisma 迁移,无新依赖。" diff --git a/server/src/routes/me.js b/server/src/routes/me.js index 078d6571..66151213 100644 --- a/server/src/routes/me.js +++ b/server/src/routes/me.js @@ -28,6 +28,22 @@ function parseNotificationPreferences(body) { return parsed.data; } +function buildProfileCompleteness(employee) { + const hasEmergencyContact = Boolean(employee?.emergency_contact_name && employee?.emergency_contact_phone); + const hasBankAccount = Boolean(employee?.bank_account_encrypted && employee?.bank_name); + const hasAvatar = Boolean(employee?.avatar_path); + const hasIdNumber = Boolean(employee?.id_number_encrypted); + const completedCount = [hasEmergencyContact, hasBankAccount, hasAvatar, hasIdNumber].filter(Boolean).length; + + return { + hasEmergencyContact, + hasBankAccount, + hasAvatar, + hasIdNumber, + score: completedCount * 25, + }; +} + async function meRoutes(fastify) { fastify.get( "/me", @@ -46,6 +62,12 @@ async function meRoutes(fastify) { department_id: true, direct_manager_id: true, status: true, + emergency_contact_name: true, + emergency_contact_phone: true, + bank_account_encrypted: true, + bank_name: true, + avatar_path: true, + id_number_encrypted: true, notification_push_enabled: true, roles: { select: { role_code: true } }, wecom_identity_binding: { select: { external_user_id: true, created_at: true } }, @@ -53,6 +75,7 @@ async function meRoutes(fastify) { }); const roleCodes = (employee?.roles ?? []).map((r) => r.role_code); + const profileCompleteness = buildProfileCompleteness(employee); return sendData(reply, request, { id: employee?.id ?? request.employee.id, employeeNo: employee?.employee_no ?? "", @@ -64,6 +87,7 @@ async function meRoutes(fastify) { status: employee?.status ?? "active", notificationPushEnabled: employee?.notification_push_enabled ?? true, roleCodes, + profileCompleteness, wecomBinding: employee?.wecom_identity_binding ? { externalUserId: employee.wecom_identity_binding.external_user_id, createdAt: employee.wecom_identity_binding.created_at?.toISOString?.() ?? null } : null, }); }, diff --git a/server/tests/unit/routes/me.test.js b/server/tests/unit/routes/me.test.js index 53d7de34..04e51337 100644 --- a/server/tests/unit/routes/me.test.js +++ b/server/tests/unit/routes/me.test.js @@ -14,6 +14,12 @@ function createPrismaState(overrides = {}) { department_id: 'department-1', direct_manager_id: 'manager-1', status: 'active', + emergency_contact_name: null, + emergency_contact_phone: null, + bank_account_encrypted: '', + bank_name: '', + avatar_path: null, + id_number_encrypted: null, notification_push_enabled: true, roles: [{ role_code: 'employee' }], wecom_identity_binding: null, @@ -107,12 +113,48 @@ describe('me routes', () => { expect.objectContaining({ id: 'employee-1', notificationPushEnabled: false, + profileCompleteness: { + hasEmergencyContact: false, + hasBankAccount: false, + hasAvatar: false, + hasIdNumber: false, + score: 0, + }, }), ); await app.close(); }); + it('calculates profileCompleteness score from completed profile fields', async () => { + const { prisma } = createPrismaState({ + emergency_contact_name: '联系人', + emergency_contact_phone: '13900000000', + bank_account_encrypted: 'ENC', + bank_name: '招商银行', + avatar_path: null, + id_number_encrypted: null, + }); + const app = await buildApp(prisma); + + const response = await app.inject({ + method: 'GET', + url: '/api/me', + headers: { authorization: 'Bearer test-token' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.profileCompleteness).toEqual({ + hasEmergencyContact: true, + hasBankAccount: true, + hasAvatar: false, + hasIdNumber: false, + score: 50, + }); + + await app.close(); + }); + it('updates notification preferences for current employee', async () => { const { prisma, employeeRecord, auditEntries } = createPrismaState(); const app = await buildApp(prisma);