重构个人设置与管理边界(05-30-profile-settings-boundary)

这个提交包含在:
2026-05-30 11:01:44 +08:00
父节点 9557d1e789
当前提交 9203b79d78
共修改 9 个文件,包含 111 行新增和 52 行删除
@@ -1192,12 +1192,18 @@ training or permission-maintenance surfaces:
- `个人设置` default content is personal profile, password/security, - `个人设置` default content is personal profile, password/security,
notification preference and Enterprise WeChat binding. Organization, report notification preference and Enterprise WeChat binding. Organization, report
line and role summary may be folded under `账号详情`; permission code lists line and role summary may be folded under `账号详情`; permission code lists
and full employee dossier fields stay out of the default view. and full employee dossier fields stay out of the default view. Do not add a
default `查看员工档案` / employee-maintenance jump from the ordinary profile
page; profile fields are read-only personal context, while dossier
maintenance belongs to `人员` / `管理`.
- Keep account settings separate from admin configuration. `个人设置` may show - Keep account settings separate from admin configuration. `个人设置` may show
personal profile, password/security, notification preferences and Enterprise personal profile, password/security, notification preferences and Enterprise
WeChat binding only. Organization maintenance, role assignment, approval WeChat binding only. Organization maintenance, role assignment, approval
rules, feature flags, master data, audit logs, employee maintenance and rules, feature flags, master data, audit logs, employee maintenance and
system configuration belong under `管理`, not the ordinary profile route. system configuration belong under `管理`, not the ordinary profile route.
- Mobile `我的` and `个人设置` must not expose an admin matrix or raw internal
labels. Hide `管理入口` / `人员管理` / `组织架构` / `系统设置` from mobile `我的`,
and map account status plus role codes to Chinese labels before display.
**Why**: Small teams need fewer default decisions. Persistent onboarding and **Why**: Small teams need fewer default decisions. Persistent onboarding and
permission-heavy profile pages make ordinary users inspect system internals permission-heavy profile pages make ordinary users inspect system internals
@@ -3,7 +3,7 @@
"name": "profile-settings-boundary", "name": "profile-settings-boundary",
"title": "个人设置与管理配置边界重构", "title": "个人设置与管理配置边界重构",
"description": "重构个人设置、移动我的和管理配置入口边界:个人只放个人资料/安全/通知/企微绑定,管理能力统一进管理。", "description": "重构个人设置、移动我的和管理配置入口边界:个人只放个人资料/安全/通知/企微绑定,管理能力统一进管理。",
"status": "planning", "status": "in_progress",
"dev_type": null, "dev_type": null,
"scope": "frontend-page-redesign", "scope": "frontend-page-redesign",
"package": null, "package": null,
+3 -24
查看文件
@@ -53,14 +53,6 @@ function MobileActionLink({
export function MobileMePage() { export function MobileMePage() {
const { me, permissions, refreshMe, logout } = useAuth(); const { me, permissions, refreshMe, logout } = useAuth();
const canAccessAdmin = hasAnyPermission(permissions, [
"*",
"system.settings",
"system.users",
"masterdata.manage",
"notification.channel.manage",
"integration.manage",
]);
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]); const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const canAccessProjects = hasAnyPermission(permissions, ["project.view_own", "project.view_all", "project.manage"]); const canAccessProjects = hasAnyPermission(permissions, ["project.view_own", "project.view_all", "project.manage"]);
const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]); const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
@@ -115,17 +107,14 @@ export function MobileMePage() {
<SectionPanel title="账号与绑定"> <SectionPanel title="账号与绑定">
<div className="grid gap-2"> <div className="grid gap-2">
<MobileActionLink to="/mobile/profile" label="个人设置" description="企业微信、账号与通知" icon="manage_accounts" /> <MobileActionLink to="/mobile/profile" label="个人设置" description="企业微信、账号安全与通知" icon="manage_accounts" />
{me?.id ? (
<MobileActionLink to="/mobile/profile" label="账号资料" description="员工号和联系方式" icon="badge" />
) : null}
</div> </div>
</SectionPanel> </SectionPanel>
<SectionPanel title="更多入口" description="常用移动入口集中在这里。"> <SectionPanel title="移动入口" description="只保留手机上常用的个人办事入口。">
<div className="mobile-entry-grid mobile-me-entry-grid" data-testid="mobile-more-links"> <div className="mobile-entry-grid mobile-me-entry-grid" data-testid="mobile-more-links">
<MobileActionLink to="/mobile/notifications" label="通知中心" icon="notifications" /> <MobileActionLink to="/mobile/notifications" label="通知中心" icon="notifications" />
<MobileActionLink to="/mobile/leave" label="请假" icon="event_available" /> <MobileActionLink to="/mobile/leave" label="请假记录" icon="event_available" />
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" /> <MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
{canAccessPurchase ? <MobileActionLink to="/mobile/purchase-requests" label="采购" icon="shopping_cart" /> : null} {canAccessPurchase ? <MobileActionLink to="/mobile/purchase-requests" label="采购" icon="shopping_cart" /> : null}
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "待办" : "进度"} icon="fact_check" /> <MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "待办" : "进度"} icon="fact_check" />
@@ -135,16 +124,6 @@ export function MobileMePage() {
</div> </div>
</SectionPanel> </SectionPanel>
{canAccessAdmin ? (
<SectionPanel title="管理入口">
<div className="mobile-entry-grid mobile-me-entry-grid">
<MobileActionLink to="/mobile/me" label="人员管理" icon="group" />
<MobileActionLink to="/mobile/me" label="组织架构" icon="account_tree" />
<MobileActionLink to="/mobile/me" label="系统设置" icon="settings" />
</div>
</SectionPanel>
) : null}
<SectionPanel title="企业微信消息提醒"> <SectionPanel title="企业微信消息提醒">
<label className="flex items-center gap-3 text-sm text-zinc-800"> <label className="flex items-center gap-3 text-sm text-zinc-800">
<input <input
+60 -12
查看文件
@@ -1,6 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { fetchWecomConfig, startWecomBind, unbindWecom, updateNotificationPreferences } from "../api/auth"; import { fetchWecomConfig, startWecomBind, unbindWecom, updateNotificationPreferences } from "../api/auth";
import { isApiError } from "../api/types"; import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
@@ -30,6 +30,25 @@ function statusTone(status?: string) {
return "badge-neutral"; return "badge-neutral";
} }
function accountStatusLabel(status?: string) {
if (status === "active") return "账号正常";
if (status === "inactive") return "账号停用";
return "账号待确认";
}
function roleLabel(roleCode: string) {
const labels: Record<string, string> = {
admin: "管理员",
ceo: "负责人",
employee: "员工",
finance: "财务",
finance_mgr: "财务负责人",
hr: "人事",
manager: "审批人",
};
return labels[roleCode] ?? "其他角色";
}
function resolveProfileError(error: unknown) { function resolveProfileError(error: unknown) {
if (isApiError(error)) { if (isApiError(error)) {
return `${error.code}: ${error.message}`; return `${error.code}: ${error.message}`;
@@ -107,13 +126,14 @@ export function MobileProfilePage() {
const isBound = Boolean(binding.externalUserId); const isBound = Boolean(binding.externalUserId);
const wecomStatus = resolveWecomEntryStatus(wecomConfigQuery.data); const wecomStatus = resolveWecomEntryStatus(wecomConfigQuery.data);
const bindNotice = resolveWecomBindNotice(searchParams.get("wecomBind")); const bindNotice = resolveWecomBindNotice(searchParams.get("wecomBind"));
const roleLabels = (me?.roleCodes ?? []).map(roleLabel);
return ( return (
<PageScaffold className="pb-4"> <PageScaffold className="pb-4">
<div className="space-y-3" data-testid="mobile-profile-page"> <div className="space-y-3" data-testid="mobile-profile-page">
<PageHeader <PageHeader
title="个人设置" title="个人设置"
meta={<span className={statusTone(me?.status)}>{me?.status === "active" ? "账号正常" : "账号待确认"}</span>} meta={<span className={statusTone(me?.status)}>{accountStatusLabel(me?.status)}</span>}
/> />
{bindNotice ? ( {bindNotice ? (
@@ -144,6 +164,18 @@ export function MobileProfilePage() {
</dl> </dl>
</SectionPanel> </SectionPanel>
<SectionPanel title="密码与安全">
<div className="mobile-list-card flex min-h-12 items-center justify-between gap-3 text-sm">
<div>
<div className="font-semibold text-zinc-900">账号密码</div>
<div className="mt-0.5 text-xs leading-5 text-zinc-500">修改密码需要输入当前密码。</div>
</div>
<Button asChild type="button" variant="secondary">
<Link to="/change-password">修改密码</Link>
</Button>
</div>
</SectionPanel>
<SectionPanel title="企业微信消息提醒"> <SectionPanel title="企业微信消息提醒">
<label className="mobile-list-card flex min-h-12 items-center justify-between gap-3 text-sm text-zinc-800"> <label className="mobile-list-card flex min-h-12 items-center justify-between gap-3 text-sm text-zinc-800">
<span>接收企业微信消息提醒</span> <span>接收企业微信消息提醒</span>
@@ -206,16 +238,32 @@ export function MobileProfilePage() {
</div> </div>
</SectionPanel> </SectionPanel>
<SectionPanel title="账号状态"> <SectionPanel title="组织与角色摘要">
<div className="space-y-3 text-sm"> <details className="mobile-list-card text-sm">
<div className="flex flex-wrap gap-2"> <summary className="flex cursor-pointer list-none items-center justify-between gap-3">
<span className={statusTone(me?.status)}>{me?.status === "active" ? "active" : me?.status ?? "unknown"}</span> <span className="font-semibold text-zinc-900">查看组织和角色</span>
{(me?.roleCodes ?? []).map((role) => ( <span className="text-xs text-zinc-500">展开</span>
<span className="badge-neutral" key={role}>{role}</span> </summary>
))} <dl className="mt-3 grid gap-2 text-sm">
</div> <div className="mobile-subcard">
<p className="text-zinc-500">角色和权限由管理员统一维护。</p> <dt className="text-xs text-zinc-500">账号状态</dt>
</div> <dd className="mt-1 font-medium text-zinc-900">{accountStatusLabel(me?.status)}</dd>
</div>
<div className="mobile-subcard">
<dt className="text-xs text-zinc-500">组织归属</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.departmentId ? "已关联组织" : "待管理员维护"}</dd>
</div>
<div className="mobile-subcard">
<dt className="text-xs text-zinc-500">直属汇报关系</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.directManagerId ? "已维护" : "待管理员维护"}</dd>
</div>
<div className="mobile-subcard">
<dt className="text-xs text-zinc-500">角色</dt>
<dd className="mt-1 font-medium text-zinc-900">{roleLabels.length ? roleLabels.join("、") : "员工"}</dd>
</div>
</dl>
<p className="mt-3 text-xs leading-5 text-zinc-500">组织、角色和权限由管理员在管理入口统一维护。</p>
</details>
</SectionPanel> </SectionPanel>
</div> </div>
</PageScaffold> </PageScaffold>
+16 -11
查看文件
@@ -53,7 +53,7 @@ function roleLabel(roleCode: string) {
hr: "人事", hr: "人事",
manager: "审批人", manager: "审批人",
}; };
return labels[roleCode] ?? roleCode; return labels[roleCode] ?? "其他角色";
} }
function resolveProfileError(error: unknown) { function resolveProfileError(error: unknown) {
@@ -109,21 +109,14 @@ export function ProfilePage() {
<PageHeader <PageHeader
title="个人设置" title="个人设置"
eyebrow="账号与通知" eyebrow="账号与通知"
subtitle="查看自己的账号信息,设置消息提醒和企业微信入口。员工档案由对应页面维护。" subtitle="只处理个人资料、安全、通知偏好和企业微信绑定。组织和权限维护统一在管理入口处理。"
meta={<span className={accountStatusClassName(me?.status)}>{accountStatusLabel(me?.status)}</span>} meta={<span className={accountStatusClassName(me?.status)}>{accountStatusLabel(me?.status)}</span>}
/> />
<div className="mx-auto max-w-5xl space-y-5"> <div className="mx-auto max-w-5xl space-y-5">
<SectionPanel <SectionPanel
title="账号信息" title="个人资料"
description="这些信息用于识别你的账号。需要维护更多员工档案时,进入完整档案页。" 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"> <dl className="grid gap-4 text-sm text-zinc-700 md:grid-cols-2">
<div> <div>
@@ -145,6 +138,18 @@ export function ProfilePage() {
</dl> </dl>
</SectionPanel> </SectionPanel>
<SectionPanel title="密码与安全" description="修改登录密码,保持账号入口可控。">
<div className="flex flex-col gap-3 text-sm text-zinc-600 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="font-medium text-zinc-900">账号密码</div>
<p className="mt-1">修改密码需要输入当前密码;忘记密码时请从登录页找回。</p>
</div>
<Button asChild type="button" variant="secondary">
<Link to="/change-password">修改密码</Link>
</Button>
</div>
</SectionPanel>
<SectionPanel title="通知设置" description="选择是否接收企业微信个人应用提醒。"> <SectionPanel title="通知设置" description="选择是否接收企业微信个人应用提醒。">
<div className="space-y-3 text-sm text-zinc-600"> <div className="space-y-3 text-sm text-zinc-600">
<label className="flex items-center gap-3 text-zinc-800"> <label className="flex items-center gap-3 text-zinc-800">
+4 -1
查看文件
@@ -41,7 +41,10 @@ export function SystemSettingsPage() {
return ( return (
<ListWorkspace> <ListWorkspace>
<WorkspaceHeader title="管理" subtitle="低频配置集中到这里,侧边栏只保留一个管理入口。" /> <WorkspaceHeader
title="管理"
subtitle="人员、组织、角色、审批规则、主数据、通知渠道、外部系统、功能开关和审计统一在这里处理。"
/>
<div className="finance-status-strip" data-testid="system-settings-summary"> <div className="finance-status-strip" data-testid="system-settings-summary">
<div className="finance-metric-card"> <div className="finance-metric-card">
<div className="finance-metric-card__label">可用入口</div> <div className="finance-metric-card__label">可用入口</div>
+7 -1
查看文件
@@ -587,10 +587,16 @@ test("个人设置默认只展示账号通知和企业微信,不展示权限 c
await page.goto("/settings/profile"); await page.goto("/settings/profile");
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(); await expect(page.getByRole("heading", { name: "通知设置" })).toBeVisible();
await expect(page.getByRole("heading", { name: "企业微信绑定" })).toBeVisible(); await expect(page.getByRole("heading", { name: "企业微信绑定" })).toBeVisible();
await expect(page.getByText("账号详情")).toBeVisible(); await expect(page.getByText("账号详情")).toBeVisible();
await expect(page.getByRole("link", { name: "查看员工档案" })).toHaveCount(0);
await expect(page.getByText("审批规则")).not.toBeVisible();
await expect(page.getByText("功能开关")).not.toBeVisible();
await expect(page.getByText("主数据")).not.toBeVisible();
await expect(page.getByText("审计日志")).not.toBeVisible();
await expect(page.getByText("权限摘要")).not.toBeVisible(); await expect(page.getByText("权限摘要")).not.toBeVisible();
await expect(page.getByText("expense.create")).not.toBeVisible(); await expect(page.getByText("expense.create")).not.toBeVisible();
await expect(page.getByText("leave.submit")).not.toBeVisible(); await expect(page.getByText("leave.submit")).not.toBeVisible();
@@ -531,6 +531,11 @@ test.describe("R124 企业微信移动入口矩阵", () => {
await mobile.page.getByTestId("mobile-nav-me").click(); await mobile.page.getByTestId("mobile-nav-me").click();
await expect(mobile.page).toHaveURL(/\/mobile\/me$/); await expect(mobile.page).toHaveURL(/\/mobile\/me$/);
await expect(mobile.page.getByTestId("mobile-more-links")).toBeVisible(); await expect(mobile.page.getByTestId("mobile-more-links")).toBeVisible();
await expect(mobile.page.getByText("管理入口")).toHaveCount(0);
await expect(mobile.page.getByRole("link", { name: /账号资料/ })).toHaveCount(0);
await expect(mobile.page.getByRole("link", { name: /人员管理/ })).toHaveCount(0);
await expect(mobile.page.getByRole("link", { name: /组织架构/ })).toHaveCount(0);
await expect(mobile.page.getByRole("link", { name: /系统设置/ })).toHaveCount(0);
await mobile.page.screenshot({ path: screenshotPath("mobile-me") }); await mobile.page.screenshot({ path: screenshotPath("mobile-me") });
await expectMobilePageContract(mobile.page); await expectMobilePageContract(mobile.page);
@@ -546,6 +551,12 @@ test.describe("R124 企业微信移动入口矩阵", () => {
await expect(mobile.page).toHaveURL(/\/mobile\/profile$/); await expect(mobile.page).toHaveURL(/\/mobile\/profile$/);
await expect(mobile.page.getByTestId("mobile-profile-page")).toBeVisible(); await expect(mobile.page.getByTestId("mobile-profile-page")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-profile-wecom-card")).toBeVisible(); await expect(mobile.page.getByTestId("mobile-profile-wecom-card")).toBeVisible();
await expect(mobile.page.getByRole("heading", { name: "密码与安全" })).toBeVisible();
await expect(mobile.page.getByRole("heading", { name: "组织与角色摘要" })).toBeVisible();
await expect(mobile.page.getByText("active", { exact: true })).toHaveCount(0);
await expect(mobile.page.getByText("manager", { exact: true })).toHaveCount(0);
await mobile.page.getByText("查看组织和角色").click();
await expect(mobile.page.getByText("审批人、财务")).toBeVisible();
await mobile.page.screenshot({ path: screenshotPath("mobile-profile") }); await mobile.page.screenshot({ path: screenshotPath("mobile-profile") });
await expectMobilePageContract(mobile.page); await expectMobilePageContract(mobile.page);
+2 -1
查看文件
@@ -121,8 +121,9 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
- 首页不承担常驻新手教学职责;首次欢迎提示只做一次性、可关闭的常用业务入口,最多 3 个,不放个人设置、通知中心或“了解流程”这类说明型入口。 - 首页不承担常驻新手教学职责;首次欢迎提示只做一次性、可关闭的常用业务入口,最多 3 个,不放个人设置、通知中心或“了解流程”这类说明型入口。
- 审批入口同时服务审批人和发起人:审批人处理待我审批,员工查看我发起的进度。 - 审批入口同时服务审批人和发起人:审批人处理待我审批,员工查看我发起的进度。
- 普通员工不应看到不可点或无权限的审批入口;审批、财务、负责人和管理员只能看到服务端允许的动作。 - 普通员工不应看到不可点或无权限的审批入口;审批、财务、负责人和管理员只能看到服务端允许的动作。
- 个人设置默认只放个人资料、密码/安全、通知偏好和企业微信绑定;组织归属、直属汇报和角色摘要可折叠展示,不默认暴露权限 code 或完整员工档案维护内容。 - 个人设置默认只放个人资料、密码/安全、通知偏好和企业微信绑定;组织归属、直属汇报和角色摘要可折叠展示,不默认暴露权限 code、role code、原始账号状态或完整员工档案维护入口。
- 个人设置和管理配置彻底分离:组织、角色、审批规则、功能开关、主数据、审计日志、人员维护和系统配置统一进入 `管理`,不出现在普通员工路径或个人资料页里。 - 个人设置和管理配置彻底分离:组织、角色、审批规则、功能开关、主数据、审计日志、人员维护和系统配置统一进入 `管理`,不出现在普通员工路径或个人资料页里。
- 移动端 `我的` 不放管理矩阵;`人员管理`、`组织架构`、`系统设置` 等低频配置只从 PC 管理入口承接。
- PC 侧边栏是小团队任务入口,不是全量模块目录;一级入口必须按角色、权限、频次和真实办事价值收敛。已启用且有权限的 OA 主办业务台账,例如 `合同`、`采购`、`资产`、`项目`,不默认藏到 `更多`,否则会给常用业务增加二次点击负担。 - PC 侧边栏是小团队任务入口,不是全量模块目录;一级入口必须按角色、权限、频次和真实办事价值收敛。已启用且有权限的 OA 主办业务台账,例如 `合同`、`采购`、`资产`、`项目`,不默认藏到 `更多`,否则会给常用业务增加二次点击负担。
- 审批人、负责人和财务看到 `待我处理`;普通员工默认不显示 `待办` 或 `审批待办` 入口;通知只保留顶部铃铛或通知中心,不进入侧边栏。 - 审批人、负责人和财务看到 `待我处理`;普通员工默认不显示 `待办` 或 `审批待办` 入口;通知只保留顶部铃铛或通知中心,不进入侧边栏。
- PC 请假入口默认表达为 `请假记录`;提交请假、补卡、外出、加班仍优先走企业微信或移动入口,只有 OA 应急接管或指定管理场景才突出提交入口。 - PC 请假入口默认表达为 `请假记录`;提交请假、补卡、外出、加班仍优先走企业微信或移动入口,只有 OA 应急接管或指定管理场景才突出提交入口。