Archived
工作台撤顶栏:身份菜单收进左轨底部(悬停从轨右侧弹出);姓名展示改姓在前(token claims 与名册同步两处拼装同尺)
This commit is contained in:
@@ -27,6 +27,11 @@ describe('identityOf —— claims 归一成一个人', () => {
|
|||||||
expect('email' in id).toBe(false);
|
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 缺 → 空角色', () => {
|
it('realm_access 有但 roles 缺 → 空角色', () => {
|
||||||
expect(identityOf({ sub: 's', preferred_username: 'bob', realm_access: {} }).roles).toEqual([]);
|
expect(identityOf({ sub: 's', preferred_username: 'bob', realm_access: {} }).roles).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,13 +42,13 @@ function fakeKeycloak(): { fetch: typeof fetch; calls: string[] } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Keycloak Admin API 名册', () => {
|
describe('Keycloak Admin API 名册', () => {
|
||||||
it('分页拉全(100 + 5)、姓名拼 firstName lastName、缺名回退用户名、角色滤噪声、enabled 照抄', async () => {
|
it('分页拉全(100 + 5)、姓名姓在前拼 lastName firstName、缺名回退用户名、角色滤噪声、enabled 照抄', async () => {
|
||||||
const kc = fakeKeycloak();
|
const kc = fakeKeycloak();
|
||||||
const roster = createKeycloakRoster({ url: 'https://kc.test/', realm: 'corp', clientId: 'provisioning', secret: 's', fetch: kc.fetch });
|
const roster = createKeycloakRoster({ url: 'https://kc.test/', realm: 'corp', clientId: 'provisioning', secret: 's', fetch: kc.fetch });
|
||||||
const users = await roster.listUsers();
|
const users = await roster.listUsers();
|
||||||
expect(users).toHaveLength(105);
|
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[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(users[3]?.enabled).toBe(false);
|
||||||
expect(kc.calls.filter((c) => c.includes('/users?')).length).toBe(2);
|
expect(kc.calls.filter((c) => c.includes('/users?')).length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ export function createKeycloakRoster(options: KeycloakRosterOptions): RosterSour
|
|||||||
const out: RosterUser[] = [];
|
const out: RosterUser[] = [];
|
||||||
for (const u of users) {
|
for (const u of users) {
|
||||||
const roles = await adminGet<KcRole[]>(`/users/${encodeURIComponent(u.id)}/role-mappings/realm/composite`);
|
const roles = await adminGet<KcRole[]>(`/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() !== '')
|
.filter((s): s is string => s !== undefined && s.trim() !== '')
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.trim();
|
.trim();
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ const claimsSchema = z.object({
|
|||||||
sub: z.string().min(1),
|
sub: z.string().min(1),
|
||||||
preferred_username: z.string().min(1),
|
preferred_username: z.string().min(1),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
given_name: z.string().optional(),
|
||||||
|
family_name: z.string().optional(),
|
||||||
email: z.string().optional(),
|
email: z.string().optional(),
|
||||||
realm_access: z.object({ roles: z.array(z.string()).optional() }).optional(),
|
realm_access: z.object({ roles: z.array(z.string()).optional() }).optional(),
|
||||||
});
|
});
|
||||||
@@ -39,6 +41,8 @@ function toVerifiedClaims(claims: z.infer<typeof claimsSchema>): VerifiedClaims
|
|||||||
sub: claims.sub,
|
sub: claims.sub,
|
||||||
preferred_username: claims.preferred_username,
|
preferred_username: claims.preferred_username,
|
||||||
...(claims.name === undefined ? {} : { name: claims.name }),
|
...(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.email === undefined ? {} : { email: claims.email }),
|
||||||
...(claims.realm_access === undefined
|
...(claims.realm_access === undefined
|
||||||
? {}
|
? {}
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ export function normalizeRoles(roles: readonly string[]): string[] {
|
|||||||
|
|
||||||
export function identityOf(claims: VerifiedClaims): Identity {
|
export function identityOf(claims: VerifiedClaims): Identity {
|
||||||
const account = claims.preferred_username;
|
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();
|
const email = claims.email === undefined || claims.email.trim() === '' ? undefined : claims.email.trim();
|
||||||
return {
|
return {
|
||||||
sub: claims.sub,
|
sub: claims.sub,
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export type VerifiedClaims = {
|
|||||||
readonly sub: string;
|
readonly sub: string;
|
||||||
readonly preferred_username: string;
|
readonly preferred_username: string;
|
||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
|
/* 名/姓分开收(2026-08-26 用户定:中文姓名姓在前)—— Keycloak 的 `name` 是全名(名+姓),
|
||||||
|
展示要「姓 名」得靠这两个散件拼;没带就回退 `name` 原样 */
|
||||||
|
readonly given_name?: string;
|
||||||
|
readonly family_name?: string;
|
||||||
readonly email?: string;
|
readonly email?: string;
|
||||||
readonly realm_access?: { readonly roles?: readonly string[] };
|
readonly realm_access?: { readonly roles?: readonly string[] };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 工作台外壳 —— 自原型 `(proto)/page.tsx` 平移(README《前端 UI · 原型 1:1 平移》)。
|
* 工作台外壳 —— 自原型 `(proto)/page.tsx` 平移(README《前端 UI · 原型 1:1 平移》)。
|
||||||
*
|
*
|
||||||
* 左轨 = 品牌 → **工作台五要素**(任务 / 审批 / 消息 / 纪要 / 汇报);身份在顶栏右侧菜单;
|
* 左轨 = 品牌 → **工作台五要素**(任务 / 审批 / 消息 / 纪要 / 汇报)→ 轨底身份菜单;
|
||||||
* 任务四区 = 左栏列表区 / 中上标题区 / 中下工作区 / 右栏对话区。
|
* 任务四区 = 左栏列表区 / 中上标题区 / 中下工作区 / 右栏对话区。
|
||||||
*
|
*
|
||||||
* **前端零过程状态**:数据全部来自服务端投影,刷新即从投影重建(§9.2)。
|
* **前端零过程状态**:数据全部来自服务端投影,刷新即从投影重建(§9.2)。
|
||||||
@@ -167,38 +167,31 @@ export function AppShell() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* 轨底不再报身份:头像与姓名挪到顶栏右侧的身份菜单里(2026-08-25 用户定) */}
|
{/* 身份菜单收在轨底(2026-08-26 用户定:顶栏撤掉,内容挪到左轨底部)——
|
||||||
|
轨宽 78px,头像上姓名下竖排;悬停/聚焦从轨右侧弹出,登出仍是全站唯一入口 */}
|
||||||
|
<div className="jc-usermenu jc-usermenu-rail">
|
||||||
|
<span className="jc-nav-ava">{me.slice(0, 1)}</span>
|
||||||
|
<span className="jc-topbar-name">{me}</span>
|
||||||
|
<div className="jc-usermenu-pop">
|
||||||
|
{/* 个人中心独立成页(/me 台面),整页导航过去(2026-08-25 用户定) */}
|
||||||
|
<a href="/me">
|
||||||
|
<Ico name="user" s={14} />
|
||||||
|
个人中心
|
||||||
|
</a>
|
||||||
|
{/* 设计中心直链数据域页,整页导航过去(2026-08-25 用户定) */}
|
||||||
|
<a href="/design#/design/data/domains">
|
||||||
|
<Ico name="pen-line" s={14} />
|
||||||
|
设计中心
|
||||||
|
</a>
|
||||||
|
<button type="button" className="danger" onClick={logout}>
|
||||||
|
<Ico name="log-out" s={14} />
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="app-main">
|
<div className="app-main">
|
||||||
<header className="jc-topbar jc-topbar-app">
|
|
||||||
{/* 顶栏不挂模块名,只留右侧身份菜单(2026-08-25 用户定) */}
|
|
||||||
<div className="jc-topbar-user">
|
|
||||||
{/* 身份菜单:悬停头像/姓名出下拉 —— 个人中心(建设中提示)+ 退出登录;登出仍是全站唯一入口 */}
|
|
||||||
<div className="jc-usermenu">
|
|
||||||
<span className="jc-nav-ava">{me.slice(0, 1)}</span>
|
|
||||||
<span className="jc-topbar-name">{me}</span>
|
|
||||||
<Ico name="chevron-down" s={12} />
|
|
||||||
<div className="jc-usermenu-pop">
|
|
||||||
{/* 个人中心独立成页(/me 台面),整页导航过去(2026-08-25 用户定) */}
|
|
||||||
<a href="/me">
|
|
||||||
<Ico name="user" s={14} />
|
|
||||||
个人中心
|
|
||||||
</a>
|
|
||||||
{/* 设计中心直链数据域页,整页导航过去(2026-08-25 用户定) */}
|
|
||||||
<a href="/design#/design/data/domains">
|
|
||||||
<Ico name="pen-line" s={14} />
|
|
||||||
设计中心
|
|
||||||
</a>
|
|
||||||
<button type="button" className="danger" onClick={logout}>
|
|
||||||
<Ico name="log-out" s={14} />
|
|
||||||
退出登录
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="app-main-body">
|
<div className="app-main-body">
|
||||||
{module === 'tasks' ? (
|
{module === 'tasks' ? (
|
||||||
<div className={'workspace' + (collapsed ? ' jc-side-collapsed' : '')} style={split.style}>
|
<div className={'workspace' + (collapsed ? ' jc-side-collapsed' : '')} style={split.style}>
|
||||||
|
|||||||
@@ -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: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; }
|
.proto-root .jc-sup:disabled { background: #f3f4f6; color: #5c626b; }
|
||||||
|
|
||||||
/* ===== 左侧一体化导航轨:品牌 → 五大模块(身份已挪去顶栏右侧,见下)===== */
|
/* ===== 左侧一体化导航轨:品牌 → 五大模块 → 轨底身份区(2026-08-26 用户定:顶栏撤了,身份收回轨底)===== */
|
||||||
.proto-root .app-nav { padding: 14px 10px 12px; }
|
.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 .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 { 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); }
|
.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); }
|
.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 { 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; display: flex; flex-direction: column; }
|
||||||
.proto-root .app-main-body > * { flex: 1 1 auto; min-width: 0; min-height: 0; }
|
.proto-root .app-main-body > * { flex: 1 1 auto; min-width: 0; min-height: 0; }
|
||||||
@@ -500,8 +498,8 @@
|
|||||||
/* 但右上角装饰光晕定位 right:-60px,裁切一放开它就探出视口右缘 60px,整页出现横向滚动
|
/* 但右上角装饰光晕定位 right:-60px,裁切一放开它就探出视口右缘 60px,整页出现横向滚动
|
||||||
(2026-08-25 用户报:页面能左右滑)—— 把它收回盒内,光晕照留 */
|
(2026-08-25 用户报:页面能左右滑)—— 把它收回盒内,光晕照留 */
|
||||||
.proto-root .jc-topbar.jc-topbar-app::after { right: 0; }
|
.proto-root .jc-topbar.jc-topbar-app::after { right: 0; }
|
||||||
/* 顶栏右侧身份菜单:头像 + 姓名,悬停出下拉(个人中心 / 退出登录)—— 纯 CSS 悬停无 JS;
|
/* 身份菜单:头像 + 姓名,悬停出下拉(个人中心 / 设计中心 / 退出登录)—— 纯 CSS 悬停无 JS;
|
||||||
登出入口从独立按钮收进下拉,仍是全站唯一入口(2026-08-25 用户定) */
|
登出入口收进下拉,是全站唯一入口(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 { 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:hover { background: var(--soft); }
|
||||||
.proto-root .jc-usermenu .jc-nav-ava { width: 28px; height: 28px; font-size: 12px; }
|
.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.danger:hover { background: var(--danger-soft); color: var(--danger); }
|
||||||
.proto-root .jc-usermenu-pop button:disabled { opacity: .6; cursor: default; }
|
.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-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; }
|
.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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user