agent-exec transport 5cc70be566de95cb35d2d8bdfb05b392/b0fcba7575e5
This commit is contained in:
@@ -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 = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
||||
const [rows, setRows] = useState<MemberRow[] | null>(null)
|
||||
const [canManage, setCanManage] = useState(initialCanManage ?? false)
|
||||
const [rows, setRows] = useState<MemberRow[] | null>(initialRows ?? null)
|
||||
const [memberNext, setMemberNext] = useState('')
|
||||
const [memberTotal, setMemberTotal] = useState<number | null>(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<RdmMemberPage>('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,6 +159,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
return (
|
||||
<div>
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
{canManage ? (
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
|
||||
@@ -177,6 +198,9 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
</div>
|
||||
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="ad-hint">仅项目创建者可管理成员;你可以查看成员列表。</p>
|
||||
)}
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
|
||||
onChange={e => setMemberQuery(e.target.value)} />
|
||||
@@ -185,7 +209,16 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
</div>
|
||||
{!directoryOk && <p className="ad-hint">账号目录暂不可用:成员姓名可能缺失,仅按账号显示。</p>}
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>成员</th>
|
||||
<th>专业角色</th>
|
||||
<th>项目身份</th>
|
||||
<th>添加人</th>
|
||||
<th>添加时间</th>
|
||||
{canManage && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(rows ?? []).map(m => (
|
||||
<tr key={m.owner}>
|
||||
@@ -194,6 +227,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td>
|
||||
<td className="ad-dim">{m.addedBy || '—'}</td>
|
||||
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
|
||||
{canManage && (
|
||||
<td>
|
||||
<button className="ad-mini" disabled={busy || !!writeError}
|
||||
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
|
||||
@@ -205,10 +239,11 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
onClick={() => run(`已移除 ${m.owner}`,
|
||||
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
|
||||
{rows === null && <tr><td colSpan={6} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={canManage ? 6 : 5} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
|
||||
{rows === null && <tr><td colSpan={canManage ? 6 : 5} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{memberNext && rows !== null && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 列(无末尾操作列头 <th></th>)
|
||||
const thMatches = html.match(/<th\b/g) || []
|
||||
expect(thMatches).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('canManage=true 时:添加成员与移除按钮呈现,不显示只读提示,动作列头渲染', () => {
|
||||
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 列(包含末尾操作列头 <th></th>)
|
||||
const thMatches = html.match(/<th\b/g) || []
|
||||
expect(thMatches).toHaveLength(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RdmMembersSection 动态加载与命令门控交互', () => {
|
||||
let commandsCalled: { id: string; params?: Record<string, unknown> }[] = []
|
||||
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 += `</${el.tagName.toLowerCase()}>`
|
||||
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('仅项目创建者可管理成员')
|
||||
})
|
||||
})
|
||||
+9
-3
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user