Archived
接入统一鉴权中心(Keycloak,ADR-012);活动模块 L1/L2 运营态模版(历史活动列表+检索+沙箱);数据域与工作台界面改版;个人中心 /me
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,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':
|
||||
|
||||
@@ -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'} />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user