接入统一鉴权中心(Keycloak,ADR-012);活动模块 L1/L2 运营态模版(历史活动列表+检索+沙箱);数据域与工作台界面改版;个人中心 /me

This commit is contained in:
shanqin
2026-08-26 13:29:36 +08:00
parent f3397a60ef
commit f01286a89d
40 changed files with 2502 additions and 480 deletions
@@ -34,6 +34,12 @@ afterEach(() => {
vi.unstubAllGlobals();
});
/**
* fetch 发出前要先过一道 `freshToken()` 微任务(ADR-012 挂 Bearer,auth.ts)——
* 断言「发了几笔」之前先让那几拍微任务落完,否则看到的永远是 0 笔。
*/
const flushToken = () => vi.advanceTimersByTimeAsync(0);
describe('callKey —— 差一个字就是另一笔', () => {
it('方法 + 落地地址 + 报文,三样合起来才是身份', () => {
expect(callKey(write, { params: { commId: 'a' } })).toBe('POST /api/desk/comms/a/read ');
@@ -52,6 +58,7 @@ describe('在途去重', () => {
const g = gateway();
const a = call(write, { params: { commId: 'a' } });
const b = call(write, { params: { commId: 'a' } });
await flushToken();
expect(g.calls).toHaveLength(1);
g.settleAll();
expect(await a).toEqual(await b);
@@ -61,6 +68,7 @@ describe('在途去重', () => {
const g = gateway();
void call(write, { params: { commId: 'a' } });
void call(write, { params: { commId: 'b' } });
await flushToken();
expect(g.calls).toEqual(['POST /api/desk/comms/a/read', 'POST /api/desk/comms/b/read']);
g.settleAll();
await vi.runAllTimersAsync();
@@ -70,6 +78,7 @@ describe('在途去重', () => {
const g = gateway();
void call(read);
void call(read);
await flushToken();
expect(g.calls).toHaveLength(1);
g.settleAll();
await vi.runAllTimersAsync();
@@ -80,6 +89,7 @@ describe('落地后的双击窗 —— 只管写请求', () => {
it('**刚成功那一笔在窗内原样重来,交回上一次的结果、不再发**', async () => {
const g = gateway();
const first = call(write, { params: { commId: 'a' } });
await flushToken();
g.settleAll();
await first;
@@ -91,11 +101,13 @@ describe('落地后的双击窗 —— 只管写请求', () => {
it('过了窗口就是新的一次点击,照发', async () => {
const g = gateway();
const first = call(write, { params: { commId: 'a' } });
await flushToken();
g.settleAll();
await first;
vi.advanceTimersByTime(DOUBLE_CLICK_MS + 1);
void call(write, { params: { commId: 'a' } });
await flushToken();
expect(g.calls).toHaveLength(2);
g.settleAll();
await vi.runAllTimersAsync();
@@ -104,10 +116,12 @@ describe('落地后的双击窗 —— 只管写请求', () => {
it('**读请求不设这个窗** —— 写完立刻重取的那一下不能被当成余震压掉', async () => {
const g = gateway();
const first = call(read);
await flushToken();
g.settleAll();
await first;
void call(read);
await flushToken();
expect(g.calls).toHaveLength(2);
g.settleAll();
await vi.runAllTimersAsync();
+84
View File
@@ -0,0 +1,84 @@
/**
* 统一鉴权中心(Keycloak)的前端落点(ADR-012;手册 `docs/keycloak-frontend-guide` §4)。
*
* 后端是**资源服务器**:不做登录、没有会话 cookie,每个请求带
* `Authorization: Bearer <access_token>`。token 的取 / 刷 / 登出全部在这里,
* 业务代码只见 `freshToken()` 一个口子。
*
* 硬性约束(手册 §16):
* · token 只放内存(keycloak-js 默认),**不进 localStorage** —— 刷新靠 SSO 会话静默恢复;
* · 授权 URL 与 PKCE 一律由 keycloak-js 完成,不自己拼;
* · 登出必须走 `keycloak.logout()` 结束 SSO 会话,只清本地状态会「登不出去」。
*/
import Keycloak from 'keycloak-js';
export const keycloak = new Keycloak({
url: import.meta.env.VITE_KEYCLOAK_URL,
realm: import.meta.env.VITE_KEYCLOAK_REALM,
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID,
});
const REQUIRED_ROLE = import.meta.env.VITE_REQUIRED_ROLE as string;
/** init 是否已跑过 —— 测试环境(无 Keycloak)下 freshToken 据此直接空手而归 */
let ready = false;
export type AuthState = { authenticated: boolean; authorized: boolean };
/**
* 初始化:静默探测浏览器是否已有 Keycloak 会话(隐藏 iframe,用户无感)。
* 不发跳转 —— 未登录交回 `authenticated: false`,由壳层决定摆登录页。
*/
export async function initAuth(): Promise<AuthState> {
const authenticated = await keycloak.init({
onLoad: 'check-sso',
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
pkceMethod: 'S256',
checkLoginIframe: false,
});
ready = true;
if (!authenticated) return { authenticated: false, authorized: false };
// access token 只有 5 分钟:到期前用 refresh token 悄悄续,续不上就重新登录
keycloak.onTokenExpired = () => {
keycloak.updateToken(30).catch(() => keycloak.login());
};
return { authenticated: true, authorized: keycloak.hasRealmRole(REQUIRED_ROLE) };
}
export function login(): Promise<void> {
return keycloak.login();
}
/** 必须用这个登出:只清本地状态的话 Keycloak 的 SSO 会话还在,下次会静默登回来 */
export function logout(): Promise<void> {
return keycloak.logout({ redirectUri: window.location.origin });
}
/**
* 请求前调用,拿回一个保证没过期的 token(剩余有效期不足 30 秒才真刷新)。
* 未到阈值时 `updateToken` 不发网络请求,每个请求都调是安全的。
* 未登录 / 未初始化(测试)时回 `undefined`,请求就不带头 —— 让后端照实回 401。
*/
export async function freshToken(): Promise<string | undefined> {
if (!ready || !keycloak.authenticated) return undefined;
await keycloak.updateToken(30);
return keycloak.token;
}
/**
* 401 之后的强刷(typed-fetch 用):常规 `freshToken` 在阈值内不发请求,
* 而后端已经明说这一枚不行 —— 传一个极大的 minValidity 逼它真换一枚。
* 刷不出来(SSO 会话也没了)回 false,调用方照旧把 401 抛给页面。
*/
export async function forceRefreshToken(): Promise<boolean> {
if (!ready || !keycloak.authenticated) return false;
try {
await keycloak.updateToken(Number.MAX_SAFE_INTEGER);
return keycloak.token !== undefined;
} catch {
return false;
}
}
@@ -5,21 +5,18 @@
* 三者都是纯函数,壳层只消费 —— 配置错了在这里就红,不必等到点开界面才发现。
*/
import { describe, expect, it } from 'vitest';
import type { Session } from '@contracts';
import { sessionSchema } from '@contracts';
import type { Principal } from '@contracts';
import { principalSchema } from '@contracts';
import { GOV_ZONES } from '../gov-route.ts';
import { SURFACE_ZONES, canSeeSurface, legacyGovTarget, surfaceOfZone } from '../surfaces.ts';
/** 经 schema 构造合法会话 —— §12.5 禁 `as unknown as`,测试同样遵守 */
const sessionWith = (fns: readonly string[]): Session =>
sessionSchema.parse({
/** 经 schema 构造合法 Principal —— §12.5 禁 `as unknown as`,测试同样遵守 */
const meWith = (ends: readonly string[]): Principal =>
principalSchema.parse({
staff: { id: 's-1', account: 'a', name: 'n', positionId: 'p-1', state: 'active' },
position: { id: 'p-1', code: 'PM', name: '项目经理' },
grants: fns.map((fn) => ({
positionId: 'p-1',
object: { kind: 'governance', function: fn },
action: 'read',
})),
roles: ['user'],
ends,
});
describe('SURFACE_ZONES —— 每个区恰属一面', () => {
@@ -41,27 +38,20 @@ describe('SURFACE_ZONES —— 每个区恰属一面', () => {
});
});
describe('canSeeSurface —— 入口按授权亮灯', () => {
it('持有该面任一功能的授权即亮', () => {
expect(canSeeSurface(sessionWith(['process']), 'design')).toBe(true);
expect(canSeeSurface(sessionWith(['task']), 'ops')).toBe(true);
describe('canSeeSurface —— 入口按 ends 亮灯(ADR-012)', () => {
it('ends 含该端即亮', () => {
expect(canSeeSurface(meWith(['design', 'ops', 'workbench']), 'design')).toBe(true);
expect(canSeeSurface(meWith(['ops']), 'ops')).toBe(true);
});
it('只有另一面的授权不亮', () => {
expect(canSeeSurface(sessionWith(['task']), 'design')).toBe(false);
expect(canSeeSurface(sessionWith(['activity']), 'ops')).toBe(false);
it('ends 不含不亮;工作台身份进不了治理端', () => {
expect(canSeeSurface(meWith(['workbench']), 'design')).toBe(false);
expect(canSeeSurface(meWith(['design']), 'ops')).toBe(false);
});
it('trigger 与 data 两面都算 —— 定义归设计,处置与行数据归运营', () => {
expect(canSeeSurface(sessionWith(['trigger']), 'design')).toBe(true);
expect(canSeeSurface(sessionWith(['trigger']), 'ops')).toBe(true);
expect(canSeeSurface(sessionWith(['data']), 'design')).toBe(true);
expect(canSeeSurface(sessionWith(['data']), 'ops')).toBe(true);
});
it('无会话不亮;无治理授权不亮', () => {
it('无身份不亮;ends 为空不亮', () => {
expect(canSeeSurface(undefined, 'design')).toBe(false);
expect(canSeeSurface(sessionWith([]), 'ops')).toBe(false);
expect(canSeeSurface(meWith([]), 'ops')).toBe(false);
});
});
+17 -5
View File
@@ -17,6 +17,12 @@ export type Route =
| { k: 'act'; id: string }
| { k: 'tool'; id: string }
| { k: 'obj'; id: string }
/* 单条记录(三级页,2026-08-25 用户定):id = `<domain>.<object>.<行序号>` ——
行序号是当前浏览页内的下标;没有单行接口,详情页复用台账浏览的同一份缓存取行 */
| { k: 'row'; id: string }
/* 活动定义详情(运营中心 · 活动模块 L2,2026-08-25 重构):id = 活动 id;
与设计中心的 `act`(活动库)是两回事 —— 那是技术安装面,这是业务运营面 */
| { k: 'actd'; id: string }
| { k: 'llm'; id: string }
| { k: 'chan'; id: string };
@@ -65,7 +71,7 @@ export const ZONES: ReadonlyArray<{ key: GovZone; label: string; icon: string }>
{ key: 'testbed', label: '测试台', icon: 'flask-conical' },
{ key: 'cockpit', label: '驾驶舱', icon: 'gauge' },
{ key: 'runs', label: '工作流', icon: 'activity' },
{ key: 'tasks', label: '任务', icon: 'list-checks' },
{ key: 'tasks', label: '活动', icon: 'list-checks' },
{ key: 'people', label: '员工', icon: 'users' },
{ key: 'ledgers', label: '台账', icon: 'table' },
{ key: 'system', label: '系统', icon: 'settings' },
@@ -98,7 +104,7 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
desc: '流程包的运行时:在途工作流的监控,和每个包的运行配置——起单开关、命中后要不要人工确认、谁来确认、每个节点归谁负责。设计中心定义流程长什么样,这里管它今天怎么跑。',
},
tasks: {
title: '任务管理',
title: '活动管理',
icon: 'list-checks',
accent: 'linear-gradient(140deg,#3370ff,#245bdb)',
desc: '活动的运行时:任务 = 活动被调起的一次运行,恰有一名负责人。这里做任务与审批单的在途监控、干预与控制——暂停可恢复、叫停归负责人、终止级联作废。工作流一级去「工作流」区看。',
@@ -193,10 +199,12 @@ export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label:
{ key: 'board', label: '在途工作流', ico: 'activity' },
{ key: 'conf', label: '运行配置', ico: 'settings' },
],
/* 页签二分(2026-08-25 用户定):活动按运营状态分「激活中 / 已禁用」——
激活/禁用是运营态,L2 里可改;「已发布/审核中」是设计部分的功能,暂缓,
等运营功能做好再回头改 */
tasks: [
{ key: 'list', label: '任务', ico: 'list-checks' },
{ key: 'apr', label: '审批单', ico: 'shield-check' },
{ key: 'todo', label: '需处置', ico: 'flag' },
{ key: 'active', label: '激活中', ico: 'zap' },
{ key: 'disabled', label: '已禁用', ico: 'ban' },
],
people: [
{ key: 'emp', label: '员工', ico: 'users' },
@@ -231,6 +239,10 @@ export function routeLabel(r: Route): string {
return '工具';
case 'obj':
return '数据对象';
case 'row':
return '记录详情';
case 'actd':
return '活动详情';
case 'llm':
return 'LLM 实例';
case 'chan':
+8
View File
@@ -72,6 +72,7 @@ export function Kpi({
unit,
sub,
dial,
hint,
}: {
tone?: string;
ico: string;
@@ -80,6 +81,8 @@ export function Kpi({
unit?: string;
sub?: string;
dial?: number;
/** 角标说明:右上角一枚 info,悬停出气泡解释这个数的口径(纯展示,aria 同源) */
hint?: string;
}) {
return (
<div className={'g-kpi' + (tone ? ' t-' + tone : '')}>
@@ -87,6 +90,11 @@ export function Kpi({
<Ico name={ico} s={12} />
{label}
</span>
{hint !== undefined && (
<span className="g-kpi-hint" data-tip={hint} role="img" aria-label={hint}>
<Ico name="info" s={11} />
</span>
)}
{dial !== undefined ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Dial v={dial} tone={tone ?? 'brand'} />
+6 -19
View File
@@ -4,7 +4,7 @@
* 产品分三面:设计中心定义企业、运营中心看企业跑、工作台在企业里干活。
* 区归属、入口亮灯、旧链接改写都从这里出 —— 壳层只消费,不各写各的。
*/
import type { GovernanceFunction, Session } from '@contracts';
import type { Principal } from '@contracts';
import { GOV_ZONES } from './gov-route.ts';
import type { GovZone } from './gov-route.ts';
@@ -31,26 +31,13 @@ export const SURFACE_ZONES: Readonly<Record<Surface, readonly GovZone[]>> = {
};
/**
* 亮灯功能集 —— 授权模型不动(仍是九功能 × 读/写/治),这里只说「哪个功能属哪一面」。
* 台面入口是否亮灯(ADR-012)。
*
* `trigger` 与 `data` 两面都算:触发器**定义归设计、启停归运营**;
* 数据**结构归设计、行数据归运营**。同一功能的两种动作各归其位,不拆授权。
* 授权模型已换成「角色 → 端」:后端不再有功能级授权,`SURFACE_FNS` 随之删除。
* 判据就一条 —— `me.ends` 含不含这个端;进去之后哪条接口能调由后端按路由归属自己判。
*/
export const SURFACE_FNS: Readonly<Record<Surface, readonly GovernanceFunction[]>> = {
design: ['process', 'activity', 'tool', 'data', 'trigger', 'system'],
ops: ['task', 'staff', 'memory', 'system', 'trigger', 'data'],
};
/**
* 台面入口是否亮灯。
*
* 判据是「持有该面任一功能的**任意**授权」—— 动作蕴含 govern ⊃ write ⊃ read,
* 有 grant 即至少可读,入口就该看得见;进去之后动作仍逐功能校验。
*/
export function canSeeSurface(session: Session | undefined, surface: Surface): boolean {
if (session === undefined) return false;
const fns = SURFACE_FNS[surface];
return session.grants.some((g) => g.object.kind === 'governance' && fns.includes(g.object.function));
export function canSeeSurface(me: Principal | undefined, surface: Surface): boolean {
return me !== undefined && (me.ends as readonly string[]).includes(surface);
}
export function surfaceOfZone(zone: GovZone): Surface {
+13
View File
@@ -0,0 +1,13 @@
/**
* 查询键 —— 纯数据,自 `app/query.ts` 析出(2026-08-25):
* feature 层(desk-me)也要按同一批键共享缓存,而 feature 不许回引 app 内部(§11.3),
* 键放 shared 两面都能取。键名仍以此处为唯一出处,不手写字面量。
*/
export const queryKeys = {
/** 「我是谁、能进哪几个端」—— ADR-012 后身份来自 `GET /api/me`(Principal) */
me: ['me'] as const,
tasks: ['desk', 'tasks'] as const,
task: (id: string) => ['desk', 'task', id] as const,
messages: ['desk', 'messages'] as const,
approvals: ['desk', 'approvals'] as const,
};
+24 -6
View File
@@ -13,6 +13,7 @@
* 否则屏上摆的是改之前的数(界面不撒谎)。
*/
import type { RouteContract } from '@contracts';
import { forceRefreshToken, freshToken } from './auth.ts';
export class ApiCallError extends Error {
constructor(
@@ -75,13 +76,30 @@ export function resetCallGuards(): void {
justDone.clear();
}
/**
* 发一笔请求(ADR-012):统一鉴权中心后**不再靠 cookie**,每笔都挂
* `Authorization: Bearer <token>`;token 由 `auth.ts` 管(取 / 刷 / 登出)。
*
* 401 先强刷一次 token 重试 —— 可能只是手里这枚过期而 SSO 会话还在;
* 仍 401 就如实抛出,由壳层(Gate)落登录页。**403 不重试**:那是端不够,
* 重登没用,抛给页面按 `detail.need` / `detail.ends` 提示。
*/
async function send<T>(route: RouteContract, options: CallOptions): Promise<T> {
const response = await fetch(resolvePath(route.path, options.params) + queryString(options.query), {
method: route.method,
headers: { 'content-type': 'application/json' },
credentials: 'same-origin',
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
});
const url = resolvePath(route.path, options.params) + queryString(options.query);
const raw = async () => {
const token = await freshToken();
return fetch(url, {
method: route.method,
headers: {
'content-type': 'application/json',
...(token === undefined ? {} : { authorization: `Bearer ${token}` }),
},
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
});
};
let response = await raw();
if (response.status === 401 && (await forceRefreshToken())) response = await raw();
const payload: unknown = await response.json().catch(() => undefined);
if (!response.ok) {