refactor: 简化工作台上手入口和个人设置(portal-onboarding-profile-simplification)

这个提交包含在:
2026-05-29 18:28:40 +08:00
父节点 48260b57b7
当前提交 6b7dce4651
共修改 12 个文件,包含 443 行新增和 319 行删除
@@ -18,34 +18,22 @@ type WelcomeLinkItem = {
const WELCOME_LINKS: WelcomeLinkItem[] = [
{
icon: "event_available",
label: "请假申请",
description: "提交和查看请假流程",
to: "/leave-requests",
icon: "fact_check",
label: "处理待办",
description: "先看今天需要审批或跟进的事项",
to: "/workflow/tasks",
},
{
icon: "request_quote",
label: "报销提交",
description: "进入报销工作台创建单据",
label: "提交报销",
description: "录入票据和报销说明,查看处理进度",
to: "/reimbursements",
},
{
icon: "schedule",
label: "考勤汇总",
description: "查看企微打卡摘要并提交 OA 修正",
to: "/attendance",
},
{
icon: "manage_accounts",
label: "个人设置",
description: "补充资料并维护通知偏好",
to: "/settings/profile",
},
{
icon: "notifications",
label: "通知中心",
description: "查看审批和业务消息",
to: "/notifications",
icon: "event_available",
label: "请假",
description: "提交请假申请或查看自己的请假记录",
to: "/leave-requests?action=create",
},
];
@@ -60,22 +48,22 @@ export function WelcomeDialog({
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
<DialogContent className="max-w-2xl" data-testid="onboarding-welcome-dialog">
<DialogHeader>
<DialogTitle>欢迎加入海音达工作台!</DialogTitle>
<DialogTitle>常用入口</DialogTitle>
<DialogDescription>
你的账号已就绪。以下是常用功能入口,点击即可开始使用:
你的账号已就绪。先从工作台处理待办,也可以直接进入常用业务。
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-3">
{WELCOME_LINKS.map((item) => (
<Link
key={item.label}
to={item.to}
onClick={onClose}
className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 shadow-sm transition-colors hover:bg-zinc-50"
className="rounded-lg border border-zinc-200 bg-white px-4 py-4 shadow-sm transition-colors hover:bg-zinc-50"
>
<div className="flex items-start gap-3">
<div className="flex size-10 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<div className="flex h-full flex-col gap-3">
<div className="flex size-10 items-center justify-center rounded-md bg-primary/10 text-primary">
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
</div>
<div className="min-w-0">
@@ -87,8 +75,8 @@ export function WelcomeDialog({
))}
</div>
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-600">
你可以随时在左侧菜单找到所有功能模块。
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-600">
关闭后不再显示这个提示;所有功能都可以从左侧菜单进入。
</div>
<DialogFooter>
-130
查看文件
@@ -25,8 +25,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"
import { employeePortalSchema, managerPortalSchema } from "../features/portal/schema";
import {
clearWelcomeDialogFlag,
dismissGettingStartedCard,
shouldShowGettingStartedCard,
shouldShowWelcomeDialog,
} from "../lib/onboardingGuide";
import { readRecentVisits } from "../lib/recentVisits";
@@ -77,14 +75,6 @@ type PortalFocusLink = {
to: string;
};
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 PROCUREMENT_PORTAL_PERMISSIONS = ["procurement.view_own", "procurement.view_all", "procurement.create", "procurement.submit"];
@@ -581,70 +571,6 @@ function PortalSectionFallback({
);
}
function GettingStartedCard({
items,
completedCount,
onDismiss,
}: {
items: GettingStartedChecklistItem[];
completedCount: number;
onDismiss: () => void;
}) {
const progressPercent = items.length ? Math.round((completedCount / items.length) * 100) : 0;
return (
<SectionPanel
title="快速上手"
description="基础设置"
actions={(
<button
type="button"
className="text-sm font-medium text-zinc-500 transition-colors hover:text-zinc-700"
data-testid="onboarding-getting-started-dismiss"
onClick={onDismiss}
>
关闭清单
</button>
)}
className="shadow-sm"
>
<div className="space-y-3" data-testid="onboarding-getting-started-card">
{items.map((item) => (
<Link
key={item.key}
to={item.href}
className="portal-list-card flex items-center gap-3"
>
<span className={item.completed ? "text-emerald-600" : "text-zinc-400"}>
<span className="material-symbols-outlined text-[20px]">
{item.completed ? "check_circle" : "radio_button_unchecked"}
</span>
</span>
<div className="min-w-0 flex-1">
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="mt-1 text-sm leading-6 text-zinc-500">{item.description}</div>
</div>
<span className="material-symbols-outlined text-zinc-400">arrow_forward</span>
</Link>
))}
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3.5 py-3">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-zinc-900">完成进度</span>
<span className="text-zinc-500">{completedCount}/{items.length}</span>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-slate-200">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
</div>
</SectionPanel>
);
}
function QuickActionTile({
icon,
label,
@@ -882,7 +808,6 @@ function ActivityAnnouncementsPanel({
export function PortalHomePage() {
const { me, permissions } = useAuth();
const [showWelcome, setShowWelcome] = useState(false);
const [showGettingStarted, setShowGettingStarted] = useState(false);
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() });
const workflowSummaryQuery = useQuery({
enabled: hasAnyPermission(permissions, APPROVAL_PORTAL_PERMISSIONS),
@@ -898,41 +823,8 @@ export function PortalHomePage() {
useEffect(() => {
setShowWelcome(shouldShowWelcomeDialog());
setShowGettingStarted(shouldShowGettingStartedCard());
}, []);
const gettingStartedItems: GettingStartedChecklistItem[] = [
{
key: "profile",
title: "完善个人资料",
description: "补充紧急联系人和银行账户",
href: "/settings/profile",
completed: (me?.profileCompleteness?.score ?? 0) >= 50,
},
{
key: "notification-preferences",
title: "设置通知偏好",
description: "选择是否接收外部渠道推送",
href: "/settings/profile",
completed: true,
},
{
key: "leave-flow",
title: "了解请假流程",
description: "查看假期类型和余额",
href: "/leave-requests",
completed: false,
},
{
key: "notifications",
title: "查看通知中心",
description: "了解审批和业务消息",
href: "/notifications",
completed: false,
},
];
const gettingStartedCompletedCount = gettingStartedItems.filter((item) => item.completed).length;
if (portalQuery.isLoading) {
return <div className="card-std p-5 text-sm text-zinc-500">加载工作台中...</div>;
}
@@ -976,11 +868,6 @@ export function PortalHomePage() {
setShowWelcome(false);
}
function handleDismissGettingStarted() {
dismissGettingStartedCard();
setShowGettingStarted(false);
}
const employeeSummaryItems = ([
{
key: "expense",
@@ -1208,23 +1095,6 @@ export function PortalHomePage() {
hiddenFocusCount={Math.max(todoAggregateItems.length - 1, 0)}
/>
{showGettingStarted ? (
<ErrorBoundary
fallback={(
<PortalSectionFallback
title="快速上手"
description="基础设置"
/>
)}
>
<GettingStartedCard
items={gettingStartedItems}
completedCount={gettingStartedCompletedCount}
onDismiss={handleDismissGettingStarted}
/>
</ErrorBoundary>
) : null}
<SecondaryWorkspacePanel
title="团队与通知"
description="团队状态、动态和公告"
+139 -155
查看文件
@@ -31,12 +31,31 @@ function maskExternalUserId(value: string) {
return `${value.slice(0, 4)}****${value.slice(-4)}`;
}
function statusTone(status?: string) {
function accountStatusLabel(status?: string) {
if (status === "active") return "正常";
if (status === "inactive") return "停用";
return "待确认";
}
function accountStatusClassName(status?: string) {
if (status === "active") return "badge-success";
if (status === "inactive") return "badge-warning";
return "badge-neutral";
}
function roleLabel(roleCode: string) {
const labels: Record<string, string> = {
admin: "管理员",
ceo: "负责人",
employee: "员工",
finance: "财务",
finance_mgr: "财务负责人",
hr: "人事",
manager: "审批人",
};
return labels[roleCode] ?? roleCode;
}
function resolveProfileError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${error.message}`;
@@ -48,7 +67,7 @@ function resolveProfileError(error: unknown) {
}
export function ProfilePage() {
const { me, permissions, refreshMe } = useAuth();
const { me, refreshMe } = useAuth();
const [searchParams] = useSearchParams();
const wecomConfigQuery = useQuery({
queryKey: ["auth", "wecom-config"],
@@ -73,7 +92,7 @@ export function ProfilePage() {
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
onSuccess: async () => {
await refreshMe();
toast.success("企业微信消息提醒偏好已更新");
toast.success("通知设置已更新");
},
onError: (error) => {
toast.error(resolveProfileError(error));
@@ -83,174 +102,139 @@ export function ProfilePage() {
const wecomStatus = resolveWecomEntryStatus(wecomConfigQuery.data);
const bindNotice = resolveWecomBindNotice(searchParams.get("wecomBind"));
const isBound = Boolean(me?.wecomBinding?.externalUserId);
const roleLabels = (me?.roleCodes ?? []).map(roleLabel);
return (
<PageScaffold>
<PageHeader
title="个人设置"
eyebrow="账号与入口"
subtitle="维护个人资料、角色摘要与企业微信绑定状态。复杂业务仍在海音达工作台内完成,企业微信只负责进入与提醒。"
meta={<span className={statusTone(me?.status)}>{me?.status === "active" ? "账号正常" : "账号状态待确认"}</span>}
eyebrow="账号与通知"
subtitle="查看自己的账号信息,设置消息提醒和企业微信入口。员工档案由对应页面维护。"
meta={<span className={accountStatusClassName(me?.status)}>{accountStatusLabel(me?.status)}</span>}
/>
<div className="page-shell-grid">
<div className="space-y-6">
<SectionPanel
title="基本信息"
description="员工可直接理解的个人资料与联系方式。"
actions={
me?.id ? (
<Button asChild type="button">
<Link to={`/people/${me.id}`}>查看完整档案</Link>
</Button>
) : null
}
>
<dl className="grid gap-4 text-sm text-zinc-700 md:grid-cols-2">
<div>
<dt className="text-zinc-500">姓名</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.name ?? "-"}</dd>
</div>
<div>
<dt className="text-zinc-500">员工号(系统生成)</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.employeeNo ?? "-"}</dd>
</div>
<div>
<dt className="text-zinc-500">邮箱</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.email ?? "-"}</dd>
</div>
<div>
<dt className="text-zinc-500">手机号</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.phone ?? "-"}</dd>
</div>
</dl>
</SectionPanel>
{me?.id ? (
<SectionPanel title="完整员工档案" description="办公地点、紧急联系人、绩效和薪资等员工档案信息在完整档案页维护或查看。">
<div className="flex flex-col gap-3 text-sm text-zinc-600 md:flex-row md:items-center md:justify-between">
<p>进入完整档案页后,可按当前权限查看本人绩效、已发放工资条,并编辑自助开放的档案字段。</p>
<Button asChild type="button" variant="secondary">
<Link to={`/people/${me.id}`}>进入完整员工档案</Link>
</Button>
</div>
</SectionPanel>
) : null}
<SectionPanel title="企业微信消息提醒" description="控制是否接收企业微信个人应用消息;关闭后仍保留 OA 站内通知,不影响企业微信绑定与工作台登录。">
<div className="space-y-3 text-sm text-zinc-600">
<label className="flex items-center gap-3 text-zinc-800">
<input
type="checkbox"
className="h-4 w-4 rounded border-zinc-300"
checked={Boolean(me?.notificationPushEnabled ?? true)}
disabled={!me || notificationPreferenceMutation.isPending}
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
/>
接收企业微信消息提醒
</label>
<p>适用于审批待办、报销付款、资产流转等业务提醒。关闭后不会停止站内通知,也不会移除你的业务权限。</p>
<div className="mx-auto max-w-5xl space-y-5">
<SectionPanel
title="账号信息"
description="这些信息用于识别你的账号。需要维护更多员工档案时,进入完整档案页。"
actions={
me?.id ? (
<Button asChild type="button" variant="secondary">
<Link to={`/people/${me.id}`}>查看员工档案</Link>
</Button>
) : null
}
>
<dl className="grid gap-4 text-sm text-zinc-700 md:grid-cols-2">
<div>
<dt className="text-zinc-500">姓名</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.name ?? "-"}</dd>
</div>
</SectionPanel>
<SectionPanel title="企业微信绑定" description="绑定后可从企业微信工作台直接进入海音达工作台;消息只做提醒与回流,不承接第二套业务录入。">
<div className="space-y-4" data-testid="profile-wecom-card">
{bindNotice ? (
<div className={`rounded-2xl border px-4 py-3 text-sm ${noticeClasses(bindNotice.tone)}`} data-testid="profile-wecom-notice">
<div className="font-medium">{bindNotice.title}</div>
<div className="mt-1">{bindNotice.description}</div>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="surface-muted px-4 py-4">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">接入状态</div>
<div className="mt-2 text-lg font-semibold text-zinc-900">{wecomStatus.label}</div>
<div className="mt-2 text-sm leading-6 text-zinc-600">{wecomStatus.description}</div>
{wecomStatus.reasonLabels.length ? (
<ul className="mt-3 list-disc space-y-1 pl-5 text-xs text-zinc-500">
{wecomStatus.reasonLabels.map((label) => (
<li key={label}>{label}</li>
))}
</ul>
) : null}
</div>
<div className="surface-muted px-4 py-4">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">当前绑定状态</div>
<div className="mt-2 text-lg font-semibold text-zinc-900">{isBound ? "已绑定" : "未绑定"}</div>
<div className="mt-2 text-sm leading-6 text-zinc-600">
{isBound ? "下次可直接从企业微信工作台进入海音达工作台。" : "未绑定时,企业微信登录会自动回退到账号密码登录。"}
</div>
{isBound ? (
<div className="mt-3 rounded-xl border border-zinc-200 bg-white px-3 py-3 text-xs text-zinc-500">
<div>绑定标识:{maskExternalUserId(me?.wecomBinding?.externalUserId ?? "")}</div>
<div className="mt-1">绑定时间:{me?.wecomBinding?.createdAt ? new Date(me.wecomBinding.createdAt).toLocaleString() : "-"}</div>
</div>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
disabled={!wecomConfigQuery.data?.bindEnabled || bindMutation.isPending}
onClick={() => bindMutation.mutate()}
>
{isBound ? "重新绑定企业微信" : "立即绑定企业微信"}
</Button>
<Button
type="button"
variant="secondary"
disabled={!isBound || unbindMutation.isPending}
onClick={() => unbindMutation.mutate()}
>
解绑
</Button>
</div>
<div>
<dt className="text-zinc-500">员工号</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.employeeNo ?? "-"}</dd>
</div>
</SectionPanel>
</div>
<div>
<dt className="text-zinc-500">邮箱</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.email ?? "-"}</dd>
</div>
<div>
<dt className="text-zinc-500">手机号</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.phone ?? "-"}</dd>
</div>
</dl>
</SectionPanel>
<div className="space-y-6">
<SectionPanel title="组织信息" description="对员工展示可理解的说明,不直接暴露内部标识字段。">
<div className="space-y-4 text-sm text-zinc-600">
<SectionPanel title="通知设置" description="选择是否接收企业微信个人应用提醒。">
<div className="space-y-3 text-sm text-zinc-600">
<label className="flex items-center gap-3 text-zinc-800">
<input
type="checkbox"
className="h-4 w-4 rounded border-zinc-300"
checked={Boolean(me?.notificationPushEnabled ?? true)}
disabled={!me || notificationPreferenceMutation.isPending}
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
/>
接收企业微信消息提醒
</label>
<p>关闭后仍会保留 OA 站内通知,不影响你的业务权限和工作台登录。</p>
</div>
</SectionPanel>
<SectionPanel title="企业微信绑定" description="绑定后可从企业微信工作台直接进入 OA;企业微信只负责入口和提醒。">
<div className="space-y-4" data-testid="profile-wecom-card">
{bindNotice ? (
<div className={`rounded-lg border px-4 py-3 text-sm ${noticeClasses(bindNotice.tone)}`} data-testid="profile-wecom-notice">
<div className="font-medium">{bindNotice.title}</div>
<div className="mt-1">{bindNotice.description}</div>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="surface-muted px-4 py-4">
<div className="font-medium text-zinc-900">组织归属</div>
<div className="mt-2 leading-6">部门与直属汇报关系由管理员统一维护,当前个人页只展示员工可理解的设置入口,避免把内部 ID 直接暴露给员工。</div>
<div className="text-sm text-zinc-500">企业微信入口</div>
<div className="mt-2 text-lg font-semibold text-zinc-900">{wecomStatus.label}</div>
<div className="mt-2 text-sm leading-6 text-zinc-600">{wecomStatus.description}</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-zinc-200 px-4 py-4">
<div className="text-zinc-500">部门维护状态</div>
<div className="mt-2 font-medium text-zinc-900">{me?.departmentId ? "已关联组织" : "待管理员维护"}</div>
</div>
<div className="rounded-2xl border border-zinc-200 px-4 py-4">
<div className="text-zinc-500">直属汇报关系</div>
<div className="mt-2 font-medium text-zinc-900">{me?.directManagerId ? "已维护" : "待管理员维护"}</div>
</div>
</div>
</div>
</SectionPanel>
<SectionPanel title="账号状态与角色" description="角色与权限保持透明,但不让设置页变成技术字段堆叠。">
<div className="space-y-4 text-sm text-zinc-600">
<div className="flex flex-wrap gap-2">
<span className={statusTone(me?.status)}>{me?.status === "active" ? "active" : me?.status ?? "unknown"}</span>
{(me?.roleCodes ?? []).map((role) => (
<span className="badge-neutral" key={role}>{role}</span>
))}
</div>
<div>
<div className="text-sm font-medium text-zinc-900">权限摘要</div>
<div className="mt-2 flex flex-wrap gap-2">
{permissions.map((permission) => (
<span className="badge-neutral" key={permission}>{permission}</span>
))}
<div className="surface-muted px-4 py-4">
<div className="text-sm text-zinc-500">当前绑定</div>
<div className="mt-2 text-lg font-semibold text-zinc-900">{isBound ? "已绑定" : "未绑定"}</div>
<div className="mt-2 text-sm leading-6 text-zinc-600">
{isBound ? "下次可直接从企业微信工作台进入 OA。" : "未绑定时仍可使用账号密码登录。"}
</div>
{isBound ? (
<div className="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-3 text-xs text-zinc-500">
<div>绑定标识:{maskExternalUserId(me?.wecomBinding?.externalUserId ?? "")}</div>
<div className="mt-1">绑定时间:{me?.wecomBinding?.createdAt ? new Date(me.wecomBinding.createdAt).toLocaleString() : "-"}</div>
</div>
) : null}
</div>
</div>
</SectionPanel>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
disabled={!wecomConfigQuery.data?.bindEnabled || bindMutation.isPending}
onClick={() => bindMutation.mutate()}
>
{isBound ? "重新绑定" : "绑定企业微信"}
</Button>
<Button
type="button"
variant="secondary"
disabled={!isBound || unbindMutation.isPending}
onClick={() => unbindMutation.mutate()}
>
解绑
</Button>
</div>
</div>
</SectionPanel>
<details className="card-std">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-5 py-4">
<div>
<div className="text-base font-semibold text-zinc-900">账号详情</div>
<div className="mt-1 text-sm text-zinc-500">组织归属和角色摘要,通常不需要修改。</div>
</div>
<span className="text-sm text-zinc-500">展开 / 收起</span>
</summary>
<div className="grid gap-3 border-t border-zinc-200 px-5 py-5 text-sm text-zinc-700 md:grid-cols-3">
<div className="rounded-lg border border-zinc-200 px-4 py-4">
<div className="text-zinc-500">部门状态</div>
<div className="mt-2 font-medium text-zinc-900">{me?.departmentId ? "已关联组织" : "待管理员维护"}</div>
</div>
<div className="rounded-lg border border-zinc-200 px-4 py-4">
<div className="text-zinc-500">直属汇报关系</div>
<div className="mt-2 font-medium text-zinc-900">{me?.directManagerId ? "已维护" : "待管理员维护"}</div>
</div>
<div className="rounded-lg border border-zinc-200 px-4 py-4">
<div className="text-zinc-500">角色</div>
<div className="mt-2 font-medium text-zinc-900">{roleLabels.length ? roleLabels.join("、") : "员工"}</div>
</div>
</div>
</details>
</div>
</PageScaffold>
);