Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c19439283 | ||
|
|
dab16052cf | ||
|
|
ce15f7977b | ||
|
|
f560201f8b | ||
|
|
6f8e7ec604 | ||
|
|
aa49e0dbf3 | ||
|
|
9df5517e9c | ||
|
|
b4fec2fdf4 | ||
|
|
164ea66039 | ||
|
|
23877af960 | ||
|
|
e6bb387f71 | ||
|
|
6db528df10 | ||
|
|
1544781c28 | ||
|
|
cfabed8fae | ||
|
|
c9d5acf089 | ||
|
|
139f2ce654 | ||
|
|
01ec12e77c | ||
|
|
34950997f8 | ||
|
|
006c815675 | ||
|
|
9d7681cec4 | ||
|
|
5bc2e12c01 | ||
|
|
64ba640b77 | ||
|
|
329bdf53d6 | ||
|
|
0bbccbe39e | ||
|
|
3087b0621c | ||
|
|
4d1d64a83e | ||
|
|
861c45edb5 | ||
|
|
c63f6c7470 | ||
|
|
75a420210d | ||
|
|
175210f4bb | ||
|
|
b00fa4433b | ||
|
|
8be95c8807 | ||
|
|
54fe339e65 | ||
|
|
82c08bd983 |
@@ -13,7 +13,7 @@ jobs:
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
UI_DEPLOY_KEY: ${{ secrets.UI_DEPLOY_KEY }}
|
||||
UI_SHA: 5c2b1036dd1aff89ec4eeae0c96493694e33ad4c
|
||||
UI_SHA: fadabceadeec210d52cda7e9cec71e988d2e4482
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf 'context server=%s repository=%s sha=%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_SHA}"
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { readFileSync, readlinkSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
const manifestPath=process.env.WORK_CHECKPOINT_HTTP_MANIFEST
|
||||
test.skip(!manifestPath,'Run explicitly with a task-owned mechanism instance manifest')
|
||||
const manifest=manifestPath?JSON.parse(readFileSync(manifestPath,'utf8')):null
|
||||
if(manifest&&(manifest.mode!=='mechanism'||!manifest.dataDir.includes('fast-slow-u2-u3-')||readlinkSync(`/proc/${manifest.pid}/exe`)!==manifest.binary||createHash('sha256').update(readFileSync(manifest.binary)).digest('hex')!==manifest.binarySHA256))throw new Error('Owned mechanism instance verification failed')
|
||||
|
||||
type Scenario='normal'|'fast-ack'|'queue-hold'|'slow-stages'|'zero-failure'|'partial-failure'|'choice'
|
||||
async function setup(request:any,scenario:Scenario='normal',awaitTask=true,options:{text?:string,small?:boolean,startFailure?:boolean}={}){
|
||||
const project=await(await request.post(manifest.base+'/api/projects',{data:{name:'browser-'+Date.now()}})).json()
|
||||
const conv=await(await request.post(manifest.base+`/api/projects/${project.id}/conversations`,{data:{title:'材料整理',profileId:'checkpoint-eval'}})).json()
|
||||
const ids:string[]=[]
|
||||
for(let i=0;i<(options.small?1:3);i++){
|
||||
const marker=i===0&&scenario!=='normal'?`[fixture:${scenario}]\n`:''
|
||||
const res=await request.post(manifest.base+`/api/projects/${project.id}/uploads`,{multipart:{file:{name:`来源${i+1}.txt`,mimeType:'text/plain',buffer:Buffer.from(marker+'上线六月。验收条件需要补齐,预算50万元尚未审批。交付包含12项需求,其中3项依赖外部接口。第一期先完成8项核心需求。\n\n'+(options.small?'':('整理记录说明:保留日期、金额、限制和待确认事项。\n\n').repeat(options.text?250:100)))}}})
|
||||
expect(res.ok()).toBeTruthy();ids.push((await res.json()).artifactId)
|
||||
}
|
||||
if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:true}})).status()).toBe(204)
|
||||
let sendBody:any
|
||||
try{
|
||||
const sent=await request.post(manifest.base+`/api/conversations/${conv.id}/messages`,{data:{text:options.text??'',attachmentIds:ids,requestId:'browser-'+Date.now()}})
|
||||
expect(sent.ok()).toBeTruthy()
|
||||
sendBody=await sent.json()
|
||||
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${sendBody.run.id}`)).json()).run.status).toBe('completed')
|
||||
}finally{
|
||||
if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:false}})).status()).toBe(204)
|
||||
}
|
||||
if(awaitTask)await expect.poll(async()=>{
|
||||
const out=await(await request.get(manifest.base+`/api/conversations/${conv.id}/cards`)).json()
|
||||
return out.cards.find((c:any)=>c.task)?.task.executionState
|
||||
}).toBe(scenario.endsWith('failure')?'failed':'completed')
|
||||
return {project,conv,ids,sendRunId:sendBody.run.id}
|
||||
}
|
||||
async function waitForFailedWorkCard(request:any,seed:any){
|
||||
await expect.poll(async()=>{
|
||||
const out=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()
|
||||
const card=out.cards.find((c:any)=>c.task)
|
||||
return card?.task.executionState==='failed'&&card.status==='failed'&&Boolean(card.work)
|
||||
}).toBe(true)
|
||||
}
|
||||
async function expectOnlySendRun(request:any,seed:any){
|
||||
const current=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}`)).json()
|
||||
expect(current.runs.filter((r:any)=>r.kind==='conversation').map((r:any)=>r.id)).toEqual([seed.sendRunId])
|
||||
}
|
||||
async function open(page:any,seed:any){
|
||||
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
|
||||
await page.goto('/')
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
|
||||
}
|
||||
test('attachment-only intake gives a safe first reply before sourced findings',async({page,request})=>{
|
||||
const seed=await setup(request,'fast-ack',false)
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
|
||||
expect(cards.find((c:any)=>c.task).task.checkpointSeq??0).toBe(0)
|
||||
await open(page,seed)
|
||||
const reply=page.getByText(/已收到 3 份材料.*待确认/)
|
||||
await expect(reply).toBeVisible()
|
||||
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
|
||||
await page.setViewportSize({width:390,height:844})
|
||||
await expect(reply).toBeVisible()
|
||||
if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT.replace(/\.png$/, '-mobile.png'),fullPage:true})
|
||||
await page.reload()
|
||||
await expect(reply).toBeVisible()
|
||||
})
|
||||
test('text with long material keeps the user focus in the host reply',async({page,request})=>{
|
||||
const seed=await setup(request,'fast-ack',false,{text:'重点核对预算审批'})
|
||||
await open(page,seed)
|
||||
await expect(page.getByText(/你提出的工作重点已带入整理任务.*待确认/)).toBeVisible()
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
|
||||
})
|
||||
test('running and completed resends reuse one material card',async({page,request})=>{
|
||||
const seed=await setup(request,'slow-stages',false)
|
||||
const resend=async(key:string)=>{
|
||||
const response=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:seed.ids,requestId:key}})
|
||||
expect(response.ok()).toBeTruthy()
|
||||
const body=await response.json()
|
||||
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${body.run.id}`)).json()).run.status).toBe('completed')
|
||||
}
|
||||
await resend('active-resend-'+Date.now())
|
||||
await open(page,seed)
|
||||
await expect(page.getByText(/这批材料仍在整理.*待确认/)).toBeVisible()
|
||||
await expect.poll(async()=>{
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
return cards.find((c:any)=>c.task)?.task.executionState
|
||||
}).toBe('completed')
|
||||
await resend('completed-resend-'+Date.now())
|
||||
await expect(page.getByText(/这批材料已整理完成.*待确认/)).toBeVisible()
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
|
||||
})
|
||||
test('text with a small attachment retains the ordinary reply path',async({page,request})=>{
|
||||
const seed=await setup(request,'normal',true,{text:'请先解释材料要点',small:true})
|
||||
await open(page,seed)
|
||||
await expect(page.getByText('已收到材料,正在后台整理;请结合阶段发现继续工作。').first()).toBeVisible()
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
|
||||
})
|
||||
test('real HTTP task recovery, source navigation and changed-version limitation',async({page,request})=>{
|
||||
const seed=await setup(request);const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message));await open(page,seed)
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐')
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('材料未说明具体归属')
|
||||
await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
|
||||
await page.getByRole('button',{name:'查看依据',exact:true}).first().click()
|
||||
await page.getByRole('button',{name:/查看来源/}).first().click()
|
||||
await expect(page.locator('.preview-pane')).toBeVisible();await expect(page.getByText('任务处理时引用的来源版本;下方是本项发现引用的片段')).toBeVisible()
|
||||
const card=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.find((c:any)=>c.task)
|
||||
const source=card.task.findings[0].sources[0]
|
||||
const update=await request.put(manifest.base+`/api/artifacts/${source.artifactId}/content`,{data:'上线七月。文件内容已经更新。',headers:{'Content-Type':'text/plain'}});expect(update.ok()).toBeTruthy()
|
||||
const old=await request.get(manifest.base+`/api/artifacts/${source.artifactId}/content?expectedHash=${source.contentHash}`);expect(old.status()).toBe(409)
|
||||
await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('依据文件已更新')
|
||||
await expect(page.locator('[data-conversation-work] .acard-findings').first()).not.toContainText('上线六月')
|
||||
expect(errors).toEqual([])
|
||||
if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT,fullPage:true})
|
||||
})
|
||||
test('background replay does not steal the current file, focus or viewport',async({page,request})=>{
|
||||
const seed=await setup(request);await open(page,seed)
|
||||
await page.getByRole('button',{name:'查看依据',exact:true}).first().click();await page.getByRole('button',{name:/查看来源/}).first().click();await expect(page.locator('.preview-pane')).toBeVisible()
|
||||
const tabs=await page.locator('.tab-strip').textContent()
|
||||
const composer=page.locator('textarea').last();await composer.fill('正在记录自己的想法');await composer.focus()
|
||||
const focus=await composer.evaluate((el:any)=>document.activeElement===el);expect(focus).toBe(true)
|
||||
const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'重点核对预算',attachmentIds:seed.ids,requestId:'another-'+Date.now()}});expect(sent.ok()).toBeTruthy()
|
||||
await expect.poll(async()=>{
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
return cards.filter((c:any)=>c.task?.executionState==='completed').length
|
||||
}).toBe(2)
|
||||
await expect(composer).toBeFocused();await expect(composer).toHaveValue('正在记录自己的想法')
|
||||
expect(await page.locator('.tab-strip').textContent()).toBe(tabs)
|
||||
})
|
||||
test('two sourced stages appear while the material task is still running',async({page,request})=>{
|
||||
const seed=await setup(request,'slow-stages',false)
|
||||
await open(page,seed)
|
||||
const cards=async()=>(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('running')
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
|
||||
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState==='running'&&card.task.checkpointSeq>=2&&card.task.findings?.length>1}).toBe(true)
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐')
|
||||
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('completed')
|
||||
})
|
||||
for(const scenario of ['zero-failure','partial-failure'] as const){
|
||||
test(`${scenario} shows the saved scope without a deliverable choice`,async({page,request})=>{
|
||||
const seed=await setup(request,scenario)
|
||||
await waitForFailedWorkCard(request,seed)
|
||||
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
|
||||
await page.goto('/')
|
||||
const work=page.locator('[data-conversation-work]')
|
||||
await expect(work).toContainText('执行失败')
|
||||
if(scenario==='zero-failure')await expect(work).toContainText('暂无阶段结论记录')
|
||||
else await expect(work).toContainText('上线六月')
|
||||
await expect(page.locator('.work-choice')).toHaveCount(0)
|
||||
await expect(page.locator('.hitl-choice')).toHaveCount(0)
|
||||
await expectOnlySendRun(request,seed)
|
||||
})
|
||||
}
|
||||
test('completed sourced work offers only its grounded suggestions',async({page,request})=>{
|
||||
const seed=await setup(request,'choice')
|
||||
await open(page,seed)
|
||||
const work=page.locator('[data-conversation-work]')
|
||||
await expect(work.locator('.work-choice')).toHaveCount(3)
|
||||
for(const label of ['会议摘要','验收清单','联调计划'])await expect(work.locator('.work-choice').filter({hasText:label})).toHaveCount(1)
|
||||
await expect(work.locator('.work-decision legend')).toContainText('接下来需要做什么?')
|
||||
await page.reload()
|
||||
await expect(work.locator('.work-choice')).toHaveCount(3)
|
||||
})
|
||||
test('failed material registration reports no started task or unsupported finding',async({page,request})=>{
|
||||
const seed=await setup(request,'normal',false,{startFailure:true})
|
||||
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
|
||||
await page.goto('/')
|
||||
await expect(page.getByText(/后台整理未能启动/).first()).toBeVisible()
|
||||
const conversation=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}`)).json()
|
||||
expect(conversation.messages.some((m:any)=>m.role==='assistant'&&(m.parts??[]).some((p:any)=>p.type==='text'&&p.text?.includes('未能启动')))).toBe(true)
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
expect(cards.filter((c:any)=>c.task)).toHaveLength(0)
|
||||
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
|
||||
})
|
||||
test('saturated material slots show queued status without claiming execution',async({page,request})=>{
|
||||
const seed=await setup(request,'queue-hold',false)
|
||||
const sendAnother=async(label:string,hold:boolean)=>{
|
||||
const uploaded=await request.post(manifest.base+`/api/projects/${seed.project.id}/uploads`,{
|
||||
multipart:{file:{name:`${label}.txt`,mimeType:'text/plain',buffer:Buffer.from((hold?'[fixture:queue-hold]\n':'')+'上线六月。验收条件需要补齐。'+label)}}
|
||||
})
|
||||
expect(uploaded.ok()).toBeTruthy()
|
||||
const artifactId=(await uploaded.json()).artifactId
|
||||
const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:[artifactId],requestId:'queue-'+label}})
|
||||
expect(sent.ok()).toBeTruthy()
|
||||
const runId=(await sent.json()).run.id
|
||||
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${runId}`)).json()).run.status).toBe('completed')
|
||||
}
|
||||
await sendAnother('占用二',true)
|
||||
await sendAnother('占用三',true)
|
||||
await sendAnother('等待四',false)
|
||||
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
|
||||
await page.goto('/')
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('排队中')
|
||||
await expect(page.getByText(/整理任务正在排队.*待确认/).first()).toBeVisible()
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.filter((c:any)=>c.task)
|
||||
expect(cards).toHaveLength(4)
|
||||
expect(cards.filter((c:any)=>c.task.executionState==='queued')).toHaveLength(1)
|
||||
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
|
||||
})
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"exports": 51
|
||||
"exports": 58
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// 账号管理动作呈现门控(超级管理员 R3/R4/R8):只决定 UI 暴露哪些入口,
|
||||
// 服务端授权仍是唯一权威——隐藏的入口经直连 API 调用同样会被拒绝。
|
||||
interface AccountIdentity { isAdmin?: boolean; isSuperAdmin?: boolean }
|
||||
|
||||
type AccountIdentityKind = 'super' | 'admin' | 'user'
|
||||
|
||||
export const accountIdentityKind = (account: AccountIdentity): AccountIdentityKind =>
|
||||
account.isSuperAdmin ? 'super' : account.isAdmin ? 'admin' : 'user'
|
||||
|
||||
/** 创建管理员账号与任命/撤销管理员仅对超级管理员开放。 */
|
||||
export const canAppointAdmins = (me: AccountIdentity | null | undefined) => !!me?.isSuperAdmin
|
||||
|
||||
interface AccountActionPolicy {
|
||||
/** 资料、角色与系统权限维护。 */
|
||||
maintain: boolean
|
||||
toggleEnabled: boolean
|
||||
/** 管理员重置临时密码与恢复提案。 */
|
||||
resetPassword: boolean
|
||||
adminChange: 'promote' | 'demote' | null
|
||||
protectedReason: string
|
||||
}
|
||||
|
||||
export function accountActionPolicy(me: AccountIdentity | null | undefined, target: AccountIdentity): AccountActionPolicy {
|
||||
if (target.isSuperAdmin) {
|
||||
const self = !!me?.isSuperAdmin
|
||||
return {
|
||||
maintain: self, toggleEnabled: false, resetPassword: false, adminChange: null,
|
||||
protectedReason: self
|
||||
? '超级管理员账号不能停用、降级或由管理员重置密码;本人密码请在「设置 → 账号」修改'
|
||||
: '超级管理员账号受保护:普通管理员不能编辑、停用、降级或重置其密码',
|
||||
}
|
||||
}
|
||||
return {
|
||||
maintain: true, toggleEnabled: true, resetPassword: true,
|
||||
adminChange: canAppointAdmins(me) ? (target.isAdmin ? 'demote' : 'promote') : null,
|
||||
protectedReason: '',
|
||||
}
|
||||
}
|
||||
+14
-9
@@ -44,12 +44,12 @@ export interface RecoveryProposal {
|
||||
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
|
||||
export interface AccountRecord {
|
||||
userId: string; owner: string; profile: AccountProfile; profileVersion: number
|
||||
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; passwordSet?: boolean
|
||||
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; isSuperAdmin?: boolean; passwordSet?: boolean
|
||||
initializationState?: string; initializationGeneration?: number; createdAt?: string
|
||||
}
|
||||
/** 管理端用户目录/详情视图:账号状态与版本字段必有(乐观并发依赖 stateVersion)。 */
|
||||
export interface ManagedAccountRecord extends AccountRecord {
|
||||
enabled: boolean; stateVersion: number; isAdmin: boolean; passwordSet: boolean
|
||||
enabled: boolean; stateVersion: number; isAdmin: boolean; isSuperAdmin: boolean; passwordSet: boolean
|
||||
initializationState: string; initializationGeneration: number
|
||||
}
|
||||
interface AccountSearchPage { accounts: ManagedAccountRecord[]; nextCursor?: string; directoryVersion: string }
|
||||
@@ -128,11 +128,11 @@ export const api = {
|
||||
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||||
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
|
||||
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; 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[] }>(r)),
|
||||
// ---- 认证与会话(管理员发放制,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 => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
@@ -140,7 +140,7 @@ export const api = {
|
||||
// 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。
|
||||
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/recovery-grants/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
@@ -151,11 +151,12 @@ export const api = {
|
||||
},
|
||||
changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
|
||||
}),
|
||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
|
||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
|
||||
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
|
||||
createAdminUser: (owner: string, name: string, password?: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
|
||||
// isAdmin 仅超级管理员可用(R3/R4),普通管理员携带时由服务端拒绝。
|
||||
createAdminUser: (owner: string, name: string, password?: string, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}), ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
|
||||
adminResetUserPassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<{ owner: string; temporaryPassword: string; mustChangePassword: boolean }>(r)),
|
||||
searchAdminUsers: (request: { query: string; mode: 'exact' | 'fuzzy'; status: 'all' | 'enabled' | 'disabled'; fields: string[]; cursor?: string; limit?: number }) => {
|
||||
const params = new URLSearchParams({ query: request.query, mode: request.mode, status: request.status,
|
||||
@@ -163,10 +164,12 @@ export const api = {
|
||||
if (request.cursor) params.set('cursor', request.cursor)
|
||||
return auth.fetch(`${BASE}/api/admin/users/search?${params}`).then(r => json<AccountSearchPage>(r))
|
||||
},
|
||||
createManagedUser: (owner: string, profile: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
createManagedUser: (owner: string, profile: Partial<AccountProfile>, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile, ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
adminGetUser: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}`).then(r => json<ManagedAccountRecord>(r)),
|
||||
adminPatchProfile: (userId: string, expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
adminSetUserEnabled: (userId: string, expectedStateVersion: number, enabled: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/state`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, enabled }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
// 任命/撤销管理员:仅超级管理员(R3);超级管理员本身不可降级(R5)。
|
||||
adminSetUserAdmin: (userId: string, expectedStateVersion: number, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/admin`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, isAdmin }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
// ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除;
|
||||
// list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)----
|
||||
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
|
||||
@@ -218,6 +221,8 @@ export const api = {
|
||||
auth.fetch(`${BASE}/api/hitl/${cardId}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(resp) })
|
||||
.then(r => json<{ resolved: boolean; pivoted: boolean; pivotText: string }>(r)),
|
||||
|
||||
taskSource: (cardId:string,sourceId:string) => auth.fetch(`${BASE}/api/cards/${encodeURIComponent(cardId)}/sources/${encodeURIComponent(sourceId)}`).then(r=>json<{source:import('./contracts').TaskSourceRef;versionState:string;artifact?:Artifact}>(r)),
|
||||
listCardsSnapshot: (conversationId: string): Promise<{cards: ActivityCard[]; replaySeq?: number}> => auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{cards: ActivityCard[]; replaySeq?: number}>(r)),
|
||||
listCards: (conversationId: string) =>
|
||||
auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []),
|
||||
cardAction: (cardId: string, actionId: string, payload?: string | WorkCardAction) =>
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
import { useStore } from '../store'
|
||||
import { Collapsible, Icon } from '@ui'
|
||||
import { hasSystemAction } from './RolePermissionsPanel'
|
||||
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../accountAuthority'
|
||||
|
||||
type IdentityFlags = { isAdmin?: boolean; isSuperAdmin?: boolean }
|
||||
|
||||
const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' })
|
||||
const fields: { key: keyof AccountProfile; label: string }[] = [
|
||||
@@ -23,6 +26,25 @@ function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile;
|
||||
</div>)}</div>
|
||||
}
|
||||
|
||||
export function IdentityChip({ account }: { account: IdentityFlags }) {
|
||||
const kind = accountIdentityKind(account)
|
||||
if (kind === 'super') return <span className="ad-chip ad-chip-super">超级管理员</span>
|
||||
if (kind === 'admin') return <span className="ad-chip ad-chip-admin">管理员</span>
|
||||
return <span className="ad-chip">普通用户</span>
|
||||
}
|
||||
|
||||
export function CreateAdminOption({ me, checked, disabled, onChange }: { me: IdentityFlags | null; checked: boolean; disabled: boolean; onChange: (value: boolean) => void }) {
|
||||
if (!canAppointAdmins(me)) return <p className="ad-hint">仅超级管理员可创建或任命管理员;此处创建普通账号</p>
|
||||
return <label className="ad-check"><input type="checkbox" checked={checked} disabled={disabled}
|
||||
onChange={event => onChange(event.target.checked)} />创建为管理员账号</label>
|
||||
}
|
||||
|
||||
export function AdminAppointmentButton({ me, account, busy, onChange }: { me: IdentityFlags | null; account: IdentityFlags; busy: boolean; onChange: (isAdmin: boolean) => void }) {
|
||||
const change = accountActionPolicy(me, account).adminChange
|
||||
if (!change) return null
|
||||
return <button className="ad-mini" disabled={busy} onClick={() => onChange(change === 'promote')}>{change === 'promote' ? '设为管理员' : '撤销管理员'}</button>
|
||||
}
|
||||
|
||||
export function AccountUsersPanel() {
|
||||
const me = useStore(state => state.me)
|
||||
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
|
||||
@@ -45,6 +67,7 @@ export function AccountUsersPanel() {
|
||||
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
|
||||
const [newOwner, setNewOwner] = useState('')
|
||||
const [newProfile, setNewProfile] = useState<AccountProfile>(emptyProfile)
|
||||
const [newIsAdmin, setNewIsAdmin] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||||
const [field, setField] = useState('default')
|
||||
@@ -129,10 +152,10 @@ export function AccountUsersPanel() {
|
||||
if (!newOwner.trim()) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const account = await api.createManagedUser(newOwner.trim(), newProfile)
|
||||
setNewOwner(''); setNewProfile(emptyProfile())
|
||||
const account = await api.createManagedUser(newOwner.trim(), newProfile, canAppointAdmins(me) && newIsAdmin)
|
||||
setNewOwner(''); setNewProfile(emptyProfile()); setNewIsAdmin(false)
|
||||
await open(account)
|
||||
setNotice(`已创建 ${account.owner};账号资料可空,角色另行分配`)
|
||||
setNotice(`已创建 ${account.owner}${account.isAdmin ? '(管理员)' : ''};账号资料可空,角色另行分配`)
|
||||
await refreshFirst()
|
||||
} catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
@@ -182,33 +205,48 @@ export function AccountUsersPanel() {
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const setAdmin = async (isAdmin: boolean) => {
|
||||
if (!selected) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const updated = await api.adminSetUserAdmin(selected.userId, selected.stateVersion, isAdmin)
|
||||
setSelected(updated); await refreshFirst(); setNotice(isAdmin ? '已任命为管理员' : '已撤销管理员')
|
||||
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const policy = selected ? accountActionPolicy(me, selected) : null
|
||||
|
||||
if (!allowed('system.users.view')) return <p className="ad-hint">当前账号没有查看用户的系统权限</p>
|
||||
return <div>
|
||||
{allowed('system.users.create') && <div className="ad-create">
|
||||
<div className="ad-row"><input className="lg-input" aria-label="新用户登录名" placeholder="登录名" value={newOwner} onChange={event => setNewOwner(event.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !newOwner.trim()} onClick={create}>创建用户</button></div>
|
||||
<CreateAdminOption me={me} checked={newIsAdmin} disabled={busy} onChange={setNewIsAdmin} />
|
||||
<Collapsible id="new-user-profile" trigger="可选资料:姓名、电话、邮箱、工号、部门、备注">
|
||||
<ProfileInputs profile={newProfile} onChange={setNewProfile} prefix="new-user" />
|
||||
</Collapsible>
|
||||
</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>}
|
||||
{selected ? <div className="rp-editor">
|
||||
{selected && policy ? <div className="rp-editor">
|
||||
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
|
||||
<b>{selected.owner}</b><span className="ad-dim ad-mono">{selected.userId}</span></div>
|
||||
<b>{selected.owner}</b><IdentityChip account={selected} /><span className="ad-dim ad-mono">{selected.userId}</span></div>
|
||||
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · {credentialLabel(selected)} · 资料版本 {selected.profileVersion} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
|
||||
{selected.initializationState === 'pending' && <p className="ad-hint">交给可信 owner 的初始化定位:userId <span className="ad-mono">{selected.userId}</span> · 状态版本 {selected.stateVersion} · 代数 {selected.initializationGeneration}。凭据只经保护文件交付。</p>}
|
||||
{allowed('system.users.edit') && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
|
||||
{policy.protectedReason && <p className="ad-hint" role="note">{policy.protectedReason}</p>}
|
||||
{allowed('system.users.edit') && policy.maintain && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
|
||||
<ProfileInputs profile={draft} onChange={setDraft} prefix="user-profile" />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveProfile}>保存资料</button>
|
||||
</Collapsible>}
|
||||
<div className="ad-row">
|
||||
{canAssignRoles && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
|
||||
{canAssignRoles && policy.maintain && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
|
||||
<option value="">请选择角色</option>{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
|
||||
</select><button className="ad-mini" disabled={busy || !selectedRoleId || selectedRoleId === roleView?.role?.roleId} onClick={assignRole}>替换角色</button></>}
|
||||
{allowed('system.users.disable') && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
|
||||
{allowed('system.users.disable') && policy.toggleEnabled && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
|
||||
<AdminAppointmentButton me={me} account={selected} busy={busy} onChange={setAdmin} />
|
||||
</div>
|
||||
{canAssignRoles && <div className="ad-row">
|
||||
{canAssignRoles && policy.maintain && <div className="ad-row">
|
||||
<input className="lg-input" aria-label="搜索可分配角色" placeholder="搜索有效角色" value={roleQuery}
|
||||
onChange={event => { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
|
||||
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
|
||||
@@ -216,10 +254,10 @@ export function AccountUsersPanel() {
|
||||
</div>}
|
||||
{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}>
|
||||
<input type="checkbox" checked={grants.includes(item.actionId)} disabled={!canManageGrants || busy} onChange={event => setGrants(current => event.target.checked
|
||||
<input type="checkbox" checked={grants.includes(item.actionId)} disabled={!canManageGrants || !policy.maintain || busy} onChange={event => setGrants(current => event.target.checked
|
||||
? [...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 && <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>}
|
||||
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
|
||||
</div> : <>
|
||||
@@ -232,9 +270,9 @@ export function AccountUsersPanel() {
|
||||
</select>
|
||||
<select className="lg-input" aria-label="用户状态" value={status} onChange={event => { setStatus(event.target.value as typeof status); resetPaging() }}><option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option></select>
|
||||
</div>
|
||||
<table className="ad-table"><thead><tr><th>登录名</th><th>姓名</th><th>工号</th><th>系统身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
|
||||
<table className="ad-table"><thead><tr><th>登录名</th><th>姓名</th><th>工号</th><th>身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
|
||||
<tbody>{(accounts ?? []).map(account => <tr key={account.userId}><td className="ad-mono">{account.owner}</td><td>{account.profile.name || '—'}</td>
|
||||
<td>{account.profile.staffNo || '—'}</td><td>{account.isAdmin ? '系统管理员' : '普通账号'}</td><td>{account.enabled ? '有效' : '停用'}</td>
|
||||
<td>{account.profile.staffNo || '—'}</td><td><IdentityChip account={account} /></td><td>{account.enabled ? '有效' : '停用'}</td>
|
||||
<td>{credentialLabel(account)}</td><td><button className="ad-mini" onClick={() => open(account)}>查看与管理</button></td></tr>)}
|
||||
{accounts !== null && accounts.length === 0 && <tr><td colSpan={7} className="ad-dim">没有匹配用户</td></tr>}
|
||||
{accounts === null && <tr><td colSpan={7} className="ad-dim">加载中…</td></tr>}</tbody></table>
|
||||
|
||||
@@ -110,7 +110,7 @@ export function AdminZone() {
|
||||
<Icon name="chevron-down" size={14} /> 返回工作台
|
||||
</button>
|
||||
<span className="az-title">系统管理</span>
|
||||
<span className="az-sub">{isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
|
||||
<span className="az-sub">{me?.isSuperAdmin ? '超级管理员' : isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="az-tenant">{me?.owner ?? ''}</span>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,9 @@ export function ConversationWork() {
|
||||
if (card.kind === 'confirm' && card.anchorMessageId
|
||||
&& messages.some(message => message.role === 'user' && message.id === card.anchorMessageId)) continue
|
||||
const summary = activitySummary(card)
|
||||
if (card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
|
||||
const taskNotice = card.task?.limitations?.length || card.task?.phase.id === 'correction'
|
||||
|| card.task?.findings?.some(f => f.status === 'active' && (f.kind === 'needs_user' || f.corrects))
|
||||
if (taskNotice || card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
|
||||
else remaining.push(card)
|
||||
}
|
||||
// Preserve the host's newest-first order for completed results and history.
|
||||
|
||||
@@ -24,6 +24,7 @@ export function PreviewPane() {
|
||||
})
|
||||
: undefined
|
||||
const art = active ? artifacts.find(a => a.id === active.id) : undefined
|
||||
const contentUrl=art?api.artifactContentUrl(art.id)+(active?.sourceContext?.expectedHash?`?expectedHash=${encodeURIComponent(active.sourceContext.expectedHash)}`:''):''
|
||||
const textKind = !!art && isTextKind(art)
|
||||
const [content, setContent] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -35,12 +36,12 @@ export function PreviewPane() {
|
||||
setContent(null)
|
||||
if (!art || !isTextKind(art)) return
|
||||
let cancelled = false
|
||||
fetch(api.artifactContentUrl(art.id))
|
||||
.then(r => r.text())
|
||||
fetch(contentUrl)
|
||||
.then(r => {if(!r.ok)throw new Error('source unavailable');return r.text()})
|
||||
.then(text => { if (!cancelled) setContent(text) })
|
||||
.catch(() => { if (!cancelled) setContent('(读取失败)') })
|
||||
return () => { cancelled = true }
|
||||
}, [art?.id])
|
||||
}, [art?.id,contentUrl])
|
||||
|
||||
if (!active) return null
|
||||
if (active.id.startsWith('surface:')) {
|
||||
@@ -96,6 +97,7 @@ export function PreviewPane() {
|
||||
await api.saveArtifactContent(art.id, content ?? '')
|
||||
setDirty(false)
|
||||
store.toast('已保存')
|
||||
const conv=store.getState().currentConvId;if(conv)void store.refreshCards(conv)
|
||||
}}>保存</Btn>}
|
||||
</>
|
||||
)}
|
||||
@@ -108,6 +110,7 @@ export function PreviewPane() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{active.sourceContext&&<div className="acard-status-note" role="note"><p>{active.sourceContext.notice}</p>{active.sourceContext.quote&&<blockquote>{active.sourceContext.quote}</blockquote>}</div>}
|
||||
<div className="preview-pane-body">
|
||||
{editing && textKind ? (
|
||||
<textarea className="search-input preview-pane-editor" value={content ?? ''}
|
||||
@@ -118,7 +121,7 @@ export function PreviewPane() {
|
||||
id: art.id,
|
||||
name: art.name,
|
||||
mime: art.mime,
|
||||
contentUrl: api.artifactContentUrl(art.id),
|
||||
contentUrl,
|
||||
downloadUrl: api.artifactDownloadUrl(art.id),
|
||||
}} />
|
||||
) : <div className="empty-note" style={{ padding: 14 }}>这个文件已不在当前项目里</div>}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, type PermissionDescriptor, type RoleRecord } from '../api'
|
||||
import {
|
||||
createRolePermissionDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
|
||||
type RolePermissionDraft, type RolePermissionValue,
|
||||
} from '../rolePermissions'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, type RoleRecord } from '../api'
|
||||
import { projectAccessOf, projectAccessPatch } from '../rolePermissions'
|
||||
import { Collapsible, Icon } from '@ui'
|
||||
import { useStore } from '../store'
|
||||
import { useStore, type Me } from '../store'
|
||||
|
||||
/** DR9 离开管理区守卫:面板有未保存权限草稿时注册;AdminZone「返回工作台」消费。 */
|
||||
/** DR9 离开管理区守卫:面板有未保存项目访问草稿时注册;AdminZone「返回工作台」消费。 */
|
||||
export const roleDraftLeaveGuard: { current: (() => boolean) | null } = { current: null }
|
||||
|
||||
export const PROJECT_ACCESS_MANAGE_REASON = '需要角色管理权限才能修改项目访问'
|
||||
|
||||
export function filterRoles(
|
||||
roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled',
|
||||
): RoleRecord[] {
|
||||
@@ -23,11 +22,6 @@ export function filterRoles(
|
||||
})
|
||||
}
|
||||
|
||||
export function groupPermissionPage(catalog: PermissionDescriptor[], group: string, page: number, size: number): PermissionDescriptor[] {
|
||||
const grouped = catalog.filter(item => !group || item.group === group)
|
||||
return grouped.slice(page * size, page * size + size)
|
||||
}
|
||||
|
||||
export function shouldAcceptRoleResponse(activeSequence: number, responseSequence: number): boolean {
|
||||
return activeSequence === responseSequence
|
||||
}
|
||||
@@ -36,41 +30,59 @@ export function hasSystemAction(actions: string[] | undefined, action: string):
|
||||
return actions?.includes(action) ?? false
|
||||
}
|
||||
|
||||
function roleValues(role: RoleRecord, catalog: PermissionDescriptor[]): Record<string, RolePermissionValue> {
|
||||
const existing = new Map(role.permissions.map(item => [item.actionId, item]))
|
||||
return Object.fromEntries(catalog.filter(item => !item.protected).map(item => {
|
||||
const value = existing.get(item.actionId)
|
||||
return [item.actionId, { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }]
|
||||
}))
|
||||
/** R13:角色面板只认 system.roles.view / system.roles.manage;isAdmin 不旁路,与服务端同语义。 */
|
||||
export function rolePanelAccess(me: Pick<Me, 'systemActions'> | null | undefined): { canView: boolean; canManage: boolean } {
|
||||
return {
|
||||
canView: hasSystemAction(me?.systemActions, 'system.roles.view'),
|
||||
canManage: hasSystemAction(me?.systemActions, 'system.roles.manage'),
|
||||
}
|
||||
}
|
||||
|
||||
function roleValue(role: RoleRecord, actionId: string): RolePermissionValue {
|
||||
const value = role.permissions.find(item => item.actionId === actionId)
|
||||
return { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }
|
||||
type ProjectAccessSaveResult = { kind: 'unchanged' } | { kind: 'saved' } | { kind: 'conflict'; fresh: RoleRecord }
|
||||
|
||||
/** 409 时读取最新角色交由调用方按新基线重放本地布尔;其他失败原样抛出。 */
|
||||
export async function saveProjectAccess(role: RoleRecord, next: boolean, idempotencyKey: string): Promise<ProjectAccessSaveResult> {
|
||||
const changes = projectAccessPatch(role, next)
|
||||
if (changes.length === 0) return { kind: 'unchanged' }
|
||||
try {
|
||||
await api.replaceRolePermissions(role.roleId, role.roleVersion, changes, idempotencyKey)
|
||||
return { kind: 'saved' }
|
||||
} catch (error) {
|
||||
if (!String(error).includes('409')) throw error
|
||||
return { kind: 'conflict', fresh: await api.getRole(role.roleId) }
|
||||
}
|
||||
}
|
||||
|
||||
export function ProjectAccessCheckbox({ roleName, checked, canManage, disabled, showReason = true, onChange }: {
|
||||
roleName: string; checked: boolean; canManage: boolean; disabled: boolean; showReason?: boolean
|
||||
onChange: (next: boolean) => void
|
||||
}) {
|
||||
return <span className="rp-access">
|
||||
<label className="ad-check">
|
||||
<input type="checkbox" aria-label={`${roleName} 项目访问`} checked={checked} disabled={disabled || !canManage}
|
||||
onChange={event => onChange(event.target.checked)} />
|
||||
项目访问(可创建项目)
|
||||
</label>
|
||||
{!canManage && showReason && <span className="ad-hint">{PROJECT_ACCESS_MANAGE_REASON}</span>}
|
||||
</span>
|
||||
}
|
||||
|
||||
export function RolePermissionsPanel() {
|
||||
const me = useStore(state => state.me)
|
||||
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
|
||||
const canManageRoles = allowed('system.roles.manage')
|
||||
const canViewPermissions = allowed('system.permissions.view')
|
||||
const canManagePermissions = canViewPermissions && allowed('system.permissions.manage')
|
||||
const { canView, canManage } = rolePanelAccess(me)
|
||||
const [roles, setRoles] = useState<RoleRecord[] | null>(null)
|
||||
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [drafts, setDrafts] = useState<Record<string, RolePermissionDraft>>({})
|
||||
// DR9:草稿按 roleId 隔离,只存本地意图;显示值 = 草稿 ?? 服务端值,刷新不覆盖本地编辑。
|
||||
const [drafts, setDrafts] = useState<Record<string, boolean>>({})
|
||||
const [confirmDisable, setConfirmDisable] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||||
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||||
const [group, setGroup] = useState('')
|
||||
const [actionFilter, setActionFilter] = useState('')
|
||||
const [roleCursor, setRoleCursor] = useState('')
|
||||
const [roleTrail, setRoleTrail] = useState<string[]>([])
|
||||
const [nextRoleCursor, setNextRoleCursor] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
const [view, setView] = useState<'single' | 'matrix'>('single')
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newDescription, setNewDescription] = useState('')
|
||||
@@ -83,62 +95,54 @@ export function RolePermissionsPanel() {
|
||||
const savePermsRef = useRef<HTMLButtonElement>(null)
|
||||
selectedIdRef.current = selectedId
|
||||
|
||||
const selected = roles?.find(role => role.roleId === selectedId) ?? null
|
||||
const draft = selected ? drafts[selected.roleId] ?? null : null
|
||||
const allEditableIds = useMemo(() => catalog.filter(item => !item.protected).map(item => item.actionId), [catalog])
|
||||
const dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0
|
||||
const groups = useMemo(() => [...new Set(catalog.map(item => item.group))], [catalog])
|
||||
const filtered = roles ?? []
|
||||
const pageItems = useMemo(() => groupPermissionPage(catalog, group, page, 6), [catalog, group, page])
|
||||
const editablePageItems = pageItems.filter(item => !item.protected)
|
||||
const changes = draft ? visiblePatch(draft, allEditableIds) : []
|
||||
const selected = filtered.find(role => role.roleId === selectedId) ?? null
|
||||
const accessOf = (role: RoleRecord) => drafts[role.roleId] ?? projectAccessOf(role)
|
||||
const changes = selected ? projectAccessPatch(selected, accessOf(selected)) : []
|
||||
const dirty = changes.length > 0
|
||||
|
||||
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
|
||||
filters = { query, mode, status, action: actionFilter }) => {
|
||||
const load = async (pageCursor = roleCursor, preferredId = selectedId, filters = { query, mode, status }) => {
|
||||
const sequence = ++requestSequence.current
|
||||
setRoleLoading(true)
|
||||
setErr('')
|
||||
const [roleResult, catalogResult] = await Promise.allSettled([
|
||||
api.listRoles({ query: filters.query.trim(), mode: filters.mode, status: filters.status,
|
||||
action: filters.action || undefined, cursor: pageCursor, limit: 50 }),
|
||||
canViewPermissions && (refreshCatalog || catalog.length === 0) ? api.permissionCatalog() : Promise.resolve(catalog),
|
||||
])
|
||||
if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return
|
||||
setRoleLoading(false)
|
||||
const descriptors = catalogResult.status === 'fulfilled' ? catalogResult.value : catalog
|
||||
if (catalogResult.status === 'fulfilled') setCatalog(descriptors)
|
||||
if (roleResult.status === 'rejected') {
|
||||
setErr(String(roleResult.reason))
|
||||
return
|
||||
try {
|
||||
const rolePage = await api.listRoles({ query: filters.query.trim(), mode: filters.mode, status: filters.status, cursor: pageCursor, limit: 50 })
|
||||
if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return
|
||||
setRoleLoading(false)
|
||||
setRoles(rolePage.roles ?? [])
|
||||
setNextRoleCursor(rolePage.nextCursor ?? '')
|
||||
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
|
||||
if (nextSelected) selectRole(nextSelected, sequence)
|
||||
else setSelectedId('')
|
||||
} catch (error) {
|
||||
if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return
|
||||
setRoleLoading(false)
|
||||
setErr(String(error))
|
||||
}
|
||||
const rolePage = roleResult.value
|
||||
setRoles(rolePage.roles ?? [])
|
||||
setNextRoleCursor(rolePage.nextCursor ?? '')
|
||||
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
|
||||
if (nextSelected) selectRole(nextSelected, descriptors, sequence)
|
||||
else setSelectedId('')
|
||||
// DR10:目录失败是部分失败态,需在角色选中(selectRole 会清空提示)之后仍可见。
|
||||
if (catalogResult.status === 'rejected') setErr(prev => prev || `权限目录加载失败:${catalogResult.reason}`)
|
||||
}
|
||||
|
||||
const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => {
|
||||
const selectRole = (role: RoleRecord, sequence?: number) => {
|
||||
const nextSequence = sequence ?? ++requestSequence.current
|
||||
if (!shouldAcceptRoleResponse(requestSequence.current, nextSequence)) return
|
||||
setSelectedId(role.roleId)
|
||||
setName(role.name)
|
||||
setDescription(role.description)
|
||||
// DR9:草稿按 roleId 隔离保存;翻页、过滤、视图切换回到该角色时保留,
|
||||
// 切换角色永不转移他人草稿。仅首次选中时以服务端值为基线。
|
||||
setDrafts(prev => prev[role.roleId] ? prev
|
||||
: { ...prev, [role.roleId]: createRolePermissionDraft(roleValues(role, descriptors), nextSequence) })
|
||||
setNotice('')
|
||||
setErr('')
|
||||
}
|
||||
|
||||
const replaceRole = (fresh: RoleRecord) => setRoles(current => current?.some(role => role.roleId === fresh.roleId)
|
||||
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
|
||||
|
||||
const clearDraft = (roleId: string) => setDrafts(current => {
|
||||
if (!(roleId in current)) return current
|
||||
const next = { ...current }; delete next[roleId]; return next
|
||||
})
|
||||
|
||||
// DR9:离开管理区须显式决定;带未保存草稿时刷新走浏览器原生确认。
|
||||
useEffect(() => {
|
||||
if (!dirty || !selected) { roleDraftLeaveGuard.current = null; return }
|
||||
const message = `角色「${selected.name}」有未保存的权限修改,离开将放弃这些修改。确定离开吗?`
|
||||
const message = `角色「${selected.name}」有未保存的项目访问修改,离开将放弃这些修改。确定离开吗?`
|
||||
roleDraftLeaveGuard.current = () => window.confirm(message)
|
||||
return () => { roleDraftLeaveGuard.current = null }
|
||||
}, [dirty, selected?.roleId, selected?.name])
|
||||
@@ -150,10 +154,11 @@ export function RolePermissionsPanel() {
|
||||
}, [dirty])
|
||||
|
||||
useEffect(() => {
|
||||
if (!canView) return
|
||||
setNextRoleCursor(''); setRoleLoading(true)
|
||||
const timer = window.setTimeout(() => { void load(roleCursor) }, 180)
|
||||
return () => { window.clearTimeout(timer); requestSequence.current++ }
|
||||
}, [query, mode, status, actionFilter, roleCursor, canViewPermissions])
|
||||
}, [query, mode, status, roleCursor, canView])
|
||||
|
||||
const create = async () => {
|
||||
if (!newName.trim()) return
|
||||
@@ -162,11 +167,11 @@ export function RolePermissionsPanel() {
|
||||
try {
|
||||
const role = await api.createRole(newName.trim(), newDescription.trim())
|
||||
setNewName(''); setNewDescription('')
|
||||
setQuery(''); setStatus('all'); setActionFilter(''); setRoleCursor(''); setRoleTrail([])
|
||||
setQuery(''); setStatus('all'); setRoleCursor(''); setRoleTrail([])
|
||||
setRoles(current => [role, ...(current ?? []).filter(item => item.roleId !== role.roleId)])
|
||||
selectRole(role, catalog, ++requestSequence.current)
|
||||
selectRole(role, ++requestSequence.current)
|
||||
setNotice(`已创建角色 ${role.name}`)
|
||||
await load('', true, role.roleId, { query: '', mode, status: 'all', action: '' })
|
||||
await load('', role.roleId, { query: '', mode, status: 'all' })
|
||||
setNotice(`已创建角色 ${role.name}`)
|
||||
} catch (error) { setErr(String(error)) } finally { setBusy(false) }
|
||||
}
|
||||
@@ -185,8 +190,7 @@ export function RolePermissionsPanel() {
|
||||
try {
|
||||
const fresh = await api.getRole(roleId)
|
||||
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
|
||||
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
|
||||
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
|
||||
replaceRole(fresh)
|
||||
setErr('角色资料版本冲突;已读取最新版本并保留本地名称与描述,请核对后重试')
|
||||
} catch (refreshError) { setErr(String(refreshError)) }
|
||||
} else setErr(String(error))
|
||||
@@ -208,45 +212,34 @@ export function RolePermissionsPanel() {
|
||||
try {
|
||||
const fresh = await api.getRole(roleId)
|
||||
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
|
||||
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
|
||||
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
|
||||
replaceRole(fresh)
|
||||
setErr('角色状态版本冲突;已刷新当前状态,请重新确认')
|
||||
} catch (refreshError) { setErr(String(refreshError)) }
|
||||
} else setErr(String(error))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const rebasePermissionConflict = async (roleId: string, currentDraft: RolePermissionDraft, operationSequence: number) => {
|
||||
const fresh = await api.getRole(roleId)
|
||||
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
|
||||
setRoles(current => current?.some(role => role.roleId === roleId)
|
||||
? current.map(role => role.roleId === roleId ? fresh : role) : [fresh, ...(current ?? [])])
|
||||
// DR9:409 只重放同一角色的草稿;基于最新服务端值回放本地相对基线的变化。
|
||||
setDrafts(prev => ({ ...prev, [roleId]: rebaseRoleDraft(currentDraft, roleValues(fresh, catalog)) }))
|
||||
setErr('权限版本冲突;已基于最新版本重放本地草稿,请核对后再次保存')
|
||||
}
|
||||
|
||||
const savePermissions = async () => {
|
||||
if (!selected || !draft) return
|
||||
const saveAccess = async () => {
|
||||
if (!selected || !canManage) return
|
||||
const roleId = selected.roleId
|
||||
const next = accessOf(selected)
|
||||
const operationSequence = ++requestSequence.current
|
||||
setRoleLoading(false)
|
||||
const invalid = validateRolePermissionDraft(draft)
|
||||
if (invalid) { setErr(`${invalid.actionId} 的默认权限不能超过角色上限`); return }
|
||||
const full = Object.entries(draft.values).map(([actionId, value]) => ({ actionId, ...value }))
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID())
|
||||
setDrafts(current => {
|
||||
if (!(roleId in current)) return current
|
||||
const next = { ...current }; delete next[roleId]; return next
|
||||
})
|
||||
await load(roleCursor, true, selected.roleId); setNotice('角色权限已保存')
|
||||
} catch (error) {
|
||||
if (String(error).includes('409')) {
|
||||
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
|
||||
} else setErr(String(error))
|
||||
}
|
||||
const result = await saveProjectAccess(selected, next, crypto.randomUUID())
|
||||
if (result.kind === 'conflict') {
|
||||
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
|
||||
// DR9:409 只重放同一角色的草稿——本地布尔保留,基线换成最新服务端值。
|
||||
replaceRole(result.fresh)
|
||||
setErr(projectAccessOf(result.fresh) === next
|
||||
? '项目访问版本冲突;最新版本已与本地修改一致,无需再次保存'
|
||||
: '项目访问版本冲突;已基于最新版本重放本地修改,请核对后再次保存')
|
||||
return
|
||||
}
|
||||
clearDraft(roleId)
|
||||
await load(roleCursor, roleId); setNotice('项目访问已保存')
|
||||
} catch (error) { setErr(String(error)) }
|
||||
finally {
|
||||
setBusy(false)
|
||||
// DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 掉回 body,则归还。
|
||||
@@ -256,52 +249,40 @@ export function RolePermissionsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const editValue = (actionId: string, field: 'allowed' | 'capAllowed', checked: boolean) => {
|
||||
const current = drafts[selectedId]
|
||||
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 editAccess = (role: RoleRecord, next: boolean) => {
|
||||
if (role.roleId !== selectedId) return
|
||||
setDrafts(prev => {
|
||||
const updated = { ...prev }
|
||||
if (next === projectAccessOf(role)) delete updated[role.roleId]
|
||||
else updated[role.roleId] = next
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if (!canView) return <div className="rp-panel"><p className="ad-hint">当前账号没有查看角色的系统权限</p></div>
|
||||
|
||||
const matrix = () => <div className="rp-matrix-wrap">
|
||||
<table className="ad-table rp-matrix">
|
||||
<thead><tr><th>角色</th>{pageItems.map(item => <th key={item.actionId}>{item.label}</th>)}</tr></thead>
|
||||
<tbody>{filtered.map(role => <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
|
||||
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
|
||||
{pageItems.map(item => {
|
||||
const value = role.roleId === selectedId && draft ? draft.values[item.actionId] : roleValue(role, item.actionId)
|
||||
return <td key={item.actionId}>
|
||||
{item.protected ? <span className="ad-dim">系统授权</span> : <>
|
||||
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 默认允许`} checked={value?.allowed ?? false}
|
||||
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} />默认</label>
|
||||
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 角色上限`} checked={value?.capAllowed ?? false}
|
||||
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} />上限</label>
|
||||
</>}
|
||||
</td>
|
||||
})}
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
const saveBar = selected && canManage && <div className="ad-row rp-save">
|
||||
<span className="ad-hint">{dirty ? `未保存:「${selected.name}」项目访问将${accessOf(selected) ? '开启' : '关闭'}` : '没有未保存的修改'}</span>
|
||||
<button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || !dirty} onClick={saveAccess}>保存项目访问</button>
|
||||
</div>
|
||||
|
||||
const matrix = () => <>
|
||||
{!canManage && <p className="ad-hint">{PROJECT_ACCESS_MANAGE_REASON}</p>}
|
||||
<div className="rp-matrix-wrap">
|
||||
<table className="ad-table rp-matrix">
|
||||
<thead><tr><th>角色</th><th>项目访问</th></tr></thead>
|
||||
<tbody>{filtered.map(role => <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
|
||||
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
|
||||
<td><ProjectAccessCheckbox roleName={role.name} checked={accessOf(role)} canManage={canManage} showReason={false}
|
||||
disabled={busy || roleLoading || role.roleId !== selectedId} onChange={next => editAccess(role, next)} /></td>
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{saveBar}
|
||||
</>
|
||||
|
||||
return <div className="rp-panel">
|
||||
{canManageRoles && <div className="ad-create">
|
||||
{canManage && <div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="新角色名称" placeholder="角色名称" value={newName} onChange={event => setNewName(event.target.value)} />
|
||||
<input className="lg-input" aria-label="新角色描述" placeholder="描述(可空)" value={newDescription} onChange={event => setNewDescription(event.target.value)} />
|
||||
@@ -317,44 +298,29 @@ 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) }}>
|
||||
<option value="all">全部状态</option><option value="enabled">启用</option><option value="disabled">停用</option>
|
||||
</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}
|
||||
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>)}
|
||||
</select>}
|
||||
</div>
|
||||
</Collapsible>
|
||||
<div className="ad-row rp-view-toggle">
|
||||
<button className={`ad-mini ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</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 ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</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>
|
||||
{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>
|
||||
{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>}
|
||||
{view === 'matrix' ? matrix() : <div className="rp-single">
|
||||
<aside className="rp-role-list">
|
||||
{(filtered).map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
|
||||
{filtered.map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
|
||||
<span>{role.name}</span><span className="ad-dim">{role.bindingCount} 人</span>
|
||||
</button>)}
|
||||
</aside>
|
||||
<section className="rp-editor">
|
||||
{selected && draft ? <>
|
||||
{selected ? <>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="角色名称" value={name} disabled={!canManageRoles || busy || roleLoading} onChange={event => setName(event.target.value)} />
|
||||
<input className="lg-input" aria-label="角色描述" value={description} disabled={!canManageRoles || busy || roleLoading} onChange={event => setDescription(event.target.value)} />
|
||||
{canManageRoles && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
|
||||
{canManageRoles && (selected.enabled
|
||||
<input className="lg-input" aria-label="角色名称" value={name} disabled={!canManage || busy || roleLoading} onChange={event => setName(event.target.value)} />
|
||||
<input className="lg-input" aria-label="角色描述" value={description} disabled={!canManage || busy || roleLoading} onChange={event => setDescription(event.target.value)} />
|
||||
{canManage && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
|
||||
{canManage && (selected.enabled
|
||||
? <button className="ad-mini" disabled={busy || roleLoading} onClick={() => setConfirmDisable(true)}>停用角色</button>
|
||||
: <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>启用角色</button>)}
|
||||
</div>
|
||||
@@ -363,22 +329,9 @@ export function RolePermissionsPanel() {
|
||||
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
|
||||
<button className="ad-mini" disabled={busy} onClick={() => setConfirmDisable(false)}>取消</button>
|
||||
</div>}
|
||||
{canViewPermissions ? <><table className="ad-table"><thead><tr><th>权限</th><th>默认允许</th><th>角色上限</th><th>说明</th></tr></thead>
|
||||
<tbody>{pageItems.map(item => {
|
||||
const value = draft.values[item.actionId] ?? { allowed: false, capAllowed: false }
|
||||
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})`}>
|
||||
{changes.length === 0 ? <p className="ad-hint">没有权限变化</p> : <ul className="rp-diff">{changes.map(change =>
|
||||
<li key={change.actionId}><span className="ad-mono">{change.actionId}</span>:默认 {change.allowed ? '允许' : '拒绝'},上限 {change.capAllowed ? '允许' : '拒绝'}</li>)}</ul>}
|
||||
</Collapsible>
|
||||
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || changes.length === 0 || editablePageItems.length === 0} onClick={savePermissions}>保存权限</button>}
|
||||
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
|
||||
<ProjectAccessCheckbox roleName={selected.name} checked={accessOf(selected)} canManage={canManage}
|
||||
disabled={busy || roleLoading} onChange={next => editAccess(selected, next)} />
|
||||
{saveBar}
|
||||
</> : <p className="ad-hint">请选择角色</p>}
|
||||
</section>
|
||||
</div>}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { WorkExecutionRecord } from './WorkExecutionRecord'
|
||||
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
|
||||
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
|
||||
export const docBridge: DocBridge = {
|
||||
onOpenTaskSource: (cardId, source) => store.openTaskSource(cardId, source),
|
||||
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
|
||||
onOpenDoc: id => {
|
||||
const art = store.getState().artifacts.find(a => a.id === id)
|
||||
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected,
|
||||
recoveryStatusLabel, type RecoveryAttempt,
|
||||
} from '../recovery'
|
||||
import { accountActionPolicy, canAppointAdmins } from '../accountAuthority'
|
||||
import { AdminAppointmentButton, CreateAdminOption, IdentityChip } from './AccountUsersPanel'
|
||||
|
||||
// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码;
|
||||
// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ----
|
||||
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
||||
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
||||
|
||||
export function UsersPanel() {
|
||||
const me = useStore(s => s.me)
|
||||
@@ -26,6 +28,7 @@ export function UsersPanel() {
|
||||
const [owner, setOwner] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [initialPassword, setInitialPassword] = useState('')
|
||||
const [createAsAdmin, setCreateAsAdmin] = useState(false)
|
||||
const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'temporary' } | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。
|
||||
@@ -53,10 +56,10 @@ export function UsersPanel() {
|
||||
setErr(''); setNotice('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined)
|
||||
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined, canAppointAdmins(me) && createAsAdmin)
|
||||
setGeneratedCredential(out.initialPassword ? { owner: out.owner, password: out.initialPassword, kind: 'initial' } : null)
|
||||
setNotice(out.initialPassword ? `已创建 ${out.owner},请将下方一次性初始密码交付给用户。首次登录后必须改密。` : `已创建 ${out.owner}。用户首次登录后必须改密。`)
|
||||
setOwner(''); setName(''); setInitialPassword('')
|
||||
setOwner(''); setName(''); setInitialPassword(''); setCreateAsAdmin(false)
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
@@ -71,6 +74,15 @@ export function UsersPanel() {
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const setAdmin = async (u: AdminUserRow, isAdmin: boolean) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
await api.adminSetUserAdmin(u.userId, u.stateVersion, isAdmin)
|
||||
setNotice(isAdmin ? `已任命 ${u.owner} 为管理员` : `已撤销 ${u.owner} 的管理员身份`)
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e).includes('409') ? '用户状态已变化,请刷新后重试' : String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const openProposals = async (u: AdminUserRow) => {
|
||||
proposalSeq.current++
|
||||
setErr(''); setNotice(''); setPropFor(u); setProposals(null)
|
||||
@@ -142,6 +154,7 @@ export function UsersPanel() {
|
||||
<input className="lg-input" type="password" autoComplete="new-password" placeholder="初始密码(留空随机生成)" value={initialPassword} onChange={e => setInitialPassword(e.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim()} onClick={create}>创建账号</button>
|
||||
</div>
|
||||
<CreateAdminOption me={me} checked={createAsAdmin} disabled={busy} onChange={setCreateAsAdmin} />
|
||||
<p className="ad-hint">可填写管理员指定的初始密码;留空则由服务端生成一次性随机密码。用户首次登录后必须改密。</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -184,19 +197,23 @@ export function UsersPanel() {
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(users ?? []).map(u => (
|
||||
<tr key={u.owner}>
|
||||
{(users ?? []).map(u => {
|
||||
const policy = accountActionPolicy(me, u)
|
||||
return <tr key={u.owner}>
|
||||
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||
<td>{u.name || '—'}</td>
|
||||
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<td>{u.isSuperAdmin ? <IdentityChip account={u} /> : u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||
<td>{me?.isAdmin && <span className="ad-row">
|
||||
<button className="ad-mini" disabled={busy} onClick={() => resetPassword(u)}>重置临时密码</button>
|
||||
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
|
||||
{policy.resetPassword ? <>
|
||||
<button className="ad-mini" disabled={busy} onClick={() => resetPassword(u)}>重置临时密码</button>
|
||||
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
|
||||
</> : <span className="ad-dim">{policy.protectedReason}</span>}
|
||||
<AdminAppointmentButton me={me} account={u} busy={busy} onChange={isAdmin => setAdmin(u, isAdmin)} />
|
||||
</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
})}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+27
-1
@@ -150,6 +150,7 @@ export interface ActivityCard {
|
||||
id: ID; projectId: ID; conversationId?: ID;
|
||||
sourceRunId?: ID; anchorMessageId?: ID;
|
||||
kind: CardKind; title: string; status: CardStatus; tone?: string;
|
||||
task?: TaskSnapshot;
|
||||
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
|
||||
pinned?: boolean; createdAt: string; updatedAt: string; work?: WorkCard;
|
||||
}
|
||||
@@ -351,7 +352,7 @@ export interface PluginPageDecl {
|
||||
|
||||
export interface ParsedDocument {
|
||||
meta: {
|
||||
filename: string; mime?: string; parser: string; confidence?: number;
|
||||
filename: string; mime?: string; parser: string; confidence?: number; origin?: DocumentOrigin;
|
||||
crossCheck?: { with: string; diffBlocks: number; verdict: 'agree' | 'differ' };
|
||||
parsedAt: string;
|
||||
};
|
||||
@@ -414,3 +415,28 @@ export interface SurfaceMeta {
|
||||
conversationId?: ID;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Persistent work projection; host-owned identities and revisions.
|
||||
export interface DocumentOrigin { artifactId: ID; contentHash: string; parserRevision: string }
|
||||
export interface TaskPhase { id: string; label: string; observedAt?: string }
|
||||
export interface TaskSourceRef { id: string; artifactId: ID; contentHash: string; parserRevision: string; blockIndex: number; label: string; quote?: string; availability?: string }
|
||||
export interface TaskResultRef { artifactId?: ID; surfaceId?: ID; version?: string; contentHash?: string; availability: string; name?: string }
|
||||
export interface TaskFinding { id: string; text: string; impact?: string; kind: string; status: string; revision: number; sources: TaskSourceRef[]; assumptions?: string; corrects?: string }
|
||||
export interface TaskSnapshot {
|
||||
inputs?: TaskSourceRef[]
|
||||
limitations?: string[]
|
||||
schemaVersion: number; taskId: ID; attemptId: ID; cardId: ID; projectId: ID; conversationId: ID;
|
||||
sourceMessageId?: ID; parentRunId?: ID; runId?: ID; ownerInstanceId: string; executionEpoch?: number;
|
||||
revision: number; sourceSeq: number; streamScope: string; inputRevision?: string; title: string; goal?: string;
|
||||
executionState: 'queued'|'running'|'awaiting_user'|'completed'|'failed'|'interrupted'; outcomeCompleteness: 'none'|'partial'|'complete'; deliveryState: 'pending'|'available'|'reported'; phase: TaskPhase;
|
||||
focus?: string; coverage?: string; remaining?: string; nextStep?: string; conclusion?: string;
|
||||
findings?: TaskFinding[]; actions?: UIAction[]; resultRefs?: TaskResultRef[]; lastActivityAt?: string; lastMeaningfulUpdateAt?: string;
|
||||
checkpointKeys?: Record<string,string>; checkpointCount?: number; checkpointSeq?: number;
|
||||
}
|
||||
export interface TaskSourceQuote { sourceId: string; blockIndex: number; quote: string }
|
||||
interface WorkEvidencePremise { role: "total" | "phase" | "dependent" | "direct_relation"; sourceIndex: number; value?: number }
|
||||
interface WorkFindingEvidence { mode: "direct_relation" | "difference" | "unresolved_relation"; premises: WorkEvidencePremise[]; result?: number }
|
||||
export interface WorkFindingCandidate { findingKey: string; text: string; impact?: string; kind: string; assumptions?: string; corrects?: string; sources: TaskSourceQuote[]; evidence?: WorkFindingEvidence }
|
||||
export interface WorkCheckpointCandidate { checkpointId: string; phaseId: string; focus?: string; remaining?: string; nextStep?: string; findings: WorkFindingCandidate[] }
|
||||
|
||||
export interface UIAction { id: string; label: string; style?: string; command?: string }
|
||||
|
||||
+2
-1
@@ -3,13 +3,14 @@
|
||||
export interface PreviewTab {
|
||||
id: string
|
||||
name: string
|
||||
sourceContext?: {notice:string;quote:string;expectedHash?:string}
|
||||
}
|
||||
|
||||
export function openPreviewTab(tabs: PreviewTab[], tab: PreviewTab): { tabs: PreviewTab[]; activeId: string } {
|
||||
const i = tabs.findIndex(t => t.id === tab.id)
|
||||
if (i >= 0) {
|
||||
const next = tabs.slice()
|
||||
next[i] = { ...next[i], name: tab.name || next[i].name }
|
||||
next[i] = { ...next[i], name: tab.name || next[i].name,sourceContext:tab.sourceContext }
|
||||
return { tabs: next, activeId: tab.id }
|
||||
}
|
||||
return { tabs: [...tabs, tab], activeId: tab.id }
|
||||
|
||||
+11
-65
@@ -1,70 +1,16 @@
|
||||
export type RolePermissionValue = { allowed: boolean; capAllowed: boolean }
|
||||
import type { RoleRecord } from './api'
|
||||
|
||||
type RolePermissionChange = { actionId: string; allowed: boolean; capAllowed: boolean }
|
||||
|
||||
export interface RolePermissionDraft {
|
||||
baseline: Record<string, RolePermissionValue>
|
||||
values: Record<string, RolePermissionValue>
|
||||
generation: number
|
||||
const PROJECT_ACCESS_ACTION = 'project.create'
|
||||
|
||||
/** 角色唯一可编辑能力「项目访问」:project.create 行需默认与上限同时允许,缺行即无访问。 */
|
||||
export function projectAccessOf(role: RoleRecord): boolean {
|
||||
const row = role.permissions.find(item => item.actionId === PROJECT_ACCESS_ACTION)
|
||||
return !!row && row.allowed && row.capAllowed
|
||||
}
|
||||
|
||||
function copy(values: Record<string, RolePermissionValue>): Record<string, RolePermissionValue> {
|
||||
return Object.fromEntries(Object.entries(values).map(([actionId, value]) => [actionId, { ...value }]))
|
||||
}
|
||||
|
||||
export function createRolePermissionDraft(values: Record<string, RolePermissionValue>, generation = 0): RolePermissionDraft {
|
||||
return { baseline: copy(values), values: copy(values), generation }
|
||||
}
|
||||
|
||||
export function updateRolePermission(
|
||||
draft: RolePermissionDraft,
|
||||
actionId: string,
|
||||
value: RolePermissionValue,
|
||||
): RolePermissionDraft {
|
||||
return { ...draft, values: { ...draft.values, [actionId]: { ...value } }, generation: draft.generation + 1 }
|
||||
}
|
||||
|
||||
export function mergeRoleDraft(
|
||||
current: RolePermissionDraft,
|
||||
serverValues: Record<string, RolePermissionValue>,
|
||||
responseGeneration: number,
|
||||
): RolePermissionDraft {
|
||||
if (responseGeneration < current.generation) return current
|
||||
return createRolePermissionDraft(serverValues, responseGeneration)
|
||||
}
|
||||
|
||||
// Rebase local edits on a newer server baseline after an optimistic conflict.
|
||||
// Only fields changed from the old baseline are replayed.
|
||||
export function rebaseRoleDraft(
|
||||
current: RolePermissionDraft,
|
||||
serverValues: Record<string, RolePermissionValue>,
|
||||
): RolePermissionDraft {
|
||||
const values = copy(serverValues)
|
||||
for (const [actionId, local] of Object.entries(current.values)) {
|
||||
const old = current.baseline[actionId]
|
||||
if (!old || old.allowed !== local.allowed || old.capAllowed !== local.capAllowed) values[actionId] = { ...local }
|
||||
}
|
||||
return { baseline: copy(serverValues), values, generation: current.generation + 1 }
|
||||
}
|
||||
|
||||
export function visiblePatch(draft: RolePermissionDraft, visibleActionIds: string[]): RolePermissionChange[] {
|
||||
const seen = new Set<string>()
|
||||
const changes: RolePermissionChange[] = []
|
||||
for (const actionId of visibleActionIds) {
|
||||
if (seen.has(actionId)) continue
|
||||
seen.add(actionId)
|
||||
const value = draft.values[actionId]
|
||||
if (!value) continue
|
||||
const baseline = draft.baseline[actionId]
|
||||
if (baseline && baseline.allowed === value.allowed && baseline.capAllowed === value.capAllowed) continue
|
||||
changes.push({ actionId, ...value })
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
export function validateRolePermissionDraft(draft: RolePermissionDraft): { actionId: string; reason: string } | null {
|
||||
for (const actionId of Object.keys(draft.values).sort()) {
|
||||
const value = draft.values[actionId]
|
||||
if (value.allowed && !value.capAllowed) return { actionId, reason: 'default-exceeds-cap' }
|
||||
}
|
||||
return null
|
||||
export function projectAccessPatch(role: RoleRecord, next: boolean): RolePermissionChange[] {
|
||||
if (projectAccessOf(role) === next) return []
|
||||
return [{ actionId: PROJECT_ACCESS_ACTION, allowed: next, capAllowed: next }]
|
||||
}
|
||||
|
||||
+75
-11
@@ -23,6 +23,7 @@ export function isFontSizeChoice(value: number): value is FontSizeChoice {
|
||||
}
|
||||
import type {
|
||||
ActivityCard,
|
||||
TaskSourceRef,
|
||||
AgentProfile,
|
||||
Artifact,
|
||||
Conversation,
|
||||
@@ -90,7 +91,7 @@ export interface UIState {
|
||||
projectSettingsId: string | null
|
||||
}
|
||||
|
||||
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[] }
|
||||
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[] }
|
||||
|
||||
interface AppState {
|
||||
contextObs: Record<string, ContextObs>
|
||||
@@ -232,7 +233,8 @@ function syncFilesEntry(hasTabs: boolean) {
|
||||
|
||||
function setUI(patch: Partial<UIState>) {
|
||||
const ui = { ...state.ui, ...patch }
|
||||
state = { ...state, ui }
|
||||
const navigation=(['rightTab','filesView','rightCollapsed','activeWorkspace'] as const).some(key=>key in patch&&patch[key]!==state.ui[key])
|
||||
state = { ...state, ui,previewOpenIntent:state.previewOpenIntent+(navigation?1:0) }
|
||||
try { localStorage.setItem('as.ui', JSON.stringify(ui)) } catch { /* ignore */ }
|
||||
applyTheme(ui.theme)
|
||||
applyFontSize(ui.fontSize)
|
||||
@@ -298,6 +300,9 @@ let projectsRequest = 0
|
||||
let tasksRequest = 0
|
||||
let meRequest = 0
|
||||
let streamSeq = 0
|
||||
let streamHasWatermark = false
|
||||
let gapReconnectPending = false
|
||||
let streamPending = new Map<number, StreamEvent>()
|
||||
let detailRequest = 0
|
||||
let detailSeq = 0
|
||||
let detailVersion = 0
|
||||
@@ -353,12 +358,24 @@ function scheduleTerminalReconcile() {
|
||||
|
||||
// Snapshot queries may overlap events. Only rows changed after the request began
|
||||
// override that snapshot; a newer request/context invalidates the old query entirely.
|
||||
export function mergeTaskSnapshot<T extends {taskId:string;attemptId:string;revision:number}>(current:T,incoming:T):T {
|
||||
if(current.taskId!==incoming.taskId||current.attemptId!==incoming.attemptId||!Number.isSafeInteger(incoming.revision)||incoming.revision<=current.revision)return current
|
||||
return incoming
|
||||
}
|
||||
function mergeTaskCard(current:ActivityCard|undefined,incoming:ActivityCard):ActivityCard {
|
||||
if(!current?.task||!incoming.task||current.task.schemaVersion!==1||incoming.task.schemaVersion!==1)return incoming
|
||||
const a=current.task,b=incoming.task
|
||||
if(a.taskId!==b.taskId||a.attemptId!==b.attemptId||a.projectId!==b.projectId||a.conversationId!==b.conversationId||a.ownerInstanceId!==b.ownerInstanceId||(a.executionEpoch??0)>(b.executionEpoch??0)||a.revision>b.revision)return current
|
||||
if(['completed','failed','interrupted'].includes(a.executionState)&&!['completed','failed','interrupted'].includes(b.executionState))return current
|
||||
if(a.revision!==b.revision&&mergeTaskSnapshot(a,b)===a)return current
|
||||
return incoming
|
||||
}
|
||||
function mergeCardSnapshot(snapshot: ActivityCard[], current: ActivityCard[], versions: Record<string, number>, started: number,
|
||||
patches: Record<string, Pick<ActivityCard, 'status' | 'tone'>>) {
|
||||
const fresh = current.filter(c => (versions[c.id] ?? 0) > started)
|
||||
const freshIds = new Set(fresh.map(c => c.id))
|
||||
return [...fresh, ...snapshot.filter(c => !freshIds.has(c.id)).map(c =>
|
||||
(versions[c.id] ?? 0) > started && patches[c.id] ? { ...c, ...patches[c.id], tone: patches[c.id].tone ?? c.tone } : c)]
|
||||
const combined=fresh.map(c=>{const row=snapshot.find(r=>r.id===c.id);return row?.task&&c.task&&row.task.revision>c.task.revision?mergeTaskCard(c,row):c})
|
||||
return [...combined, ...snapshot.filter(c => !freshIds.has(c.id)).map(row => {const c=mergeTaskCard(current.find(old=>old.id===row.id),row);return (versions[c.id] ?? 0) > started && patches[c.id] ? { ...c, ...patches[c.id], tone: patches[c.id].tone ?? c.tone } : c})]
|
||||
}
|
||||
|
||||
function hitlSource(card: ActivityCard): string | undefined {
|
||||
@@ -530,7 +547,7 @@ export const store = {
|
||||
projectSeq = 0
|
||||
projectCardsRequest++
|
||||
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
|
||||
conversationEpoch++; streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||
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: [],
|
||||
@@ -621,7 +638,7 @@ export const store = {
|
||||
async openConversation(convId: string) {
|
||||
if (sse) { sse.close(); sse = null }
|
||||
const epoch = ++conversationEpoch
|
||||
streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||
streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], workRecord: null, workReference: null, surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||||
setUI({ previewExpanded: false })
|
||||
@@ -719,15 +736,21 @@ export const store = {
|
||||
streamSeq = Math.max(streamSeq, since ?? state.lastSeq)
|
||||
sse?.close()
|
||||
sse = connectEvents(convId, since ?? state.lastSeq, {
|
||||
onOpen: () => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ connected: true }) },
|
||||
onOpen: () => { if (state.currentConvId === convId && conversationEpoch === epoch) {set({ connected: true });void store.refreshCards(convId)} },
|
||||
onError: () => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ connected: false }) },
|
||||
onEvent: e => { if (state.currentConvId === convId && conversationEpoch === epoch) store.applyEvent(e) },
|
||||
})
|
||||
},
|
||||
|
||||
applyEvent(e: StreamEvent) {
|
||||
if (!state.currentConvId || e.conversationId !== state.currentConvId || !Number.isFinite(e.seq) || e.seq <= streamSeq) return
|
||||
if (!state.currentConvId || e.conversationId !== state.currentConvId || !Number.isSafeInteger(e.seq) || e.seq <= streamSeq) return
|
||||
if(streamHasWatermark&&e.seq>streamSeq+1){
|
||||
if(streamPending.size<1000)streamPending.set(e.seq,e)
|
||||
if(!gapReconnectPending){gapReconnectPending=true;const conv=state.currentConvId,epoch=conversationEpoch;queueMicrotask(()=>{gapReconnectPending=false;if(conv===state.currentConvId&&epoch===conversationEpoch)store.connectLive(streamSeq)})}
|
||||
return
|
||||
}
|
||||
streamSeq = e.seq
|
||||
if(streamPending.has(e.seq+1)){const pending=streamPending.get(e.seq+1)!;streamPending.delete(e.seq+1);queueMicrotask(()=>store.applyEvent(pending))}
|
||||
const previousSeq = state.lastSeq
|
||||
// Running-turn replay reconstructs transient output, but cannot regress the
|
||||
// persistent card/plan/status snapshot already returned by the detail query.
|
||||
@@ -878,6 +901,7 @@ export const store = {
|
||||
}
|
||||
case 'artifact.created':
|
||||
case 'artifact.updated': {
|
||||
if(e.type==='artifact.updated'&&state.currentConvId)void store.refreshCards(state.currentConvId)
|
||||
const p = e.payload as { artifact?: { id: string; name: string; kind?: string } }
|
||||
if (p?.artifact) {
|
||||
const ref = { artifactId: p.artifact.id, name: p.artifact.name, kind: p.artifact.kind }
|
||||
@@ -1074,6 +1098,7 @@ export const store = {
|
||||
if (card.conversationId && card.conversationId !== state.currentConvId) return
|
||||
cardVersions[card.id] = ++cardVersion
|
||||
delete cardPatches[card.id]
|
||||
card=mergeTaskCard(state.cards.find(c=>c.id===card.id),card)
|
||||
const i = state.cards.findIndex(c => c.id === card.id)
|
||||
const cards = i >= 0 ? state.cards.map(c => c.id === card.id ? card : c) : [card, ...state.cards]
|
||||
set({ cards })
|
||||
@@ -1085,8 +1110,10 @@ export const store = {
|
||||
const request = ++cardsRequest
|
||||
const started = cardVersion
|
||||
try {
|
||||
const snapshot = await api.listCards(convId)
|
||||
const out = await api.listCardsSnapshot(convId)
|
||||
const snapshot=out.cards??[]
|
||||
if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== cardsRequest) return
|
||||
if(Number.isSafeInteger(out.replaySeq)&&out.replaySeq!>=0)streamHasWatermark=true
|
||||
const cards = mergeCardSnapshot(snapshot, state.cards, cardVersions, started, cardPatches)
|
||||
set({ cards })
|
||||
settleCardFeedback(cards)
|
||||
@@ -1288,7 +1315,8 @@ export const store = {
|
||||
applyProjectEvent(e: StreamEvent) {
|
||||
if (!Number.isFinite(e.seq) || e.seq <= projectSeq) return
|
||||
projectSeq = e.seq
|
||||
const upsert = (card: ActivityCard) => {
|
||||
const upsert = (incoming: ActivityCard) => {
|
||||
const card=mergeTaskCard(state.projectCards.find(c=>c.id===incoming.id),incoming)
|
||||
if (card.projectId !== state.currentProjectId) return
|
||||
projectCardVersions[card.id] = ++projectCardVersion
|
||||
const list = store.getState().projectCards
|
||||
@@ -1401,6 +1429,41 @@ export const store = {
|
||||
* 工件预览唯一入口:在第三栏「文件」入口打开或激活标签(多文件多标签)。
|
||||
* 打开第一张自动切到预览子视图;关掉最后一张回到文件列表,并收回展开态。
|
||||
*/
|
||||
async openTaskSource(cardId: string, source: TaskSourceRef) {
|
||||
const conversationId = state.currentConvId
|
||||
const projectId = state.currentProjectId
|
||||
const epoch = conversationEpoch
|
||||
const intent = state.previewOpenIntent + 1
|
||||
set({ previewOpenIntent: intent })
|
||||
const isCurrent = () => conversationId === state.currentConvId && projectId === state.currentProjectId
|
||||
&& epoch === conversationEpoch && intent === state.previewOpenIntent
|
||||
try {
|
||||
const out = await api.taskSource(cardId, source.id)
|
||||
if (!isCurrent()) return
|
||||
if (!out.artifact) {
|
||||
store.toast('来源当前不可用,正在核实工作记录')
|
||||
if (conversationId) void store.refreshCards(conversationId)
|
||||
return
|
||||
}
|
||||
store.requestTaskSourcePreview(out.artifact, {
|
||||
notice: out.versionState === 'changed'
|
||||
? '当前文件已更新;下方引用来自任务处理时的版本,不能作为当前文件的结论'
|
||||
: '任务处理时引用的来源版本;下方是本项发现引用的片段',
|
||||
quote: out.source.quote ?? '',
|
||||
expectedHash: out.versionState === 'current' ? out.source.contentHash : undefined,
|
||||
})
|
||||
if (out.versionState === 'changed' && conversationId) void store.refreshCards(conversationId)
|
||||
} catch {
|
||||
if (!isCurrent()) return
|
||||
store.toast('来源当前无法读取,正在核实工作记录')
|
||||
if (conversationId) void store.refreshCards(conversationId)
|
||||
}
|
||||
},
|
||||
requestTaskSourcePreview(a:Artifact,sourceContext:{notice:string;quote:string;expectedHash?:string}){
|
||||
store.requestPreview(a)
|
||||
const conv=previewTabsKey(state.currentConvId)
|
||||
set({previewTabs:{...state.previewTabs,[conv]:(state.previewTabs[conv]??[]).map(tab=>tab.id===a.id?{...tab,sourceContext}:tab)}})
|
||||
},
|
||||
requestPreview(a: Artifact) {
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name })
|
||||
@@ -1452,7 +1515,7 @@ export const store = {
|
||||
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
|
||||
activatePreview(id: string) {
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
set({ previewActive: { ...state.previewActive, [conv]: id } })
|
||||
set({ previewActive: { ...state.previewActive, [conv]: id },previewOpenIntent:state.previewOpenIntent+1 })
|
||||
},
|
||||
closePreview(id: string) {
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
@@ -1460,6 +1523,7 @@ export const store = {
|
||||
set({
|
||||
previewTabs: { ...state.previewTabs, [conv]: next.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: next.activeId },
|
||||
previewOpenIntent:state.previewOpenIntent+1,
|
||||
})
|
||||
if (next.tabs.length === 0) setUI({ previewExpanded: false })
|
||||
syncFilesEntry(next.tabs.length > 0)
|
||||
|
||||
+4
-2
@@ -527,11 +527,12 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.rp-role-list .az-nav-item { justify-content: space-between; width: 100%; }
|
||||
.rp-editor { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.rp-matrix-wrap { overflow-x: auto; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
|
||||
.rp-matrix { min-width: 760px; }
|
||||
.rp-matrix { min-width: 420px; }
|
||||
.rp-matrix th:first-child, .rp-matrix td:first-child { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
|
||||
.rp-matrix tr.rp-selected td { background: var(--bg-active); }
|
||||
.rp-matrix tr.rp-selected td:first-child { background: var(--bg-active); }
|
||||
.rp-diff { margin: 0; padding-left: 20px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||
.rp-access { display: flex; flex-direction: column; gap: 4px; }
|
||||
.rp-save { justify-content: space-between; flex-wrap: wrap; }
|
||||
.rp-grants { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; margin-bottom: 12px; }
|
||||
.rp-grants .ad-check { align-items: flex-start; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-raised); }
|
||||
.rp-grants .ad-mono { margin-left: auto; }
|
||||
@@ -543,6 +544,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.ad-dim { color: var(--text-dim); }
|
||||
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
|
||||
.ad-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
|
||||
.ad-chip-super { background: var(--accent); color: var(--text-on-accent); }
|
||||
.ad-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
|
||||
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
|
||||
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
|
||||
|
||||
@@ -1,36 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PermissionDescriptor, RoleRecord } from '../src/api'
|
||||
// 角色面板(计划 U1 / R13):角色只编辑单一「项目访问」布尔;可编辑性只看 system.roles.manage,
|
||||
// 可读性只看 system.roles.view,isAdmin 不旁路。服务端授权仍为权威,这里锁定门禁、呈现与请求体。
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { api, type RoleRecord } from '../src/api'
|
||||
import {
|
||||
filterRoles, groupPermissionPage, hasSystemAction, shouldAcceptRoleResponse,
|
||||
PROJECT_ACCESS_MANAGE_REASON, ProjectAccessCheckbox, filterRoles, hasSystemAction, rolePanelAccess, saveProjectAccess,
|
||||
shouldAcceptRoleResponse,
|
||||
} from '../src/components/RolePermissionsPanel'
|
||||
import { projectAccessPatch } from '../src/rolePermissions'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const roles: RoleRecord[] = [
|
||||
{ roleId: 'role_engineer', name: '软件工程师', description: '研发', enabled: true, roleVersion: 1, bindingCount: 2, permissions: [] },
|
||||
{ roleId: 'role_reviewer', name: '审核工程师', description: '质量', enabled: false, roleVersion: 1, bindingCount: 1, permissions: [] },
|
||||
]
|
||||
const withAccess = (role: RoleRecord, access: boolean, roleVersion = role.roleVersion): RoleRecord => ({
|
||||
...role, roleVersion, permissions: [{ actionId: 'project.create', allowed: access, capAllowed: access, permissionVersion: roleVersion }],
|
||||
})
|
||||
const noop = () => undefined
|
||||
|
||||
const catalog: PermissionDescriptor[] = [
|
||||
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', scope: 'system', protected: false },
|
||||
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', scope: 'project', protected: false },
|
||||
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', scope: 'project', protected: false },
|
||||
]
|
||||
|
||||
describe('role permission UI helpers', () => {
|
||||
describe('role panel helpers', () => {
|
||||
it('combines exact or fuzzy search with the role status filter', () => {
|
||||
expect(filterRoles(roles, '软件工程师', 'exact', 'enabled').map(role => role.roleId)).toEqual(['role_engineer'])
|
||||
expect(filterRoles(roles, '工程师', 'fuzzy', 'all').map(role => role.roleId)).toEqual(['role_engineer', 'role_reviewer'])
|
||||
expect(filterRoles(roles, '', 'fuzzy', 'disabled').map(role => role.roleId)).toEqual(['role_reviewer'])
|
||||
})
|
||||
|
||||
it('groups and pages matrix columns without changing their stable action IDs', () => {
|
||||
expect(groupPermissionPage(catalog, '文档动作', 0, 1).map(item => item.actionId)).toEqual(['document.read'])
|
||||
expect(groupPermissionPage(catalog, '文档动作', 1, 1).map(item => item.actionId)).toEqual(['document.author'])
|
||||
})
|
||||
|
||||
it('drops late results and checks explicit system actions', () => {
|
||||
expect(shouldAcceptRoleResponse(4, 3)).toBe(false)
|
||||
expect(shouldAcceptRoleResponse(4, 4)).toBe(true)
|
||||
expect(hasSystemAction(['system.roles.view'], 'system.roles.view')).toBe(true)
|
||||
expect(hasSystemAction(['system.roles.view'], 'system.permissions.manage')).toBe(false)
|
||||
expect(hasSystemAction(['system.roles.view'], 'system.roles.manage')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('R13 门禁', () => {
|
||||
it('可编辑性只看 system.roles.manage,与权限目录系统授权无关', () => {
|
||||
expect(rolePanelAccess({ systemActions: ['system.roles.view', 'system.roles.manage'] })).toEqual({ canView: true, canManage: true })
|
||||
expect(rolePanelAccess({ systemActions: ['system.roles.view', 'system.permissions.view', 'system.permissions.manage'] }))
|
||||
.toEqual({ canView: true, canManage: false })
|
||||
expect(rolePanelAccess({ systemActions: ['system.roles.view'] })).toEqual({ canView: true, canManage: false })
|
||||
})
|
||||
|
||||
it('isAdmin 不再默认可读可写;缺 me 时全部关闭', () => {
|
||||
const admin = { isAdmin: true, systemActions: [] as string[] }
|
||||
expect(rolePanelAccess(admin)).toEqual({ canView: false, canManage: false })
|
||||
expect(rolePanelAccess(null)).toEqual({ canView: false, canManage: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('项目访问复选框', () => {
|
||||
it('可管理时呈现单一可勾选控件,aria-label 带角色名', () => {
|
||||
const html = renderToStaticMarkup(createElement(ProjectAccessCheckbox, { roleName: '软件工程师', checked: true, canManage: true, disabled: false, onChange: noop }))
|
||||
expect(html.match(/type="checkbox"/g)).toHaveLength(1)
|
||||
expect(html).toContain('aria-label="软件工程师 项目访问"')
|
||||
expect(html).toContain('checked=""')
|
||||
expect(html).not.toContain('disabled=""')
|
||||
expect(html).not.toContain(PROJECT_ACCESS_MANAGE_REASON)
|
||||
})
|
||||
|
||||
it('无角色管理权限时禁用并说明原因', () => {
|
||||
const html = renderToStaticMarkup(createElement(ProjectAccessCheckbox, { roleName: '审核工程师', checked: false, canManage: false, disabled: false, onChange: noop }))
|
||||
expect(html).toContain('disabled=""')
|
||||
expect(html).toContain(PROJECT_ACCESS_MANAGE_REASON)
|
||||
const matrixCell = renderToStaticMarkup(createElement(ProjectAccessCheckbox, { roleName: '审核工程师', checked: false, canManage: false, disabled: false, showReason: false, onChange: noop }))
|
||||
expect(matrixCell).toContain('disabled=""')
|
||||
expect(matrixCell).not.toContain(PROJECT_ACCESS_MANAGE_REASON)
|
||||
})
|
||||
|
||||
it('零裸字段:不暴露动作 ID、默认/上限双控件或原始字段名', () => {
|
||||
const html = renderToStaticMarkup(createElement(ProjectAccessCheckbox, { roleName: '软件工程师', checked: false, canManage: false, disabled: true, onChange: noop }))
|
||||
for (const raw of ['project.create', 'actionId', 'allowed', 'capAllowed', '默认', '上限']) expect(html).not.toContain(raw)
|
||||
expect(html).toContain('项目访问(可创建项目)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('保存项目访问', () => {
|
||||
it('只提交单布尔补丁,并携带角色版本与幂等键', async () => {
|
||||
const replace = vi.spyOn(api, 'replaceRolePermissions').mockResolvedValue({ receiptId: 'r1', roleVersion: 2, authorityEpoch: 1 })
|
||||
const result = await saveProjectAccess(roles[0], true, 'key-1')
|
||||
expect(result).toEqual({ kind: 'saved' })
|
||||
expect(replace).toHaveBeenCalledTimes(1)
|
||||
expect(replace).toHaveBeenCalledWith('role_engineer', 1, [{ actionId: 'project.create', allowed: true, capAllowed: true }], 'key-1')
|
||||
})
|
||||
|
||||
it('值不变时不发请求', async () => {
|
||||
const replace = vi.spyOn(api, 'replaceRolePermissions')
|
||||
expect(await saveProjectAccess(withAccess(roles[0], true), true, 'key-2')).toEqual({ kind: 'unchanged' })
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('409 时读取最新版本,并在新基线上重放本地布尔', async () => {
|
||||
const fresh = withAccess(roles[0], false, 5)
|
||||
const replace = vi.spyOn(api, 'replaceRolePermissions')
|
||||
.mockRejectedValueOnce(new Error('409: role version conflict'))
|
||||
.mockResolvedValueOnce({ receiptId: 'r2', roleVersion: 6, authorityEpoch: 2 })
|
||||
const getRole = vi.spyOn(api, 'getRole').mockResolvedValue(fresh)
|
||||
|
||||
const conflict = await saveProjectAccess(roles[0], true, 'key-3')
|
||||
expect(conflict).toEqual({ kind: 'conflict', fresh })
|
||||
expect(getRole).toHaveBeenCalledWith('role_engineer')
|
||||
expect(projectAccessPatch(fresh, true)).toEqual([{ actionId: 'project.create', allowed: true, capAllowed: true }])
|
||||
|
||||
expect(await saveProjectAccess(fresh, true, 'key-4')).toEqual({ kind: 'saved' })
|
||||
expect(replace).toHaveBeenLastCalledWith('role_engineer', 5, [{ actionId: 'project.create', allowed: true, capAllowed: true }], 'key-4')
|
||||
})
|
||||
|
||||
it('409 后最新版本已等于本地值时重放补丁为空', async () => {
|
||||
vi.spyOn(api, 'replaceRolePermissions').mockRejectedValue(new Error('409: role version conflict'))
|
||||
const fresh = withAccess(roles[0], true, 4)
|
||||
vi.spyOn(api, 'getRole').mockResolvedValue(fresh)
|
||||
expect(await saveProjectAccess(roles[0], true, 'key-5')).toEqual({ kind: 'conflict', fresh })
|
||||
expect(projectAccessPatch(fresh, true)).toEqual([])
|
||||
})
|
||||
|
||||
it('非冲突失败原样抛出,不读取最新版本', async () => {
|
||||
vi.spyOn(api, 'replaceRolePermissions').mockRejectedValue(new Error('403: forbidden'))
|
||||
const getRole = vi.spyOn(api, 'getRole')
|
||||
await expect(saveProjectAccess(roles[0], true, 'key-6')).rejects.toThrow('403')
|
||||
expect(getRole).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,50 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createRolePermissionDraft, mergeRoleDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
|
||||
type RolePermissionValue,
|
||||
} from '../src/rolePermissions'
|
||||
import type { RoleRecord, RolePermissionRecord } from '../src/api'
|
||||
import { projectAccessOf, projectAccessPatch } from '../src/rolePermissions'
|
||||
|
||||
const initial: Record<string, RolePermissionValue> = {
|
||||
'project.create': { allowed: false, capAllowed: true },
|
||||
'document.read': { allowed: true, capAllowed: true },
|
||||
'document.author': { allowed: false, capAllowed: true },
|
||||
}
|
||||
const role = (permissions: RolePermissionRecord[]): RoleRecord => ({
|
||||
roleId: 'role_engineer', name: '软件工程师', description: '', enabled: true, roleVersion: 3, bindingCount: 0, permissions,
|
||||
})
|
||||
const row = (allowed: boolean, capAllowed: boolean, actionId = 'project.create'): RolePermissionRecord =>
|
||||
({ actionId, allowed, capAllowed, permissionVersion: 1 })
|
||||
|
||||
describe('role permission draft', () => {
|
||||
it('retains hidden edits and patches only explicit visible actions', () => {
|
||||
let draft = createRolePermissionDraft(initial)
|
||||
draft = updateRolePermission(draft, 'project.create', { allowed: true, capAllowed: true })
|
||||
draft = updateRolePermission(draft, 'document.author', { allowed: true, capAllowed: true })
|
||||
|
||||
expect(visiblePatch(draft, ['project.create'])).toEqual([
|
||||
{ actionId: 'project.create', allowed: true, capAllowed: true },
|
||||
])
|
||||
expect(draft.values['document.author']).toEqual({ allowed: true, capAllowed: true })
|
||||
describe('project access normalization', () => {
|
||||
it('requires both default and cap on the project.create row', () => {
|
||||
expect(projectAccessOf(role([row(true, true)]))).toBe(true)
|
||||
expect(projectAccessOf(role([row(false, true)]))).toBe(false)
|
||||
expect(projectAccessOf(role([row(true, false)]))).toBe(false)
|
||||
})
|
||||
|
||||
it('produces the same patch for single-role and matrix edits', () => {
|
||||
const single = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: false, capAllowed: true })
|
||||
const matrix = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: false, capAllowed: true })
|
||||
expect(visiblePatch(single, Object.keys(initial))).toEqual(visiblePatch(matrix, Object.keys(initial)))
|
||||
it('treats a missing project.create row as no access', () => {
|
||||
expect(projectAccessOf(role([]))).toBe(false)
|
||||
expect(projectAccessOf(role([row(true, true, 'document.read')]))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('discards a stale server refresh without replacing newer edits', () => {
|
||||
const edited = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true })
|
||||
const merged = mergeRoleDraft(edited, initial, edited.generation - 1)
|
||||
expect(merged).toBe(edited)
|
||||
describe('project access patch', () => {
|
||||
it('emits a single project.create change with default and cap moving together', () => {
|
||||
expect(projectAccessPatch(role([]), true)).toEqual([{ actionId: 'project.create', allowed: true, capAllowed: true }])
|
||||
expect(projectAccessPatch(role([row(true, true)]), false)).toEqual([{ actionId: 'project.create', allowed: false, capAllowed: false }])
|
||||
})
|
||||
|
||||
it('rejects defaults that exceed the role cap', () => {
|
||||
const invalid = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: true, capAllowed: false })
|
||||
expect(validateRolePermissionDraft(invalid)).toEqual({ actionId: 'document.read', reason: 'default-exceeds-cap' })
|
||||
})
|
||||
|
||||
it('rebases local changes on a newer role version without losing the draft', () => {
|
||||
const edited = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true })
|
||||
const server = { ...initial, 'document.read': { allowed: false, capAllowed: true } }
|
||||
const rebased = rebaseRoleDraft(edited, server)
|
||||
expect(rebased.baseline['document.read']).toEqual({ allowed: false, capAllowed: true })
|
||||
expect(rebased.values['project.create']).toEqual({ allowed: true, capAllowed: true })
|
||||
expect(rebased.values['document.read']).toEqual({ allowed: false, capAllowed: true })
|
||||
it('returns an empty patch when the effective value is unchanged', () => {
|
||||
expect(projectAccessPatch(role([row(true, true)]), true)).toEqual([])
|
||||
expect(projectAccessPatch(role([row(false, true)]), false)).toEqual([])
|
||||
expect(projectAccessPatch(role([]), false)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// 超级管理员 UI 门控(计划 2026-10-19-0113 R3/R4/R8):身份标签可见;建号为管理员与
|
||||
// 任命/撤销入口只对超级管理员出现;超级管理员不可降级、停用或被普通管理员维护。
|
||||
// 服务端授权仍为权威,这里只锁定前端呈现与请求体。
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { api } from '../src/api'
|
||||
import { auth } from '../src/auth'
|
||||
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../src/accountAuthority'
|
||||
import { AdminAppointmentButton, CreateAdminOption, IdentityChip } from '../src/components/AccountUsersPanel'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const superAdmin = { isAdmin: true, isSuperAdmin: true }
|
||||
const admin = { isAdmin: true, isSuperAdmin: false }
|
||||
const user = { isAdmin: false, isSuperAdmin: false }
|
||||
const noop = () => undefined
|
||||
|
||||
describe('身份标签', () => {
|
||||
it('超级管理员、管理员与普通用户各有可辨识标签', () => {
|
||||
expect(accountIdentityKind(superAdmin)).toBe('super')
|
||||
expect(accountIdentityKind(admin)).toBe('admin')
|
||||
expect(accountIdentityKind(user)).toBe('user')
|
||||
expect(renderToStaticMarkup(createElement(IdentityChip, { account: superAdmin }))).toContain('超级管理员')
|
||||
expect(renderToStaticMarkup(createElement(IdentityChip, { account: admin }))).toContain('>管理员<')
|
||||
expect(renderToStaticMarkup(createElement(IdentityChip, { account: user }))).toContain('普通用户')
|
||||
})
|
||||
it('isAdmin 不推导超级管理员身份', () => {
|
||||
expect(accountIdentityKind({ isAdmin: true })).toBe('admin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('管理员任命入口只对超级管理员开放', () => {
|
||||
it('建号:超级管理员看到「创建为管理员账号」,普通管理员只见普通建号说明', () => {
|
||||
expect(canAppointAdmins(superAdmin)).toBe(true)
|
||||
expect(canAppointAdmins(admin)).toBe(false)
|
||||
expect(canAppointAdmins(null)).toBe(false)
|
||||
const superHtml = renderToStaticMarkup(createElement(CreateAdminOption, { me: superAdmin, checked: false, disabled: false, onChange: noop }))
|
||||
expect(superHtml).toContain('type="checkbox"')
|
||||
expect(superHtml).toContain('创建为管理员账号')
|
||||
const adminHtml = renderToStaticMarkup(createElement(CreateAdminOption, { me: admin, checked: false, disabled: false, onChange: noop }))
|
||||
expect(adminHtml).not.toContain('checkbox')
|
||||
expect(adminHtml).toContain('仅超级管理员可创建或任命管理员')
|
||||
})
|
||||
it('任命/撤销:超级管理员可任命普通用户、撤销管理员;普通管理员看不到任何任命按钮', () => {
|
||||
expect(accountActionPolicy(superAdmin, user).adminChange).toBe('promote')
|
||||
expect(accountActionPolicy(superAdmin, admin).adminChange).toBe('demote')
|
||||
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: user, busy: false, onChange: noop }))).toContain('设为管理员')
|
||||
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: admin, busy: false, onChange: noop }))).toContain('撤销管理员')
|
||||
for (const target of [user, admin, superAdmin]) {
|
||||
expect(accountActionPolicy(admin, target).adminChange).toBeNull()
|
||||
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: admin, account: target, busy: false, onChange: noop }))).toBe('')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('超级管理员账号受保护', () => {
|
||||
it('超级管理员本人不可被降级或停用,也不走管理员重置密码', () => {
|
||||
const self = accountActionPolicy(superAdmin, superAdmin)
|
||||
expect(self).toMatchObject({ adminChange: null, toggleEnabled: false, resetPassword: false, maintain: true })
|
||||
expect(self.protectedReason).toContain('不能停用、降级')
|
||||
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: superAdmin, busy: false, onChange: noop }))).toBe('')
|
||||
})
|
||||
it('普通管理员不能编辑、停用、降级或重置超级管理员,并获得明确说明', () => {
|
||||
const policy = accountActionPolicy(admin, superAdmin)
|
||||
expect(policy).toMatchObject({ maintain: false, toggleEnabled: false, resetPassword: false, adminChange: null })
|
||||
expect(policy.protectedReason).toContain('超级管理员账号受保护')
|
||||
})
|
||||
it('普通账号的既有维护能力不受影响', () => {
|
||||
expect(accountActionPolicy(admin, user)).toMatchObject({ maintain: true, toggleEnabled: true, resetPassword: true, protectedReason: '' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('账号管理请求体', () => {
|
||||
const stub = (body: unknown) => vi.spyOn(auth, 'fetch').mockImplementation(async () => new Response(JSON.stringify(body)))
|
||||
|
||||
it('adminSetUserAdmin 以乐观版本 PUT /api/admin/users/:userId/admin', async () => {
|
||||
const request = stub({ userId: 'u 1' })
|
||||
await api.adminSetUserAdmin('u 1', 7, true)
|
||||
const [url, options] = request.mock.calls[0]
|
||||
expect(url).toBe('/api/admin/users/u%201/admin')
|
||||
expect(options?.method).toBe('PUT')
|
||||
expect(JSON.parse(String(options?.body))).toEqual({ expectedStateVersion: 7, isAdmin: true })
|
||||
})
|
||||
it('建号只在请求管理员身份时携带 isAdmin', async () => {
|
||||
const request = stub({ userId: 'u1', owner: 'alice' })
|
||||
await api.createManagedUser('alice', { name: 'A' })
|
||||
await api.createManagedUser('bob', {}, true)
|
||||
await api.createAdminUser('carol', 'C')
|
||||
await api.createAdminUser('dave', 'D', undefined, true)
|
||||
const bodies = request.mock.calls.map(call => JSON.parse(String(call[1]?.body)))
|
||||
expect(bodies[0]).toEqual({ owner: 'alice', profile: { name: 'A' } })
|
||||
expect(bodies[1]).toEqual({ owner: 'bob', profile: {}, isAdmin: true })
|
||||
expect(bodies[2]).toEqual({ owner: 'carol', profile: { name: 'C' } })
|
||||
expect(bodies[3]).toEqual({ owner: 'dave', profile: { name: 'D' }, isAdmin: true })
|
||||
})
|
||||
})
|
||||
+60
-1
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { store } from '../src/store'
|
||||
import { store, mergeTaskSnapshot } from '../src/store'
|
||||
import type { ActivityCard, Conversation, Message, Run, StreamEvent, SurfaceSnapshot } from '../src/contracts'
|
||||
|
||||
function deferred<T>() {
|
||||
@@ -32,6 +32,7 @@ beforeEach(async () => {
|
||||
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'listCards').mockResolvedValue([])
|
||||
vi.spyOn(api,'listCardsSnapshot').mockImplementation(async id=>({cards:await api.listCards(id)}))
|
||||
vi.spyOn(api, 'listTasks').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listArtifacts').mockResolvedValue([])
|
||||
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
|
||||
@@ -518,3 +519,61 @@ describe('shared action feedback', () => {
|
||||
expect(store.getState().cards).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a newer task result when an older projection arrives in a later stream event',()=>{
|
||||
const task=(revision:number,executionState:string)=>({...card(),task:{schemaVersion:1,taskId:'t',attemptId:'a1',cardId:'task',projectId:'p',conversationId:'a',ownerInstanceId:'i',executionEpoch:1,revision,sourceSeq:revision,streamScope:'conversation:a',title:'整理',executionState,outcomeCompleteness:'partial',deliveryState:'available',phase:{id:'reading',label:'读取材料'}}}) as ActivityCard
|
||||
store.applyEvent(event(1,'card.created',{card:task(9,'completed')}))
|
||||
store.applyEvent(event(2,'card.updated',{card:task(8,'running')}))
|
||||
expect(store.getState().cards[0].task?.revision).toBe(9)
|
||||
expect(store.getState().cards[0].task?.executionState).toBe('completed')
|
||||
})
|
||||
it('does not lose an event behind a detected sequence gap',async()=>{
|
||||
vi.mocked(api.listCardsSnapshot).mockResolvedValue({cards:[],replaySeq:0})
|
||||
await store.refreshCards('a')
|
||||
store.applyEvent(event(2,'card.updated',{card:card('later')}))
|
||||
expect(store.getState().lastSeq).toBe(0)
|
||||
store.applyEvent(event(1,'card.created',{card:card('earlier')}))
|
||||
await Promise.resolve()
|
||||
expect(store.getState().cards.map(c=>c.id)).toEqual(['later','earlier'])
|
||||
expect(store.getState().lastSeq).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects old revisions and foreign attempts in the task merge contract',()=>{
|
||||
expect(mergeTaskSnapshot({taskId:'t',attemptId:'a',revision:9},{taskId:'t',attemptId:'a',revision:8}).revision).toBe(9)
|
||||
expect(mergeTaskSnapshot({taskId:'t',attemptId:'a',revision:9},{taskId:'t',attemptId:'b',revision:10}).attemptId).toBe('a')
|
||||
})
|
||||
|
||||
it('keeps the newer explicit preview when a source response arrives late',async()=>{
|
||||
const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'}
|
||||
const pending=deferred<Awaited<ReturnType<typeof api.taskSource>>>()
|
||||
vi.spyOn(api,'taskSource').mockReturnValue(pending.promise)
|
||||
const {docBridge}=await import('../src/components/Workbench')
|
||||
const opening=docBridge.onOpenTaskSource!('card',source)
|
||||
store.requestPreview({id:'b',name:'B',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0})
|
||||
pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'})
|
||||
await opening
|
||||
expect(store.getState().previewActive.a).toBe('b')
|
||||
})
|
||||
|
||||
it('rejects a source response after leaving and returning to the same conversation',async()=>{
|
||||
const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'}
|
||||
const pending=deferred<Awaited<ReturnType<typeof api.taskSource>>>()
|
||||
vi.spyOn(api,'taskSource').mockReturnValue(pending.promise)
|
||||
const opening=store.openTaskSource('card',source)
|
||||
await store.openConversation('b');await store.openConversation('a')
|
||||
store.requestPreview({id:'new',name:'New',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0})
|
||||
pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'})
|
||||
await opening
|
||||
expect(store.getState().previewActive.a).toBe('new')
|
||||
})
|
||||
it('keeps the activated tab when a source request is still pending',async()=>{
|
||||
store.requestPreview({id:'b',name:'B',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0})
|
||||
store.requestPreview({id:'c',name:'C',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0})
|
||||
const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'}
|
||||
const pending=deferred<Awaited<ReturnType<typeof api.taskSource>>>()
|
||||
vi.spyOn(api,'taskSource').mockReturnValue(pending.promise)
|
||||
const opening=store.openTaskSource('card',source);store.activatePreview('b')
|
||||
pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'})
|
||||
await opening
|
||||
expect(store.getState().previewActive.a).toBe('b')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user