From b496c4476dd2f2c8a73891e3cd2eba29b62d6b11 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Tue, 20 Oct 2026 03:54:20 +0000 Subject: [PATCH] agent-exec transport 5cc70be566de95cb35d2d8bdfb05b392/b0fcba7575e5 --- src/components/adminRdmSections.tsx | 143 +++++++++------- src/rdmManagement.ts | 1 + src/store.ts | 2 +- test/rdm-members-gating.test.tsx | 243 ++++++++++++++++++++++++++++ test/send.test.ts | 12 +- 5 files changed, 343 insertions(+), 58 deletions(-) create mode 100644 test/rdm-members-gating.test.tsx diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index b04ce63..882e9e2 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -24,11 +24,20 @@ interface MemberRow { profRole?: { roleId: string; name: string } | null } -export function RdmMembersSection({ project }: { project: Project }) { +export function RdmMembersSection({ + project, + initialCanManage, + initialRows, +}: { + project: Project + initialCanManage?: boolean + initialRows?: MemberRow[] +}) { const projectId = project.id const writeError = projectWorkError(project) const cmd = (id: string, params: Record = {}) => api.rdmProjectCommand(project, id, params) - const [rows, setRows] = useState(null) + const [canManage, setCanManage] = useState(initialCanManage ?? false) + const [rows, setRows] = useState(initialRows ?? null) const [memberNext, setMemberNext] = useState('') const [memberTotal, setMemberTotal] = useState(null) const [directoryOk, setDirectoryOk] = useState(true) @@ -54,15 +63,23 @@ export function RdmMembersSection({ project }: { project: Project }) { const memberGen = useRef(0) useEffect(() => { + setCanManage(false) setPicked([]); setPickedItems([]); knownPicks.current.clear() setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('') setRoleOptions(null) + }, [project]) + + useEffect(() => { + if (!canManage) { + setRoleOptions(null) + return + } let live = true cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId)) .then(out => { if (live) setRoleOptions(out.roles ?? []) }) .catch(() => { if (live) setRoleOptions(null) }) return () => { live = false } - }, [project]) + }, [project, canManage]) const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }), [candMatch, candFields, candRoleId]) @@ -90,6 +107,9 @@ export function RdmMembersSection({ project }: { project: Project }) { const out = await cmd('rdm.project.member.list', memberListParams(projectId, { query: memberQuery, roleId: memberRoleId, cursor })) if (gen !== memberGen.current) return + if (!cursor) { + setCanManage(Boolean(out.canManage)) + } setRows(prev => appendMemberPage(cursor ? prev : null, out.members)) setMemberNext(out.next || '') setMemberTotal(typeof out.total === 'number' ? out.total : null) @@ -139,44 +159,48 @@ export function RdmMembersSection({ project }: { project: Project }) { return (
{writeError &&

{writeError}

} -
-
- {roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')} - - + {canManage ? ( +
+
+ {roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')} + + +
+
+ + + +
+

候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。

-
- - - -
-

候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。

-
+ ) : ( +

仅项目创建者可管理成员;你可以查看成员列表。

+ )}
setMemberQuery(e.target.value)} /> @@ -185,7 +209,16 @@ export function RdmMembersSection({ project }: { project: Project }) {
{!directoryOk &&

账号目录暂不可用:成员姓名可能缺失,仅按账号显示。

} - + + + + + + + + {canManage && } + + {(rows ?? []).map(m => ( @@ -194,21 +227,23 @@ export function RdmMembersSection({ project }: { project: Project }) { - + {canManage && ( + + )} ))} - {rows !== null && rows.length === 0 && } - {rows === null && } + {rows !== null && rows.length === 0 && } + {rows === null && }
成员专业角色项目身份添加人添加时间
成员专业角色项目身份添加人添加时间
{m.role === 'owner' ? 项目负责人 : 项目成员} {m.addedBy || '—'} {m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'} - {' '} - - + {' '} + +
{filtering ? '没有匹配的成员' : '暂无成员'}
{err ? '成员清单未加载(见下方提示)' : '加载中…'}
{filtering ? '没有匹配的成员' : '暂无成员'}
{err ? '成员清单未加载(见下方提示)' : '加载中…'}
{memberNext && rows !== null && ( diff --git a/src/rdmManagement.ts b/src/rdmManagement.ts index 06ae571..088b9a0 100644 --- a/src/rdmManagement.ts +++ b/src/rdmManagement.ts @@ -212,6 +212,7 @@ export interface RdmMemberRow { } export interface RdmMemberPage { members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean + canManage?: boolean } /** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库);id 为稳定 userId,owner 为提交用账号。 */ export interface PersonPickItem { diff --git a/src/store.ts b/src/store.ts index f6bb89f..bd22c45 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1580,5 +1580,5 @@ export function mergeStartedRun(runs: Run[], run: Run): Run[] { export function eventTime(e: { ts?: string }, now = Date.now()): number { if (!e.ts) return now const t = Date.parse(e.ts) - return Number.isNaN(t) ? now : t + return Number.isNaN(t) || t <= 0 ? now : t } diff --git a/test/rdm-members-gating.test.tsx b/test/rdm-members-gating.test.tsx new file mode 100644 index 0000000..32d2de8 --- /dev/null +++ b/test/rdm-members-gating.test.tsx @@ -0,0 +1,243 @@ +// RDM 项目设置:成员页门控测试 +// 当 canManage=false 时:添加成员入口与行操作按钮(移除/升降级)不出现,不调用 rdm.role.options; +// 当 canManage=true 时:添加成员与行操作按钮出现,请求 rdm.role.options。 +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createElement, act } from 'react' +import { createRoot } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { api } from '../src/api' +import { RdmMembersSection } from '../src/components/adminRdmSections' +import type { Project } from '../src/contracts' +import type { RdmMemberRow } from '../src/rdmManagement' + +const project: Project = { + id: 'prj_test', + name: '测试项目', + settings: { + 'rdm.projectId': 'rdm_prj_test', + 'rdm.state': 'ready', + }, + createdAt: '2026-10-01T00:00:00Z', + updatedAt: '2026-10-01T00:00:00Z', +} + +const mockRows: RdmMemberRow[] = [ + { owner: 'alice', name: 'Alice', role: 'owner', addedBy: 'admin', addedAt: '2026-10-01T10:00:00Z' }, + { owner: 'bob', name: 'Bob', role: 'member', addedBy: 'alice', addedAt: '2026-10-02T10:00:00Z' }, +] + +describe('RdmMembersSection 静态组件门控呈现', () => { + it('canManage=false 时:添加成员与移除按钮不呈现,显示只读提示,动作列头不渲染', () => { + const html = renderToStaticMarkup(createElement(RdmMembersSection, { + project, + initialCanManage: false, + initialRows: mockRows, + })) + expect(html).toContain('仅项目创建者可管理成员;你可以查看成员列表。') + expect(html).not.toContain('添加成员') + expect(html).not.toContain('移除') + expect(html).not.toContain('降为成员') + expect(html).not.toContain('升为负责人') + // 列表基础内容、搜索框、成员信息仍可见 + expect(html).toContain('alice') + expect(html).toContain('bob') + expect(html).toContain('搜索成员') + // 表头只有 5 列(无末尾操作列头 ) + const thMatches = html.match(/ { + const html = renderToStaticMarkup(createElement(RdmMembersSection, { + project, + initialCanManage: true, + initialRows: mockRows, + })) + expect(html).not.toContain('仅项目创建者可管理成员') + expect(html).toContain('添加成员') + expect(html).toContain('移除') + expect(html).toContain('降为成员') + expect(html).toContain('升为负责人') + // 表头共 6 列(包含末尾操作列头 ) + const thMatches = html.match(/ { + let commandsCalled: { id: string; params?: Record }[] = [] + let mockEnv: { container: any; cleanup: () => void } + + function setupMockDom() { + const origDoc = globalThis.document + const origWindow = globalThis.window + const origAct = (globalThis as any).IS_REACT_ACT_ENVIRONMENT + + ;(globalThis as any).HTMLIFrameElement = class {} + ;(globalThis as any).Element = class {} + ;(globalThis as any).HTMLElement = class extends (globalThis as any).Element {} + + function createMockElement(tag: string): any { + const el: any = { + nodeType: 1, + tagName: tag.toUpperCase(), + children: [], + style: {}, + ownerDocument: doc, + get options() { + return el.children.filter((c: any) => c.tagName === 'OPTION') + }, + get length() { + return el.children.filter((c: any) => c.tagName === 'OPTION').length + }, + setAttribute(k: string, v: string) { el[k] = v }, + removeAttribute(k: string) { delete el[k] }, + getAttribute(k: string) { return el[k] }, + appendChild(c: any) { + el.children.push(c) + c.parentNode = el + if (c.tagName === 'OPTION') { + const idx = el.children.filter((x: any) => x.tagName === 'OPTION').indexOf(c) + el[idx] = c + } + return c + }, + removeChild(c: any) { + const i = el.children.indexOf(c) + if (i >= 0) el.children.splice(i, 1) + delete c.parentNode + }, + insertBefore(newNode: any, refNode: any) { + const i = el.children.indexOf(refNode) + if (i >= 0) el.children.splice(i, 0, newNode) + else el.children.push(newNode) + newNode.parentNode = el + return newNode + }, + addEventListener() {}, + removeEventListener() {}, + } + return el + } + + const doc: any = { + nodeType: 9, + createElement(tag: string) { return createMockElement(tag) }, + createElementNS(_ns: string, tag: string) { return createMockElement(tag) }, + createTextNode(text: string) { + return { nodeType: 3, nodeValue: text, ownerDocument: doc } + }, + createComment(text: string) { + return { nodeType: 8, nodeValue: text, ownerDocument: doc } + }, + addEventListener() {}, + removeEventListener() {}, + defaultView: globalThis, + } + doc.ownerDocument = doc + globalThis.document = doc + globalThis.window = globalThis as any + ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true + + return { + container: createMockElement('div'), + cleanup() { + globalThis.document = origDoc + globalThis.window = origWindow + ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = origAct + }, + } + } + + function serializeDom(el: any): string { + if (!el) return '' + if (el.nodeType === 3) return el.nodeValue || '' + if (el.nodeType === 8) return '' + let out = `<${el.tagName.toLowerCase()}` + if (el.className) out += ` class="${el.className}"` + out += '>' + if (el.textContent && el.children.length === 0) { + out += el.textContent + } else { + for (const c of el.children) out += serializeDom(c) + } + out += `` + return out + } + + beforeEach(() => { + commandsCalled = [] + mockEnv = setupMockDom() + }) + + afterEach(() => { + mockEnv.cleanup() + vi.restoreAllMocks() + }) + + it('当 rdm.project.member.list 返回 canManage=false 时:不请求 rdm.role.options,添加成员与移除按钮缺席', async () => { + vi.spyOn(api, 'rdmProjectCommand').mockImplementation(async (_p, id, params) => { + commandsCalled.push({ id, params }) + if (id === 'rdm.project.member.list') { + return { members: mockRows, canManage: false, total: 2 } as any + } + if (id === 'rdm.role.options') { + return { roles: [{ roleId: 'r1', name: '架构师' }] } as any + } + return {} as any + }) + + const root = createRoot(mockEnv.container) + await act(async () => { + root.render(createElement(RdmMembersSection, { project })) + }) + + // 等待 useEffect 及微任务完成 + await act(async () => { + await new Promise(r => setTimeout(r, 20)) + }) + + const html = serializeDom(mockEnv.container) + // 证明请求了成员列表,但未请求 rdm.role.options + expect(commandsCalled.some(c => c.id === 'rdm.project.member.list')).toBe(true) + expect(commandsCalled.some(c => c.id === 'rdm.role.options')).toBe(false) + + // 证明添加成员和移除按钮缺席,显示只读提示 + expect(html).not.toContain('添加成员') + expect(html).not.toContain('移除') + expect(html).toContain('仅项目创建者可管理成员;你可以查看成员列表。') + }) + + it('当 rdm.project.member.list 返回 canManage=true 时:触发 rdm.role.options 请求,呈现添加成员与移除按钮', async () => { + vi.spyOn(api, 'rdmProjectCommand').mockImplementation(async (_p, id, params) => { + commandsCalled.push({ id, params }) + if (id === 'rdm.project.member.list') { + return { members: mockRows, canManage: true, total: 2 } as any + } + if (id === 'rdm.role.options') { + return { roles: [{ roleId: 'r1', name: '架构师' }] } as any + } + return {} as any + }) + + const root = createRoot(mockEnv.container) + await act(async () => { + root.render(createElement(RdmMembersSection, { project })) + }) + + // 等待 useEffect 及微任务完成 + await act(async () => { + await new Promise(r => setTimeout(r, 20)) + }) + + const html = serializeDom(mockEnv.container) + // 证明请求了成员列表,且在 canManage=true 后请求了 rdm.role.options + expect(commandsCalled.some(c => c.id === 'rdm.project.member.list')).toBe(true) + expect(commandsCalled.some(c => c.id === 'rdm.role.options')).toBe(true) + + // 证明添加成员和移除按钮存在 + expect(html).toContain('添加成员') + expect(html).toContain('移除') + expect(html).not.toContain('仅项目创建者可管理成员') + }) +}) diff --git a/test/send.test.ts b/test/send.test.ts index af661c6..39fff53 100644 --- a/test/send.test.ts +++ b/test/send.test.ts @@ -26,12 +26,18 @@ describe('mergeStartedRun', () => { }) describe('eventTime', () => { - it('parses the server timestamp', () => { - expect(eventTime({ ts: '2026-10-03T14:50:25.073Z' }, 0)).toBe(Date.parse('2026-10-03T14:50:25.073Z')) + it('parses normal ISO server timestamps', () => { + const iso = '2026-10-03T14:50:25.073Z' + expect(eventTime({ ts: iso }, 0)).toBe(Date.parse(iso)) + expect(eventTime({ ts: iso }, 42)).toBe(Date.parse(iso)) }) - it('falls back when the timestamp is missing or unparseable', () => { + it('falls back to now when timestamp is missing, invalid or zero-time 0001-01-01T00:00:00Z', () => { expect(eventTime({}, 42)).toBe(42) + expect(eventTime({ ts: undefined }, 42)).toBe(42) + expect(eventTime({ ts: '' }, 42)).toBe(42) expect(eventTime({ ts: 'not-a-time' }, 42)).toBe(42) + expect(eventTime({ ts: '0001-01-01T00:00:00Z' }, 42)).toBe(42) + expect(eventTime({ ts: '1970-01-01T00:00:00Z' }, 42)).toBe(42) }) }) -- 2.54.0