diff --git a/apps/api/src/modules/org/__tests__/claims.test.ts b/apps/api/src/modules/org/__tests__/claims.test.ts index 559c3c9..50b79c3 100644 --- a/apps/api/src/modules/org/__tests__/claims.test.ts +++ b/apps/api/src/modules/org/__tests__/claims.test.ts @@ -27,6 +27,11 @@ describe('identityOf —— claims 归一成一个人', () => { expect('email' in id).toBe(false); }); + it('姓/名散件在 → 姓在前拼「姓 名」,不采用全名字段的名+姓序(2026-08-26 用户定)', () => { + const id = identityOf({ sub: 's', preferred_username: 'shan', name: '亲 删', given_name: '亲', family_name: '删' }); + expect(id.name).toBe('删 亲'); + }); + it('realm_access 有但 roles 缺 → 空角色', () => { expect(identityOf({ sub: 's', preferred_username: 'bob', realm_access: {} }).roles).toEqual([]); }); diff --git a/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts b/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts index 8689740..218787b 100644 --- a/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts +++ b/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts @@ -42,13 +42,13 @@ function fakeKeycloak(): { fetch: typeof fetch; calls: string[] } { } describe('Keycloak Admin API 名册', () => { - it('分页拉全(100 + 5)、姓名拼 firstName lastName、缺名回退用户名、角色滤噪声、enabled 照抄', async () => { + it('分页拉全(100 + 5)、姓名姓在前拼 lastName firstName、缺名回退用户名、角色滤噪声、enabled 照抄', async () => { const kc = fakeKeycloak(); const roster = createKeycloakRoster({ url: 'https://kc.test/', realm: 'corp', clientId: 'provisioning', secret: 's', fetch: kc.fetch }); const users = await roster.listUsers(); expect(users).toHaveLength(105); expect(users[0]).toEqual({ sub: 'sub-0', account: 'u0', name: 'u0', email: 'u0@x.test', roles: ['user'], enabled: true }); - expect(users[1]).toEqual({ sub: 'sub-1', account: 'u1', name: '名 1', email: 'u1@x.test', roles: ['admin', 'user'], enabled: true }); + expect(users[1]).toEqual({ sub: 'sub-1', account: 'u1', name: '1 名', email: 'u1@x.test', roles: ['admin', 'user'], enabled: true }); expect(users[3]?.enabled).toBe(false); expect(kc.calls.filter((c) => c.includes('/users?')).length).toBe(2); }); diff --git a/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts b/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts index bffb86b..841e858 100644 --- a/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts +++ b/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts @@ -76,7 +76,8 @@ export function createKeycloakRoster(options: KeycloakRosterOptions): RosterSour const out: RosterUser[] = []; for (const u of users) { const roles = await adminGet(`/users/${encodeURIComponent(u.id)}/role-mappings/realm/composite`); - const name = [u.firstName, u.lastName] + /* 姓在前(2026-08-26 用户定)—— 与 token 那条路(claims.ts `identityOf`)同一把尺 */ + const name = [u.lastName, u.firstName] .filter((s): s is string => s !== undefined && s.trim() !== '') .join(' ') .trim(); diff --git a/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts b/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts index 06c6795..70d2363 100644 --- a/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts +++ b/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts @@ -24,6 +24,8 @@ const claimsSchema = z.object({ sub: z.string().min(1), preferred_username: z.string().min(1), name: z.string().optional(), + given_name: z.string().optional(), + family_name: z.string().optional(), email: z.string().optional(), realm_access: z.object({ roles: z.array(z.string()).optional() }).optional(), }); @@ -39,6 +41,8 @@ function toVerifiedClaims(claims: z.infer): VerifiedClaims sub: claims.sub, preferred_username: claims.preferred_username, ...(claims.name === undefined ? {} : { name: claims.name }), + ...(claims.given_name === undefined ? {} : { given_name: claims.given_name }), + ...(claims.family_name === undefined ? {} : { family_name: claims.family_name }), ...(claims.email === undefined ? {} : { email: claims.email }), ...(claims.realm_access === undefined ? {} diff --git a/apps/api/src/modules/org/internal/domain/claims.ts b/apps/api/src/modules/org/internal/domain/claims.ts index 0015c65..40ecd23 100644 --- a/apps/api/src/modules/org/internal/domain/claims.ts +++ b/apps/api/src/modules/org/internal/domain/claims.ts @@ -21,7 +21,13 @@ export function normalizeRoles(roles: readonly string[]): string[] { export function identityOf(claims: VerifiedClaims): Identity { const account = claims.preferred_username; - const name = claims.name === undefined || claims.name.trim() === '' ? account : claims.name.trim(); + /* 姓名展示姓在前(2026-08-26 用户定):有姓/名散件就按「姓 名」拼; + 没有散件才用 Keycloak 的全名 `name`(它按「名 姓」拼,中文场景顺序是反的) */ + const cjk = [claims.family_name, claims.given_name] + .filter((s): s is string => s !== undefined && s.trim() !== '') + .join(' '); + const fallback = claims.name === undefined || claims.name.trim() === '' ? account : claims.name.trim(); + const name = cjk !== '' ? cjk : fallback; const email = claims.email === undefined || claims.email.trim() === '' ? undefined : claims.email.trim(); return { sub: claims.sub, diff --git a/apps/api/src/modules/org/port.ts b/apps/api/src/modules/org/port.ts index b438108..3207f5e 100644 --- a/apps/api/src/modules/org/port.ts +++ b/apps/api/src/modules/org/port.ts @@ -14,6 +14,10 @@ export type VerifiedClaims = { readonly sub: string; readonly preferred_username: string; readonly name?: string; + /* 名/姓分开收(2026-08-26 用户定:中文姓名姓在前)—— Keycloak 的 `name` 是全名(名+姓), + 展示要「姓 名」得靠这两个散件拼;没带就回退 `name` 原样 */ + readonly given_name?: string; + readonly family_name?: string; readonly email?: string; readonly realm_access?: { readonly roles?: readonly string[] }; }; diff --git a/apps/web/src/app/app-shell.tsx b/apps/web/src/app/app-shell.tsx index 58c81b3..f751eef 100644 --- a/apps/web/src/app/app-shell.tsx +++ b/apps/web/src/app/app-shell.tsx @@ -1,7 +1,7 @@ /** * 工作台外壳 —— 自原型 `(proto)/page.tsx` 平移(README《前端 UI · 原型 1:1 平移》)。 * - * 左轨 = 品牌 → **工作台五要素**(任务 / 审批 / 消息 / 纪要 / 汇报);身份在顶栏右侧菜单; + * 左轨 = 品牌 → **工作台五要素**(任务 / 审批 / 消息 / 纪要 / 汇报)→ 轨底身份菜单; * 任务四区 = 左栏列表区 / 中上标题区 / 中下工作区 / 右栏对话区。 * * **前端零过程状态**:数据全部来自服务端投影,刷新即从投影重建(§9.2)。 @@ -167,38 +167,31 @@ export function AppShell() { ); })} - {/* 轨底不再报身份:头像与姓名挪到顶栏右侧的身份菜单里(2026-08-25 用户定) */} + {/* 身份菜单收在轨底(2026-08-26 用户定:顶栏撤掉,内容挪到左轨底部)—— + 轨宽 78px,头像上姓名下竖排;悬停/聚焦从轨右侧弹出,登出仍是全站唯一入口 */} +
+ {me.slice(0, 1)} + {me} +
+ {/* 个人中心独立成页(/me 台面),整页导航过去(2026-08-25 用户定) */} + + + 个人中心 + + {/* 设计中心直链数据域页,整页导航过去(2026-08-25 用户定) */} + + + 设计中心 + + +
+
-
- {/* 顶栏不挂模块名,只留右侧身份菜单(2026-08-25 用户定) */} -
- {/* 身份菜单:悬停头像/姓名出下拉 —— 个人中心(建设中提示)+ 退出登录;登出仍是全站唯一入口 */} -
- {me.slice(0, 1)} - {me} - -
- {/* 个人中心独立成页(/me 台面),整页导航过去(2026-08-25 用户定) */} - - - 个人中心 - - {/* 设计中心直链数据域页,整页导航过去(2026-08-25 用户定) */} - - - 设计中心 - - -
-
-
-
-
{module === 'tasks' ? (
diff --git a/apps/web/src/app/styles/app.css b/apps/web/src/app/styles/app.css index df384bc..a167b91 100644 --- a/apps/web/src/app/styles/app.css +++ b/apps/web/src/app/styles/app.css @@ -476,16 +476,14 @@ .proto-root .jc-sup:focus { outline: 0; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(51,112,255,.12); } .proto-root .jc-sup:disabled { background: #f3f4f6; color: #5c626b; } -/* ===== 左侧一体化导航轨:品牌 → 五大模块(身份已挪去顶栏右侧,见下)===== */ +/* ===== 左侧一体化导航轨:品牌 → 五大模块 → 轨底身份区(2026-08-26 用户定:顶栏撤了,身份收回轨底)===== */ .proto-root .app-nav { padding: 14px 10px 12px; } .proto-root .jc-rail-brand { text-align: center; font-size: 10px; font-weight: 800; letter-spacing: .14em; color: var(--ink-3); margin: -4px 0 6px; } .proto-root .app-nav button { min-height: 58px; transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); } .proto-root .app-nav button:active { transform: scale(.97); } -/* 轨底身份区已撤:头像与姓名挪去顶栏右侧身份菜单(2026-08-25 用户定); - .jc-nav-ava 保留 —— 顶栏身份菜单复用这枚渐变头像 */ .proto-root .jc-nav-ava { width: 32px; height: 32px; border-radius: 999px; display: grid; place-items: center; flex: 0 0 auto; line-height: 1; color: #fff; font-size: 13px; font-weight: 800; background: linear-gradient(140deg, #2bc7d4, #0e9fd8); box-shadow: 0 2px 6px rgba(14, 159, 216, .35); } -/* ===== 顶栏:44px 通栏,只放右侧身份菜单 —— 与左轨互不重叠,登出只有这一处入口 ===== */ +/* ===== 主区:顶栏已撤(2026-08-26 用户定),内容区直接铺满 ===== */ .proto-root .app-main { min-width: 0; min-height: 0; display: flex; flex-direction: column; } .proto-root .app-main-body { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; } .proto-root .app-main-body > * { flex: 1 1 auto; min-width: 0; min-height: 0; } @@ -500,8 +498,8 @@ /* 但右上角装饰光晕定位 right:-60px,裁切一放开它就探出视口右缘 60px,整页出现横向滚动 (2026-08-25 用户报:页面能左右滑)—— 把它收回盒内,光晕照留 */ .proto-root .jc-topbar.jc-topbar-app::after { right: 0; } -/* 顶栏右侧身份菜单:头像 + 姓名,悬停出下拉(个人中心 / 退出登录)—— 纯 CSS 悬停无 JS; - 登出入口从独立按钮收进下拉,仍是全站唯一入口(2026-08-25 用户定) */ +/* 身份菜单:头像 + 姓名,悬停出下拉(个人中心 / 设计中心 / 退出登录)—— 纯 CSS 悬停无 JS; + 登出入口收进下拉,是全站唯一入口(2026-08-25 用户定);2026-08-26 从顶栏挪进左轨底部(见下方 rail 变体) */ .proto-root .jc-usermenu { position: relative; display: flex; align-items: center; gap: 8px; padding: 4px 8px 4px 4px; border-radius: 10px; cursor: default; color: var(--ink-4); transition: background var(--t-fast); } .proto-root .jc-usermenu:hover { background: var(--soft); } .proto-root .jc-usermenu .jc-nav-ava { width: 28px; height: 28px; font-size: 12px; } @@ -514,6 +512,14 @@ .proto-root .jc-usermenu-pop button.danger:hover { background: var(--danger-soft); color: var(--danger); } .proto-root .jc-usermenu-pop button:disabled { opacity: .6; cursor: default; } +/* 轨底变体(2026-08-26 用户定):轨宽 78px,头像上姓名下竖排,与模块钮之间一道细分隔; + 下拉改从轨右侧弹出、底缘与触发区对齐,悬停桥跟着转到右侧 */ +.proto-root .jc-usermenu-rail { margin-top: auto; align-self: stretch; flex-direction: column; justify-content: center; gap: 4px; padding: 10px 2px 4px; border-top: 1px solid var(--line-soft); border-radius: 0; } +.proto-root .jc-usermenu-rail:hover { background: none; } +.proto-root .jc-usermenu-rail .jc-topbar-name { max-width: 66px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; text-align: center; } +.proto-root .jc-usermenu-rail::after { top: 0; bottom: 0; left: 100%; right: auto; width: 10px; height: auto; } +.proto-root .jc-usermenu-rail .jc-usermenu-pop { top: auto; right: auto; bottom: 0; left: calc(100% + 10px); transform: translateX(-4px); } + /* 「建设中」提示弹窗:未开放模块(审批/消息/纪要/汇报)与个人中心的占位口径 —— 不跳页只弹提示 */ .proto-root .jc-wip-overlay { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; background: rgba(31, 35, 41, .45); } .proto-root .jc-wip-modal { width: 360px; max-width: 92vw; padding: 24px 20px 18px; background: #fff; border-radius: 16px; box-shadow: 0 24px 60px rgba(31, 35, 41, .22); text-align: center; animation: rise var(--t-med) both; }