IAM U4 frontend-auto-discover (5s polling + capability-group UI + creator/owner/member duties) #115
@@ -0,0 +1,32 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { loadSignedInWorkspace } from './App'
|
||||||
|
import { store } from './store'
|
||||||
|
|
||||||
|
afterEach(() => { vi.restoreAllMocks() })
|
||||||
|
|
||||||
|
function stubLoad() {
|
||||||
|
const order: string[] = []
|
||||||
|
vi.spyOn(store, 'refreshMe').mockImplementation(async () => { order.push('me') })
|
||||||
|
vi.spyOn(store, 'refreshProfiles').mockImplementation(async () => {})
|
||||||
|
vi.spyOn(store, 'refreshPlugins').mockImplementation(async () => {})
|
||||||
|
vi.spyOn(store, 'refreshProjects').mockImplementation(async () => { order.push('projects') })
|
||||||
|
vi.spyOn(store, 'startProjectSync').mockImplementation(() => { order.push('sync') })
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AutoDiscover ownership after sign-in', () => {
|
||||||
|
it('checks projects immediately on sign-in, then hands re-checks to the single sync owner', async () => {
|
||||||
|
const order = stubLoad()
|
||||||
|
await loadSignedInWorkspace({ current: true })
|
||||||
|
expect(order).toEqual(['me', 'projects', 'sync'])
|
||||||
|
expect(store.startProjectSync).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not start a sync owner for a page that was torn down during loading', async () => {
|
||||||
|
const order = stubLoad()
|
||||||
|
const active = { current: true }
|
||||||
|
vi.mocked(store.refreshProjects).mockImplementation(async () => { order.push('projects'); active.current = false })
|
||||||
|
await loadSignedInWorkspace(active)
|
||||||
|
expect(store.startProjectSync).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
+13
-8
@@ -25,6 +25,16 @@ const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
|
|||||||
const RIGHT_MIN = 280, RIGHT_MAX = 560
|
const RIGHT_MIN = 280, RIGHT_MAX = 560
|
||||||
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
|
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
|
||||||
|
|
||||||
|
/** 放行后的首轮装载:首次项目列表就是登录即时核对;其后由唯一的自动发现所有者接管重查。 */
|
||||||
|
export async function loadSignedInWorkspace(active: { current: boolean }) {
|
||||||
|
await store.refreshMe()
|
||||||
|
if (!active.current) return
|
||||||
|
void store.refreshProfiles()
|
||||||
|
void store.refreshPlugins()
|
||||||
|
await store.refreshProjects()
|
||||||
|
if (active.current) store.startProjectSync()
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const ui = useStore(s => s.ui)
|
const ui = useStore(s => s.ui)
|
||||||
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
|
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
|
||||||
@@ -114,14 +124,9 @@ export function App() {
|
|||||||
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
|
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
|
||||||
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
|
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
|
||||||
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
|
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
|
||||||
let active = true
|
const active = { current: true }
|
||||||
store.refreshMe().then(() => {
|
void loadSignedInWorkspace(active)
|
||||||
if (!active) return
|
return () => { active.current = false; store.stopProjectSync() }
|
||||||
void store.refreshProjects()
|
|
||||||
void store.refreshProfiles()
|
|
||||||
void store.refreshPlugins()
|
|
||||||
})
|
|
||||||
return () => { active = false }
|
|
||||||
}, [authState])
|
}, [authState])
|
||||||
|
|
||||||
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
|
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { api } from './api'
|
||||||
|
|
||||||
|
afterEach(() => { vi.unstubAllGlobals() })
|
||||||
|
|
||||||
|
describe('AutoDiscover list request', () => {
|
||||||
|
it('passes the abort signal so an over-budget project list request is cancelled', async () => {
|
||||||
|
const seen: (RequestInit | undefined)[] = []
|
||||||
|
vi.stubGlobal('fetch', async (_path: string, init?: RequestInit) => {
|
||||||
|
seen.push(init)
|
||||||
|
return new Response('[]', { status: 200 })
|
||||||
|
})
|
||||||
|
const controller = new AbortController()
|
||||||
|
await api.listProjects(controller.signal)
|
||||||
|
await api.listProjects()
|
||||||
|
expect(seen[0]?.signal).toBe(controller.signal)
|
||||||
|
expect(seen[1]).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the linked creation capability from the identity response', async () => {
|
||||||
|
vi.stubGlobal('fetch', async () => new Response(JSON.stringify({ owner: 'bob', name: 'Bob', auth: true,
|
||||||
|
linkedCapabilities: { projectCreate: true, source: 'system-grant', linkedActions: ['project.members.manage'], authorityEpoch: 4 } }), { status: 200 }))
|
||||||
|
const me = await api.me()
|
||||||
|
expect(me.linkedCapabilities).toMatchObject({ projectCreate: true, source: 'system-grant' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the linked expansion from a role permission write receipt', async () => {
|
||||||
|
vi.stubGlobal('fetch', async () => new Response(JSON.stringify({ receiptId: 'r1', roleVersion: 3, authorityEpoch: 9,
|
||||||
|
linkedExpansion: ['project.members.manage'] }), { status: 200 }))
|
||||||
|
const receipt = await api.replaceRolePermissions('role_pm', 2, [{ actionId: 'project.create', allowed: true, capAllowed: true }], 'key')
|
||||||
|
expect(receipt.linkedExpansion).toEqual(['project.members.manage'])
|
||||||
|
})
|
||||||
|
})
|
||||||
+10
-4
@@ -26,6 +26,12 @@ export interface UserRoleBinding {
|
|||||||
assignedBy: string; assignedAt: string; userStateVersion: number; authorityEpoch: number
|
assignedBy: string; assignedAt: string; userStateVersion: number; authorityEpoch: number
|
||||||
}
|
}
|
||||||
export interface UserRoleView { binding?: UserRoleBinding; role?: RoleRecord }
|
export interface UserRoleView { binding?: UserRoleBinding; role?: RoleRecord }
|
||||||
|
/** Backend 计算的本人 project.create 联动(/api/me linkedCapabilities)。 */
|
||||||
|
export interface LinkedCapabilities {
|
||||||
|
projectCreate: boolean; source?: string; linkedActions: string[]; conflicts?: string[]; authorityEpoch: number
|
||||||
|
}
|
||||||
|
/** 角色/系统授权写入回执:linkedExpansion 为服务端为 project.create 联动补齐的动作。 */
|
||||||
|
interface PolicyWriteReceipt { receiptId: string; authorityEpoch: number; linkedExpansion?: string[]; migrationNote?: string }
|
||||||
|
|
||||||
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
|
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
|
||||||
* passwordChangeMode 由服务端从会话派生;grant_setup/one_time_password 免当前密码,
|
* passwordChangeMode 由服务端从会话派生;grant_setup/one_time_password 免当前密码,
|
||||||
@@ -97,7 +103,7 @@ export const api = {
|
|||||||
body: JSON.stringify({ expectedRoleVersion, ...patch }) }).then(r => json<RoleRecord>(r)),
|
body: JSON.stringify({ expectedRoleVersion, ...patch }) }).then(r => json<RoleRecord>(r)),
|
||||||
replaceRolePermissions: (roleId: string, expectedRoleVersion: number, changes: { actionId: string; allowed: boolean; capAllowed: boolean }[], idempotencyKey: string) =>
|
replaceRolePermissions: (roleId: string, expectedRoleVersion: number, changes: { actionId: string; allowed: boolean; capAllowed: boolean }[], idempotencyKey: string) =>
|
||||||
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}/permissions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}/permissions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json<{ receiptId: string; roleVersion: number; authorityEpoch: number }>(r)),
|
body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json<PolicyWriteReceipt & { roleVersion: number }>(r)),
|
||||||
getUserRole: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`).then(r => json<UserRoleView>(r)),
|
getUserRole: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`).then(r => json<UserRoleView>(r)),
|
||||||
assignUserRole: (userId: string, roleId: string, expectedStateVersion: number, idempotencyKey: string) =>
|
assignUserRole: (userId: string, roleId: string, expectedStateVersion: number, idempotencyKey: string) =>
|
||||||
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -105,9 +111,9 @@ export const api = {
|
|||||||
getSystemGrants: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`).then(r => json<{ actions: string[]; authorityEpoch: number }>(r)),
|
getSystemGrants: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`).then(r => json<{ actions: string[]; authorityEpoch: number }>(r)),
|
||||||
replaceSystemGrants: (userId: string, actions: string[], expectedAuthorityEpoch: number, idempotencyKey: string) =>
|
replaceSystemGrants: (userId: string, actions: string[], expectedAuthorityEpoch: number, idempotencyKey: string) =>
|
||||||
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ actions, expectedAuthorityEpoch, idempotencyKey }) }).then(r => json<{ receiptId: string; authorityEpoch: number }>(r)),
|
body: JSON.stringify({ actions, expectedAuthorityEpoch, idempotencyKey }) }).then(r => json<PolicyWriteReceipt>(r)),
|
||||||
explainSystemAction: (action: string) => auth.fetch(`${BASE}/api/authz/explain?action=${encodeURIComponent(action)}`).then(r => json<{ allowed: boolean; actionId: string; source?: string; reason?: string; authorityEpoch: number }>(r)),
|
explainSystemAction: (action: string) => auth.fetch(`${BASE}/api/authz/explain?action=${encodeURIComponent(action)}`).then(r => json<{ allowed: boolean; actionId: string; source?: string; reason?: string; authorityEpoch: number }>(r)),
|
||||||
listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json<Project[]>(r)),
|
listProjects: (signal?: AbortSignal) => auth.fetch(`${BASE}/api/projects`, signal ? { signal } : undefined).then(r => json<Project[]>(r)),
|
||||||
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
||||||
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
||||||
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
|
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
|
||||||
@@ -126,7 +132,7 @@ export const api = {
|
|||||||
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||||||
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
|
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
|
||||||
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
|
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
|
||||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[] }>(r)),
|
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities }>(r)),
|
||||||
// ---- 认证与会话(管理员发放制,R2)----
|
// ---- 认证与会话(管理员发放制,R2)----
|
||||||
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
|
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
|
||||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import type { PermissionDescriptor } from '../api'
|
||||||
|
import { GrantChangePreview, GrantGroupEditor, ProjectCreateSourceNote, toggleGrantGroup } from './AccountUsersPanel'
|
||||||
|
import { buildCapabilityGroups } from '../rolePermissions'
|
||||||
|
|
||||||
|
const descriptor = (actionId: string, label: string, group: string, scope: 'system' | 'project' = 'system'): PermissionDescriptor =>
|
||||||
|
({ actionId, label, description: '', group, scope, protected: scope === 'system' && actionId !== 'project.create' })
|
||||||
|
const catalog = [
|
||||||
|
descriptor('system.users.view', '查看用户', '用户管理'),
|
||||||
|
descriptor('system.users.create', '创建用户', '用户管理'),
|
||||||
|
descriptor('system.roles.view', '查看角色', '角色管理'),
|
||||||
|
descriptor('project.create', '创建项目', '项目入口'),
|
||||||
|
descriptor('project.members.manage', '管理项目成员', '项目管理', 'project'),
|
||||||
|
]
|
||||||
|
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
|
||||||
|
|
||||||
|
describe('CapabilityGroup explicit grants', () => {
|
||||||
|
it('grants system capabilities by group instead of one checkbox per action', () => {
|
||||||
|
const html = renderToStaticMarkup(<GrantGroupEditor catalog={catalog} grants={['system.users.view']} disabled={false} onChange={vi.fn()} />)
|
||||||
|
expect(html.match(/type="checkbox"/g)).toHaveLength(3)
|
||||||
|
expect(html).toContain('授予 用户管理')
|
||||||
|
expect(html).toContain('用户管理(部分)')
|
||||||
|
expect(html).toContain('联动本人创建项目的设置、成员、项目权限与文档职责管理')
|
||||||
|
expect(html).not.toContain('管理项目成员')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles every action of a group together', () => {
|
||||||
|
const users = buildCapabilityGroups(catalog.filter(item => item.scope === 'system')).find(group => group.id === 'catalog:用户管理')!
|
||||||
|
expect(toggleGrantGroup(['system.roles.view'], users, true)).toEqual(['system.roles.view', 'system.users.create', 'system.users.view'])
|
||||||
|
expect(toggleGrantGroup(['system.users.view', 'system.roles.view'], users, false)).toEqual(['system.roles.view'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('previews the grant difference with its source and the single affected user', () => {
|
||||||
|
const html = renderToStaticMarkup(<GrantChangePreview baseline={['system.users.view']} grants={['project.create']} labels={labels} owner="bob" />)
|
||||||
|
expect(html).toContain('影响来源:明确系统授权 · 影响 1 名用户(bob)')
|
||||||
|
expect(html).toContain('新增:创建项目(联动本人创建项目的管理资格)')
|
||||||
|
expect(html).toContain('撤销:查看用户')
|
||||||
|
const revoke = renderToStaticMarkup(<GrantChangePreview baseline={['project.create']} grants={[]} labels={labels} owner="bob" />)
|
||||||
|
expect(revoke).toContain('已创建项目的管理资格不被反向删除')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CreatorOwnerMemberDuties capability source', () => {
|
||||||
|
it('shows where an account gets project creation and what it links to', () => {
|
||||||
|
const role = { name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }
|
||||||
|
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={role} grants={['project.create']} />))
|
||||||
|
.toContain('创建项目来源:职位角色「项目经理」 + 明确系统授权')
|
||||||
|
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={null} grants={[]} />))
|
||||||
|
.toContain('被加入项目后由成员关系提供基础协作')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,6 +6,9 @@ import { useStore } from '../store'
|
|||||||
import { Collapsible, Icon } from '@ui'
|
import { Collapsible, Icon } from '@ui'
|
||||||
import { hasSystemAction } from './RolePermissionsPanel'
|
import { hasSystemAction } from './RolePermissionsPanel'
|
||||||
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../accountAuthority'
|
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../accountAuthority'
|
||||||
|
import {
|
||||||
|
PROJECT_CREATE_ACTION, buildCapabilityGroups, projectCreateSources, rebaseGrantDraft, type CapabilityGroup, type GroupLevel,
|
||||||
|
} from '../rolePermissions'
|
||||||
|
|
||||||
type IdentityFlags = { isAdmin?: boolean; isSuperAdmin?: boolean }
|
type IdentityFlags = { isAdmin?: boolean; isSuperAdmin?: boolean }
|
||||||
|
|
||||||
@@ -45,6 +48,60 @@ export function AdminAppointmentButton({ me, account, busy, onChange }: { me: Id
|
|||||||
return <button className="ad-mini" disabled={busy} onClick={() => onChange(change === 'promote')}>{change === 'promote' ? '设为管理员' : '撤销管理员'}</button>
|
return <button className="ad-mini" disabled={busy} onClick={() => onChange(change === 'promote')}>{change === 'promote' ? '设为管理员' : '撤销管理员'}</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function grantLevel(grants: string[], group: CapabilityGroup): GroupLevel {
|
||||||
|
const held = group.actions.filter(id => grants.includes(id)).length
|
||||||
|
return held === 0 ? 'off' : held === group.actions.length ? 'on' : 'mixed'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleGrantGroup(grants: string[], group: CapabilityGroup, checked: boolean): string[] {
|
||||||
|
return checked ? [...new Set([...grants, ...group.actions])].sort() : grants.filter(id => !group.actions.includes(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 明确系统授权按业务组授予;创建项目组说明联动形成的本人项目管理资格。 */
|
||||||
|
export function GrantGroupEditor({ catalog, grants, disabled, onChange }: {
|
||||||
|
catalog: PermissionDescriptor[]
|
||||||
|
grants: string[]
|
||||||
|
disabled: boolean
|
||||||
|
onChange: (grants: string[]) => void
|
||||||
|
}) {
|
||||||
|
const groups = buildCapabilityGroups(catalog.filter(item => item.scope === 'system'))
|
||||||
|
return <div className="rp-grants">{groups.map(group => {
|
||||||
|
const state = grantLevel(grants, group)
|
||||||
|
return <label className="ad-check" key={group.id} title={`内部动作 ${group.actions.join('、')}`}>
|
||||||
|
<input type="checkbox" aria-label={`授予 ${group.label}`} checked={state === 'on'} disabled={disabled}
|
||||||
|
onChange={event => onChange(toggleGrantGroup(grants, group, event.target.checked))} />
|
||||||
|
{group.label}{state === 'mixed' ? '(部分)' : ''}
|
||||||
|
{group.actions.includes(PROJECT_CREATE_ACTION) && <span className="ad-dim">:联动本人创建项目的设置、成员、项目权限与文档职责管理,不涉及他人项目</span>}
|
||||||
|
</label>
|
||||||
|
})}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 授权差异预览:受影响来源为明确系统授权,影响人数为当前账号。 */
|
||||||
|
export function GrantChangePreview({ baseline, grants, labels, owner }: {
|
||||||
|
baseline: string[]; grants: string[]; labels: Record<string, string>; owner: string
|
||||||
|
}) {
|
||||||
|
const added = grants.filter(id => !baseline.includes(id))
|
||||||
|
const removed = baseline.filter(id => !grants.includes(id))
|
||||||
|
if (added.length === 0 && removed.length === 0) return <p className="ad-hint">系统授权没有变化</p>
|
||||||
|
const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、')
|
||||||
|
return <div>
|
||||||
|
<p className="ad-hint" role="note">影响来源:明确系统授权 · 影响 1 名用户({owner})</p>
|
||||||
|
<ul className="rp-diff">
|
||||||
|
{added.length > 0 && <li>新增:{names(added)}{added.includes(PROJECT_CREATE_ACTION) ? '(联动本人创建项目的管理资格)' : ''}</li>}
|
||||||
|
{removed.length > 0 && <li>撤销:{names(removed)}{removed.includes(PROJECT_CREATE_ACTION) ? '(仅停止未来创建,已创建项目的管理资格不被反向删除)' : ''}</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账号的创建能力来源与联动说明(角色默认与明确授权可并存)。 */
|
||||||
|
export function ProjectCreateSourceNote({ role, grants }: {
|
||||||
|
role: { name: string; permissions: { actionId: string; allowed: boolean }[] } | null | undefined; grants: string[]
|
||||||
|
}) {
|
||||||
|
const sources = projectCreateSources(role, grants)
|
||||||
|
if (sources.length === 0) return <p className="ad-hint">未获得创建项目能力:被加入项目后由成员关系提供基础协作</p>
|
||||||
|
return <p className="ad-hint">创建项目来源:{sources.join(' + ')} · 联动形成本人创建项目的设置、成员、项目权限与文档职责管理资格</p>
|
||||||
|
}
|
||||||
|
|
||||||
export function AccountUsersPanel() {
|
export function AccountUsersPanel() {
|
||||||
const me = useStore(state => state.me)
|
const me = useStore(state => state.me)
|
||||||
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
|
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
|
||||||
@@ -63,6 +120,7 @@ export function AccountUsersPanel() {
|
|||||||
const [roleLoading, setRoleLoading] = useState(false)
|
const [roleLoading, setRoleLoading] = useState(false)
|
||||||
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
|
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
|
||||||
const [grants, setGrants] = useState<string[]>([])
|
const [grants, setGrants] = useState<string[]>([])
|
||||||
|
const [grantBaseline, setGrantBaseline] = useState<string[]>([])
|
||||||
const [authorityEpoch, setAuthorityEpoch] = useState(0)
|
const [authorityEpoch, setAuthorityEpoch] = useState(0)
|
||||||
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
|
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
|
||||||
const [newOwner, setNewOwner] = useState('')
|
const [newOwner, setNewOwner] = useState('')
|
||||||
@@ -81,6 +139,7 @@ export function AccountUsersPanel() {
|
|||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const sequence = useRef(0)
|
const sequence = useRef(0)
|
||||||
const roleSequence = useRef(0)
|
const roleSequence = useRef(0)
|
||||||
|
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
|
||||||
|
|
||||||
const search = async (pageCursor = cursor, afterWrite = false) => {
|
const search = async (pageCursor = cursor, afterWrite = false) => {
|
||||||
const request = ++sequence.current
|
const request = ++sequence.current
|
||||||
@@ -123,8 +182,9 @@ export function AccountUsersPanel() {
|
|||||||
if (catalogResult.status === 'fulfilled' && catalogResult.value) setCatalog(catalogResult.value)
|
if (catalogResult.status === 'fulfilled' && catalogResult.value) setCatalog(catalogResult.value)
|
||||||
else setCatalog([])
|
else setCatalog([])
|
||||||
if (grantsResult.status === 'fulfilled' && grantsResult.value) {
|
if (grantsResult.status === 'fulfilled' && grantsResult.value) {
|
||||||
setGrants(grantsResult.value.actions ?? []); setAuthorityEpoch(grantsResult.value.authorityEpoch)
|
const actions = [...(grantsResult.value.actions ?? [])].sort()
|
||||||
} else { setGrants([]); setAuthorityEpoch(0) }
|
setGrants(actions); setGrantBaseline(actions); setAuthorityEpoch(grantsResult.value.authorityEpoch)
|
||||||
|
} else { setGrants([]); setGrantBaseline([]); setAuthorityEpoch(0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -187,10 +247,25 @@ export function AccountUsersPanel() {
|
|||||||
const saveGrants = async () => {
|
const saveGrants = async () => {
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
setBusy(true); setErr(''); setNotice('')
|
setBusy(true); setErr(''); setNotice('')
|
||||||
|
const userId = selected.userId
|
||||||
try {
|
try {
|
||||||
const result = await api.replaceSystemGrants(selected.userId, grants, authorityEpoch, crypto.randomUUID())
|
const result = await api.replaceSystemGrants(userId, grants, authorityEpoch, crypto.randomUUID())
|
||||||
setAuthorityEpoch(result.authorityEpoch); setNotice('系统权限已保存')
|
setAuthorityEpoch(result.authorityEpoch); setGrantBaseline(grants)
|
||||||
} catch (error) { setErr(String(error).includes('final permission manager') ? '不能撤销最后一个有效权限管理者' : String(error)) }
|
const expanded = result.linkedExpansion ?? []
|
||||||
|
setNotice(expanded.length ? `系统权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '系统权限已保存')
|
||||||
|
} catch (error) {
|
||||||
|
const raw = String(error)
|
||||||
|
if (raw.includes('final permission manager')) setErr('不能撤销最后一个有效权限管理者')
|
||||||
|
else if (raw.includes('409')) {
|
||||||
|
// 版本冲突:读取最新授权并重放本地增删,草稿不丢。
|
||||||
|
try {
|
||||||
|
const fresh = await api.getSystemGrants(userId)
|
||||||
|
const server = [...(fresh.actions ?? [])].sort()
|
||||||
|
setGrants(rebaseGrantDraft(grantBaseline, grants, server)); setGrantBaseline(server); setAuthorityEpoch(fresh.authorityEpoch)
|
||||||
|
setErr('系统权限版本冲突;已基于最新授权重放本地修改,请核对后再次保存')
|
||||||
|
} catch (refreshError) { setErr(`${String(refreshError)};本地草稿已保留`) }
|
||||||
|
} else setErr(`${raw.replace(/^Error:\s*/, '')};本地草稿已保留`)
|
||||||
|
}
|
||||||
finally { setBusy(false) }
|
finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,11 +326,10 @@ export function AccountUsersPanel() {
|
|||||||
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
|
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
|
||||||
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
|
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
|
||||||
</div>}
|
</div>}
|
||||||
|
{(canViewRoles || canViewGrants) && <ProjectCreateSourceNote role={roleView?.role} grants={grantBaseline} />}
|
||||||
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
|
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
|
||||||
<div className="rp-grants">{catalog.filter(item => item.scope === 'system').map(item => <label className="ad-check" key={item.actionId}>
|
<GrantGroupEditor catalog={catalog} grants={grants} disabled={!canManageGrants || !policy.maintain || busy} onChange={setGrants} />
|
||||||
<input type="checkbox" checked={grants.includes(item.actionId)} disabled={!canManageGrants || !policy.maintain || busy} onChange={event => setGrants(current => event.target.checked
|
<GrantChangePreview baseline={grantBaseline} grants={grants} labels={labels} owner={selected.owner} />
|
||||||
? [...new Set([...current, item.actionId])] : current.filter(value => value !== item.actionId))} />{item.label}<span className="ad-dim ad-mono">{item.actionId}</span>
|
|
||||||
</label>)}</div>
|
|
||||||
{canManageGrants && policy.maintain && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
|
{canManageGrants && policy.maintain && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
|
||||||
</Collapsible>}
|
</Collapsible>}
|
||||||
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
|
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { CapabilityChangePreview, CapabilityGroupEditor, rolePermissionSaveError } from './RolePermissionsPanel'
|
||||||
|
import {
|
||||||
|
buildCapabilityGroups, capabilityGroupChanges, createRolePermissionDraft, linkedChainBreaks, setCapabilityGroup,
|
||||||
|
type RolePermissionValue,
|
||||||
|
} from '../rolePermissions'
|
||||||
|
|
||||||
|
const catalog = [
|
||||||
|
{ actionId: 'project.create', label: '创建项目', group: '项目入口' },
|
||||||
|
{ actionId: 'project.settings', label: '项目设置', group: '项目管理' },
|
||||||
|
{ actionId: 'project.members.manage', label: '管理项目成员', group: '项目管理' },
|
||||||
|
{ actionId: 'project.permissions.manage', label: '管理项目权限', group: '项目管理' },
|
||||||
|
{ actionId: 'document.duties.assign', label: '分配文档三权', group: '文档职责' },
|
||||||
|
{ actionId: 'document.author', label: '编制文档', group: '文档动作' },
|
||||||
|
{ actionId: 'document.review', label: '审核文档', group: '文档动作' },
|
||||||
|
{ actionId: 'document.approve', label: '批准文档', group: '文档动作' },
|
||||||
|
]
|
||||||
|
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
|
||||||
|
const off: RolePermissionValue = { allowed: false, capAllowed: false }
|
||||||
|
const values = Object.fromEntries(catalog.map(item => [item.actionId, off]))
|
||||||
|
const groups = buildCapabilityGroups(catalog)
|
||||||
|
|
||||||
|
describe('CapabilityGroup role editor', () => {
|
||||||
|
it('replaces the per-capability checkbox grid with one default/cap pair per business group', () => {
|
||||||
|
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={values} labels={labels} disabled={false} onToggle={vi.fn()} />)
|
||||||
|
expect(html.match(/type="checkbox"/g)).toHaveLength(groups.length * 2)
|
||||||
|
expect(html).toContain('创建与管理本人项目 默认授予')
|
||||||
|
expect(html).toContain('文档编制与审签 角色上限')
|
||||||
|
expect(html).not.toContain('编制文档 默认允许')
|
||||||
|
expect(html).not.toContain('管理项目成员 默认授予')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows linked own-project management as controlled items with their association source', () => {
|
||||||
|
const create = groups.find(group => group.id === 'project-create')!
|
||||||
|
const linked = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true).values
|
||||||
|
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={linked} labels={labels} disabled onToggle={vi.fn()} />)
|
||||||
|
expect(html).toContain('管理项目成员:已联动(来源:创建项目,仅本人创建的项目)')
|
||||||
|
expect(html).toContain('分配文档三权:已联动')
|
||||||
|
const before = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={values} labels={labels} disabled onToggle={vi.fn()} />)
|
||||||
|
expect(before).toContain('未联动(开启创建项目后自动补齐)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a partially granted group instead of hiding the difference', () => {
|
||||||
|
const partial = { ...values, 'document.author': { allowed: true, capAllowed: true } }
|
||||||
|
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={partial} labels={labels} disabled={false} onToggle={vi.fn()} />)
|
||||||
|
expect(html).toContain('默认授予(部分)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('previews the grouped difference with the affected source and affected user count', () => {
|
||||||
|
const create = groups.find(group => group.id === 'project-create')!
|
||||||
|
const draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
|
||||||
|
const html = renderToStaticMarkup(<CapabilityChangePreview changes={capabilityGroupChanges(draft, groups)} breaks={linkedChainBreaks(draft.values)}
|
||||||
|
labels={labels} roleName="项目经理" bindingCount={7} />)
|
||||||
|
expect(html).toContain('影响来源:职位角色「项目经理」 · 影响 7 名绑定用户')
|
||||||
|
expect(html).toContain('创建与管理本人项目:默认 拒绝 → 允许')
|
||||||
|
expect(html).toContain('联动补齐 项目设置、管理项目成员、管理项目权限、分配文档三权(仅本人创建的项目)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the draft and names a linked conflict the server rejected as a whole', () => {
|
||||||
|
expect(rolePermissionSaveError(new Error('422: linked capability conflict project.members.manage')))
|
||||||
|
.toMatch(/关联能力冲突.*整笔拒绝.*本地草稿已保留/)
|
||||||
|
expect(rolePermissionSaveError(new Error('500: boom'))).toContain('本地草稿已保留')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { api, type PermissionDescriptor, type RoleRecord } from '../api'
|
import { api, type PermissionDescriptor, type RoleRecord } from '../api'
|
||||||
import {
|
import {
|
||||||
createRolePermissionDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
|
buildCapabilityGroups, capabilityGroupChanges, capabilityGroupState, createRolePermissionDraft, ensureLinkedCapabilities,
|
||||||
type RolePermissionDraft, type RolePermissionValue,
|
linkedChainBreaks, rebaseRoleDraft, setCapabilityGroup, validateRolePermissionDraft, visiblePatch,
|
||||||
|
type CapabilityGroup, type CapabilityGroupChange, type GroupLevel, type RolePermissionDraft, type RolePermissionValue,
|
||||||
} from '../rolePermissions'
|
} from '../rolePermissions'
|
||||||
import { Collapsible, Icon } from '@ui'
|
import { Collapsible, Icon } from '@ui'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
@@ -44,9 +45,65 @@ function roleValues(role: RoleRecord, catalog: PermissionDescriptor[]): Record<s
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function roleValue(role: RoleRecord, actionId: string): RolePermissionValue {
|
const LEVEL_TEXT: Record<GroupLevel, string> = { on: '允许', off: '拒绝', mixed: '部分' }
|
||||||
const value = role.permissions.find(item => item.actionId === actionId)
|
|
||||||
return { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }
|
function linkedStatus(values: Record<string, RolePermissionValue>, actionId: string, createOn: boolean): string {
|
||||||
|
if (values[actionId]?.capAllowed) return '已联动(来源:创建项目,仅本人创建的项目)'
|
||||||
|
return createOn ? '待联动补齐(保存时随创建项目一并补齐)' : '未联动(开启创建项目后自动补齐)'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务能力组:组开关替代逐动作勾选;联动项为组内受控项,只展示来源。 */
|
||||||
|
export function CapabilityGroupEditor({ groups, values, labels, disabled, onToggle }: {
|
||||||
|
groups: CapabilityGroup[]
|
||||||
|
values: Record<string, RolePermissionValue>
|
||||||
|
labels: Record<string, string>
|
||||||
|
disabled: boolean
|
||||||
|
onToggle: (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => void
|
||||||
|
}) {
|
||||||
|
return <div className="rp-groups">{groups.map(group => {
|
||||||
|
const state = capabilityGroupState(values, group)
|
||||||
|
const createOn = state.allowed !== 'off' || state.capAllowed !== 'off'
|
||||||
|
return <section key={group.id} className="settings-group" aria-label={`能力组 ${group.label}`}>
|
||||||
|
<div className="ad-row">
|
||||||
|
<b title={`内部动作 ${group.actions.join('、')}`}>{group.label}</b>
|
||||||
|
<label className="ad-check"><input type="checkbox" aria-label={`${group.label} 默认授予`} checked={state.allowed === 'on'} disabled={disabled}
|
||||||
|
onChange={event => onToggle(group, 'allowed', event.target.checked)} />默认授予{state.allowed === 'mixed' ? '(部分)' : ''}</label>
|
||||||
|
<label className="ad-check"><input type="checkbox" aria-label={`${group.label} 角色上限`} checked={state.capAllowed === 'on'} disabled={disabled}
|
||||||
|
onChange={event => onToggle(group, 'capAllowed', event.target.checked)} />角色上限{state.capAllowed === 'mixed' ? '(部分)' : ''}</label>
|
||||||
|
</div>
|
||||||
|
{group.description && <p className="ad-hint">{group.description}</p>}
|
||||||
|
{group.linked.length > 0 && <ul className="rp-diff" aria-label={`${group.label} 联动项`}>{group.linked.map(id =>
|
||||||
|
<li key={id}>{labels[id] ?? id}:{linkedStatus(values, id, createOn)}</li>)}</ul>}
|
||||||
|
</section>
|
||||||
|
})}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 变更预览:按业务组列出前后差异、联动补齐项、影响来源与受影响人数。 */
|
||||||
|
export function CapabilityChangePreview({ changes, breaks, labels, roleName, bindingCount }: {
|
||||||
|
changes: CapabilityGroupChange[]
|
||||||
|
breaks: string[]
|
||||||
|
labels: Record<string, string>
|
||||||
|
roleName: string
|
||||||
|
bindingCount: number
|
||||||
|
}) {
|
||||||
|
if (changes.length === 0 && breaks.length === 0) return <p className="ad-hint">没有权限变化</p>
|
||||||
|
const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、')
|
||||||
|
return <div>
|
||||||
|
<p className="ad-hint" role="note">影响来源:职位角色「{roleName}」 · 影响 {bindingCount} 名绑定用户</p>
|
||||||
|
{breaks.length > 0 && <p className="ad-hint" role="note">关联待补齐:{names(breaks)}——保存时随「创建项目」一并补齐上限</p>}
|
||||||
|
<ul className="rp-diff">{changes.map(change => <li key={change.group.id}>
|
||||||
|
{change.group.label}:默认 {LEVEL_TEXT[change.before.allowed]} → {LEVEL_TEXT[change.after.allowed]},上限 {LEVEL_TEXT[change.before.capAllowed]} → {LEVEL_TEXT[change.after.capAllowed]}
|
||||||
|
{change.linkedAdded.length > 0 && <>;联动补齐 {names(change.linkedAdded)}(仅本人创建的项目)</>}
|
||||||
|
</li>)}</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rolePermissionSaveError(error: unknown): string {
|
||||||
|
const raw = String(error)
|
||||||
|
if (/\b(400|403|422)\b/.test(raw) && /(link|conflict|关联|冲突|cap)/i.test(raw)) {
|
||||||
|
return `关联能力冲突,服务端已整笔拒绝,未保存任何修改;本地草稿已保留,请调整冲突项后重试(${raw.replace(/^Error:\s*/, '')})`
|
||||||
|
}
|
||||||
|
return `${raw.replace(/^Error:\s*/, '')};本地草稿已保留`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RolePermissionsPanel() {
|
export function RolePermissionsPanel() {
|
||||||
@@ -65,12 +122,10 @@ export function RolePermissionsPanel() {
|
|||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
|
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||||||
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
|
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||||||
const [group, setGroup] = useState('')
|
|
||||||
const [actionFilter, setActionFilter] = useState('')
|
const [actionFilter, setActionFilter] = useState('')
|
||||||
const [roleCursor, setRoleCursor] = useState('')
|
const [roleCursor, setRoleCursor] = useState('')
|
||||||
const [roleTrail, setRoleTrail] = useState<string[]>([])
|
const [roleTrail, setRoleTrail] = useState<string[]>([])
|
||||||
const [nextRoleCursor, setNextRoleCursor] = useState('')
|
const [nextRoleCursor, setNextRoleCursor] = useState('')
|
||||||
const [page, setPage] = useState(0)
|
|
||||||
const [view, setView] = useState<'single' | 'matrix'>('single')
|
const [view, setView] = useState<'single' | 'matrix'>('single')
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
const [newDescription, setNewDescription] = useState('')
|
const [newDescription, setNewDescription] = useState('')
|
||||||
@@ -87,11 +142,11 @@ export function RolePermissionsPanel() {
|
|||||||
const draft = selected ? drafts[selected.roleId] ?? null : null
|
const draft = selected ? drafts[selected.roleId] ?? null : null
|
||||||
const allEditableIds = useMemo(() => catalog.filter(item => !item.protected).map(item => item.actionId), [catalog])
|
const allEditableIds = useMemo(() => catalog.filter(item => !item.protected).map(item => item.actionId), [catalog])
|
||||||
const dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0
|
const dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0
|
||||||
const groups = useMemo(() => [...new Set(catalog.map(item => item.group))], [catalog])
|
|
||||||
const filtered = roles ?? []
|
const filtered = roles ?? []
|
||||||
const pageItems = useMemo(() => groupPermissionPage(catalog, group, page, 6), [catalog, group, page])
|
const capabilityGroups = useMemo(() => buildCapabilityGroups(catalog.filter(item => !item.protected)), [catalog])
|
||||||
const editablePageItems = pageItems.filter(item => !item.protected)
|
const labels = useMemo(() => Object.fromEntries(catalog.map(item => [item.actionId, item.label])), [catalog])
|
||||||
const changes = draft ? visiblePatch(draft, allEditableIds) : []
|
const changes = draft ? capabilityGroupChanges(draft, capabilityGroups) : []
|
||||||
|
const breaks = draft ? linkedChainBreaks(draft.values) : []
|
||||||
|
|
||||||
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
|
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
|
||||||
filters = { query, mode, status, action: actionFilter }) => {
|
filters = { query, mode, status, action: actionFilter }) => {
|
||||||
@@ -231,21 +286,25 @@ export function RolePermissionsPanel() {
|
|||||||
const roleId = selected.roleId
|
const roleId = selected.roleId
|
||||||
const operationSequence = ++requestSequence.current
|
const operationSequence = ++requestSequence.current
|
||||||
setRoleLoading(false)
|
setRoleLoading(false)
|
||||||
const invalid = validateRolePermissionDraft(draft)
|
// 关联项不能以断链状态保存:提交前与服务端同规则补齐创建能力的联动上限。
|
||||||
if (invalid) { setErr(`${invalid.actionId} 的默认权限不能超过角色上限`); return }
|
const prepared = ensureLinkedCapabilities(draft)
|
||||||
const full = Object.entries(draft.values).map(([actionId, value]) => ({ actionId, ...value }))
|
const invalid = validateRolePermissionDraft(prepared)
|
||||||
|
if (invalid) { setErr(`${labels[invalid.actionId] ?? invalid.actionId} 的默认权限不能超过角色上限`); return }
|
||||||
|
const full = Object.entries(prepared.values).map(([actionId, value]) => ({ actionId, ...value }))
|
||||||
setBusy(true); setErr(''); setNotice('')
|
setBusy(true); setErr(''); setNotice('')
|
||||||
try {
|
try {
|
||||||
await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID())
|
const receipt = await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID())
|
||||||
setDrafts(current => {
|
setDrafts(current => {
|
||||||
if (!(roleId in current)) return current
|
if (!(roleId in current)) return current
|
||||||
const next = { ...current }; delete next[roleId]; return next
|
const next = { ...current }; delete next[roleId]; return next
|
||||||
})
|
})
|
||||||
await load(roleCursor, true, selected.roleId); setNotice('角色权限已保存')
|
await load(roleCursor, true, selected.roleId)
|
||||||
|
const expanded = receipt?.linkedExpansion ?? []
|
||||||
|
setNotice(expanded.length ? `角色权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '角色权限已保存')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (String(error).includes('409')) {
|
if (String(error).includes('409')) {
|
||||||
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
|
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
|
||||||
} else setErr(String(error))
|
} else setErr(rolePermissionSaveError(error))
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
@@ -256,47 +315,30 @@ export function RolePermissionsPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const editValue = (actionId: string, field: 'allowed' | 'capAllowed', checked: boolean) => {
|
const editGroup = (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => {
|
||||||
const current = drafts[selectedId]
|
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: setCapabilityGroup(prev[selectedId], group, field, checked) } : prev)
|
||||||
if (!current) return
|
|
||||||
const value0 = current.values[actionId] ?? { allowed: false, capAllowed: false }
|
|
||||||
const value = field === 'allowed'
|
|
||||||
? { allowed: checked, capAllowed: checked ? true : value0.capAllowed }
|
|
||||||
: { allowed: checked ? value0.allowed : false, capAllowed: checked }
|
|
||||||
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: updateRolePermission(prev[selectedId], actionId, value) } : prev)
|
|
||||||
}
|
|
||||||
|
|
||||||
const editVisible = (field: 'allowed' | 'capAllowed', checked: boolean) => {
|
|
||||||
const start = drafts[selectedId]
|
|
||||||
if (!start) return
|
|
||||||
let next = start
|
|
||||||
for (const item of editablePageItems) {
|
|
||||||
const current = next.values[item.actionId] ?? { allowed: false, capAllowed: false }
|
|
||||||
const value = field === 'allowed'
|
|
||||||
? { allowed: checked, capAllowed: checked ? true : current.capAllowed }
|
|
||||||
: { allowed: checked ? current.allowed : false, capAllowed: checked }
|
|
||||||
next = updateRolePermission(next, item.actionId, value)
|
|
||||||
}
|
|
||||||
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: next } : prev)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 矩阵按业务组汇总;只有选中角色可编辑组开关,其余角色只读摘要。
|
||||||
const matrix = () => <div className="rp-matrix-wrap">
|
const matrix = () => <div className="rp-matrix-wrap">
|
||||||
<table className="ad-table rp-matrix">
|
<table className="ad-table rp-matrix">
|
||||||
<thead><tr><th>角色</th>{pageItems.map(item => <th key={item.actionId}>{item.label}</th>)}</tr></thead>
|
<thead><tr><th>角色</th>{capabilityGroups.map(group => <th key={group.id} title={group.description}>{group.label}</th>)}</tr></thead>
|
||||||
<tbody>{filtered.map(role => <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
|
<tbody>{filtered.map(role => {
|
||||||
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
|
const values = role.roleId === selectedId && draft ? draft.values : roleValues(role, catalog)
|
||||||
{pageItems.map(item => {
|
const editable = role.roleId === selectedId && !!draft && canManagePermissions
|
||||||
const value = role.roleId === selectedId && draft ? draft.values[item.actionId] : roleValue(role, item.actionId)
|
return <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
|
||||||
return <td key={item.actionId}>
|
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
|
||||||
{item.protected ? <span className="ad-dim">系统授权</span> : <>
|
{capabilityGroups.map(group => {
|
||||||
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 默认允许`} checked={value?.allowed ?? false}
|
const state = capabilityGroupState(values, group)
|
||||||
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} />默认</label>
|
return <td key={group.id}>{editable ? <>
|
||||||
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 角色上限`} checked={value?.capAllowed ?? false}
|
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${group.label} 默认授予`} checked={state.allowed === 'on'}
|
||||||
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} />上限</label>
|
disabled={busy || roleLoading} onChange={event => editGroup(group, 'allowed', event.target.checked)} />默认</label>
|
||||||
</>}
|
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${group.label} 角色上限`} checked={state.capAllowed === 'on'}
|
||||||
</td>
|
disabled={busy || roleLoading} onChange={event => editGroup(group, 'capAllowed', event.target.checked)} />上限</label>
|
||||||
})}
|
</> : <span className="ad-dim">默认{LEVEL_TEXT[state.allowed]} · 上限{LEVEL_TEXT[state.capAllowed]}</span>}</td>
|
||||||
</tr>)}</tbody>
|
})}
|
||||||
|
</tr>
|
||||||
|
})}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -317,9 +359,6 @@ export function RolePermissionsPanel() {
|
|||||||
<select className="lg-input" aria-label="角色状态" value={status} disabled={busy} onChange={event => { setStatus(event.target.value as typeof status); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
<select className="lg-input" aria-label="角色状态" value={status} disabled={busy} onChange={event => { setStatus(event.target.value as typeof status); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
||||||
<option value="all">全部状态</option><option value="enabled">启用</option><option value="disabled">停用</option>
|
<option value="all">全部状态</option><option value="enabled">启用</option><option value="disabled">停用</option>
|
||||||
</select>
|
</select>
|
||||||
<select className="lg-input" aria-label="权限分组" value={group} onChange={event => { setGroup(event.target.value); setPage(0) }}>
|
|
||||||
<option value="">全部权限</option>{groups.map(value => <option key={value}>{value}</option>)}
|
|
||||||
</select>
|
|
||||||
{canViewPermissions && <select className="lg-input" aria-label="按权限过滤角色" value={actionFilter} disabled={busy}
|
{canViewPermissions && <select className="lg-input" aria-label="按权限过滤角色" value={actionFilter} disabled={busy}
|
||||||
onChange={event => { setActionFilter(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
onChange={event => { setActionFilter(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
||||||
<option value="">全部角色权限</option>{catalog.filter(item => !item.protected).map(item => <option key={item.actionId} value={item.actionId}>{item.label}</option>)}
|
<option value="">全部角色权限</option>{catalog.filter(item => !item.protected).map(item => <option key={item.actionId} value={item.actionId}>{item.label}</option>)}
|
||||||
@@ -329,16 +368,8 @@ export function RolePermissionsPanel() {
|
|||||||
<div className="ad-row rp-view-toggle">
|
<div className="ad-row rp-view-toggle">
|
||||||
<button className={`ad-mini ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</button>
|
<button className={`ad-mini ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</button>
|
||||||
{canViewPermissions && <button className={`ad-mini ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</button>}
|
{canViewPermissions && <button className={`ad-mini ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</button>}
|
||||||
<button className="ad-mini" disabled={page === 0} onClick={() => setPage(value => value - 1)}>上一组</button>
|
|
||||||
<button className="ad-mini" disabled={(page + 1) * 6 >= catalog.filter(item => !group || item.group === group).length} onClick={() => setPage(value => value + 1)}>下一组</button>
|
|
||||||
<button className="ad-mini" disabled={busy || roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
|
<button className="ad-mini" disabled={busy || roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
|
||||||
<button className="ad-mini" disabled={busy || roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
|
<button className="ad-mini" disabled={busy || roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
|
||||||
{view === 'matrix' && canManagePermissions && <>
|
|
||||||
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', true)}>当前页默认全选</button>
|
|
||||||
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', false)}>当前页默认清除</button>
|
|
||||||
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', true)}>当前页上限全开</button>
|
|
||||||
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', false)}>当前页上限关闭</button>
|
|
||||||
</>}
|
|
||||||
</div>
|
</div>
|
||||||
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
|
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
|
||||||
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||||
@@ -363,21 +394,13 @@ export function RolePermissionsPanel() {
|
|||||||
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
|
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
|
||||||
<button className="ad-mini" disabled={busy} onClick={() => setConfirmDisable(false)}>取消</button>
|
<button className="ad-mini" disabled={busy} onClick={() => setConfirmDisable(false)}>取消</button>
|
||||||
</div>}
|
</div>}
|
||||||
{canViewPermissions ? <><table className="ad-table"><thead><tr><th>权限</th><th>默认允许</th><th>角色上限</th><th>说明</th></tr></thead>
|
{canViewPermissions ? <>
|
||||||
<tbody>{pageItems.map(item => {
|
<CapabilityGroupEditor groups={capabilityGroups} values={draft.values} labels={labels}
|
||||||
const value = draft.values[item.actionId] ?? { allowed: false, capAllowed: false }
|
disabled={busy || roleLoading || !canManagePermissions} onToggle={editGroup} />
|
||||||
return <tr key={item.actionId}><td>{item.label}<div className="ad-dim ad-mono">{item.actionId}</div></td>
|
|
||||||
<td><input type="checkbox" aria-label={`${item.label} 默认允许`} checked={value.allowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
|
|
||||||
onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} /></td>
|
|
||||||
<td><input type="checkbox" aria-label={`${item.label} 角色上限`} checked={value.capAllowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
|
|
||||||
onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} /></td>
|
|
||||||
<td className="ad-dim">{item.protected ? '受保护系统授权,不能由普通角色授予' : item.description}</td></tr>
|
|
||||||
})}</tbody></table>
|
|
||||||
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
|
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
|
||||||
{changes.length === 0 ? <p className="ad-hint">没有权限变化</p> : <ul className="rp-diff">{changes.map(change =>
|
<CapabilityChangePreview changes={changes} breaks={breaks} labels={labels} roleName={selected.name} bindingCount={selected.bindingCount} />
|
||||||
<li key={change.actionId}><span className="ad-mono">{change.actionId}</span>:默认 {change.allowed ? '允许' : '拒绝'},上限 {change.capAllowed ? '允许' : '拒绝'}</li>)}</ul>}
|
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || changes.length === 0 || editablePageItems.length === 0} onClick={savePermissions}>保存权限</button>}
|
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || (changes.length === 0 && breaks.length === 0) || allEditableIds.length === 0} onClick={savePermissions}>保存权限</button>}
|
||||||
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
|
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
|
||||||
</> : <p className="ad-hint">请选择角色</p>}
|
</> : <p className="ad-hint">请选择角色</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { TaskSidebar } from './TaskSidebar'
|
||||||
|
|
||||||
|
const mocked = vi.hoisted(() => ({ state: {} as Record<string, unknown> }))
|
||||||
|
|
||||||
|
vi.mock('../store', () => ({
|
||||||
|
useStore: (select: (value: Record<string, unknown>) => unknown) => select(mocked.state),
|
||||||
|
store: { openInbox: vi.fn(), openFiles: vi.fn(), focusPending: vi.fn(), setUI: vi.fn(), toggleLeft: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const member = { owner: 'bob', name: 'Bob', auth: true, systemActions: [],
|
||||||
|
linkedCapabilities: { projectCreate: false, linkedActions: [], authorityEpoch: 1 } }
|
||||||
|
const creator = { ...member, linkedCapabilities: { projectCreate: true, source: 'role', linkedActions: [], authorityEpoch: 1 } }
|
||||||
|
|
||||||
|
function render(patch: Record<string, unknown>) {
|
||||||
|
mocked.state = {
|
||||||
|
projects: [], projectsLoaded: true, projectSync: { status: 'ok', failures: 0 }, tasks: [], me: member,
|
||||||
|
adminSections: [], artifacts: [], cards: [], projectCards: [], serverWorkspaces: [],
|
||||||
|
rdmAuditAvailable: false, rdmCockpitAvailable: false, currentConvId: null, currentProjectId: null,
|
||||||
|
ui: { theme: 'light', showArchived: false },
|
||||||
|
...patch,
|
||||||
|
}
|
||||||
|
return renderToStaticMarkup(<TaskSidebar />)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => { mocked.state = {} })
|
||||||
|
|
||||||
|
describe('EmptyListNoCreate sidebar', () => {
|
||||||
|
it('shows a waiting-for-invite state without inviting a same-name project for a member without creation', () => {
|
||||||
|
const html = render({})
|
||||||
|
expect(html).toContain('尚无项目:等待项目负责人邀请你加入,加入后会自动出现在这里。')
|
||||||
|
expect(html).not.toContain('新建项目')
|
||||||
|
expect(html).toMatch(/<button class="tsb-newtask-main" disabled=""/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a loading state instead of an empty state before the list arrives', () => {
|
||||||
|
const html = render({ projectsLoaded: false })
|
||||||
|
expect(html).toContain('正在加载项目列表…')
|
||||||
|
expect(html).not.toContain('等待项目负责人邀请')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('points a creator to explicit project creation', () => {
|
||||||
|
expect(render({ me: creator })).toContain('还没有项目:从「新建项目」开始。')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AutoDiscover sidebar status', () => {
|
||||||
|
it('surfaces a degraded or offline project sync instead of silently showing stale projects', () => {
|
||||||
|
expect(render({ projectSync: { status: 'degraded', failures: 2 } })).toContain('项目列表同步异常,正在自动重试')
|
||||||
|
expect(render({ projectSync: { status: 'offline', failures: 0 } })).toContain('网络已断开,恢复后自动同步项目列表')
|
||||||
|
expect(render({})).not.toContain('同步异常')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps conversation creation available once a discovered project is current', () => {
|
||||||
|
const html = render({ projects: [{ id: 'host-b', name: '项目P', owner: 'carol' }], currentProjectId: 'host-b' })
|
||||||
|
expect(html).toMatch(/<button class="tsb-newtask-main" title="在当前项目新建对话/)
|
||||||
|
expect(html).toContain('从「新建对话」开始')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,7 +7,9 @@ import { api } from '../api'
|
|||||||
import { Icon } from '@ui'
|
import { Icon } from '@ui'
|
||||||
import type { Conversation } from '../contracts'
|
import type { Conversation } from '../contracts'
|
||||||
import { relativeTime, filterTasks } from '../tasklist'
|
import { relativeTime, filterTasks } from '../tasklist'
|
||||||
import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta'
|
import {
|
||||||
|
canCreateProject, emptyProjectsNotice, projectCapabilitySummary, projectStage, projectStateLabel, projectWorkError,
|
||||||
|
} from '../projectmeta'
|
||||||
import { buildWorkspaceEntries } from '../workspaces'
|
import { buildWorkspaceEntries } from '../workspaces'
|
||||||
|
|
||||||
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
|
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
|
||||||
@@ -59,6 +61,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
|||||||
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
|
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
|
||||||
const rdmCockpitAvailable = useStore(s => s.rdmCockpitAvailable)
|
const rdmCockpitAvailable = useStore(s => s.rdmCockpitAvailable)
|
||||||
const theme = useStore(s => s.ui.theme)
|
const theme = useStore(s => s.ui.theme)
|
||||||
|
const projectsLoaded = useStore(s => !!s.projectsLoaded)
|
||||||
|
const syncStatus = useStore(s => s.projectSync?.status ?? 'idle')
|
||||||
const pendingBadge = useStore(s =>
|
const pendingBadge = useStore(s =>
|
||||||
s.cards.filter(c => c.status === 'action_required').length +
|
s.cards.filter(c => c.status === 'action_required').length +
|
||||||
s.projectCards.filter(c => c.status === 'action_required').length)
|
s.projectCards.filter(c => c.status === 'action_required').length)
|
||||||
@@ -84,6 +88,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
|||||||
const workError = currentProject ? projectWorkError(currentProject) : null
|
const workError = currentProject ? projectWorkError(currentProject) : null
|
||||||
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
|
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
|
||||||
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
|
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
|
||||||
|
const canCreate = canCreateProject(me)
|
||||||
|
const noProjects = projects.length === 0
|
||||||
|
const syncNotice = syncStatus === 'degraded' ? '项目列表同步异常,正在自动重试'
|
||||||
|
: syncStatus === 'offline' ? '网络已断开,恢复后自动同步项目列表' : null
|
||||||
|
|
||||||
const rowActions = (t: Conversation) => (
|
const rowActions = (t: Conversation) => (
|
||||||
<span className="tsb-row-acts">
|
<span className="tsb-row-acts">
|
||||||
@@ -139,7 +147,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
|||||||
{/* 主入口创建会话;项目创建保留在次级菜单。 */}
|
{/* 主入口创建会话;项目创建保留在次级菜单。 */}
|
||||||
<div className="tsb-actions">
|
<div className="tsb-actions">
|
||||||
<div className="tsb-newtask">
|
<div className="tsb-newtask">
|
||||||
<button className="tsb-newtask-main" title="在当前项目新建对话(⌘N)" onClick={() => createConversation()}>
|
<button className="tsb-newtask-main" disabled={noProjects} title={noProjects ? emptyProjectsNotice(projectsLoaded, canCreate) : '在当前项目新建对话(⌘N)'}
|
||||||
|
onClick={() => createConversation()}>
|
||||||
<Icon name="plus" size={15} />新建对话
|
<Icon name="plus" size={15} />新建对话
|
||||||
</button>
|
</button>
|
||||||
<button className="tsb-newtask-caret" title="更多创建选项"
|
<button className="tsb-newtask-caret" title="更多创建选项"
|
||||||
@@ -160,7 +169,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
|||||||
|
|
||||||
{/* 中段(方案:会话与搜索)。项目切换在顶部,文件与工作区沉底。 */}
|
{/* 中段(方案:会话与搜索)。项目切换在顶部,文件与工作区沉底。 */}
|
||||||
<div className="tsb-scroll">
|
<div className="tsb-scroll">
|
||||||
{creatingProject && (
|
{syncNotice && <div className="tsb-tool-note" role="status">{syncNotice}</div>}
|
||||||
|
{canCreate && creatingProject && (
|
||||||
<div className="tsb-newproject">
|
<div className="tsb-newproject">
|
||||||
<input className="tsb-search" autoFocus value={newProjectName}
|
<input className="tsb-search" autoFocus value={newProjectName}
|
||||||
onChange={e => setNewProjectName(e.target.value)}
|
onChange={e => setNewProjectName(e.target.value)}
|
||||||
@@ -185,7 +195,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{rest.length === 0 && pinned.length === 0 && (
|
{rest.length === 0 && pinned.length === 0 && (
|
||||||
<div className="tsb-empty">{workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
|
<div className="tsb-empty">{noProjects ? emptyProjectsNotice(projectsLoaded, canCreate)
|
||||||
|
: workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
|
||||||
)}
|
)}
|
||||||
{rest.map(t => taskRow(t))}
|
{rest.map(t => taskRow(t))}
|
||||||
</div>
|
</div>
|
||||||
@@ -231,22 +242,28 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
|||||||
{projects.map(p => {
|
{projects.map(p => {
|
||||||
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
|
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
|
||||||
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
|
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
|
||||||
|
const mine = !!p.owner && p.owner === me?.owner
|
||||||
return (
|
return (
|
||||||
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
|
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
|
||||||
{p.name}
|
{p.name}
|
||||||
<kbd>{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd>
|
<kbd>{p.id === currentProjectId ? '当前 · ' : pstage ? `${pstage} · ` : ''}{mine ? '我创建 · ' : ''}{count} 会话</kbd>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{projects.length === 0 && <div className="tsb-tool-note">还没有项目</div>}
|
{noProjects && <div className="tsb-tool-note">{emptyProjectsNotice(projectsLoaded, canCreate)}</div>}
|
||||||
<div className="tsb-menu-div" />
|
{canCreate && <>
|
||||||
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
<div className="tsb-menu-div" />
|
||||||
|
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||||
|
</>}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||||
<MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>
|
{!noProjects && <MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>}
|
||||||
<div className="tsb-menu-div" />
|
{canCreate && <>
|
||||||
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
<div className="tsb-menu-div" />
|
||||||
|
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||||
|
</>}
|
||||||
|
{noProjects && !canCreate && <div className="tsb-tool-note">{emptyProjectsNotice(projectsLoaded, canCreate)}</div>}
|
||||||
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
|
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
|
||||||
<MenuItem key={p.id} onClick={() => { createConversation(p.id); setMenu(null) }}>在「{p.name}」新建对话</MenuItem>
|
<MenuItem key={p.id} onClick={() => { createConversation(p.id); setMenu(null) }}>在「{p.name}」新建对话</MenuItem>
|
||||||
))}
|
))}
|
||||||
@@ -270,6 +287,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
|||||||
<span>
|
<span>
|
||||||
<div className="tsb-acc-name">{me?.name || '未连接'}</div>
|
<div className="tsb-acc-name">{me?.name || '未连接'}</div>
|
||||||
<div className="tsb-acc-mail">{me?.owner ? `租户:${me.owner}` : '—'}</div>
|
<div className="tsb-acc-mail">{me?.owner ? `租户:${me.owner}` : '—'}</div>
|
||||||
|
{me && <div className="tsb-acc-mail">{projectCapabilitySummary(me)}</div>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="tsb-menu-div" />
|
<div className="tsb-menu-div" />
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import type { Project } from '../contracts'
|
||||||
|
import { MemberDutyCell, RdmMembersSection, memberManagementView } from './adminRdmSections'
|
||||||
|
|
||||||
|
const project: Project = {
|
||||||
|
id: 'host-1', owner: 'alice', name: '项目P', createdAt: '', updatedAt: '',
|
||||||
|
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' },
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MemberManagementEntryHidden', () => {
|
||||||
|
it('stays read-only until the self check confirms member management', () => {
|
||||||
|
expect(memberManagementView(null, null)).toEqual({ canManage: false, notice: '正在核对成员管理权限…' })
|
||||||
|
expect(memberManagementView([], null).canManage).toBe(false)
|
||||||
|
expect(memberManagementView([{ action: 'member.manage', allowed: false, }], null).notice).toContain('成员增删由项目创建者负责')
|
||||||
|
expect(memberManagementView([{ action: 'member.manage', allowed: true }], null)).toEqual({ canManage: true, notice: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not offer member management on a project that cannot accept work', () => {
|
||||||
|
expect(memberManagementView([{ action: 'member.manage', allowed: true }], '项目已归档').canManage).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no add, role change or removal entry before authorization is known', () => {
|
||||||
|
const html = renderToStaticMarkup(<RdmMembersSection project={project} />)
|
||||||
|
expect(html).toContain('正在核对成员管理权限…')
|
||||||
|
expect(html).not.toContain('添加成员')
|
||||||
|
expect(html).not.toContain('选择人员')
|
||||||
|
expect(html).toContain('实际职责')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CreatorOwnerMemberDuties member table', () => {
|
||||||
|
it('separates the immutable creator from the owner label and plain members', () => {
|
||||||
|
const creator = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'alice', role: 'owner' }} creator="alice" />)
|
||||||
|
expect(creator).toContain('创建者')
|
||||||
|
expect(creator).toContain('成员增删')
|
||||||
|
expect(creator).toContain('随「创建项目」联动')
|
||||||
|
const owner = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'bob', role: 'owner' }} creator="alice" />)
|
||||||
|
expect(owner).toContain('项目负责人')
|
||||||
|
expect(owner).toContain('不含成员管理')
|
||||||
|
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator="alice" />)
|
||||||
|
expect(member).toContain('基础协作')
|
||||||
|
expect(member).not.toContain('成员增删')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import type { Project } from '../contracts'
|
import type { Project } from '../contracts'
|
||||||
import { projectWorkError } from '../projectmeta'
|
import { canManageProjectMembers, memberDuty, projectWorkError } from '../projectmeta'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import { Btn, Collapsible, Icon, Picker } from '@ui'
|
import { Btn, Collapsible, Icon, Picker } from '@ui'
|
||||||
import {
|
import {
|
||||||
@@ -24,10 +24,31 @@ interface MemberRow {
|
|||||||
profRole?: { roleId: string; name: string } | null
|
profRole?: { roleId: string; name: string } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 成员管理入口门控:本人自省未到达或无成员管理资格时只读(fail-closed)。 */
|
||||||
|
export function memberManagementView(actions: { action: string; allowed: boolean }[] | null, writeError: string | null) {
|
||||||
|
if (actions === null) return { canManage: false, notice: '正在核对成员管理权限…' }
|
||||||
|
if (!canManageProjectMembers(actions)) {
|
||||||
|
return { canManage: false, notice: '你可以查看成员与实际职责;成员增删由项目创建者负责(需具备项目成员管理资格)。' }
|
||||||
|
}
|
||||||
|
return { canManage: !writeError, notice: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 实际职责:创建者(不可变)与界面身份「负责人」分开显示,并标明能力关联来源。 */
|
||||||
|
export function MemberDutyCell({ member, creator }: { member: { owner: string; role: string }; creator: string | undefined }) {
|
||||||
|
const duty = memberDuty(member, creator)
|
||||||
|
return <>
|
||||||
|
<span className={duty.kind === 'member' ? 'ad-chip' : 'ad-chip ad-chip-admin'}>{duty.label}</span>
|
||||||
|
<div className="ad-dim" title={`来源:${duty.source}`}>{duty.duties}</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
export function RdmMembersSection({ project }: { project: Project }) {
|
export function RdmMembersSection({ project }: { project: Project }) {
|
||||||
const projectId = project.id
|
const projectId = project.id
|
||||||
const writeError = projectWorkError(project)
|
const writeError = projectWorkError(project)
|
||||||
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
||||||
|
const me = useStore(s => s.me)
|
||||||
|
const [manageActions, setManageActions] = useState<{ action: string; allowed: boolean }[] | null>(null)
|
||||||
|
const manage = memberManagementView(manageActions, writeError)
|
||||||
const [rows, setRows] = useState<MemberRow[] | null>(null)
|
const [rows, setRows] = useState<MemberRow[] | null>(null)
|
||||||
const [memberNext, setMemberNext] = useState('')
|
const [memberNext, setMemberNext] = useState('')
|
||||||
const [memberTotal, setMemberTotal] = useState<number | null>(null)
|
const [memberTotal, setMemberTotal] = useState<number | null>(null)
|
||||||
@@ -64,6 +85,16 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
|||||||
return () => { live = false }
|
return () => { live = false }
|
||||||
}, [project])
|
}, [project])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setManageActions(null)
|
||||||
|
if (!me?.owner) return
|
||||||
|
let live = true
|
||||||
|
cmd<{ actions?: { action: string; allowed: boolean }[] }>('rdm.perm.check', permCheckPayload(projectId, me.owner))
|
||||||
|
.then(out => { if (live) setManageActions(out.actions ?? []) })
|
||||||
|
.catch(() => { if (live) setManageActions([]) })
|
||||||
|
return () => { live = false }
|
||||||
|
}, [project, me?.owner])
|
||||||
|
|
||||||
const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
|
const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
|
||||||
[candMatch, candFields, candRoleId])
|
[candMatch, candFields, candRoleId])
|
||||||
|
|
||||||
@@ -139,7 +170,8 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||||
<div className="ad-create">
|
{manage.notice && <p className="ad-hint" role="note">{manage.notice}</p>}
|
||||||
|
{manage.canManage && <div className="ad-create">
|
||||||
<div className="ad-row">
|
<div className="ad-row">
|
||||||
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
|
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
|
||||||
<select className="lg-input" aria-label="匹配方式" value={candMatch}
|
<select className="lg-input" aria-label="匹配方式" value={candMatch}
|
||||||
@@ -167,7 +199,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
|||||||
/>
|
/>
|
||||||
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
||||||
<option value="member">项目成员</option>
|
<option value="member">项目成员</option>
|
||||||
<option value="owner">项目负责人</option>
|
<option value="owner">项目负责人(界面身份,不含成员管理)</option>
|
||||||
</select>
|
</select>
|
||||||
<button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid}
|
<button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid}
|
||||||
onClick={() => pickedItem && run(`已添加 ${personLabel({ owner: pickedItem.owner, name: pickedItem.title })}`, async () => {
|
onClick={() => pickedItem && run(`已添加 ${personLabel({ owner: pickedItem.owner, name: pickedItem.title })}`, async () => {
|
||||||
@@ -175,8 +207,8 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
|||||||
setPicked([]); setPickedItems([])
|
setPicked([]); setPickedItems([])
|
||||||
})}>添加成员</button>
|
})}>添加成员</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
|
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。被加入的成员无需重新登录,项目会自动出现在其项目列表。</p>
|
||||||
</div>
|
</div>}
|
||||||
<div className="ad-row">
|
<div className="ad-row">
|
||||||
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
|
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
|
||||||
onChange={e => setMemberQuery(e.target.value)} />
|
onChange={e => setMemberQuery(e.target.value)} />
|
||||||
@@ -185,16 +217,17 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
|||||||
</div>
|
</div>
|
||||||
{!directoryOk && <p className="ad-hint">账号目录暂不可用:成员姓名可能缺失,仅按账号显示。</p>}
|
{!directoryOk && <p className="ad-hint">账号目录暂不可用:成员姓名可能缺失,仅按账号显示。</p>}
|
||||||
<table className="ad-table">
|
<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><th>添加时间</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(rows ?? []).map(m => (
|
{(rows ?? []).map(m => (
|
||||||
<tr key={m.owner}>
|
<tr key={m.owner}>
|
||||||
<td className="ad-mono">{personLabel(m)}</td>
|
<td className="ad-mono">{personLabel(m)}</td>
|
||||||
<td className="ad-dim">{m.profRole?.name || '—'}</td>
|
<td className="ad-dim">{m.profRole?.name || '—'}</td>
|
||||||
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td>
|
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td>
|
||||||
|
<td><MemberDutyCell member={m} creator={project.owner} /></td>
|
||||||
<td className="ad-dim">{m.addedBy || '—'}</td>
|
<td className="ad-dim">{m.addedBy || '—'}</td>
|
||||||
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
|
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
|
||||||
<td>
|
<td>{manage.canManage && <>
|
||||||
<button className="ad-mini" disabled={busy || !!writeError}
|
<button className="ad-mini" disabled={busy || !!writeError}
|
||||||
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
|
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
|
||||||
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
|
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
|
||||||
@@ -204,11 +237,11 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
|||||||
<button className="ad-mini" disabled={busy || !!writeError}
|
<button className="ad-mini" disabled={busy || !!writeError}
|
||||||
onClick={() => run(`已移除 ${m.owner}`,
|
onClick={() => run(`已移除 ${m.owner}`,
|
||||||
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
|
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
|
||||||
</td>
|
</>}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
|
{rows !== null && rows.length === 0 && <tr><td colSpan={7} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
|
||||||
{rows === null && <tr><td colSpan={6} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
|
{rows === null && <tr><td colSpan={7} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{memberNext && rows !== null && (
|
{memberNext && rows !== null && (
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
canCreateProject, canManageProjectMembers, emptyProjectsNotice, memberDuty, noProjectError, projectCapabilitySummary,
|
||||||
|
} from './projectmeta'
|
||||||
|
|
||||||
|
const linked = (projectCreate: boolean) => ({ projectCreate, source: 'role', linkedActions: [], authorityEpoch: 3 })
|
||||||
|
|
||||||
|
describe('EmptyListNoCreate', () => {
|
||||||
|
it('asks a member without creation capability to wait for an invite instead of creating a project', () => {
|
||||||
|
const notice = emptyProjectsNotice(true, false)
|
||||||
|
expect(notice).toContain('等待项目负责人邀请')
|
||||||
|
expect(notice).not.toContain('新建')
|
||||||
|
expect(noProjectError(true, false)).not.toContain('新建')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call a list that has not arrived empty', () => {
|
||||||
|
expect(emptyProjectsNotice(false, true)).toBe('正在加载项目列表…')
|
||||||
|
expect(noProjectError(false, false)).toContain('尚未加载')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('points a creator to explicit project creation', () => {
|
||||||
|
expect(emptyProjectsNotice(true, true)).toContain('新建项目')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DefaultProjectDisabled creation entry', () => {
|
||||||
|
it('follows the server-computed creation link first', () => {
|
||||||
|
expect(canCreateProject({ auth: true, isAdmin: true, systemActions: ['project.create'], linkedCapabilities: linked(false) })).toBe(false)
|
||||||
|
expect(canCreateProject({ auth: true, systemActions: [], linkedCapabilities: linked(true) })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to explicit system actions and fails closed without an identity', () => {
|
||||||
|
expect(canCreateProject({ auth: true, systemActions: [] })).toBe(false)
|
||||||
|
expect(canCreateProject({ auth: true, systemActions: ['project.create'] })).toBe(true)
|
||||||
|
expect(canCreateProject(null)).toBe(false)
|
||||||
|
expect(canCreateProject({ auth: false })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('summarizes the creation source and the linked own-project management', () => {
|
||||||
|
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: linked(true) })).toContain('来源:职位角色')
|
||||||
|
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: { ...linked(true), conflicts: ['project.members.manage'] } }))
|
||||||
|
.toContain('1 项关联被角色上限阻断')
|
||||||
|
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: linked(false) })).toContain('被邀请加入后项目自动出现')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CreatorOwnerMemberDuties', () => {
|
||||||
|
it('gives member management only to the immutable creator, not to an owner label', () => {
|
||||||
|
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice')).toMatchObject({ kind: 'creator', label: '创建者' })
|
||||||
|
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice').duties).toContain('成员增删')
|
||||||
|
const owner = memberDuty({ owner: 'bob', role: 'owner' }, 'alice')
|
||||||
|
expect(owner).toMatchObject({ kind: 'owner', label: '项目负责人' })
|
||||||
|
expect(owner.duties).toContain('不含成员管理')
|
||||||
|
const member = memberDuty({ owner: 'carol', role: 'member' }, 'alice')
|
||||||
|
expect(member).toMatchObject({ kind: 'member', source: '成员关系' })
|
||||||
|
expect(member.duties).toContain('本人对话')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MemberManagementEntryHidden capability', () => {
|
||||||
|
it('requires an allowed member management action from the self check', () => {
|
||||||
|
expect(canManageProjectMembers(null)).toBe(false)
|
||||||
|
expect(canManageProjectMembers([{ action: 'member.manage', allowed: false }])).toBe(false)
|
||||||
|
expect(canManageProjectMembers([{ action: 'doc.read', allowed: true }])).toBe(false)
|
||||||
|
expect(canManageProjectMembers([{ action: 'member.manage', allowed: true }])).toBe(true)
|
||||||
|
expect(canManageProjectMembers([{ action: 'project.members.manage', allowed: true }])).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { LinkedCapabilities } from './api'
|
||||||
|
|
||||||
/** 阶段徽章:只读项目设置里的 stage 字符串;没有就不显示。 */
|
/** 阶段徽章:只读项目设置里的 stage 字符串;没有就不显示。 */
|
||||||
export function projectStage(settings?: Record<string, unknown> | null): string | null {
|
export function projectStage(settings?: Record<string, unknown> | null): string | null {
|
||||||
const stage = settings?.stage
|
const stage = settings?.stage
|
||||||
@@ -41,3 +43,70 @@ export function projectActionError(error: unknown): string {
|
|||||||
if (/\b503\b/.test(raw)) return '项目服务暂未就绪,请稍后重试或联系项目负责人'
|
if (/\b503\b/.test(raw)) return '项目服务暂未就绪,请稍后重试或联系项目负责人'
|
||||||
return raw.replace(/^Error:\s*/, '')
|
return raw.replace(/^Error:\s*/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProjectActor {
|
||||||
|
auth?: boolean; isAdmin?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只决定是否展示创建入口;服务端仍按当前身份最终判定。 */
|
||||||
|
export function canCreateProject(me: ProjectActor | null | undefined): boolean {
|
||||||
|
if (!me) return false
|
||||||
|
if (me.auth === false) return true
|
||||||
|
if (me.linkedCapabilities) return me.linkedCapabilities.projectCreate
|
||||||
|
// Backends without capability exposure keep the server as the only gate.
|
||||||
|
if (me.systemActions === undefined) return true
|
||||||
|
return !!me.isAdmin || me.systemActions.includes('project.create')
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAPABILITY_SOURCE_LABELS: Record<string, string> = {
|
||||||
|
role: '职位角色', 'system-grant': '明确系统授权', 'bootstrap-admin': '初始管理员',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function capabilitySourceLabel(source: string | undefined): string {
|
||||||
|
return (source && CAPABILITY_SOURCE_LABELS[source]) || source || '未知来源'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账户区一句话:本人是否能创建项目、来源、以及联动形成的本人项目管理资格。 */
|
||||||
|
export function projectCapabilitySummary(me: ProjectActor | null | undefined): string {
|
||||||
|
const linked = me?.linkedCapabilities
|
||||||
|
if (!linked) return canCreateProject(me) ? '可创建项目' : '未授予创建项目 · 被邀请加入后项目自动出现'
|
||||||
|
if (!linked.projectCreate) return '未授予创建项目 · 被邀请加入后项目自动出现'
|
||||||
|
const conflicts = linked.conflicts?.length ? ` · ${linked.conflicts.length} 项关联被角色上限阻断` : ''
|
||||||
|
return `可创建项目(来源:${capabilitySourceLabel(linked.source)})· 本人创建的项目自动具备设置、成员、项目权限与文档职责管理${conflicts}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空项目列表:未加载不当作空;无创建能力时只提示等待邀请,不诱导新建同名项目。 */
|
||||||
|
export function emptyProjectsNotice(loaded: boolean, canCreate: boolean): string {
|
||||||
|
if (!loaded) return '正在加载项目列表…'
|
||||||
|
if (canCreate) return '还没有项目:从「新建项目」开始。'
|
||||||
|
return '尚无项目:等待项目负责人邀请你加入,加入后会自动出现在这里。'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建对话时没有可用项目:不再自动创建「默认项目」。 */
|
||||||
|
export function noProjectError(loaded: boolean, canCreate: boolean): string {
|
||||||
|
if (!loaded) return '项目列表尚未加载完成,请稍候再试'
|
||||||
|
if (canCreate) return '还没有项目,请先新建项目'
|
||||||
|
return '尚无可用项目,请等待项目负责人邀请你加入'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberDuty { kind: 'creator' | 'owner' | 'member'; label: string; duties: string; source: string }
|
||||||
|
|
||||||
|
/** 项目内实际职责:成员管理只随不可变创建者;负责人只是界面身份。 */
|
||||||
|
export function memberDuty(member: { owner: string; role: string }, creator: string | undefined): MemberDuty {
|
||||||
|
if (creator && member.owner === creator) {
|
||||||
|
return { kind: 'creator', label: '创建者', duties: '项目设置、成员增删、项目内权限、文档职责分配 + 基础协作',
|
||||||
|
source: '随「创建项目」联动(仅本人创建的项目)' }
|
||||||
|
}
|
||||||
|
if (member.role === 'owner') {
|
||||||
|
return { kind: 'owner', label: '项目负责人', duties: '基础协作:项目阅读与本人对话;不含成员管理',
|
||||||
|
source: '成员关系(界面身份不授予创建者管理权)' }
|
||||||
|
}
|
||||||
|
return { kind: 'member', label: '项目成员', duties: '基础协作:查看与切换项目、两树一网阅读、本人对话', source: '成员关系' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEMBER_MANAGE_ACTIONS = new Set(['member.manage', 'project.members.manage'])
|
||||||
|
|
||||||
|
/** 本人自省结果里成员管理是否生效;未知(null)按无权处理。 */
|
||||||
|
export function canManageProjectMembers(actions: { action: string; allowed: boolean }[] | null | undefined): boolean {
|
||||||
|
return !!actions?.some(a => MEMBER_MANAGE_ACTIONS.has(a.action) && a.allowed)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
PROJECT_CREATE_LINKED_ACTIONS, buildCapabilityGroups, capabilityGroupChanges, capabilityGroupState, createRolePermissionDraft,
|
||||||
|
ensureLinkedCapabilities, linkedChainBreaks, projectCreateSources, rebaseGrantDraft, rebaseRoleDraft, setCapabilityGroup,
|
||||||
|
type RolePermissionValue,
|
||||||
|
} from './rolePermissions'
|
||||||
|
|
||||||
|
const catalog = [
|
||||||
|
{ actionId: 'project.create', label: '创建项目', group: '项目入口' },
|
||||||
|
{ actionId: 'project.settings', label: '项目设置', group: '项目管理' },
|
||||||
|
{ actionId: 'project.archive', label: '归档项目', group: '项目管理' },
|
||||||
|
{ actionId: 'project.stage.advance', label: '推进项目阶段', group: '项目管理' },
|
||||||
|
{ actionId: 'project.members.manage', label: '管理项目成员', group: '项目管理' },
|
||||||
|
{ actionId: 'project.permissions.manage', label: '管理项目权限', group: '项目管理' },
|
||||||
|
{ actionId: 'document.duties.assign', label: '分配文档三权', group: '文档职责' },
|
||||||
|
{ actionId: 'document.read', label: '读取文档', group: '文档动作' },
|
||||||
|
{ actionId: 'document.author', label: '编制文档', group: '文档动作' },
|
||||||
|
{ actionId: 'document.review', label: '审核文档', group: '文档动作' },
|
||||||
|
{ actionId: 'future.action', label: '未来动作', group: '扩展' },
|
||||||
|
]
|
||||||
|
const off: RolePermissionValue = { allowed: false, capAllowed: false }
|
||||||
|
const values = (overrides: Record<string, RolePermissionValue> = {}) =>
|
||||||
|
({ ...Object.fromEntries(catalog.map(item => [item.actionId, off])), ...overrides })
|
||||||
|
|
||||||
|
describe('CapabilityGroup model', () => {
|
||||||
|
const groups = buildCapabilityGroups(catalog)
|
||||||
|
const create = groups.find(group => group.id === 'project-create')!
|
||||||
|
|
||||||
|
it('groups actions by business capability and keeps unknown actions visible', () => {
|
||||||
|
expect(groups.map(group => group.id)).toEqual(['project-create', 'project-governance', 'document-duties', 'document-read', 'catalog:扩展'])
|
||||||
|
expect(create.actions).toEqual(['project.create'])
|
||||||
|
expect(create.linked).toEqual([...PROJECT_CREATE_LINKED_ACTIONS])
|
||||||
|
const all = groups.flatMap(group => [...group.actions, ...group.linked]).sort()
|
||||||
|
expect(all).toEqual(catalog.map(item => item.actionId).sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enabling project creation links own-project management caps without granting them by default', () => {
|
||||||
|
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
|
||||||
|
expect(draft.values['project.create']).toEqual({ allowed: true, capAllowed: true })
|
||||||
|
for (const id of PROJECT_CREATE_LINKED_ACTIONS) expect(draft.values[id]).toEqual({ allowed: false, capAllowed: true })
|
||||||
|
expect(capabilityGroupState(draft.values, create)).toEqual({ allowed: 'on', capAllowed: 'on', linkedReady: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('turning project creation off keeps already linked management caps', () => {
|
||||||
|
const enabled = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
|
||||||
|
const disabled = setCapabilityGroup(createRolePermissionDraft(enabled.values), create, 'capAllowed', false)
|
||||||
|
expect(disabled.values['project.create']).toEqual(off)
|
||||||
|
for (const id of PROJECT_CREATE_LINKED_ACTIONS) expect(disabled.values[id].capAllowed).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports mixed group state and the grouped preview with linked additions', () => {
|
||||||
|
const duties = groups.find(group => group.id === 'document-duties')!
|
||||||
|
const draft = setCapabilityGroup(createRolePermissionDraft(values({ 'document.author': { allowed: true, capAllowed: true } })), create, 'capAllowed', true)
|
||||||
|
expect(capabilityGroupState(draft.values, duties).allowed).toBe('mixed')
|
||||||
|
const changes = capabilityGroupChanges(draft, groups)
|
||||||
|
expect(changes.map(change => change.group.id)).toEqual(['project-create'])
|
||||||
|
expect(changes[0].before.capAllowed).toBe('off')
|
||||||
|
expect(changes[0].after.capAllowed).toBe('on')
|
||||||
|
expect(changes[0].linkedAdded).toEqual([...PROJECT_CREATE_LINKED_ACTIONS])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects and repairs a broken link from a server baseline before saving', () => {
|
||||||
|
const broken = createRolePermissionDraft(values({ 'project.create': { allowed: true, capAllowed: true } }))
|
||||||
|
expect(linkedChainBreaks(broken.values)).toEqual([...PROJECT_CREATE_LINKED_ACTIONS])
|
||||||
|
expect(linkedChainBreaks(ensureLinkedCapabilities(broken).values)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves the grouped draft when an optimistic conflict is replayed on newer server values', () => {
|
||||||
|
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
|
||||||
|
const server = values({ 'document.review': { allowed: true, capAllowed: true } })
|
||||||
|
const rebased = rebaseRoleDraft(draft, server)
|
||||||
|
expect(rebased.values['project.create']).toEqual({ allowed: true, capAllowed: true })
|
||||||
|
expect(rebased.values['project.members.manage'].capAllowed).toBe(true)
|
||||||
|
expect(rebased.values['document.review']).toEqual({ allowed: true, capAllowed: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replays explicit grant edits on the latest grants after a version conflict', () => {
|
||||||
|
expect(rebaseGrantDraft(['system.users.view'], ['project.create'], ['system.users.view', 'system.roles.view']))
|
||||||
|
.toEqual(['project.create', 'system.roles.view'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names every project creation source', () => {
|
||||||
|
const role = { name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }
|
||||||
|
expect(projectCreateSources(role, ['project.create'])).toEqual(['职位角色「项目经理」', '明确系统授权'])
|
||||||
|
expect(projectCreateSources(null, [])).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -61,6 +61,160 @@ export function visiblePatch(draft: RolePermissionDraft, visibleActionIds: strin
|
|||||||
return changes
|
return changes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 业务能力组(KTD1):普通界面按业务组配置,低层动作留给服务端鉴权与审计 ----
|
||||||
|
|
||||||
|
export const PROJECT_CREATE_ACTION = 'project.create'
|
||||||
|
/** 与 Backend catalog.ProjectCreateLinkedActions 同序:创建者在本人项目里的管理资格。 */
|
||||||
|
export const PROJECT_CREATE_LINKED_ACTIONS = [
|
||||||
|
'project.settings', 'project.members.manage', 'project.permissions.manage', 'document.duties.assign',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
interface CapabilityCatalogItem { actionId: string; label: string; group: string; protected?: boolean }
|
||||||
|
|
||||||
|
export interface CapabilityGroup {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
/** 组开关直接控制的动作。 */
|
||||||
|
actions: string[]
|
||||||
|
/** 组内受控项:随主能力联动,不单独勾选。 */
|
||||||
|
linked: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUSINESS_GROUPS: (Omit<CapabilityGroup, 'actions' | 'linked'> & { actions: readonly string[]; linked?: readonly string[] })[] = [
|
||||||
|
{ id: 'project-create', label: '创建与管理本人项目',
|
||||||
|
description: '创建新项目;本人创建的项目随之具备设置、成员增删、项目内权限与文档职责分配资格,不涉及他人项目',
|
||||||
|
actions: [PROJECT_CREATE_ACTION], linked: PROJECT_CREATE_LINKED_ACTIONS },
|
||||||
|
{ id: 'project-governance', label: '项目归档与阶段', description: '归档/恢复与推进本人创建项目的阶段',
|
||||||
|
actions: ['project.archive', 'project.stage.advance'] },
|
||||||
|
{ id: 'document-duties', label: '文档编制与审签', description: '履行已分配的文档编制、审核、批准与发布职责',
|
||||||
|
actions: ['document.author', 'document.review', 'document.approve', 'document.publish'] },
|
||||||
|
{ id: 'document-read', label: '文档阅读', description: '读取获准的项目文档;项目成员的基础协作另由成员关系提供',
|
||||||
|
actions: ['document.read'] },
|
||||||
|
{ id: 'project-content', label: '追溯与知识', description: '编辑追溯网、发布项目知识与读取业务审计',
|
||||||
|
actions: ['trace.edit', 'knowledge.publish', 'project.audit.read'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 目录里未归入业务组的动作按目录分组兜底,保证新增动作不会从界面消失。 */
|
||||||
|
export function buildCapabilityGroups(catalog: CapabilityCatalogItem[]): CapabilityGroup[] {
|
||||||
|
const known = new Set(catalog.map(item => item.actionId))
|
||||||
|
const claimed = new Set<string>()
|
||||||
|
const groups: CapabilityGroup[] = []
|
||||||
|
for (const group of BUSINESS_GROUPS) {
|
||||||
|
const actions = group.actions.filter(id => known.has(id))
|
||||||
|
if (actions.length === 0) continue
|
||||||
|
const linked = (group.linked ?? []).filter(id => known.has(id))
|
||||||
|
for (const id of [...actions, ...linked]) claimed.add(id)
|
||||||
|
groups.push({ id: group.id, label: group.label, description: group.description, actions, linked })
|
||||||
|
}
|
||||||
|
const rest = new Map<string, CapabilityGroup>()
|
||||||
|
for (const item of catalog) {
|
||||||
|
if (claimed.has(item.actionId)) continue
|
||||||
|
const group = rest.get(item.group) ?? { id: `catalog:${item.group}`, label: item.group, description: '', actions: [], linked: [] }
|
||||||
|
group.actions.push(item.actionId)
|
||||||
|
rest.set(item.group, group)
|
||||||
|
}
|
||||||
|
return [...groups, ...rest.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupLevel = 'on' | 'off' | 'mixed'
|
||||||
|
|
||||||
|
function level(flags: boolean[]): GroupLevel {
|
||||||
|
if (flags.length > 0 && flags.every(Boolean)) return 'on'
|
||||||
|
return flags.some(Boolean) ? 'mixed' : 'off'
|
||||||
|
}
|
||||||
|
|
||||||
|
const OFF: RolePermissionValue = { allowed: false, capAllowed: false }
|
||||||
|
|
||||||
|
export function capabilityGroupState(values: Record<string, RolePermissionValue>, group: CapabilityGroup) {
|
||||||
|
const items = group.actions.map(id => values[id] ?? OFF)
|
||||||
|
return {
|
||||||
|
allowed: level(items.map(item => item.allowed)),
|
||||||
|
capAllowed: level(items.map(item => item.capAllowed)),
|
||||||
|
linkedReady: group.linked.every(id => (values[id] ?? OFF).capAllowed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建能力开启(默认或上限)即补齐联动项上限;只开上限,不把管理动作默认授予所有成员项目。 */
|
||||||
|
export function ensureLinkedCapabilities(draft: RolePermissionDraft): RolePermissionDraft {
|
||||||
|
const create = draft.values[PROJECT_CREATE_ACTION]
|
||||||
|
if (!create?.allowed && !create?.capAllowed) return draft
|
||||||
|
let next = draft
|
||||||
|
for (const id of PROJECT_CREATE_LINKED_ACTIONS) {
|
||||||
|
const current = next.values[id]
|
||||||
|
if (!current || current.capAllowed) continue
|
||||||
|
next = updateRolePermission(next, id, { ...current, capAllowed: true })
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组开关:沿用「默认不超过上限」耦合;关闭创建不反向删除已有联动资格。 */
|
||||||
|
export function setCapabilityGroup(
|
||||||
|
draft: RolePermissionDraft, group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean,
|
||||||
|
): RolePermissionDraft {
|
||||||
|
let next = draft
|
||||||
|
for (const id of group.actions) {
|
||||||
|
const current = next.values[id] ?? OFF
|
||||||
|
const value = field === 'allowed'
|
||||||
|
? { allowed: checked, capAllowed: checked ? true : current.capAllowed }
|
||||||
|
: { allowed: checked ? current.allowed : false, capAllowed: checked }
|
||||||
|
next = updateRolePermission(next, id, value)
|
||||||
|
}
|
||||||
|
return ensureLinkedCapabilities(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建能力已开但联动项上限仍关:断链,需在保存时补齐或由服务端整笔拒绝。 */
|
||||||
|
export function linkedChainBreaks(values: Record<string, RolePermissionValue>): string[] {
|
||||||
|
const create = values[PROJECT_CREATE_ACTION]
|
||||||
|
if (!create?.allowed && !create?.capAllowed) return []
|
||||||
|
return PROJECT_CREATE_LINKED_ACTIONS.filter(id => id in values && !values[id].capAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityGroupChange {
|
||||||
|
group: CapabilityGroup
|
||||||
|
before: ReturnType<typeof capabilityGroupState>
|
||||||
|
after: ReturnType<typeof capabilityGroupState>
|
||||||
|
/** 本次联动补齐上限的受控项。 */
|
||||||
|
linkedAdded: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function capabilityGroupChanges(draft: RolePermissionDraft, groups: CapabilityGroup[]): CapabilityGroupChange[] {
|
||||||
|
const changed = (id: string) => {
|
||||||
|
const before = draft.baseline[id] ?? OFF
|
||||||
|
const after = draft.values[id] ?? OFF
|
||||||
|
return before.allowed !== after.allowed || before.capAllowed !== after.capAllowed
|
||||||
|
}
|
||||||
|
return groups.flatMap(group => {
|
||||||
|
if (![...group.actions, ...group.linked].some(changed)) return []
|
||||||
|
return [{
|
||||||
|
group,
|
||||||
|
before: capabilityGroupState(draft.baseline, group),
|
||||||
|
after: capabilityGroupState(draft.values, group),
|
||||||
|
linkedAdded: group.linked.filter(id => !(draft.baseline[id] ?? OFF).capAllowed && (draft.values[id] ?? OFF).capAllowed),
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 系统授权草稿冲突重放:只把本地相对旧基线的增删应用到最新服务端授权上。 */
|
||||||
|
export function rebaseGrantDraft(baseline: string[], local: string[], server: string[]): string[] {
|
||||||
|
const before = new Set(baseline)
|
||||||
|
const now = new Set(local)
|
||||||
|
const next = new Set(server)
|
||||||
|
for (const id of now) if (!before.has(id)) next.add(id)
|
||||||
|
for (const id of before) if (!now.has(id)) next.delete(id)
|
||||||
|
return [...next].sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 某账号 project.create 的实际来源(角色默认与明确系统授权可并存)。 */
|
||||||
|
export function projectCreateSources(
|
||||||
|
role: { name: string; permissions: { actionId: string; allowed: boolean }[] } | null | undefined, grants: string[],
|
||||||
|
): string[] {
|
||||||
|
const sources: string[] = []
|
||||||
|
if (role?.permissions.some(item => item.actionId === PROJECT_CREATE_ACTION && item.allowed)) sources.push(`职位角色「${role.name}」`)
|
||||||
|
if (grants.includes(PROJECT_CREATE_ACTION)) sources.push('明确系统授权')
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
|
||||||
export function validateRolePermissionDraft(draft: RolePermissionDraft): { actionId: string; reason: string } | null {
|
export function validateRolePermissionDraft(draft: RolePermissionDraft): { actionId: string; reason: string } | null {
|
||||||
for (const actionId of Object.keys(draft.values).sort()) {
|
for (const actionId of Object.keys(draft.values).sort()) {
|
||||||
const value = draft.values[actionId]
|
const value = draft.values[actionId]
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { auth } from './auth'
|
||||||
|
import {
|
||||||
|
PROJECT_SYNC_INTERVAL_MS, PROJECT_SYNC_RENDER_BUDGET_MS, PROJECT_SYNC_REQUEST_BUDGET_MS,
|
||||||
|
createProjectAutoDiscover, projectSyncDelay, store, type ProjectSyncEnv, type ProjectSyncStatus,
|
||||||
|
} from './store'
|
||||||
|
import type { Conversation, Project } from './contracts'
|
||||||
|
|
||||||
|
const project = (id: string, owner = 'alice'): Project => ({ id, owner, name: id, createdAt: '', updatedAt: '' })
|
||||||
|
const task = (id: string, projectId: string): Conversation => ({ id, projectId, title: id, createdAt: '', updatedAt: '' })
|
||||||
|
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
let reject!: (reason: unknown) => void
|
||||||
|
const promise = new Promise<T>((ok, fail) => { resolve = ok; reject = fail })
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 可控的页面环境:可见性、联网与 wake/sleep 事件由测试驱动。 */
|
||||||
|
function fakeEnv() {
|
||||||
|
const env = {
|
||||||
|
visible: true,
|
||||||
|
online: true,
|
||||||
|
handlers: null as null | { wake: () => void; sleep: (status: 'paused' | 'offline') => void },
|
||||||
|
}
|
||||||
|
const port: ProjectSyncEnv = {
|
||||||
|
visible: () => env.visible,
|
||||||
|
online: () => env.online,
|
||||||
|
subscribe: handlers => { env.handlers = handlers; return () => { env.handlers = null } },
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
port,
|
||||||
|
hide() { env.visible = false; env.handlers?.sleep('paused') },
|
||||||
|
show() { env.visible = true; env.handlers?.wake() },
|
||||||
|
offline() { env.online = false; env.handlers?.sleep('offline') },
|
||||||
|
online() { env.online = true; env.handlers?.wake() },
|
||||||
|
focus() { env.handlers?.wake() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let projects: Project[]
|
||||||
|
let tasks: Conversation[]
|
||||||
|
let requests: { path: string; init?: RequestInit }[]
|
||||||
|
let route: (path: string, init?: RequestInit) => Promise<Response>
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
projects = [project('host-a')]
|
||||||
|
tasks = [task('conv-a', 'host-a')]
|
||||||
|
requests = []
|
||||||
|
const saved = new Map<string, string>()
|
||||||
|
vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => { saved.set(key, value) },
|
||||||
|
removeItem: (key: string) => { saved.delete(key) } })
|
||||||
|
route = async (path, init) => {
|
||||||
|
if (path === '/api/projects' && !init?.method) return response(projects)
|
||||||
|
if (path === '/api/conversations' && !init?.method) return response(tasks.filter(t => projects.some(p => p.id === t.projectId)))
|
||||||
|
if (path === '/api/conversations/conv-a') return response({ conversation: tasks[0], messages: [], runs: [], activeHitl: [], lastSeq: 0 })
|
||||||
|
if (path === '/api/me') return response({ owner: 'bob', name: 'Bob', auth: true, systemActions: [] })
|
||||||
|
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
|
||||||
|
if (path.endsWith('/artifacts') || path.endsWith('/surfaces')) return response([])
|
||||||
|
if (path.endsWith('/cards')) return response({ cards: [] })
|
||||||
|
return response({})
|
||||||
|
}
|
||||||
|
vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => {
|
||||||
|
requests.push({ path, init })
|
||||||
|
return route(path, init)
|
||||||
|
})
|
||||||
|
vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
|
||||||
|
auth.beginSession()
|
||||||
|
await store.refreshMe()
|
||||||
|
await store.refreshProjects()
|
||||||
|
await store.openTask('conv-a')
|
||||||
|
requests = []
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.stopProjectSync()
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function advanceUntil(check: () => boolean, limitMs: number, stepMs = 250): Promise<number> {
|
||||||
|
let elapsed = 0
|
||||||
|
while (!check() && elapsed < limitMs) {
|
||||||
|
await vi.advanceTimersByTimeAsync(stepMs)
|
||||||
|
elapsed += stepMs
|
||||||
|
}
|
||||||
|
return elapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AutoDiscover project list', () => {
|
||||||
|
it('keeps the visible-page budget inside 5 seconds', () => {
|
||||||
|
expect(PROJECT_SYNC_INTERVAL_MS + PROJECT_SYNC_REQUEST_BUDGET_MS + PROJECT_SYNC_RENDER_BUDGET_MS).toBeLessThanOrEqual(5000)
|
||||||
|
expect(projectSyncDelay(0)).toBe(PROJECT_SYNC_INTERVAL_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces a project the member was added to within 5 seconds without switching the current work', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const env = fakeEnv()
|
||||||
|
store.startProjectSync(env.port)
|
||||||
|
projects = [project('host-a'), project('host-b', 'carol')]
|
||||||
|
const elapsed = await advanceUntil(() => store.getState().projects.some(p => p.id === 'host-b'), 5000)
|
||||||
|
expect(store.getState().projects.map(p => p.id)).toEqual(['host-a', 'host-b'])
|
||||||
|
expect(elapsed).toBeLessThanOrEqual(5000)
|
||||||
|
expect(store.getState()).toMatchObject({ currentProjectId: 'host-a', currentConvId: 'conv-a', projectsLoaded: true })
|
||||||
|
expect(requests.map(r => r.path)).not.toContain('/api/conversations/conv-a')
|
||||||
|
expect(store.getState().projectSync.status).toBe('ok')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears a removed current project within 5 seconds and keeps the remaining projects', async () => {
|
||||||
|
projects = [project('host-a'), project('host-b', 'carol')]
|
||||||
|
await store.refreshProjects()
|
||||||
|
vi.useFakeTimers()
|
||||||
|
store.startProjectSync(fakeEnv().port)
|
||||||
|
projects = [project('host-b', 'carol')]
|
||||||
|
const elapsed = await advanceUntil(() => store.getState().currentProjectId !== 'host-a', 5000)
|
||||||
|
expect(elapsed).toBeLessThanOrEqual(5000)
|
||||||
|
expect(store.getState()).toMatchObject({ currentProjectId: 'host-b', currentConvId: null })
|
||||||
|
expect(store.getState().projects.map(p => p.id)).toEqual(['host-b'])
|
||||||
|
expect(store.getState().tasks.map(t => t.id)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not re-render or re-open anything when the list is unchanged', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
store.startProjectSync(fakeEnv().port)
|
||||||
|
const before = store.getState()
|
||||||
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 3)
|
||||||
|
expect(requests.filter(r => r.path === '/api/projects')).toHaveLength(3)
|
||||||
|
expect(store.getState().projects).toBe(before.projects)
|
||||||
|
expect(requests.map(r => r.path)).not.toContain('/api/conversations')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RefreshSingleFlight', () => {
|
||||||
|
it('never overlaps requests and folds wake-ups during a request into one immediate follow-up', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const env = fakeEnv()
|
||||||
|
let concurrent = 0
|
||||||
|
let peak = 0
|
||||||
|
const calls: ReturnType<typeof deferred<void>>[] = []
|
||||||
|
const fetchOnce = vi.fn(() => {
|
||||||
|
concurrent++; peak = Math.max(peak, concurrent)
|
||||||
|
const call = deferred<void>()
|
||||||
|
calls.push(call)
|
||||||
|
return call.promise.finally(() => { concurrent-- })
|
||||||
|
})
|
||||||
|
const sync = createProjectAutoDiscover(fetchOnce, env.port, () => {})
|
||||||
|
sync.start()
|
||||||
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
||||||
|
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||||
|
env.focus(); env.online(); env.show()
|
||||||
|
await vi.advanceTimersByTimeAsync(500)
|
||||||
|
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||||
|
calls[0].resolve()
|
||||||
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
|
expect(fetchOnce).toHaveBeenCalledTimes(2)
|
||||||
|
calls[1].resolve()
|
||||||
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS - 1)
|
||||||
|
expect(fetchOnce).toHaveBeenCalledTimes(2)
|
||||||
|
await vi.advanceTimersByTimeAsync(1)
|
||||||
|
expect(fetchOnce).toHaveBeenCalledTimes(3)
|
||||||
|
expect(peak).toBe(1)
|
||||||
|
sync.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schedules the next round only after the current one finishes', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const slow = deferred<void>()
|
||||||
|
const fetchOnce = vi.fn().mockReturnValueOnce(slow.promise).mockResolvedValue(undefined)
|
||||||
|
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, () => {})
|
||||||
|
sync.start()
|
||||||
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS + 1500)
|
||||||
|
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||||
|
slow.resolve()
|
||||||
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
||||||
|
expect(fetchOnce).toHaveBeenCalledTimes(2)
|
||||||
|
sync.stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RefreshBackoff', () => {
|
||||||
|
it('reports a sync failure, backs off exponentially and returns to the 2-second rhythm on success', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const statuses: [ProjectSyncStatus, number][] = []
|
||||||
|
let fail = true
|
||||||
|
const fetchOnce = vi.fn(async () => { if (fail) throw new Error('503: unavailable') })
|
||||||
|
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, (status, failures) => statuses.push([status, failures]))
|
||||||
|
sync.start()
|
||||||
|
await vi.advanceTimersByTimeAsync(2000)
|
||||||
|
expect(statuses.at(-1)).toEqual(['degraded', 1])
|
||||||
|
await vi.advanceTimersByTimeAsync(3999)
|
||||||
|
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||||
|
await vi.advanceTimersByTimeAsync(1)
|
||||||
|
expect(fetchOnce).toHaveBeenCalledTimes(2)
|
||||||
|
expect(statuses.at(-1)).toEqual(['degraded', 2])
|
||||||
|
fail = false
|
||||||
|
await vi.advanceTimersByTimeAsync(8000)
|
||||||
|
expect(fetchOnce).toHaveBeenCalledTimes(3)
|
||||||
|
expect(statuses.at(-1)).toEqual(['ok', 0])
|
||||||
|
await vi.advanceTimersByTimeAsync(2000)
|
||||||
|
expect(fetchOnce).toHaveBeenCalledTimes(4)
|
||||||
|
expect(projectSyncDelay(10)).toBe(30000)
|
||||||
|
sync.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aborts a request that exceeds its 2-second budget and counts it as a failure', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
let signal: AbortSignal | undefined
|
||||||
|
const fetchOnce = vi.fn((s: AbortSignal) => { signal = s; return new Promise<void>(() => {}) })
|
||||||
|
const statuses: ProjectSyncStatus[] = []
|
||||||
|
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, status => statuses.push(status))
|
||||||
|
sync.start()
|
||||||
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS + PROJECT_SYNC_REQUEST_BUDGET_MS)
|
||||||
|
expect(signal?.aborted).toBe(true)
|
||||||
|
expect(statuses.at(-1)).toBe('degraded')
|
||||||
|
sync.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the store sync state as degraded while the list endpoint fails', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const previous = route
|
||||||
|
route = (path, init) => path === '/api/projects' ? Promise.resolve(response({ error: 'down' }, 503)) : previous(path, init)
|
||||||
|
store.startProjectSync(fakeEnv().port)
|
||||||
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
||||||
|
expect(store.getState().projectSync).toEqual({ status: 'degraded', failures: 1 })
|
||||||
|
expect(store.getState().projects.map(p => p.id)).toEqual(['host-a'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RefreshHiddenPause', () => {
|
||||||
|
it('pauses on a hidden page and resumes immediately when it becomes visible again', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const env = fakeEnv()
|
||||||
|
const statuses: ProjectSyncStatus[] = []
|
||||||
|
const fetchOnce = vi.fn(async () => {})
|
||||||
|
const sync = createProjectAutoDiscover(fetchOnce, env.port, status => statuses.push(status))
|
||||||
|
sync.start()
|
||||||
|
env.hide()
|
||||||
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 10)
|
||||||
|
expect(fetchOnce).not.toHaveBeenCalled()
|
||||||
|
expect(statuses.at(-1)).toBe('paused')
|
||||||
|
env.show()
|
||||||
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
|
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||||
|
expect(statuses.at(-1)).toBe('ok')
|
||||||
|
sync.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not start when the page is already hidden', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const env = fakeEnv()
|
||||||
|
env.hide()
|
||||||
|
const fetchOnce = vi.fn(async () => {})
|
||||||
|
const sync = createProjectAutoDiscover(fetchOnce, env.port, () => {})
|
||||||
|
sync.start()
|
||||||
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 5)
|
||||||
|
expect(fetchOnce).not.toHaveBeenCalled()
|
||||||
|
sync.stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RefreshReconnectCompensate', () => {
|
||||||
|
it('stops while offline and re-checks immediately when the network returns', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const env = fakeEnv()
|
||||||
|
const statuses: ProjectSyncStatus[] = []
|
||||||
|
const fetchOnce = vi.fn(async () => {})
|
||||||
|
const sync = createProjectAutoDiscover(fetchOnce, env.port, status => statuses.push(status))
|
||||||
|
sync.start()
|
||||||
|
env.offline()
|
||||||
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 5)
|
||||||
|
expect(fetchOnce).not.toHaveBeenCalled()
|
||||||
|
expect(statuses.at(-1)).toBe('offline')
|
||||||
|
env.online()
|
||||||
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
|
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||||
|
sync.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-checks immediately on focus instead of waiting for the next interval', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const env = fakeEnv()
|
||||||
|
store.startProjectSync(env.port)
|
||||||
|
projects = [project('host-a'), project('host-b', 'carol')]
|
||||||
|
env.focus()
|
||||||
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
|
expect(store.getState().projects.map(p => p.id)).toContain('host-b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a late list from the previous identity after a reconnect under another account', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const env = fakeEnv()
|
||||||
|
const late = deferred<Response>()
|
||||||
|
const previous = route
|
||||||
|
route = (path, init) => path === '/api/projects' ? late.promise : previous(path, init)
|
||||||
|
store.startProjectSync(env.port)
|
||||||
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
||||||
|
auth.beginSession()
|
||||||
|
route = previous
|
||||||
|
projects = [project('host-c', 'dave')]
|
||||||
|
await store.refreshProjects()
|
||||||
|
late.resolve(response([project('host-a'), project('alice-secret')]))
|
||||||
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
|
expect(store.getState().projects.map(p => p.id)).toEqual(['host-c'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DefaultProjectDisabled', () => {
|
||||||
|
it('does not create a default project when the member has no projects yet', async () => {
|
||||||
|
projects = []
|
||||||
|
await store.refreshProjects()
|
||||||
|
requests = []
|
||||||
|
await expect(store.createTask()).rejects.toThrow(/等待项目负责人邀请/)
|
||||||
|
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
|
||||||
|
expect(store.getState().toast).toMatch(/等待项目负责人邀请/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not treat a list that has not arrived as empty', async () => {
|
||||||
|
auth.beginSession()
|
||||||
|
const pending = deferred<Response>()
|
||||||
|
route = path => path === '/api/projects' ? pending.promise : Promise.resolve(response({}))
|
||||||
|
void store.refreshProjects()
|
||||||
|
await expect(store.createTask()).rejects.toThrow(/尚未加载/)
|
||||||
|
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
|
||||||
|
pending.resolve(response([]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('points a creator to explicit project creation instead of creating one silently', async () => {
|
||||||
|
const previous = route
|
||||||
|
route = (path, init) => path === '/api/me'
|
||||||
|
? Promise.resolve(response({ owner: 'bob', name: 'Bob', auth: true, systemActions: ['project.create'],
|
||||||
|
linkedCapabilities: { projectCreate: true, source: 'role', linkedActions: [], authorityEpoch: 1 } }))
|
||||||
|
: previous(path, init)
|
||||||
|
await store.refreshMe()
|
||||||
|
projects = []
|
||||||
|
await store.refreshProjects()
|
||||||
|
requests = []
|
||||||
|
await expect(store.createTask()).rejects.toThrow(/请先新建项目/)
|
||||||
|
expect(requests.filter(r => r.path === '/api/projects' && r.init?.method === 'POST')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
+188
-24
@@ -1,9 +1,9 @@
|
|||||||
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
|
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
|
||||||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||||||
import { useSyncExternalStore } from 'react'
|
import { useSyncExternalStore } from 'react'
|
||||||
import { type AdminZoneSection, api, connectEvents } from './api'
|
import { type AdminZoneSection, type LinkedCapabilities, api, connectEvents } from './api'
|
||||||
import { auth } from './auth'
|
import { auth } from './auth'
|
||||||
import { projectActionError, projectWorkError } from './projectmeta'
|
import { canCreateProject, noProjectError, projectActionError, projectWorkError } from './projectmeta'
|
||||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
||||||
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
|
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
|
||||||
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage, type WorkReference } from './outbox'
|
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage, type WorkReference } from './outbox'
|
||||||
@@ -91,11 +91,21 @@ export interface UIState {
|
|||||||
projectSettingsId: string | null
|
projectSettingsId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[] }
|
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities }
|
||||||
|
|
||||||
|
/** KTD9 项目自动发现:可见页每 2 秒串行重查;单次请求预算 2 秒、渲染预留 1 秒,正常链路 5 秒内可见。 */
|
||||||
|
export const PROJECT_SYNC_INTERVAL_MS = 2000
|
||||||
|
export const PROJECT_SYNC_REQUEST_BUDGET_MS = 2000
|
||||||
|
export const PROJECT_SYNC_RENDER_BUDGET_MS = 1000
|
||||||
|
const PROJECT_SYNC_MAX_BACKOFF_MS = 30000
|
||||||
|
export type ProjectSyncStatus = 'idle' | 'ok' | 'degraded' | 'paused' | 'offline'
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
contextObs: Record<string, ContextObs>
|
contextObs: Record<string, ContextObs>
|
||||||
projects: Project[]
|
projects: Project[]
|
||||||
|
/** 本身份的项目列表是否已到达过;未到达不等于空列表。 */
|
||||||
|
projectsLoaded: boolean
|
||||||
|
projectSync: { status: ProjectSyncStatus; failures: number }
|
||||||
currentProjectId: string | null
|
currentProjectId: string | null
|
||||||
tasks: Conversation[]
|
tasks: Conversation[]
|
||||||
me: Me | null
|
me: Me | null
|
||||||
@@ -199,7 +209,8 @@ const surfaceActionsInflight: Record<string, boolean> = {}
|
|||||||
|
|
||||||
let state: AppState = {
|
let state: AppState = {
|
||||||
contextObs: {},
|
contextObs: {},
|
||||||
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
projects: [], projectsLoaded: false, projectSync: { status: 'idle', failures: 0 },
|
||||||
|
currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||||
serverWorkspaces: [],
|
serverWorkspaces: [],
|
||||||
@@ -322,6 +333,21 @@ const actionInflight = new Set<string>()
|
|||||||
const actionFeedbackOwners: Record<string, symbol> = {}
|
const actionFeedbackOwners: Record<string, symbol> = {}
|
||||||
const workActionExpectations = new Map<string, { actionId: string; payload: WorkCardAction }>()
|
const workActionExpectations = new Map<string, { actionId: string; payload: WorkCardAction }>()
|
||||||
|
|
||||||
|
/** 离开当前项目:关闭项目/会话流并清空项目内投影(不触碰服务端历史)。 */
|
||||||
|
function resetProjectScope() {
|
||||||
|
projectSse?.close(); projectSse = null
|
||||||
|
sse?.close(); sse = null
|
||||||
|
projectSeq = 0
|
||||||
|
projectCardsRequest++
|
||||||
|
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
|
||||||
|
conversationEpoch++; streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||||
|
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||||||
|
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||||
|
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
|
||||||
|
artifacts: [], outbox: [], outboxDraining: false, posting: false })
|
||||||
|
setUI({ previewExpanded: false, filesView: 'list' })
|
||||||
|
}
|
||||||
|
|
||||||
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
|
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
|
||||||
const version = ++detailVersion
|
const version = ++detailVersion
|
||||||
observations[id] = { ...observations[id], version, seq,
|
observations[id] = { ...observations[id], version, seq,
|
||||||
@@ -438,9 +464,140 @@ export function useStore<T>(selector: (s: AppState) => T): T {
|
|||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
(cb) => { listeners.add(cb); return () => listeners.delete(cb) },
|
(cb) => { listeners.add(cb); return () => listeners.delete(cb) },
|
||||||
() => selector(state),
|
() => selector(state),
|
||||||
|
() => selector(state),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectSyncEnv {
|
||||||
|
visible: () => boolean
|
||||||
|
online: () => boolean
|
||||||
|
/** wake:重新聚焦/页面恢复可见/网络恢复;sleep:页面隐藏或断网。 */
|
||||||
|
subscribe: (handlers: { wake: () => void; sleep: (status: 'paused' | 'offline') => void }) => () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectSyncDelay(failures: number): number {
|
||||||
|
return failures <= 0 ? PROJECT_SYNC_INTERVAL_MS : Math.min(PROJECT_SYNC_MAX_BACKOFF_MS, PROJECT_SYNC_INTERVAL_MS * 2 ** failures)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单一所有者的串行重查:同一时刻至多一个请求;进行中被唤醒只记一次补偿,完成后立即再查。
|
||||||
|
* 下一轮在本轮结束后才排期(不重叠);失败/超时按指数退避,成功或唤醒补偿后恢复 2 秒节奏。
|
||||||
|
*/
|
||||||
|
export function createProjectAutoDiscover(
|
||||||
|
fetchOnce: (signal: AbortSignal) => Promise<void>,
|
||||||
|
env: ProjectSyncEnv,
|
||||||
|
report: (status: ProjectSyncStatus, failures: number) => void,
|
||||||
|
) {
|
||||||
|
let active = false
|
||||||
|
let inflight = false
|
||||||
|
let pending = false
|
||||||
|
let failures = 0
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let controller: AbortController | null = null
|
||||||
|
let unsubscribe: (() => void) | null = null
|
||||||
|
const clear = () => { if (timer !== null) { clearTimeout(timer); timer = null } }
|
||||||
|
const blocked = (): 'paused' | 'offline' | null => !env.online() ? 'offline' : !env.visible() ? 'paused' : null
|
||||||
|
const schedule = () => {
|
||||||
|
clear()
|
||||||
|
const reason = blocked()
|
||||||
|
if (reason) { report(reason, failures); return }
|
||||||
|
timer = setTimeout(() => { timer = null; void run() }, projectSyncDelay(failures))
|
||||||
|
}
|
||||||
|
const run = async (): Promise<void> => {
|
||||||
|
if (!active) return
|
||||||
|
const reason = blocked()
|
||||||
|
if (reason) { clear(); report(reason, failures); return }
|
||||||
|
if (inflight) { pending = true; return }
|
||||||
|
clear()
|
||||||
|
inflight = true
|
||||||
|
const own = new AbortController()
|
||||||
|
controller = own
|
||||||
|
const budget = setTimeout(() => own.abort(), PROJECT_SYNC_REQUEST_BUDGET_MS)
|
||||||
|
const aborted = new Promise<never>((_, reject) =>
|
||||||
|
own.signal.addEventListener('abort', () => reject(new Error('project sync timeout')), { once: true }))
|
||||||
|
try {
|
||||||
|
await Promise.race([fetchOnce(own.signal), aborted])
|
||||||
|
if (active && controller === own) { failures = 0; report('ok', 0) }
|
||||||
|
} catch {
|
||||||
|
if (active && controller === own) { failures++; report('degraded', failures) }
|
||||||
|
} finally {
|
||||||
|
clearTimeout(budget)
|
||||||
|
inflight = false
|
||||||
|
if (controller === own) controller = null
|
||||||
|
}
|
||||||
|
if (!active) return
|
||||||
|
if (pending) { pending = false; return run() }
|
||||||
|
schedule()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
if (active) return
|
||||||
|
active = true
|
||||||
|
unsubscribe = env.subscribe({
|
||||||
|
wake: () => { void run() },
|
||||||
|
sleep: status => { clear(); report(status, failures) },
|
||||||
|
})
|
||||||
|
schedule()
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
active = false; pending = false; failures = 0
|
||||||
|
clear()
|
||||||
|
controller?.abort(); controller = null
|
||||||
|
unsubscribe?.(); unsubscribe = null
|
||||||
|
report('idle', 0)
|
||||||
|
},
|
||||||
|
trigger: () => run(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserProjectSyncEnv(): ProjectSyncEnv {
|
||||||
|
return {
|
||||||
|
visible: () => typeof document === 'undefined' || document.visibilityState !== 'hidden',
|
||||||
|
online: () => typeof navigator === 'undefined' || navigator.onLine !== false,
|
||||||
|
subscribe: ({ wake, sleep }) => {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') return () => {}
|
||||||
|
const onVisibility = () => { if (document.visibilityState === 'hidden') sleep('paused'); else wake() }
|
||||||
|
const onOffline = () => sleep('offline')
|
||||||
|
document.addEventListener('visibilitychange', onVisibility)
|
||||||
|
window.addEventListener('focus', wake)
|
||||||
|
window.addEventListener('online', wake)
|
||||||
|
window.addEventListener('offline', onOffline)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility)
|
||||||
|
window.removeEventListener('focus', wake)
|
||||||
|
window.removeEventListener('online', wake)
|
||||||
|
window.removeEventListener('offline', onOffline)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let projectSync: ReturnType<typeof createProjectAutoDiscover> | null = null
|
||||||
|
|
||||||
|
const projectIdsKey = (projects: Project[]) => projects.map(p => p.id).sort().join('\n')
|
||||||
|
|
||||||
|
/** 列表合并只更新投影:不重复 openProject、不切对话、不重建流;仅当前项目失效时清除其状态。 */
|
||||||
|
async function mergeProjectList(projects: Project[]) {
|
||||||
|
if (state.projectsLoaded && JSON.stringify(state.projects) === JSON.stringify(projects)) return
|
||||||
|
const membershipChanged = !state.projectsLoaded || projectIdsKey(state.projects) !== projectIdsKey(projects)
|
||||||
|
set({ projects, projectsLoaded: true, error: null })
|
||||||
|
const current = state.currentProjectId
|
||||||
|
if (current && !projects.some(p => p.id === current)) {
|
||||||
|
const fallback = projects[0]
|
||||||
|
if (fallback) await store.openProject(fallback.id)
|
||||||
|
else {
|
||||||
|
resetProjectScope()
|
||||||
|
set({ currentProjectId: null })
|
||||||
|
setUI({ lastProjectId: null })
|
||||||
|
}
|
||||||
|
store.toast('你已不在原项目中,已清除该项目的入口')
|
||||||
|
} else if (!current) {
|
||||||
|
const next = projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
|
||||||
|
if (next) await store.openProject(next.id)
|
||||||
|
}
|
||||||
|
if (membershipChanged) await store.refreshTasks()
|
||||||
|
}
|
||||||
|
|
||||||
export const store = {
|
export const store = {
|
||||||
getState: () => state,
|
getState: () => state,
|
||||||
getIdentityEpoch: () => identityEpoch,
|
getIdentityEpoch: () => identityEpoch,
|
||||||
@@ -460,7 +617,7 @@ export const store = {
|
|||||||
for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key]
|
for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key]
|
||||||
convRestoreDone = true
|
convRestoreDone = true
|
||||||
persistConvId(null)
|
persistConvId(null)
|
||||||
set({ projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
set({ projects: [], projectsLoaded: false, currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||||
contextObs: {}, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
contextObs: {}, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
|
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
|
||||||
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
|
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
|
||||||
@@ -481,7 +638,7 @@ export const store = {
|
|||||||
const projects = await api.listProjects()
|
const projects = await api.listProjects()
|
||||||
if (epoch !== identityEpoch || request !== projectsRequest) return
|
if (epoch !== identityEpoch || request !== projectsRequest) return
|
||||||
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
|
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
|
||||||
set({ projects, currentProjectId: current?.id ?? null, error: null })
|
set({ projects, projectsLoaded: true, currentProjectId: current?.id ?? null, error: null })
|
||||||
if (current) await store.openProject(current.id)
|
if (current) await store.openProject(current.id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (epoch === identityEpoch && request === projectsRequest) set({ error: projectActionError(e) })
|
if (epoch === identityEpoch && request === projectsRequest) set({ error: projectActionError(e) })
|
||||||
@@ -489,6 +646,28 @@ export const store = {
|
|||||||
if (epoch === identityEpoch && request === projectsRequest) await store.refreshTasks()
|
if (epoch === identityEpoch && request === projectsRequest) await store.refreshTasks()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 自动发现的一次重查:与 refreshProjects 共用请求代次,迟到/旧身份响应丢弃;失败抛出供退避。 */
|
||||||
|
async syncProjects(signal?: AbortSignal) {
|
||||||
|
const epoch = identityEpoch
|
||||||
|
const request = ++projectsRequest
|
||||||
|
const projects = await api.listProjects(signal)
|
||||||
|
if (epoch !== identityEpoch || request !== projectsRequest) return
|
||||||
|
await mergeProjectList(projects)
|
||||||
|
},
|
||||||
|
|
||||||
|
startProjectSync(env: ProjectSyncEnv = browserProjectSyncEnv()) {
|
||||||
|
projectSync?.stop()
|
||||||
|
projectSync = createProjectAutoDiscover(signal => store.syncProjects(signal), env, (status, failures) => {
|
||||||
|
if (state.projectSync.status !== status || state.projectSync.failures !== failures) set({ projectSync: { status, failures } })
|
||||||
|
})
|
||||||
|
projectSync.start()
|
||||||
|
},
|
||||||
|
|
||||||
|
stopProjectSync() {
|
||||||
|
projectSync?.stop()
|
||||||
|
projectSync = null
|
||||||
|
},
|
||||||
|
|
||||||
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
|
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
|
||||||
async refreshTasks(includeArchived = state.ui.showArchived) {
|
async refreshTasks(includeArchived = state.ui.showArchived) {
|
||||||
const epoch = identityEpoch
|
const epoch = identityEpoch
|
||||||
@@ -541,19 +720,7 @@ export const store = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async openProject(projectId: string) {
|
async openProject(projectId: string) {
|
||||||
if (state.currentProjectId !== projectId) {
|
if (state.currentProjectId !== projectId) resetProjectScope()
|
||||||
projectSse?.close(); projectSse = null
|
|
||||||
sse?.close(); sse = null
|
|
||||||
projectSeq = 0
|
|
||||||
projectCardsRequest++
|
|
||||||
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
|
|
||||||
conversationEpoch++; streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
|
||||||
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
|
|
||||||
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
|
||||||
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
|
|
||||||
artifacts: [], outbox: [], outboxDraining: false, posting: false })
|
|
||||||
setUI({ previewExpanded: false, filesView: 'list' })
|
|
||||||
}
|
|
||||||
set({ currentProjectId: projectId })
|
set({ currentProjectId: projectId })
|
||||||
if (store.getState().inboxScope === 'project') {
|
if (store.getState().inboxScope === 'project') {
|
||||||
store.loadProjectCards()
|
store.loadProjectCards()
|
||||||
@@ -564,16 +731,13 @@ export const store = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。
|
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。
|
||||||
* 空库时主按钮与空状态都写着「点击新建任务开始」,这里先落一个默认项目再开会话。 */
|
* 没有项目时不代建「默认项目」:列表未到达或无创建能力时自动建项目会误触权限与同名冲突。 */
|
||||||
async createTask(projectId?: string) {
|
async createTask(projectId?: string) {
|
||||||
const epoch = identityEpoch
|
const epoch = identityEpoch
|
||||||
try {
|
try {
|
||||||
let pid = projectId ?? state.currentProjectId
|
let pid = projectId ?? state.currentProjectId
|
||||||
if (!pid && state.projects.length > 0) pid = state.projects[0].id
|
if (!pid && state.projects.length > 0) pid = state.projects[0].id
|
||||||
if (!pid) {
|
if (!pid) throw new Error(noProjectError(state.projectsLoaded, canCreateProject(state.me)))
|
||||||
const p = await store.createProject('默认项目')
|
|
||||||
pid = p.id
|
|
||||||
}
|
|
||||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||||
const error = projectWorkError(state.projects.find(p => p.id === pid))
|
const error = projectWorkError(state.projects.find(p => p.id === pid))
|
||||||
if (error) throw new Error(error)
|
if (error) throw new Error(error)
|
||||||
|
|||||||
Reference in New Issue
Block a user