Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fe321240a | ||
|
|
c6df2dc180 | ||
|
|
97b947a8e3 | ||
|
|
096947f9a2 | ||
|
|
3fe5cf1617 | ||
|
|
e73f2ad9dd | ||
|
|
d997d4a4d0 | ||
|
|
0b388e010d | ||
|
|
2bd5116f58 | ||
|
|
d7768d4ea0 | ||
|
|
3c22d0957a | ||
|
|
e296d38ec2 | ||
|
|
f01579890d | ||
|
|
259021d530 | ||
|
|
7d43ef6ff7 | ||
|
|
9baf96d937 | ||
|
|
dab16052cf | ||
|
|
ce15f7977b | ||
|
|
f560201f8b | ||
|
|
6f8e7ec604 | ||
|
|
aa49e0dbf3 | ||
|
|
b4fec2fdf4 | ||
|
|
164ea66039 | ||
|
|
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}"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 管理员随机临时密码与登录入口回归:管理员重置 → 用户密码登录 → 强制改密;
|
||||
// 登录页不再提供一次性 Key 登录 tab。Key 的分发/管理 API 与非登录页流程保留。
|
||||
// 管理员发放一次性登录密码与登录入口回归:管理员生成一次性密码
|
||||
// → 用户密码登录 → 强制改密;登录页不再提供一次性 Key 登录 tab。
|
||||
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
|
||||
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
|
||||
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
|
||||
@@ -51,17 +51,17 @@ async function adminLogin(page: Page) {
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test('管理员随机重置:临时密码登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
|
||||
test('管理员生成一次性登录密码:用户登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
|
||||
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
|
||||
test.setTimeout(120_000)
|
||||
// 1) 管理员进入用户管理,为目标用户生成随机临时密码(明文仅显示一次)。
|
||||
// 1) 管理员进入用户管理,为目标用户生成一次性登录密码(明文仅显示一次)。
|
||||
await adminLogin(page)
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||
const zone = page.getByRole('dialog', { name: '系统管理' })
|
||||
await expect(zone).toBeVisible()
|
||||
const row = zone.locator('tr', { hasText: USER })
|
||||
await row.getByRole('button', { name: '重置临时密码' }).click()
|
||||
await row.getByRole('button', { name: '生成一次性登录密码' }).click()
|
||||
const temporaryPassword = zone.locator('input[aria-label="一次性密码"]')
|
||||
await expect(temporaryPassword).toBeVisible({ timeout: 10_000 })
|
||||
const password = await temporaryPassword.inputValue()
|
||||
|
||||
@@ -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,32 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadSignedInWorkspace } from './App'
|
||||
import { store } from './store'
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
function stubLoad() {
|
||||
const order: string[] = []
|
||||
vi.spyOn(store, 'refreshMe').mockImplementation(async () => { order.push('me') })
|
||||
vi.spyOn(store, 'refreshProfiles').mockImplementation(async () => {})
|
||||
vi.spyOn(store, 'refreshPlugins').mockImplementation(async () => {})
|
||||
vi.spyOn(store, 'refreshProjects').mockImplementation(async () => { order.push('projects') })
|
||||
vi.spyOn(store, 'startProjectSync').mockImplementation(() => { order.push('sync') })
|
||||
return order
|
||||
}
|
||||
|
||||
describe('AutoDiscover ownership after sign-in', () => {
|
||||
it('checks projects immediately on sign-in, then hands re-checks to the single sync owner', async () => {
|
||||
const order = stubLoad()
|
||||
await loadSignedInWorkspace({ current: true })
|
||||
expect(order).toEqual(['me', 'projects', 'sync'])
|
||||
expect(store.startProjectSync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not start a sync owner for a page that was torn down during loading', async () => {
|
||||
const order = stubLoad()
|
||||
const active = { current: true }
|
||||
vi.mocked(store.refreshProjects).mockImplementation(async () => { order.push('projects'); active.current = false })
|
||||
await loadSignedInWorkspace(active)
|
||||
expect(store.startProjectSync).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+13
-8
@@ -25,6 +25,16 @@ const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
|
||||
const RIGHT_MIN = 280, RIGHT_MAX = 560
|
||||
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
|
||||
|
||||
/** 放行后的首轮装载:首次项目列表就是登录即时核对;其后由唯一的自动发现所有者接管重查。 */
|
||||
export async function loadSignedInWorkspace(active: { current: boolean }) {
|
||||
await store.refreshMe()
|
||||
if (!active.current) return
|
||||
void store.refreshProfiles()
|
||||
void store.refreshPlugins()
|
||||
await store.refreshProjects()
|
||||
if (active.current) store.startProjectSync()
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const ui = useStore(s => s.ui)
|
||||
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
|
||||
@@ -114,14 +124,9 @@ export function App() {
|
||||
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
|
||||
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
|
||||
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
|
||||
let active = true
|
||||
store.refreshMe().then(() => {
|
||||
if (!active) return
|
||||
void store.refreshProjects()
|
||||
void store.refreshProfiles()
|
||||
void store.refreshPlugins()
|
||||
})
|
||||
return () => { active = false }
|
||||
const active = { current: true }
|
||||
void loadSignedInWorkspace(active)
|
||||
return () => { active.current = false; store.stopProjectSync() }
|
||||
}, [authState])
|
||||
|
||||
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from './api'
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
describe('AutoDiscover list request', () => {
|
||||
it('passes the abort signal so an over-budget project list request is cancelled', async () => {
|
||||
const seen: (RequestInit | undefined)[] = []
|
||||
vi.stubGlobal('fetch', async (_path: string, init?: RequestInit) => {
|
||||
seen.push(init)
|
||||
return new Response('[]', { status: 200 })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
await api.listProjects(controller.signal)
|
||||
await api.listProjects()
|
||||
expect(seen[0]?.signal).toBe(controller.signal)
|
||||
expect(seen[1]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads the linked creation capability from the identity response', async () => {
|
||||
vi.stubGlobal('fetch', async () => new Response(JSON.stringify({ owner: 'bob', name: 'Bob', auth: true,
|
||||
linkedCapabilities: { projectCreate: true, source: 'system-grant', linkedActions: ['project.members.manage'], authorityEpoch: 4 } }), { status: 200 }))
|
||||
const me = await api.me()
|
||||
expect(me.linkedCapabilities).toMatchObject({ projectCreate: true, source: 'system-grant' })
|
||||
})
|
||||
|
||||
it('returns the linked expansion from a role permission write receipt', async () => {
|
||||
vi.stubGlobal('fetch', async () => new Response(JSON.stringify({ receiptId: 'r1', roleVersion: 3, authorityEpoch: 9,
|
||||
linkedExpansion: ['project.members.manage'] }), { status: 200 }))
|
||||
const receipt = await api.replaceRolePermissions('role_pm', 2, [{ actionId: 'project.create', allowed: true, capAllowed: true }], 'key')
|
||||
expect(receipt.linkedExpansion).toEqual(['project.members.manage'])
|
||||
})
|
||||
})
|
||||
+26
-37
@@ -25,20 +25,20 @@ export interface UserRoleBinding {
|
||||
bindingId: string; userId: string; roleId: string; active: boolean; bindingVersion: number
|
||||
assignedBy: string; assignedAt: string; userStateVersion: number; authorityEpoch: number
|
||||
}
|
||||
export interface UserRoleView { binding?: UserRoleBinding; role?: RoleRecord }
|
||||
/** getUserRole 返回的角色摘要:实测不含 permissions,权限补齐由角色列表条目提供。 */
|
||||
export interface UserRoleSummary extends Omit<RoleRecord, 'permissions'> { permissions?: RolePermissionRecord[] }
|
||||
export interface UserRoleView { binding?: UserRoleBinding; role?: UserRoleSummary }
|
||||
/** Backend 计算的本人 project.create 联动(/api/me linkedCapabilities)。 */
|
||||
export interface LinkedCapabilities {
|
||||
projectCreate: boolean; source?: string; linkedActions: string[]; conflicts?: string[]; authorityEpoch: number
|
||||
}
|
||||
/** 角色/系统授权写入回执:linkedExpansion 为服务端为 project.create 联动补齐的动作。 */
|
||||
interface PolicyWriteReceipt { receiptId: string; authorityEpoch: number; linkedExpansion?: string[]; migrationNote?: string }
|
||||
|
||||
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
|
||||
* passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码,
|
||||
* passwordChangeMode 由服务端从会话派生;grant_setup/one_time_password 免当前密码,
|
||||
* current_required 必须校验当前密码。 */
|
||||
export type PasswordChangeMode = 'grant_setup' | 'current_required'
|
||||
|
||||
/** 恢复提案(属主确认制):管理员只能提案,确认须用提案前已存在的合格完整密码会话。 */
|
||||
export interface RecoveryProposal {
|
||||
id: string; userId: string; owner: string
|
||||
status: 'pending' | 'confirmed' | 'completed' | 'revoked' | 'expired'
|
||||
version: number; generation: number; createdBy: string
|
||||
createdAt: string; expiresAt: string
|
||||
}
|
||||
export type PasswordChangeMode = 'grant_setup' | 'current_required' | 'one_time_password'
|
||||
|
||||
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
|
||||
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
|
||||
@@ -53,6 +53,12 @@ export interface ManagedAccountRecord extends AccountRecord {
|
||||
initializationState: string; initializationGeneration: number
|
||||
}
|
||||
interface AccountSearchPage { accounts: ManagedAccountRecord[]; nextCursor?: string; directoryVersion: string }
|
||||
export interface AdminIssuedOneTimePassword {
|
||||
owner: string
|
||||
id: string
|
||||
oneTimePassword: string
|
||||
mustChangePassword: boolean
|
||||
}
|
||||
|
||||
export interface ExecEnvView {
|
||||
configured: boolean
|
||||
@@ -99,7 +105,7 @@ export const api = {
|
||||
body: JSON.stringify({ expectedRoleVersion, ...patch }) }).then(r => json<RoleRecord>(r)),
|
||||
replaceRolePermissions: (roleId: string, expectedRoleVersion: number, changes: { actionId: string; allowed: boolean; capAllowed: boolean }[], idempotencyKey: string) =>
|
||||
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}/permissions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json<{ receiptId: string; roleVersion: number; authorityEpoch: number }>(r)),
|
||||
body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json<PolicyWriteReceipt & { roleVersion: number }>(r)),
|
||||
getUserRole: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`).then(r => json<UserRoleView>(r)),
|
||||
assignUserRole: (userId: string, roleId: string, expectedStateVersion: number, idempotencyKey: string) =>
|
||||
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
@@ -107,9 +113,9 @@ export const api = {
|
||||
getSystemGrants: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`).then(r => json<{ actions: string[]; authorityEpoch: number }>(r)),
|
||||
replaceSystemGrants: (userId: string, actions: string[], expectedAuthorityEpoch: number, idempotencyKey: string) =>
|
||||
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ actions, expectedAuthorityEpoch, idempotencyKey }) }).then(r => json<{ receiptId: string; authorityEpoch: number }>(r)),
|
||||
body: JSON.stringify({ actions, expectedAuthorityEpoch, idempotencyKey }) }).then(r => json<PolicyWriteReceipt>(r)),
|
||||
explainSystemAction: (action: string) => auth.fetch(`${BASE}/api/authz/explain?action=${encodeURIComponent(action)}`).then(r => json<{ allowed: boolean; actionId: string; source?: string; reason?: string; authorityEpoch: number }>(r)),
|
||||
listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json<Project[]>(r)),
|
||||
listProjects: (signal?: AbortSignal) => auth.fetch(`${BASE}/api/projects`, signal ? { signal } : undefined).then(r => json<Project[]>(r)),
|
||||
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
||||
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
||||
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
|
||||
@@ -128,7 +134,7 @@ 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; isSuperAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[] }>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities }>(r)),
|
||||
// ---- 认证与会话(管理员发放制,R2)----
|
||||
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()}`)
|
||||
@@ -136,14 +142,6 @@ export const api = {
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
// 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定;
|
||||
// 成功建立 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; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
logout: () => {
|
||||
const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
|
||||
auth.clear()
|
||||
@@ -157,7 +155,8 @@ export const api = {
|
||||
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
|
||||
// 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)),
|
||||
// 服务端严格解码 JSON 对象体:无 Content-Type 与请求体的 POST 会被拒为 400 EOF。
|
||||
adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<AdminIssuedOneTimePassword>(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,
|
||||
fields: request.fields.join(','), limit: String(request.limit ?? 20) })
|
||||
@@ -170,21 +169,9 @@ export const api = {
|
||||
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 ?? []),
|
||||
adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)),
|
||||
// ---- 管理员:恢复提案(属主确认制,替换已 410 的直接签发 Key 入口)----
|
||||
createAdminRecoveryProposal: (userId: string, expectedStateVersion: number, idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, idempotencyKey }) }).then(r => json<RecoveryProposal>(r)),
|
||||
listAdminRecoveryProposals: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
|
||||
adminRevokeRecoveryProposal: (userId: string, id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<RecoveryProposal>(r)),
|
||||
// ---- 本人:资料与恢复提案确认(须完整密码会话)----
|
||||
// ---- 本人:资料(须完整密码会话)----
|
||||
meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json<AccountRecord>(r)),
|
||||
patchMeProfile: (expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(r)),
|
||||
listMyRecoveryProposals: () => auth.fetch(`${BASE}/api/me/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
|
||||
getMyRecoveryProposal: (id: string) => auth.fetch(`${BASE}/api/me/recovery-proposals/${encodeURIComponent(id)}`).then(r => json<RecoveryProposal>(r)),
|
||||
confirmMyRecoveryProposal: (id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
|
||||
rotateMyRecoveryGrant: (id: string, expectedVersion: number, expectedGeneration: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/rotate-grant`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion, expectedGeneration }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
|
||||
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
|
||||
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
|
||||
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
|
||||
@@ -221,6 +208,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) =>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { PermissionDescriptor } from '../api'
|
||||
import { GrantChangePreview, GrantGroupEditor, ProjectCreateSourceNote, toggleGrantGroup } from './AccountUsersPanel'
|
||||
import { buildCapabilityGroups } from '../rolePermissions'
|
||||
|
||||
const descriptor = (actionId: string, label: string, group: string, scope: 'system' | 'project' = 'system'): PermissionDescriptor =>
|
||||
({ actionId, label, description: '', group, scope, protected: scope === 'system' && actionId !== 'project.create' })
|
||||
const catalog = [
|
||||
descriptor('system.users.view', '查看用户', '用户管理'),
|
||||
descriptor('system.users.create', '创建用户', '用户管理'),
|
||||
descriptor('system.roles.view', '查看角色', '角色管理'),
|
||||
descriptor('project.create', '创建项目', '项目入口'),
|
||||
descriptor('project.members.manage', '管理项目成员', '项目管理', 'project'),
|
||||
]
|
||||
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
|
||||
|
||||
describe('CapabilityGroup explicit grants', () => {
|
||||
it('grants system capabilities by group instead of one checkbox per action', () => {
|
||||
const html = renderToStaticMarkup(<GrantGroupEditor catalog={catalog} grants={['system.users.view']} disabled={false} onChange={vi.fn()} />)
|
||||
expect(html.match(/type="checkbox"/g)).toHaveLength(3)
|
||||
expect(html).toContain('授予 用户管理')
|
||||
expect(html).toContain('用户管理(部分)')
|
||||
expect(html).toContain('联动本人创建项目的设置、成员、项目权限与文档职责管理')
|
||||
expect(html).not.toContain('管理项目成员')
|
||||
})
|
||||
|
||||
it('toggles every action of a group together', () => {
|
||||
const users = buildCapabilityGroups(catalog.filter(item => item.scope === 'system')).find(group => group.id === 'catalog:用户管理')!
|
||||
expect(toggleGrantGroup(['system.roles.view'], users, true)).toEqual(['system.roles.view', 'system.users.create', 'system.users.view'])
|
||||
expect(toggleGrantGroup(['system.users.view', 'system.roles.view'], users, false)).toEqual(['system.roles.view'])
|
||||
})
|
||||
|
||||
it('previews the grant difference with its source and the single affected user', () => {
|
||||
const html = renderToStaticMarkup(<GrantChangePreview baseline={['system.users.view']} grants={['project.create']} labels={labels} owner="bob" />)
|
||||
expect(html).toContain('影响来源:明确系统授权 · 影响 1 名用户(bob)')
|
||||
expect(html).toContain('新增:创建项目(联动本人创建项目的管理资格)')
|
||||
expect(html).toContain('撤销:查看用户')
|
||||
const revoke = renderToStaticMarkup(<GrantChangePreview baseline={['project.create']} grants={[]} labels={labels} owner="bob" />)
|
||||
expect(revoke).toContain('已创建项目的管理资格不被反向删除')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CreatorOwnerMemberDuties capability source', () => {
|
||||
it('shows where an account gets project creation and what it links to', () => {
|
||||
const role = { name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }
|
||||
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={role} grants={['project.create']} />))
|
||||
.toContain('创建项目来源:职位角色「项目经理」 + 明确系统授权')
|
||||
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={null} grants={[]} />))
|
||||
.toContain('被加入项目后由成员关系提供基础协作')
|
||||
})
|
||||
|
||||
it('lists each project creation source separately when only one is present', () => {
|
||||
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={{ name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }} grants={[]} />))
|
||||
.toContain('创建项目来源:职位角色「项目经理」')
|
||||
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={null} grants={['project.create']} />))
|
||||
.toContain('创建项目来源:明确系统授权')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CapabilityGroup empty preview', () => {
|
||||
it('renders a no-change preview when baseline equals grants', () => {
|
||||
const html = renderToStaticMarkup(<GrantChangePreview baseline={['system.users.view']} grants={['system.users.view']} labels={labels} owner="bob" />)
|
||||
expect(html).toContain('系统授权没有变化')
|
||||
})
|
||||
|
||||
it('still names the affected user when nothing visible changes', () => {
|
||||
const html = renderToStaticMarkup(<GrantChangePreview baseline={[]} grants={[]} labels={labels} owner="bob" />)
|
||||
expect(html).toContain('系统授权没有变化')
|
||||
expect(html).not.toContain('影响来源')
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,9 @@ import { useStore } from '../store'
|
||||
import { Collapsible, Icon } from '@ui'
|
||||
import { hasSystemAction } from './RolePermissionsPanel'
|
||||
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../accountAuthority'
|
||||
import {
|
||||
PROJECT_CREATE_ACTION, buildCapabilityGroups, projectCreateSources, rebaseGrantDraft, type CapabilityGroup, type GroupLevel,
|
||||
} from '../rolePermissions'
|
||||
|
||||
type IdentityFlags = { isAdmin?: boolean; isSuperAdmin?: boolean }
|
||||
|
||||
@@ -16,7 +19,7 @@ const fields: { key: keyof AccountProfile; label: string }[] = [
|
||||
]
|
||||
|
||||
export const credentialLabel = (account: Pick<ManagedAccountRecord, 'initializationState' | 'passwordSet'>) =>
|
||||
account.initializationState === 'pending' ? '待可信 owner 初始化' : account.passwordSet ? '已设置密码' : '未设置密码'
|
||||
account.passwordSet ? '已设置密码' : '待设置密码'
|
||||
|
||||
function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile; onChange: (value: AccountProfile) => void; prefix: string }) {
|
||||
return <div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
|
||||
@@ -45,6 +48,67 @@ export function AdminAppointmentButton({ me, account, busy, onChange }: { me: Id
|
||||
return <button className="ad-mini" disabled={busy} onClick={() => onChange(change === 'promote')}>{change === 'promote' ? '设为管理员' : '撤销管理员'}</button>
|
||||
}
|
||||
|
||||
function grantLevel(grants: string[], group: CapabilityGroup): GroupLevel {
|
||||
const held = group.actions.filter(id => grants.includes(id)).length
|
||||
return held === 0 ? 'off' : held === group.actions.length ? 'on' : 'mixed'
|
||||
}
|
||||
|
||||
export function toggleGrantGroup(grants: string[], group: CapabilityGroup, checked: boolean): string[] {
|
||||
return checked ? [...new Set([...grants, ...group.actions])].sort() : grants.filter(id => !group.actions.includes(id))
|
||||
}
|
||||
|
||||
/** 明确系统授权按业务组授予;创建项目组说明联动形成的本人项目管理资格。 */
|
||||
export function GrantGroupEditor({ catalog, grants, disabled, onChange }: {
|
||||
catalog: PermissionDescriptor[]
|
||||
grants: string[]
|
||||
disabled: boolean
|
||||
onChange: (grants: string[]) => void
|
||||
}) {
|
||||
const groups = buildCapabilityGroups(catalog.filter(item => item.scope === 'system'))
|
||||
return <div className="rp-grants">{groups.map(group => {
|
||||
const state = grantLevel(grants, group)
|
||||
return <label className="ad-check" key={group.id} title={`内部动作 ${group.actions.join('、')}`}>
|
||||
<input type="checkbox" aria-label={`授予 ${group.label}`} checked={state === 'on'} disabled={disabled}
|
||||
onChange={event => onChange(toggleGrantGroup(grants, group, event.target.checked))} />
|
||||
{group.label}{state === 'mixed' ? '(部分)' : ''}
|
||||
{group.actions.includes(PROJECT_CREATE_ACTION) && <span className="ad-dim">:联动本人创建项目的设置、成员、项目权限与文档职责管理,不涉及他人项目</span>}
|
||||
</label>
|
||||
})}</div>
|
||||
}
|
||||
|
||||
/** 授权差异预览:受影响来源为明确系统授权,影响人数为当前账号。 */
|
||||
export function GrantChangePreview({ baseline, grants, labels, owner }: {
|
||||
baseline: string[]; grants: string[]; labels: Record<string, string>; owner: string
|
||||
}) {
|
||||
const added = grants.filter(id => !baseline.includes(id))
|
||||
const removed = baseline.filter(id => !grants.includes(id))
|
||||
if (added.length === 0 && removed.length === 0) return <p className="ad-hint">系统授权没有变化</p>
|
||||
const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、')
|
||||
return <div>
|
||||
<p className="ad-hint" role="note">影响来源:明确系统授权 · 影响 1 名用户({owner})</p>
|
||||
<ul className="rp-diff">
|
||||
{added.length > 0 && <li>新增:{names(added)}{added.includes(PROJECT_CREATE_ACTION) ? '(联动本人创建项目的管理资格)' : ''}</li>}
|
||||
{removed.length > 0 && <li>撤销:{names(removed)}{removed.includes(PROJECT_CREATE_ACTION) ? '(仅停止未来创建,已创建项目的管理资格不被反向删除)' : ''}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
/** 账号的创建能力来源与联动说明(角色默认与明确授权可并存)。 */
|
||||
export function ProjectCreateSourceNote({ role, grants }: {
|
||||
role: { name: string; permissions?: { actionId: string; allowed: boolean }[] } | null | undefined; grants: string[]
|
||||
}) {
|
||||
const sources = projectCreateSources(role, grants)
|
||||
if (sources.length === 0) return <p className="ad-hint">未获得创建项目能力:被加入项目后由成员关系提供基础协作</p>
|
||||
return <p className="ad-hint">创建项目来源:{sources.join(' + ')} · 联动形成本人创建项目的设置、成员、项目权限与文档职责管理资格</p>
|
||||
}
|
||||
|
||||
/** getUserRole 的角色摘要不带 permissions:用已加载角色列表里同一 roleId 的条目补齐,
|
||||
* 列表里也没有时按无角色默认权限处理(不新增请求,也不改写已有权限)。 */
|
||||
export function roleWithPermissions(role: UserRoleView['role'], roles: RoleRecord[]): UserRoleView['role'] {
|
||||
if (!role || role.permissions) return role
|
||||
return roles.find(item => item.roleId === role.roleId) ?? role
|
||||
}
|
||||
|
||||
export function AccountUsersPanel() {
|
||||
const me = useStore(state => state.me)
|
||||
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
|
||||
@@ -63,6 +127,7 @@ export function AccountUsersPanel() {
|
||||
const [roleLoading, setRoleLoading] = useState(false)
|
||||
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
|
||||
const [grants, setGrants] = useState<string[]>([])
|
||||
const [grantBaseline, setGrantBaseline] = useState<string[]>([])
|
||||
const [authorityEpoch, setAuthorityEpoch] = useState(0)
|
||||
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
|
||||
const [newOwner, setNewOwner] = useState('')
|
||||
@@ -81,6 +146,7 @@ export function AccountUsersPanel() {
|
||||
const [err, setErr] = useState('')
|
||||
const sequence = useRef(0)
|
||||
const roleSequence = useRef(0)
|
||||
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
|
||||
|
||||
const search = async (pageCursor = cursor, afterWrite = false) => {
|
||||
const request = ++sequence.current
|
||||
@@ -123,8 +189,9 @@ export function AccountUsersPanel() {
|
||||
if (catalogResult.status === 'fulfilled' && catalogResult.value) setCatalog(catalogResult.value)
|
||||
else setCatalog([])
|
||||
if (grantsResult.status === 'fulfilled' && grantsResult.value) {
|
||||
setGrants(grantsResult.value.actions ?? []); setAuthorityEpoch(grantsResult.value.authorityEpoch)
|
||||
} else { setGrants([]); setAuthorityEpoch(0) }
|
||||
const actions = [...(grantsResult.value.actions ?? [])].sort()
|
||||
setGrants(actions); setGrantBaseline(actions); setAuthorityEpoch(grantsResult.value.authorityEpoch)
|
||||
} else { setGrants([]); setGrantBaseline([]); setAuthorityEpoch(0) }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -187,10 +254,25 @@ export function AccountUsersPanel() {
|
||||
const saveGrants = async () => {
|
||||
if (!selected) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
const userId = selected.userId
|
||||
try {
|
||||
const result = await api.replaceSystemGrants(selected.userId, grants, authorityEpoch, crypto.randomUUID())
|
||||
setAuthorityEpoch(result.authorityEpoch); setNotice('系统权限已保存')
|
||||
} catch (error) { setErr(String(error).includes('final permission manager') ? '不能撤销最后一个有效权限管理者' : String(error)) }
|
||||
const result = await api.replaceSystemGrants(userId, grants, authorityEpoch, crypto.randomUUID())
|
||||
setAuthorityEpoch(result.authorityEpoch); setGrantBaseline(grants)
|
||||
const expanded = result.linkedExpansion ?? []
|
||||
setNotice(expanded.length ? `系统权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '系统权限已保存')
|
||||
} catch (error) {
|
||||
const raw = String(error)
|
||||
if (raw.includes('final permission manager')) setErr('不能撤销最后一个有效权限管理者')
|
||||
else if (raw.includes('409')) {
|
||||
// 版本冲突:读取最新授权并重放本地增删,草稿不丢。
|
||||
try {
|
||||
const fresh = await api.getSystemGrants(userId)
|
||||
const server = [...(fresh.actions ?? [])].sort()
|
||||
setGrants(rebaseGrantDraft(grantBaseline, grants, server)); setGrantBaseline(server); setAuthorityEpoch(fresh.authorityEpoch)
|
||||
setErr('系统权限版本冲突;已基于最新授权重放本地修改,请核对后再次保存')
|
||||
} catch (refreshError) { setErr(`${String(refreshError)};本地草稿已保留`) }
|
||||
} else setErr(`${raw.replace(/^Error:\s*/, '')};本地草稿已保留`)
|
||||
}
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
@@ -233,7 +315,6 @@ export function AccountUsersPanel() {
|
||||
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
|
||||
<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>}
|
||||
{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" />
|
||||
@@ -252,11 +333,10 @@ export function AccountUsersPanel() {
|
||||
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
|
||||
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
|
||||
</div>}
|
||||
{(canViewRoles || canViewGrants) && <ProjectCreateSourceNote role={roleWithPermissions(roleView?.role, roles)} grants={grantBaseline} />}
|
||||
{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 || !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>
|
||||
<GrantGroupEditor catalog={catalog} grants={grants} disabled={!canManageGrants || !policy.maintain || busy} onChange={setGrants} />
|
||||
<GrantChangePreview baseline={grantBaseline} grants={grants} labels={labels} owner={selected.owner} />
|
||||
{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>}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import { Icon } from '@ui'
|
||||
import { humanChangePasswordError } from './LoginPage'
|
||||
import { humanChangePasswordError } from './loginErrors'
|
||||
|
||||
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -30,15 +30,15 @@ function ProfilesPanelWrapper() {
|
||||
return <ProfilesSection profiles={profiles} />
|
||||
}
|
||||
|
||||
/** 账号目录(按系统权限开放)+ 登录凭据与恢复提案(根管理员专属,旧 /api/admin/users 列表与密码端点)。 */
|
||||
/** 账号目录(按系统权限开放)+ 登录凭据管理(根管理员专属)。 */
|
||||
function UsersSection() {
|
||||
const isAdmin = useStore(s => !!s.me?.isAdmin)
|
||||
return <>
|
||||
<AccountUsersPanel />
|
||||
{isAdmin && <>
|
||||
<div className="az-section-head" style={{ marginTop: 18 }}>
|
||||
<h3>登录凭据与恢复</h3>
|
||||
<p>建号并发放初始密码、重置临时密码、发起恢复提案(仅根管理员)</p>
|
||||
<h3>登录凭据</h3>
|
||||
<p>建号并发放初始密码、生成一次性登录密码(仅根管理员)</p>
|
||||
</div>
|
||||
<UsersPanel />
|
||||
</>}
|
||||
@@ -46,7 +46,7 @@ function UsersSection() {
|
||||
}
|
||||
|
||||
const SECTIONS: AdminSectionDef[] = [
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员另可发放与恢复登录凭据', requiredAction: 'system.users.view', render: () => <UsersSection /> },
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员可发放登录凭据', requiredAction: 'system.users.view', render: () => <UsersSection /> },
|
||||
{ key: 'roles', label: '角色与权限', icon: 'shield', desc: '职位角色、默认权限与上限;支持单角色和角色矩阵配置', requiredAction: 'system.roles.view', render: () => <RolePermissionsPanel /> },
|
||||
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', rootOnly: true, render: () => <ProfilesPanelWrapper /> },
|
||||
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
|
||||
// LoginPage —— 用户名+密码唯一入口;
|
||||
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
|
||||
// grant_setup 免当前密码(激活/恢复 Key 兑换),current_required 必须校验当前密码
|
||||
// grant_setup 与 one_time_password 免当前密码,current_required 必须校验当前密码
|
||||
//(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, type PasswordChangeMode } from '../api'
|
||||
import { Icon, BrandMark } from '@ui'
|
||||
import { ForcedPasswordModeSection, isForcedPasswordMode, requiresCurrentPassword, submitForcedPassword } from './passwordChangeMode'
|
||||
import { humanChangePasswordError, humanLoginError } from './loginErrors'
|
||||
|
||||
function Err({ msg }: { msg: string }) {
|
||||
if (!msg) return null
|
||||
@@ -74,12 +76,6 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
)
|
||||
}
|
||||
|
||||
/** current_required 模式必须由用户输入当前密码(后端 CompleteForcedPasswordChecked
|
||||
* 校验);grant_setup(激活/恢复 Key 兑换的受限会话)免当前密码。 */
|
||||
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
|
||||
return mode === 'current_required'
|
||||
}
|
||||
|
||||
/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me
|
||||
* 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */
|
||||
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
|
||||
@@ -96,8 +92,8 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
api.me().then(me => {
|
||||
if (!active) return
|
||||
if (me.owner !== owner || me.sessionScope !== 'set_password_only' ||
|
||||
(me.passwordChangeMode !== 'grant_setup' && me.passwordChangeMode !== 'current_required')) {
|
||||
setErr('当前设密凭据不再有效,请重新登录;恢复场景请先核对「设置 → 账号」中的恢复提案状态。')
|
||||
!isForcedPasswordMode(me.passwordChangeMode)) {
|
||||
setErr('当前设密凭据不再有效,请重新登录。')
|
||||
return
|
||||
}
|
||||
setMode(me.passwordChangeMode)
|
||||
@@ -113,7 +109,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
if (next.length < 8) { setErr('新密码至少 8 位'); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
|
||||
await submitForcedPassword(mode, current, next)
|
||||
setDone(true)
|
||||
} catch (e) {
|
||||
setErr(humanChangePasswordError(String(e)))
|
||||
@@ -136,12 +132,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||
<h2 className="lg-title">设置新密码</h2>
|
||||
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续</p>
|
||||
{mode === 'current_required' && <>
|
||||
<label className="lg-label" htmlFor="forced-current">当前密码</label>
|
||||
<input id="forced-current" className="lg-input" type="password" value={current}
|
||||
onChange={e => setCurrent(e.target.value)} autoComplete="current-password" />
|
||||
</>}
|
||||
{mode === 'grant_setup' && <p className="lg-hint">身份经可信激活/恢复能力确认,此次无需输入当前密码。</p>}
|
||||
<ForcedPasswordModeSection mode={mode} current={current} onCurrentChange={setCurrent} />
|
||||
<label className="lg-label">新密码(≥8 位)</label>
|
||||
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
|
||||
<label className="lg-label">确认新密码</label>
|
||||
@@ -155,34 +146,3 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
|
||||
export function humanLoginError(raw: string): string {
|
||||
if (raw.includes('401')) return '用户名或密码不正确'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
|
||||
export function humanRedeemError(raw: string): string {
|
||||
if (raw.includes('409')) return '该能力已被使用过;请由本人核对提案状态,或联系可信 owner 续办初始化'
|
||||
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return '能力无效或已过期;请核对本人提案状态或联系可信 owner 续办初始化'
|
||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
|
||||
* 不能沿用登录的「用户名或密码不正确」一刀切。
|
||||
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
|
||||
export function humanChangePasswordError(raw: string): string {
|
||||
if (raw.includes('current password incorrect')) return '当前密码不正确'
|
||||
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
|
||||
if (raw.includes('session expired')) return '会话已过期,请重新登录'
|
||||
if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
|
||||
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return '认证失败,请重新登录'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
// 我的账号与恢复(设置 → 账号):可选资料编辑 + 恢复提案的属主确认视图。
|
||||
// 管理员只能建立提案;确认必须使用提案之前已存在、来源可信的完整密码会话
|
||||
//(backend eligibleRecoveryProof 契约)。确认产出的一次性 grant 只在本次响应
|
||||
// 出现,可就地兑换进受限设密会话(完成前不放行工作台)。
|
||||
// 我的账号(设置 → 账号):可选资料编辑 + 忘记密码指引。忘记密码只有一个处置:
|
||||
// 由管理员生成一次性登录密码,用户用它登录后进入强制改密门,设置新密码才放行。
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, type AccountProfile, type AccountRecord, type RecoveryProposal } from '../api'
|
||||
import { api, type AccountProfile, type AccountRecord } from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { Collapsible, Icon, SettingsGroup } from '@ui'
|
||||
import { recoveryStatusLabel } from '../recovery'
|
||||
|
||||
const fields: { key: keyof AccountProfile; label: string }[] = [
|
||||
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
|
||||
@@ -17,8 +14,6 @@ export function MyAccountSettings() {
|
||||
const me = useStore(s => s.me)
|
||||
const [account, setAccount] = useState<AccountRecord | null>(null)
|
||||
const [draft, setDraft] = useState<AccountProfile | null>(null)
|
||||
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
|
||||
const [grant, setGrant] = useState<{ proposalId: string; value: string } | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -27,9 +22,9 @@ export function MyAccountSettings() {
|
||||
useEffect(() => {
|
||||
if (!me?.auth) return
|
||||
const seq = ++sequence.current
|
||||
Promise.all([api.meProfile(), api.listMyRecoveryProposals()]).then(([profile, statuses]) => {
|
||||
api.meProfile().then(profile => {
|
||||
if (seq !== sequence.current) return
|
||||
setAccount(profile); setDraft(profile.profile); setProposals(statuses)
|
||||
setAccount(profile); setDraft(profile.profile)
|
||||
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
|
||||
return () => { sequence.current++ }
|
||||
}, [me?.owner, me?.auth])
|
||||
@@ -44,74 +39,14 @@ export function MyAccountSettings() {
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const updated = await api.patchMeProfile(account.profileVersion, patch)
|
||||
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存;资料不会成为恢复身份的证明')
|
||||
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存')
|
||||
} catch (error) { setErr(String(error).includes('409') ? '资料版本已变化,请关闭设置后重新打开' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const refreshProposal = async (id: string) => {
|
||||
const updated = await api.getMyRecoveryProposal(id)
|
||||
setProposals(current => current?.map(item => item.id === id ? updated : item) ?? [updated])
|
||||
return updated
|
||||
}
|
||||
|
||||
const confirm = async (proposal: RecoveryProposal) => {
|
||||
setBusy(true); setErr(''); setNotice(''); setGrant(null)
|
||||
try {
|
||||
const result = await api.confirmMyRecoveryProposal(proposal.id, proposal.version)
|
||||
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
|
||||
if (!result.grant) {
|
||||
setErr('提案已确认,但一次性明文不可读回。若仍保有原本人会话,可显式重新生成设密能力。')
|
||||
return
|
||||
}
|
||||
setGrant({ proposalId: proposal.id, value: result.grant })
|
||||
setNotice('本人确认成功。设密能力只在此响应可用,请立即兑换并设置新密码。')
|
||||
} catch (error) {
|
||||
const current = await refreshProposal(proposal.id).catch(() => null)
|
||||
setErr(current?.status === 'confirmed'
|
||||
? '确认结果待核验:提案已确认,但明文不能读回。若仍保有原本人会话,可显式重新生成设密能力。'
|
||||
: `确认未完成或结果未知:${String(error)}。请核对当前提案状态。`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const rotate = async (proposal: RecoveryProposal) => {
|
||||
setBusy(true); setErr(''); setNotice(''); setGrant(null)
|
||||
try {
|
||||
const result = await api.rotateMyRecoveryGrant(proposal.id, proposal.version, proposal.generation)
|
||||
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
|
||||
if (!result.grant) {
|
||||
setErr('重新生成已提交,但一次性明文不可读回。请核对当前提案状态,再由本人显式操作。')
|
||||
return
|
||||
}
|
||||
setGrant({ proposalId: proposal.id, value: result.grant })
|
||||
setNotice('已重新生成设密能力;旧能力与派生受限会话已失效。')
|
||||
} catch (error) {
|
||||
await refreshProposal(proposal.id).catch(() => null)
|
||||
setErr(`重新生成结果待核验:${String(error)}。请核对提案状态,再由本人显式操作。`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const redeem = async () => {
|
||||
if (!grant) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
await api.redeemLoginKey(grant.value)
|
||||
location.reload() // 新 HttpOnly 受限会话由应用启动门送到设密页
|
||||
} catch (error) {
|
||||
const session = await api.me().catch(() => null)
|
||||
if (session?.sessionScope === 'set_password_only' && session.passwordChangeMode === 'grant_setup') {
|
||||
location.reload()
|
||||
return
|
||||
}
|
||||
await refreshProposal(grant.proposalId).catch(() => null)
|
||||
setGrant(null)
|
||||
setErr(`兑换结果待核验:${String(error)}。不会自动重复兑换;若仍保有原本人会话,请显式重新生成能力。`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return <SettingsGroup title="我的账号与恢复">
|
||||
return <SettingsGroup title="我的账号">
|
||||
{account && draft ? <>
|
||||
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空,也不会作为本人恢复证明。</p>
|
||||
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空。</p>
|
||||
<Collapsible id={`my-profile-${account.userId}`} trigger="编辑可选资料">
|
||||
<div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
|
||||
<label className="lg-label" htmlFor={`my-profile-${field.key}`}>{field.label}(可空)</label>
|
||||
@@ -121,21 +56,7 @@ export function MyAccountSettings() {
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={save}>保存我的资料</button></div>
|
||||
</Collapsible>
|
||||
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
|
||||
|
||||
<p className="ad-hint">忘记密码的恢复:由管理员发起提案,本人在此用当前登录会话确认后获得一次性 Key 兑换设密;历史未知来源账号无法在线证明。</p>
|
||||
{(proposals ?? []).map(proposal => <div className="ad-create" key={proposal.id}>
|
||||
<div className="ad-row"><span className="ad-mono">{proposal.id}</span><b>{recoveryStatusLabel[proposal.status]}</b>
|
||||
<span className="ad-dim">到期 {new Date(proposal.expiresAt).toLocaleString()}</span></div>
|
||||
<div className="ad-row">
|
||||
{proposal.status === 'pending' && <button className="ad-mini" disabled={busy} onClick={() => confirm(proposal)}>由本人确认</button>}
|
||||
{proposal.status === 'confirmed' && <button className="ad-mini" disabled={busy} onClick={() => rotate(proposal)}>显式重新生成设密能力</button>}
|
||||
</div>
|
||||
</div>)}
|
||||
{proposals !== null && proposals.length === 0 && <p className="ad-hint">当前没有恢复提案。</p>}
|
||||
{grant && <div className="ad-reset">
|
||||
<p className="ad-hint">设密能力仅保存在当前页面内存,离开后不可读回;不自动复制到剪贴板。</p>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={redeem}>立即兑换并设置新密码</button>
|
||||
</div>}
|
||||
<p className="ad-hint">忘记密码:请联系管理员生成一次性登录密码。用它登录后必须立即设置新密码(强制改密),原密码随生成即失效。</p>
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { CapabilityChangePreview, CapabilityGroupEditor, rolePermissionSaveError } from './RolePermissionsPanel'
|
||||
import {
|
||||
buildCapabilityGroups, capabilityGroupChanges, createRolePermissionDraft, linkedChainBreaks, setCapabilityGroup,
|
||||
type RolePermissionValue,
|
||||
} from '../rolePermissions'
|
||||
|
||||
const catalog = [
|
||||
{ actionId: 'project.create', label: '创建项目', group: '项目入口' },
|
||||
{ actionId: 'project.settings', label: '项目设置', group: '项目管理' },
|
||||
{ actionId: 'project.members.manage', label: '管理项目成员', group: '项目管理' },
|
||||
{ actionId: 'project.permissions.manage', label: '管理项目权限', group: '项目管理' },
|
||||
{ actionId: 'document.duties.assign', label: '分配文档三权', group: '文档职责' },
|
||||
{ actionId: 'document.author', label: '编制文档', group: '文档动作' },
|
||||
{ actionId: 'document.review', label: '审核文档', group: '文档动作' },
|
||||
{ actionId: 'document.approve', label: '批准文档', group: '文档动作' },
|
||||
]
|
||||
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
|
||||
const off: RolePermissionValue = { allowed: false, capAllowed: false }
|
||||
const values = Object.fromEntries(catalog.map(item => [item.actionId, off]))
|
||||
const groups = buildCapabilityGroups(catalog)
|
||||
|
||||
describe('CapabilityGroup role editor', () => {
|
||||
it('replaces the per-capability checkbox grid with one default/cap pair per business group', () => {
|
||||
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={values} labels={labels} disabled={false} onToggle={vi.fn()} />)
|
||||
expect(html.match(/type="checkbox"/g)).toHaveLength(groups.length * 2)
|
||||
expect(html).toContain('创建与管理本人项目 默认授予')
|
||||
expect(html).toContain('文档编制与审签 角色上限')
|
||||
expect(html).not.toContain('编制文档 默认允许')
|
||||
expect(html).not.toContain('管理项目成员 默认授予')
|
||||
})
|
||||
|
||||
it('shows linked own-project management as controlled items with their association source', () => {
|
||||
const create = groups.find(group => group.id === 'project-create')!
|
||||
const linked = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true).values
|
||||
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={linked} labels={labels} disabled onToggle={vi.fn()} />)
|
||||
expect(html).toContain('管理项目成员:已联动(来源:创建项目,仅本人创建的项目)')
|
||||
expect(html).toContain('分配文档三权:已联动')
|
||||
const before = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={values} labels={labels} disabled onToggle={vi.fn()} />)
|
||||
expect(before).toContain('未联动(开启创建项目后自动补齐)')
|
||||
})
|
||||
|
||||
it('marks a partially granted group instead of hiding the difference', () => {
|
||||
const partial = { ...values, 'document.author': { allowed: true, capAllowed: true } }
|
||||
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={partial} labels={labels} disabled={false} onToggle={vi.fn()} />)
|
||||
expect(html).toContain('默认授予(部分)')
|
||||
})
|
||||
|
||||
it('previews the grouped difference with the affected source and affected user count', () => {
|
||||
const create = groups.find(group => group.id === 'project-create')!
|
||||
const draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
|
||||
const html = renderToStaticMarkup(<CapabilityChangePreview changes={capabilityGroupChanges(draft, groups)} breaks={linkedChainBreaks(draft.values)}
|
||||
labels={labels} roleName="项目经理" bindingCount={7} />)
|
||||
expect(html).toContain('影响来源:职位角色「项目经理」 · 影响 7 名绑定用户')
|
||||
expect(html).toContain('创建与管理本人项目:默认 拒绝 → 允许')
|
||||
expect(html).toContain('联动补齐 项目设置、管理项目成员、管理项目权限、分配文档三权(仅本人创建的项目)')
|
||||
})
|
||||
|
||||
it('keeps the draft and names a linked conflict the server rejected as a whole', () => {
|
||||
expect(rolePermissionSaveError(new Error('422: linked capability conflict project.members.manage')))
|
||||
.toMatch(/关联能力冲突.*整笔拒绝.*本地草稿已保留/)
|
||||
expect(rolePermissionSaveError(new Error('500: boom'))).toContain('本地草稿已保留')
|
||||
})
|
||||
|
||||
it('keeps every action label visible inside the linked-source list once project creation opens', () => {
|
||||
const create = groups.find(group => group.id === 'project-create')!
|
||||
const draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
|
||||
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={draft.values} labels={labels} disabled onToggle={vi.fn()} />)
|
||||
expect(html).toContain('管理项目成员:已联动(来源:创建项目,仅本人创建的项目)')
|
||||
expect(html).toContain('项目设置:已联动')
|
||||
expect(html).toContain('管理项目权限:已联动')
|
||||
expect(html).toContain('分配文档三权:已联动')
|
||||
})
|
||||
|
||||
it('splits every preview row by business group when multiple groups change together', () => {
|
||||
const create = groups.find(group => group.id === 'project-create')!
|
||||
const duties = groups.find(group => group.id === 'document-duties')!
|
||||
let draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
|
||||
draft = setCapabilityGroup(draft, duties, 'allowed', true)
|
||||
const html = renderToStaticMarkup(<CapabilityChangePreview changes={capabilityGroupChanges(draft, groups)} breaks={linkedChainBreaks(draft.values)}
|
||||
labels={labels} roleName="项目经理" bindingCount={7} />)
|
||||
expect(html).toContain('创建与管理本人项目:默认 拒绝 → 允许')
|
||||
expect(html).toContain('文档编制与审签:默认 拒绝 → 允许')
|
||||
})
|
||||
|
||||
it('treats a 403 the same as a 400 for linked-capability rejection phrasing', () => {
|
||||
expect(rolePermissionSaveError(new Error('403: linked capability conflict'))).toMatch(/关联能力冲突/)
|
||||
expect(rolePermissionSaveError(new Error('500: unrelated boom'))).toContain('本地草稿已保留')
|
||||
expect(rolePermissionSaveError(new Error('500: unrelated boom'))).not.toMatch(/关联能力冲突/)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
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,
|
||||
buildCapabilityGroups, capabilityGroupChanges, capabilityGroupState, createRolePermissionDraft, ensureLinkedCapabilities,
|
||||
linkedChainBreaks, rebaseRoleDraft, setCapabilityGroup, validateRolePermissionDraft, visiblePatch,
|
||||
type CapabilityGroup, type CapabilityGroupChange, type GroupLevel, type RolePermissionDraft, type RolePermissionValue,
|
||||
} from '../rolePermissions'
|
||||
import { Collapsible, Icon } from '@ui'
|
||||
import { useStore } from '../store'
|
||||
@@ -44,9 +45,65 @@ function roleValues(role: RoleRecord, catalog: PermissionDescriptor[]): Record<s
|
||||
}))
|
||||
}
|
||||
|
||||
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 }
|
||||
const LEVEL_TEXT: Record<GroupLevel, string> = { on: '允许', off: '拒绝', mixed: '部分' }
|
||||
|
||||
function linkedStatus(values: Record<string, RolePermissionValue>, actionId: string, createOn: boolean): string {
|
||||
if (values[actionId]?.capAllowed) return '已联动(来源:创建项目,仅本人创建的项目)'
|
||||
return createOn ? '待联动补齐(保存时随创建项目一并补齐)' : '未联动(开启创建项目后自动补齐)'
|
||||
}
|
||||
|
||||
/** 业务能力组:组开关替代逐动作勾选;联动项为组内受控项,只展示来源。 */
|
||||
export function CapabilityGroupEditor({ groups, values, labels, disabled, onToggle }: {
|
||||
groups: CapabilityGroup[]
|
||||
values: Record<string, RolePermissionValue>
|
||||
labels: Record<string, string>
|
||||
disabled: boolean
|
||||
onToggle: (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => void
|
||||
}) {
|
||||
return <div className="rp-groups">{groups.map(group => {
|
||||
const state = capabilityGroupState(values, group)
|
||||
const createOn = state.allowed !== 'off' || state.capAllowed !== 'off'
|
||||
return <section key={group.id} className="settings-group" aria-label={`能力组 ${group.label}`}>
|
||||
<div className="ad-row">
|
||||
<b title={`内部动作 ${group.actions.join('、')}`}>{group.label}</b>
|
||||
<label className="ad-check"><input type="checkbox" aria-label={`${group.label} 默认授予`} checked={state.allowed === 'on'} disabled={disabled}
|
||||
onChange={event => onToggle(group, 'allowed', event.target.checked)} />默认授予{state.allowed === 'mixed' ? '(部分)' : ''}</label>
|
||||
<label className="ad-check"><input type="checkbox" aria-label={`${group.label} 角色上限`} checked={state.capAllowed === 'on'} disabled={disabled}
|
||||
onChange={event => onToggle(group, 'capAllowed', event.target.checked)} />角色上限{state.capAllowed === 'mixed' ? '(部分)' : ''}</label>
|
||||
</div>
|
||||
{group.description && <p className="ad-hint">{group.description}</p>}
|
||||
{group.linked.length > 0 && <ul className="rp-diff" aria-label={`${group.label} 联动项`}>{group.linked.map(id =>
|
||||
<li key={id}>{labels[id] ?? id}:{linkedStatus(values, id, createOn)}</li>)}</ul>}
|
||||
</section>
|
||||
})}</div>
|
||||
}
|
||||
|
||||
/** 变更预览:按业务组列出前后差异、联动补齐项、影响来源与受影响人数。 */
|
||||
export function CapabilityChangePreview({ changes, breaks, labels, roleName, bindingCount }: {
|
||||
changes: CapabilityGroupChange[]
|
||||
breaks: string[]
|
||||
labels: Record<string, string>
|
||||
roleName: string
|
||||
bindingCount: number
|
||||
}) {
|
||||
if (changes.length === 0 && breaks.length === 0) return <p className="ad-hint">没有权限变化</p>
|
||||
const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、')
|
||||
return <div>
|
||||
<p className="ad-hint" role="note">影响来源:职位角色「{roleName}」 · 影响 {bindingCount} 名绑定用户</p>
|
||||
{breaks.length > 0 && <p className="ad-hint" role="note">关联待补齐:{names(breaks)}——保存时随「创建项目」一并补齐上限</p>}
|
||||
<ul className="rp-diff">{changes.map(change => <li key={change.group.id}>
|
||||
{change.group.label}:默认 {LEVEL_TEXT[change.before.allowed]} → {LEVEL_TEXT[change.after.allowed]},上限 {LEVEL_TEXT[change.before.capAllowed]} → {LEVEL_TEXT[change.after.capAllowed]}
|
||||
{change.linkedAdded.length > 0 && <>;联动补齐 {names(change.linkedAdded)}(仅本人创建的项目)</>}
|
||||
</li>)}</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function rolePermissionSaveError(error: unknown): string {
|
||||
const raw = String(error)
|
||||
if (/\b(400|403|422)\b/.test(raw) && /(link|conflict|关联|冲突|cap)/i.test(raw)) {
|
||||
return `关联能力冲突,服务端已整笔拒绝,未保存任何修改;本地草稿已保留,请调整冲突项后重试(${raw.replace(/^Error:\s*/, '')})`
|
||||
}
|
||||
return `${raw.replace(/^Error:\s*/, '')};本地草稿已保留`
|
||||
}
|
||||
|
||||
export function RolePermissionsPanel() {
|
||||
@@ -65,12 +122,10 @@ export function RolePermissionsPanel() {
|
||||
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('')
|
||||
@@ -87,11 +142,11 @@ export function RolePermissionsPanel() {
|
||||
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 capabilityGroups = useMemo(() => buildCapabilityGroups(catalog.filter(item => !item.protected)), [catalog])
|
||||
const labels = useMemo(() => Object.fromEntries(catalog.map(item => [item.actionId, item.label])), [catalog])
|
||||
const changes = draft ? capabilityGroupChanges(draft, capabilityGroups) : []
|
||||
const breaks = draft ? linkedChainBreaks(draft.values) : []
|
||||
|
||||
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
|
||||
filters = { query, mode, status, action: actionFilter }) => {
|
||||
@@ -231,21 +286,25 @@ export function RolePermissionsPanel() {
|
||||
const roleId = selected.roleId
|
||||
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 }))
|
||||
// 关联项不能以断链状态保存:提交前与服务端同规则补齐创建能力的联动上限。
|
||||
const prepared = ensureLinkedCapabilities(draft)
|
||||
const invalid = validateRolePermissionDraft(prepared)
|
||||
if (invalid) { setErr(`${labels[invalid.actionId] ?? invalid.actionId} 的默认权限不能超过角色上限`); return }
|
||||
const full = Object.entries(prepared.values).map(([actionId, value]) => ({ actionId, ...value }))
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID())
|
||||
const receipt = await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID())
|
||||
setDrafts(current => {
|
||||
if (!(roleId in current)) return current
|
||||
const next = { ...current }; delete next[roleId]; return next
|
||||
})
|
||||
await load(roleCursor, true, selected.roleId); setNotice('角色权限已保存')
|
||||
await load(roleCursor, true, selected.roleId)
|
||||
const expanded = receipt?.linkedExpansion ?? []
|
||||
setNotice(expanded.length ? `角色权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '角色权限已保存')
|
||||
} catch (error) {
|
||||
if (String(error).includes('409')) {
|
||||
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
|
||||
} else setErr(String(error))
|
||||
} else setErr(rolePermissionSaveError(error))
|
||||
}
|
||||
finally {
|
||||
setBusy(false)
|
||||
@@ -256,47 +315,30 @@ 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 editVisible = (field: 'allowed' | 'capAllowed', checked: boolean) => {
|
||||
const start = drafts[selectedId]
|
||||
if (!start) return
|
||||
let next = start
|
||||
for (const item of editablePageItems) {
|
||||
const current = next.values[item.actionId] ?? { allowed: false, capAllowed: false }
|
||||
const value = field === 'allowed'
|
||||
? { allowed: checked, capAllowed: checked ? true : current.capAllowed }
|
||||
: { allowed: checked ? current.allowed : false, capAllowed: checked }
|
||||
next = updateRolePermission(next, item.actionId, value)
|
||||
}
|
||||
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: next } : prev)
|
||||
const editGroup = (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => {
|
||||
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: setCapabilityGroup(prev[selectedId], group, field, checked) } : prev)
|
||||
}
|
||||
|
||||
// 矩阵按业务组汇总;只有选中角色可编辑组开关,其余角色只读摘要。
|
||||
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>
|
||||
<thead><tr><th>角色</th>{capabilityGroups.map(group => <th key={group.id} title={group.description}>{group.label}</th>)}</tr></thead>
|
||||
<tbody>{filtered.map(role => {
|
||||
const values = role.roleId === selectedId && draft ? draft.values : roleValues(role, catalog)
|
||||
const editable = role.roleId === selectedId && !!draft && canManagePermissions
|
||||
return <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>
|
||||
{capabilityGroups.map(group => {
|
||||
const state = capabilityGroupState(values, group)
|
||||
return <td key={group.id}>{editable ? <>
|
||||
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${group.label} 默认授予`} checked={state.allowed === 'on'}
|
||||
disabled={busy || roleLoading} onChange={event => editGroup(group, 'allowed', event.target.checked)} />默认</label>
|
||||
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${group.label} 角色上限`} checked={state.capAllowed === 'on'}
|
||||
disabled={busy || roleLoading} onChange={event => editGroup(group, 'capAllowed', event.target.checked)} />上限</label>
|
||||
</> : <span className="ad-dim">默认{LEVEL_TEXT[state.allowed]} · 上限{LEVEL_TEXT[state.capAllowed]}</span>}</td>
|
||||
})}
|
||||
</tr>
|
||||
})}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -317,9 +359,6 @@ export function RolePermissionsPanel() {
|
||||
<select className="lg-input" aria-label="角色状态" value={status} disabled={busy} onChange={event => { setStatus(event.target.value as typeof status); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
||||
<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>)}
|
||||
@@ -329,16 +368,8 @@ export function RolePermissionsPanel() {
|
||||
<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" 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>}
|
||||
@@ -363,21 +394,13 @@ export function RolePermissionsPanel() {
|
||||
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
|
||||
<button className="ad-mini" disabled={busy} 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>
|
||||
{canViewPermissions ? <>
|
||||
<CapabilityGroupEditor groups={capabilityGroups} values={draft.values} labels={labels}
|
||||
disabled={busy || roleLoading || !canManagePermissions} onToggle={editGroup} />
|
||||
<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>}
|
||||
<CapabilityChangePreview changes={changes} breaks={breaks} labels={labels} roleName={selected.name} bindingCount={selected.bindingCount} />
|
||||
</Collapsible>
|
||||
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || changes.length === 0 || editablePageItems.length === 0} onClick={savePermissions}>保存权限</button>}
|
||||
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || (changes.length === 0 && breaks.length === 0) || allEditableIds.length === 0} onClick={savePermissions}>保存权限</button>}
|
||||
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
|
||||
</> : <p className="ad-hint">请选择角色</p>}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { TaskSidebar } from './TaskSidebar'
|
||||
|
||||
const mocked = vi.hoisted(() => ({ state: {} as Record<string, unknown> }))
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (select: (value: Record<string, unknown>) => unknown) => select(mocked.state),
|
||||
store: { openInbox: vi.fn(), openFiles: vi.fn(), focusPending: vi.fn(), setUI: vi.fn(), toggleLeft: vi.fn() },
|
||||
}))
|
||||
|
||||
const member = { owner: 'bob', name: 'Bob', auth: true, systemActions: [],
|
||||
linkedCapabilities: { projectCreate: false, linkedActions: [], authorityEpoch: 1 } }
|
||||
const creator = { ...member, linkedCapabilities: { projectCreate: true, source: 'role', linkedActions: [], authorityEpoch: 1 } }
|
||||
|
||||
function render(patch: Record<string, unknown>) {
|
||||
mocked.state = {
|
||||
projects: [], projectsLoaded: true, projectSync: { status: 'ok', failures: 0 }, tasks: [], me: member,
|
||||
adminSections: [], artifacts: [], cards: [], projectCards: [], serverWorkspaces: [],
|
||||
rdmAuditAvailable: false, rdmCockpitAvailable: false, currentConvId: null, currentProjectId: null,
|
||||
ui: { theme: 'light', showArchived: false },
|
||||
...patch,
|
||||
}
|
||||
return renderToStaticMarkup(<TaskSidebar />)
|
||||
}
|
||||
|
||||
beforeEach(() => { mocked.state = {} })
|
||||
|
||||
describe('EmptyListNoCreate sidebar', () => {
|
||||
it('shows a waiting-for-invite state without inviting a same-name project for a member without creation', () => {
|
||||
const html = render({})
|
||||
expect(html).toContain('尚无项目:等待项目负责人邀请你加入,加入后会自动出现在这里。')
|
||||
expect(html).not.toContain('新建项目')
|
||||
expect(html).toMatch(/<button class="tsb-newtask-main" disabled=""/)
|
||||
})
|
||||
|
||||
it('shows a loading state instead of an empty state before the list arrives', () => {
|
||||
const html = render({ projectsLoaded: false })
|
||||
expect(html).toContain('正在加载项目列表…')
|
||||
expect(html).not.toContain('等待项目负责人邀请')
|
||||
})
|
||||
|
||||
it('points a creator to explicit project creation', () => {
|
||||
expect(render({ me: creator })).toContain('还没有项目:从「新建项目」开始。')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AutoDiscover sidebar status', () => {
|
||||
it('surfaces a degraded or offline project sync instead of silently showing stale projects', () => {
|
||||
expect(render({ projectSync: { status: 'degraded', failures: 2 } })).toContain('项目列表同步异常,正在自动重试')
|
||||
expect(render({ projectSync: { status: 'offline', failures: 0 } })).toContain('网络已断开,恢复后自动同步项目列表')
|
||||
expect(render({})).not.toContain('同步异常')
|
||||
})
|
||||
|
||||
it('keeps conversation creation available once a discovered project is current', () => {
|
||||
const html = render({ projects: [{ id: 'host-b', name: '项目P', owner: 'carol' }], currentProjectId: 'host-b' })
|
||||
expect(html).toMatch(/<button class="tsb-newtask-main" title="在当前项目新建对话/)
|
||||
expect(html).toContain('从「新建对话」开始')
|
||||
})
|
||||
|
||||
it('falls back to a project-less sidebar when the current discovery has no project', () => {
|
||||
const html = render({ projects: [], currentProjectId: null })
|
||||
expect(html).toContain('尚无项目')
|
||||
expect(html).not.toContain('同步异常')
|
||||
})
|
||||
|
||||
it('does not render a sync notice when the listing succeeded', () => {
|
||||
expect(render({ projectSync: { status: 'ok', failures: 0 } })).not.toContain('同步异常')
|
||||
expect(render({ projectSync: { status: 'idle', failures: 0 } })).not.toContain('同步异常')
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,9 @@ import { api } from '../api'
|
||||
import { Icon } from '@ui'
|
||||
import type { Conversation } from '../contracts'
|
||||
import { relativeTime, filterTasks } from '../tasklist'
|
||||
import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta'
|
||||
import {
|
||||
canCreateProject, emptyProjectsNotice, projectCapabilitySummary, projectStage, projectStateLabel, projectWorkError,
|
||||
} from '../projectmeta'
|
||||
import { buildWorkspaceEntries } from '../workspaces'
|
||||
|
||||
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
|
||||
@@ -59,6 +61,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
|
||||
const rdmCockpitAvailable = useStore(s => s.rdmCockpitAvailable)
|
||||
const theme = useStore(s => s.ui.theme)
|
||||
const projectsLoaded = useStore(s => !!s.projectsLoaded)
|
||||
const syncStatus = useStore(s => s.projectSync?.status ?? 'idle')
|
||||
const pendingBadge = useStore(s =>
|
||||
s.cards.filter(c => c.status === 'action_required').length +
|
||||
s.projectCards.filter(c => c.status === 'action_required').length)
|
||||
@@ -84,6 +88,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
const workError = currentProject ? projectWorkError(currentProject) : null
|
||||
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
|
||||
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
|
||||
const canCreate = canCreateProject(me)
|
||||
const noProjects = projects.length === 0
|
||||
const syncNotice = syncStatus === 'degraded' ? '项目列表同步异常,正在自动重试'
|
||||
: syncStatus === 'offline' ? '网络已断开,恢复后自动同步项目列表' : null
|
||||
|
||||
const rowActions = (t: Conversation) => (
|
||||
<span className="tsb-row-acts">
|
||||
@@ -139,7 +147,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
{/* 主入口创建会话;项目创建保留在次级菜单。 */}
|
||||
<div className="tsb-actions">
|
||||
<div className="tsb-newtask">
|
||||
<button className="tsb-newtask-main" title="在当前项目新建对话(⌘N)" onClick={() => createConversation()}>
|
||||
<button className="tsb-newtask-main" disabled={noProjects} title={noProjects ? emptyProjectsNotice(projectsLoaded, canCreate) : '在当前项目新建对话(⌘N)'}
|
||||
onClick={() => createConversation()}>
|
||||
<Icon name="plus" size={15} />新建对话
|
||||
</button>
|
||||
<button className="tsb-newtask-caret" title="更多创建选项"
|
||||
@@ -160,7 +169,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
|
||||
{/* 中段(方案:会话与搜索)。项目切换在顶部,文件与工作区沉底。 */}
|
||||
<div className="tsb-scroll">
|
||||
{creatingProject && (
|
||||
{syncNotice && <div className="tsb-tool-note" role="status">{syncNotice}</div>}
|
||||
{canCreate && creatingProject && (
|
||||
<div className="tsb-newproject">
|
||||
<input className="tsb-search" autoFocus value={newProjectName}
|
||||
onChange={e => setNewProjectName(e.target.value)}
|
||||
@@ -185,7 +195,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
)}
|
||||
|
||||
{rest.length === 0 && pinned.length === 0 && (
|
||||
<div className="tsb-empty">{workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
|
||||
<div className="tsb-empty">{noProjects ? emptyProjectsNotice(projectsLoaded, canCreate)
|
||||
: workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
|
||||
)}
|
||||
{rest.map(t => taskRow(t))}
|
||||
</div>
|
||||
@@ -231,22 +242,28 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
{projects.map(p => {
|
||||
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
|
||||
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
|
||||
const mine = !!p.owner && p.owner === me?.owner
|
||||
return (
|
||||
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
|
||||
{p.name}
|
||||
<kbd>{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd>
|
||||
<kbd>{p.id === currentProjectId ? '当前 · ' : pstage ? `${pstage} · ` : ''}{mine ? '我创建 · ' : ''}{count} 会话</kbd>
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
{projects.length === 0 && <div className="tsb-tool-note">还没有项目</div>}
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||
{noProjects && <div className="tsb-tool-note">{emptyProjectsNotice(projectsLoaded, canCreate)}</div>}
|
||||
{canCreate && <>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||
</>}
|
||||
</Menu>
|
||||
|
||||
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||
<MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||
{!noProjects && <MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>}
|
||||
{canCreate && <>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||
</>}
|
||||
{noProjects && !canCreate && <div className="tsb-tool-note">{emptyProjectsNotice(projectsLoaded, canCreate)}</div>}
|
||||
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
|
||||
<MenuItem key={p.id} onClick={() => { createConversation(p.id); setMenu(null) }}>在「{p.name}」新建对话</MenuItem>
|
||||
))}
|
||||
@@ -270,6 +287,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
<span>
|
||||
<div className="tsb-acc-name">{me?.name || '未连接'}</div>
|
||||
<div className="tsb-acc-mail">{me?.owner ? `租户:${me.owner}` : '—'}</div>
|
||||
{me && <div className="tsb-acc-mail">{projectCapabilitySummary(me)}</div>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tsb-menu-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)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { Project } from '../contracts'
|
||||
import { MemberDutyCell, RdmMembersSection, memberManagementView } from './adminRdmSections'
|
||||
|
||||
const project: Project = {
|
||||
id: 'host-1', owner: 'alice', name: '项目P', createdAt: '', updatedAt: '',
|
||||
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' },
|
||||
}
|
||||
|
||||
describe('MemberManagementEntryHidden', () => {
|
||||
it('stays read-only until the self check confirms member management', () => {
|
||||
expect(memberManagementView(null, null)).toEqual({ canManage: false, notice: '正在核对成员管理权限…' })
|
||||
expect(memberManagementView([], null).canManage).toBe(false)
|
||||
expect(memberManagementView([{ action: 'member.manage', allowed: false, }], null).notice).toContain('成员增删由项目创建者负责')
|
||||
expect(memberManagementView([{ action: 'member.manage', allowed: true }], null)).toEqual({ canManage: true, notice: null })
|
||||
})
|
||||
|
||||
it('does not offer member management on a project that cannot accept work', () => {
|
||||
expect(memberManagementView([{ action: 'member.manage', allowed: true }], '项目已归档').canManage).toBe(false)
|
||||
})
|
||||
|
||||
it('renders no add, role change or removal entry before authorization is known', () => {
|
||||
const html = renderToStaticMarkup(<RdmMembersSection project={project} />)
|
||||
expect(html).toContain('正在核对成员管理权限…')
|
||||
expect(html).not.toContain('添加成员')
|
||||
expect(html).not.toContain('选择人员')
|
||||
expect(html).toContain('实际职责')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CreatorOwnerMemberDuties member table', () => {
|
||||
it('separates the immutable creator from the owner label and plain members', () => {
|
||||
const creator = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'alice', role: 'owner' }} creator="alice" />)
|
||||
expect(creator).toContain('创建者')
|
||||
expect(creator).toContain('成员增删')
|
||||
expect(creator).toContain('随「创建项目」联动')
|
||||
const owner = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'bob', role: 'owner' }} creator="alice" />)
|
||||
expect(owner).toContain('项目负责人')
|
||||
expect(owner).toContain('不含成员管理')
|
||||
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator="alice" />)
|
||||
expect(member).toContain('基础协作')
|
||||
expect(member).not.toContain('成员增删')
|
||||
})
|
||||
|
||||
it('falls back to plain member duty when the project has no creator recorded', () => {
|
||||
const owner = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'bob', role: 'owner' }} creator={undefined} />)
|
||||
expect(owner).toContain('项目负责人')
|
||||
expect(owner).not.toContain('<span class="ad-chip ad-chip-admin">创建者')
|
||||
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator={undefined} />)
|
||||
expect(member).toContain('基础协作')
|
||||
})
|
||||
|
||||
it('marks the creator cell as an admin chip and the plain member as a neutral chip', () => {
|
||||
const creator = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'alice', role: 'owner' }} creator="alice" />)
|
||||
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator="alice" />)
|
||||
expect(creator).toContain('ad-chip-admin')
|
||||
expect(member).toMatch(/ad-chip(?!-admin)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MemberManagementEntryHidden notice clarity', () => {
|
||||
it('names the project-wide stage error as the reason to gate management entries', () => {
|
||||
const result = memberManagementView([{ action: 'member.manage', allowed: true }], '项目已归档,请联系项目负责人恢复后再新建对话')
|
||||
expect(result).toEqual({ canManage: false, notice: null })
|
||||
})
|
||||
|
||||
it('distinguishes pending self-check from a confirmed no-permission state', () => {
|
||||
const pending = memberManagementView(null, null)
|
||||
const denied = memberManagementView([{ action: 'project.members.manage', allowed: false }], null)
|
||||
expect(pending.notice).toMatch(/正在核对/)
|
||||
expect(denied.notice).toMatch(/成员增删由项目创建者负责/)
|
||||
})
|
||||
})
|
||||
@@ -5,32 +5,50 @@
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { api } from '../api'
|
||||
import type { Project } from '../contracts'
|
||||
import { projectWorkError } from '../projectmeta'
|
||||
import { canManageProjectMembers, memberDuty, projectWorkError } from '../projectmeta'
|
||||
import { useStore } from '../store'
|
||||
import { Btn, Collapsible, Icon, Picker } from '@ui'
|
||||
import {
|
||||
memberAddPayload, memberRemovePayload, memberSetRolePayload,
|
||||
permCheckPayload, describeSource,
|
||||
} from '../rdmManagement'
|
||||
|
||||
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
|
||||
|
||||
import {
|
||||
type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type RdmMemberPage, type RdmProfRole,
|
||||
type SearchFieldsChoice,
|
||||
MATCH_MODE_LABELS, SEARCH_FIELDS, accountCandidatePager, accountSearchKey, appendMemberPage, memberListParams,
|
||||
personLabel, roleOptionsPayload,
|
||||
} from '../rdmManagement'
|
||||
|
||||
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
|
||||
|
||||
interface MemberRow {
|
||||
owner: string; name?: string; role: string; addedBy: string; addedAt: string
|
||||
profRole?: { roleId: string; name: string } | null
|
||||
}
|
||||
|
||||
/** 成员管理入口门控:本人自省未到达或无成员管理资格时只读(fail-closed)。 */
|
||||
export function memberManagementView(actions: { action: string; allowed: boolean }[] | null, writeError: string | null) {
|
||||
if (actions === null) return { canManage: false, notice: '正在核对成员管理权限…' }
|
||||
if (!canManageProjectMembers(actions)) {
|
||||
return { canManage: false, notice: '你可以查看成员与实际职责;成员增删由项目创建者负责(需具备项目成员管理资格)。' }
|
||||
}
|
||||
return { canManage: !writeError, notice: null }
|
||||
}
|
||||
|
||||
/** 实际职责:创建者(不可变)与界面身份「负责人」分开显示,并标明能力关联来源。 */
|
||||
export function MemberDutyCell({ member, creator }: { member: { owner: string; role: string }; creator: string | undefined }) {
|
||||
const duty = memberDuty(member, creator)
|
||||
return <>
|
||||
<span className={duty.kind === 'member' ? 'ad-chip' : 'ad-chip ad-chip-admin'}>{duty.label}</span>
|
||||
<div className="ad-dim" title={`来源:${duty.source}`}>{duty.duties}</div>
|
||||
</>
|
||||
}
|
||||
|
||||
export function RdmMembersSection({ project }: { project: Project }) {
|
||||
const projectId = project.id
|
||||
const writeError = projectWorkError(project)
|
||||
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
||||
const me = useStore(s => s.me)
|
||||
const [manageActions, setManageActions] = useState<{ action: string; allowed: boolean }[] | null>(null)
|
||||
const manage = memberManagementView(manageActions, writeError)
|
||||
const [rows, setRows] = useState<MemberRow[] | null>(null)
|
||||
const [memberNext, setMemberNext] = useState('')
|
||||
const [memberTotal, setMemberTotal] = useState<number | null>(null)
|
||||
@@ -60,12 +78,28 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
setPicked([]); setPickedItems([]); knownPicks.current.clear()
|
||||
setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('')
|
||||
setRoleOptions(null)
|
||||
}, [project])
|
||||
|
||||
// rdm.role.options 受 member.manage 授权;未确认前先不发请求,避免普通成员被 403。
|
||||
// 跟着 [project, manage.canManage] 重订:项目切换或权限放开后重发,迟到响应丢弃。
|
||||
useEffect(() => {
|
||||
if (!manage.canManage) return
|
||||
let live = true
|
||||
cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId))
|
||||
.then(out => { if (live) setRoleOptions(out.roles ?? []) })
|
||||
.catch(() => { if (live) setRoleOptions(null) })
|
||||
return () => { live = false }
|
||||
}, [project])
|
||||
}, [project, manage.canManage])
|
||||
|
||||
useEffect(() => {
|
||||
setManageActions(null)
|
||||
if (!me?.owner) return
|
||||
let live = true
|
||||
cmd<{ actions?: { action: string; allowed: boolean }[] }>('rdm.perm.check', permCheckPayload(projectId, me.owner))
|
||||
.then(out => { if (live) setManageActions(out.actions ?? []) })
|
||||
.catch(() => { if (live) setManageActions([]) })
|
||||
return () => { live = false }
|
||||
}, [project, me?.owner])
|
||||
|
||||
const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
|
||||
[candMatch, candFields, candRoleId])
|
||||
@@ -142,7 +176,8 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
return (
|
||||
<div>
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
<div className="ad-create">
|
||||
{manage.notice && <p className="ad-hint" role="note">{manage.notice}</p>}
|
||||
{manage.canManage && <div className="ad-create">
|
||||
<div className="ad-row">
|
||||
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
|
||||
<select className="lg-input" aria-label="匹配方式" value={candMatch}
|
||||
@@ -170,7 +205,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
/>
|
||||
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
||||
<option value="member">项目成员</option>
|
||||
<option value="owner">项目负责人</option>
|
||||
<option value="owner">项目负责人(界面身份,不含成员管理)</option>
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid}
|
||||
onClick={() => pickedItem && run(`已添加 ${personLabel({ owner: pickedItem.owner, name: pickedItem.title })}`, async () => {
|
||||
@@ -178,8 +213,8 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
setPicked([]); setPickedItems([])
|
||||
})}>添加成员</button>
|
||||
</div>
|
||||
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
|
||||
</div>
|
||||
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。被加入的成员无需重新登录,项目会自动出现在其项目列表。</p>
|
||||
</div>}
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
|
||||
onChange={e => setMemberQuery(e.target.value)} />
|
||||
@@ -188,16 +223,17 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
</div>
|
||||
{!directoryOk && <p className="ad-hint">账号目录暂不可用:成员姓名可能缺失,仅按账号显示。</p>}
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
|
||||
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>实际职责</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(rows ?? []).map(m => (
|
||||
<tr key={m.owner}>
|
||||
<td className="ad-mono">{personLabel(m)}</td>
|
||||
<td className="ad-dim">{m.profRole?.name || '—'}</td>
|
||||
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td>
|
||||
<td><MemberDutyCell member={m} creator={project.owner} /></td>
|
||||
<td className="ad-dim">{m.addedBy || '—'}</td>
|
||||
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
|
||||
<td>
|
||||
<td>{manage.canManage && <>
|
||||
<button className="ad-mini" disabled={busy || !!writeError}
|
||||
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
|
||||
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
|
||||
@@ -207,11 +243,11 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
<button className="ad-mini" disabled={busy || !!writeError}
|
||||
onClick={() => run(`已移除 ${m.owner}`,
|
||||
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
|
||||
</td>
|
||||
</>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
|
||||
{rows === null && <tr><td colSpan={6} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={7} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
|
||||
{rows === null && <tr><td colSpan={7} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{memberNext && rows !== null && (
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
|
||||
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
|
||||
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, type RecoveryProposal } from '../api'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import type { AgentProfile } from '../contracts'
|
||||
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
|
||||
import { SettingInput, flatten, unflatten } from './SettingsModal'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { docBridge } from './Workbench'
|
||||
import {
|
||||
recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected,
|
||||
recoveryStatusLabel, type RecoveryAttempt,
|
||||
} from '../recovery'
|
||||
import { accountActionPolicy, canAppointAdmins } from '../accountAuthority'
|
||||
import { AdminAppointmentButton, CreateAdminOption, IdentityChip } from './AccountUsersPanel'
|
||||
import { CreateAdminOption, IdentityChip } from './AccountUsersPanel'
|
||||
import { GeneratedCredentialPanel, oneTimePasswordCredential, type GeneratedCredential } from './oneTimePasswordUi'
|
||||
|
||||
// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码;
|
||||
// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ----
|
||||
// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码的唯一处置是
|
||||
// 管理员生成一次性登录密码,用户登录后必须立即改密。 ----
|
||||
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() {
|
||||
@@ -29,27 +26,12 @@ export function UsersPanel() {
|
||||
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 [generatedCredential, setGeneratedCredential] = useState<GeneratedCredential | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。
|
||||
const [propFor, setPropFor] = useState<AdminUserRow | null>(null)
|
||||
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
|
||||
|
||||
// 提案视图代次:切换/关闭目标推进代次,迟到响应不得覆盖新选中的账号。
|
||||
const proposalSeq = useRef(0)
|
||||
// 结果未确定的提案请求:显式重试沿用同一 idempotencyKey,不重复创建。
|
||||
const recoveryAttempt = useRef<RecoveryAttempt | null>(null)
|
||||
|
||||
const refresh = async () => {
|
||||
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
|
||||
}
|
||||
const refreshProposals = async (userId: string) => {
|
||||
const seq = proposalSeq.current
|
||||
try {
|
||||
const statuses = await api.listAdminRecoveryProposals(userId)
|
||||
if (seq === proposalSeq.current) setProposals(statuses)
|
||||
} catch (e) { if (seq === proposalSeq.current) setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
const create = async () => {
|
||||
@@ -64,86 +46,16 @@ export function UsersPanel() {
|
||||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const resetPassword = async (u: AdminUserRow) => {
|
||||
const issueOneTimePassword = async (u: AdminUserRow) => {
|
||||
setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true)
|
||||
try {
|
||||
const out = await api.adminResetUserPassword(u.owner)
|
||||
setGeneratedCredential({ owner: u.owner, password: out.temporaryPassword, kind: 'temporary' })
|
||||
setNotice(`已为 ${u.owner} 生成临时密码。该用户登录后必须立即修改密码。`)
|
||||
const out = await api.adminIssueOneTimePassword(u.owner)
|
||||
setGeneratedCredential(oneTimePasswordCredential(out))
|
||||
setNotice(`已为 ${u.owner} 生成一次性登录密码。用户使用后必须立即设置新密码。`)
|
||||
await refresh()
|
||||
} 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)
|
||||
await refreshProposals(u.userId)
|
||||
}
|
||||
const closeProposals = () => { proposalSeq.current++; setPropFor(null); setProposals(null) }
|
||||
|
||||
const propose = async () => {
|
||||
if (!propFor) return
|
||||
const target = propFor
|
||||
const seq = proposalSeq.current
|
||||
const stillSelected = () => seq === proposalSeq.current
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
const idempotencyKey = recoveryAttemptKey(recoveryAttempt.current, target.userId, target.stateVersion, () => crypto.randomUUID())
|
||||
recoveryAttempt.current = { userId: target.userId, stateVersion: target.stateVersion, key: idempotencyKey }
|
||||
try {
|
||||
const created = await api.createAdminRecoveryProposal(target.userId, target.stateVersion, idempotencyKey)
|
||||
recoveryAttempt.current = null
|
||||
const statuses = await api.listAdminRecoveryProposals(target.userId).catch(() => null)
|
||||
if (!stillSelected()) return
|
||||
if (statuses) setProposals(statuses)
|
||||
setNotice(recoveryProposalNotice(target.owner, (statuses?.find(p => p.id === created.id) ?? created).status))
|
||||
} catch (e) {
|
||||
const message = String(e)
|
||||
if (message.includes('409')) {
|
||||
recoveryAttempt.current = null
|
||||
await resetPassword(target)
|
||||
return
|
||||
}
|
||||
if (!stillSelected()) return
|
||||
if (recoveryProposalRejected(message)) {
|
||||
recoveryAttempt.current = null
|
||||
setErr(recoveryProposalErrorMessage(message))
|
||||
return
|
||||
}
|
||||
// 响应丢失或服务端异常时提案可能已提交:先重读元数据,再允许沿用同一 key 显式重试。
|
||||
try {
|
||||
const statuses = await api.listAdminRecoveryProposals(target.userId)
|
||||
if (!stillSelected()) return
|
||||
setProposals(statuses)
|
||||
if (statuses.length > 0) {
|
||||
setNotice(`提案响应未收到;状态已刷新。最近记录:${recoveryStatusLabel[statuses[0].status]}。请核对下方记录;再次提交会沿用同一请求标识。`)
|
||||
} else {
|
||||
setErr('提案结果未确定;状态已刷新。再次提交会沿用同一请求标识。')
|
||||
}
|
||||
} catch {
|
||||
if (stillSelected()) setErr('提案结果未知且状态暂时无法读取;请稍后核对,不会自动重复创建。')
|
||||
}
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const revokeProposal = async (p: RecoveryProposal) => {
|
||||
if (!propFor) return
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version)
|
||||
await refreshProposals(propFor.userId)
|
||||
setNotice('提案已撤销')
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{me?.isAdmin && (
|
||||
@@ -159,65 +71,29 @@ export function UsersPanel() {
|
||||
</div>
|
||||
)}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{generatedCredential && <div className="ad-reset">
|
||||
<p className="ad-hint">{generatedCredential.kind === 'initial' ? '初始密码' : '临时密码'}仅显示一次,请立即安全交付给 {generatedCredential.owner}。</p>
|
||||
<input className="lg-input ad-mono" readOnly value={generatedCredential.password} aria-label="一次性密码" />
|
||||
<button type="button" className="ad-mini" onClick={() => setGeneratedCredential(null)}>关闭</button>
|
||||
</div>}
|
||||
{generatedCredential && <GeneratedCredentialPanel credential={generatedCredential} onClose={() => setGeneratedCredential(null)} />}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
{propFor ? (
|
||||
<div className="ad-reset">
|
||||
<div className="ad-row">
|
||||
<span><b>{propFor.owner}</b> 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key)</span>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={propose}>发起恢复提案</button>
|
||||
<button type="button" className="ad-mini" onClick={closeProposals}>关闭</button>
|
||||
</div>
|
||||
<p className="ad-hint">前提:该用户当前在任一设备保持完整密码登录(会话证明)。已锁死且无会话的账号无法在线恢复,请走服务器本地处置。</p>
|
||||
{proposals !== null && proposals.length > 0 && (
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>提案</th><th>状态</th><th>到期时间</th><th>发起人</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{proposals.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td className="ad-mono">{p.id}</td>
|
||||
<td>{recoveryStatusLabel[p.status]}{recoveryProposalHint(p.status) && <span className="ad-dim"> · {recoveryProposalHint(p.status)}</span>}</td>
|
||||
<td className="ad-dim">{new Date(p.expiresAt).toLocaleString()}</td>
|
||||
<td className="ad-dim">{p.createdBy}</td>
|
||||
<td>{(p.status === 'pending' || p.status === 'confirmed') && (
|
||||
<button className="ad-mini" disabled={busy} onClick={() => revokeProposal(p)}>撤销</button>
|
||||
)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{proposals !== null && proposals.length === 0 && <p className="ad-hint">该用户当前没有恢复提案。</p>}
|
||||
</div>
|
||||
) : (
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(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.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">
|
||||
{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>
|
||||
)}
|
||||
{me?.isAdmin && <p className="ad-hint">用户忘记密码时,点击该用户行的「生成一次性登录密码」:用户登录后必须立即设置新密码。</p>}
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(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.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 && (policy.resetPassword
|
||||
? <button className="ad-mini" disabled={busy} onClick={() => issueOneTimePassword(u)}>生成一次性登录密码</button>
|
||||
: <span className="ad-dim">{policy.protectedReason || '受保护账号不可生成一次性登录密码'}</span>
|
||||
)}</td>
|
||||
</tr>
|
||||
})}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
|
||||
export function humanLoginError(raw: string): string {
|
||||
if (raw.includes('401')) return '用户名或密码不正确'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
|
||||
* 不能沿用登录的「用户名或密码不正确」一刀切。
|
||||
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
|
||||
export function humanChangePasswordError(raw: string): string {
|
||||
if (raw.includes('current password incorrect')) return '当前密码不正确'
|
||||
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
|
||||
if (raw.includes('session expired')) return '会话已过期,请重新登录'
|
||||
if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
|
||||
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return '认证失败,请重新登录'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { AdminIssuedOneTimePassword } from '../api'
|
||||
|
||||
export interface GeneratedCredential {
|
||||
owner: string
|
||||
password: string
|
||||
kind: 'initial' | 'one-time'
|
||||
}
|
||||
|
||||
export function oneTimePasswordCredential(out: AdminIssuedOneTimePassword): GeneratedCredential {
|
||||
return { owner: out.owner, password: out.oneTimePassword, kind: 'one-time' }
|
||||
}
|
||||
|
||||
export function GeneratedCredentialPanel({ credential, onClose }: { credential: GeneratedCredential; onClose: () => void }) {
|
||||
return <div className="ad-reset">
|
||||
<p className="ad-hint">{credential.kind === 'initial'
|
||||
? `初始密码仅显示一次,请立即安全交付给 ${credential.owner}。`
|
||||
: `一次性登录密码仅显示一次,请立即安全交付给 ${credential.owner};使用后必须强制改密。`}</p>
|
||||
<input className="lg-input ad-mono" readOnly value={credential.password} aria-label="一次性密码" />
|
||||
<button type="button" className="ad-mini" onClick={onClose}>关闭</button>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { api, type PasswordChangeMode } from '../api'
|
||||
|
||||
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
|
||||
return mode === 'current_required'
|
||||
}
|
||||
|
||||
export function isForcedPasswordMode(mode: PasswordChangeMode | null | undefined): mode is PasswordChangeMode {
|
||||
return mode === 'grant_setup' || mode === 'current_required' || mode === 'one_time_password'
|
||||
}
|
||||
|
||||
export function ForcedPasswordModeSection({ mode, current, onCurrentChange }: { mode: PasswordChangeMode | null; current: string; onCurrentChange: (value: string) => void }) {
|
||||
if (mode === 'current_required') {
|
||||
return <>
|
||||
<label className="lg-label" htmlFor="forced-current">当前密码</label>
|
||||
<input id="forced-current" className="lg-input" type="password" value={current}
|
||||
onChange={e => onCurrentChange(e.target.value)} autoComplete="current-password" />
|
||||
</>
|
||||
}
|
||||
if (mode === 'grant_setup' || mode === 'one_time_password') {
|
||||
return <p className="lg-hint">当前为受限设密会话,此次无需输入当前密码。</p>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function submitForcedPassword(mode: PasswordChangeMode, current: string, next: string) {
|
||||
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
|
||||
}
|
||||
+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 }
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canCreateProject, canManageProjectMembers, emptyProjectsNotice, memberDuty, noProjectError, projectCapabilitySummary,
|
||||
} from './projectmeta'
|
||||
|
||||
const linked = (projectCreate: boolean) => ({ projectCreate, source: 'role', linkedActions: [], authorityEpoch: 3 })
|
||||
|
||||
describe('EmptyListNoCreate', () => {
|
||||
it('asks a member without creation capability to wait for an invite instead of creating a project', () => {
|
||||
const notice = emptyProjectsNotice(true, false)
|
||||
expect(notice).toContain('等待项目负责人邀请')
|
||||
expect(notice).not.toContain('新建')
|
||||
expect(noProjectError(true, false)).not.toContain('新建')
|
||||
})
|
||||
|
||||
it('does not call a list that has not arrived empty', () => {
|
||||
expect(emptyProjectsNotice(false, true)).toBe('正在加载项目列表…')
|
||||
expect(noProjectError(false, false)).toContain('尚未加载')
|
||||
})
|
||||
|
||||
it('points a creator to explicit project creation', () => {
|
||||
expect(emptyProjectsNotice(true, true)).toContain('新建项目')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DefaultProjectDisabled creation entry', () => {
|
||||
it('follows the server-computed creation link first', () => {
|
||||
expect(canCreateProject({ auth: true, isAdmin: true, systemActions: ['project.create'], linkedCapabilities: linked(false) })).toBe(false)
|
||||
expect(canCreateProject({ auth: true, systemActions: [], linkedCapabilities: linked(true) })).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to explicit system actions and fails closed without an identity', () => {
|
||||
expect(canCreateProject({ auth: true, systemActions: [] })).toBe(false)
|
||||
expect(canCreateProject({ auth: true, systemActions: ['project.create'] })).toBe(true)
|
||||
expect(canCreateProject(null)).toBe(false)
|
||||
expect(canCreateProject({ auth: false })).toBe(true)
|
||||
})
|
||||
|
||||
it('summarizes the creation source and the linked own-project management', () => {
|
||||
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: linked(true) })).toContain('来源:职位角色')
|
||||
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: { ...linked(true), conflicts: ['project.members.manage'] } }))
|
||||
.toContain('1 项关联被角色上限阻断')
|
||||
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: linked(false) })).toContain('被邀请加入后项目自动出现')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CreatorOwnerMemberDuties', () => {
|
||||
it('gives member management only to the immutable creator, not to an owner label', () => {
|
||||
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice')).toMatchObject({ kind: 'creator', label: '创建者' })
|
||||
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice').duties).toContain('成员增删')
|
||||
const owner = memberDuty({ owner: 'bob', role: 'owner' }, 'alice')
|
||||
expect(owner).toMatchObject({ kind: 'owner', label: '项目负责人' })
|
||||
expect(owner.duties).toContain('不含成员管理')
|
||||
const member = memberDuty({ owner: 'carol', role: 'member' }, 'alice')
|
||||
expect(member).toMatchObject({ kind: 'member', source: '成员关系' })
|
||||
expect(member.duties).toContain('本人对话')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MemberManagementEntryHidden capability', () => {
|
||||
it('requires an allowed member management action from the self check', () => {
|
||||
expect(canManageProjectMembers(null)).toBe(false)
|
||||
expect(canManageProjectMembers([{ action: 'member.manage', allowed: false }])).toBe(false)
|
||||
expect(canManageProjectMembers([{ action: 'doc.read', allowed: true }])).toBe(false)
|
||||
expect(canManageProjectMembers([{ action: 'member.manage', allowed: true }])).toBe(true)
|
||||
expect(canManageProjectMembers([{ action: 'project.members.manage', allowed: true }])).toBe(true)
|
||||
})
|
||||
|
||||
it('treats the two old and new capability ids as equivalent for backward compatibility', () => {
|
||||
const onlyLegacy = canManageProjectMembers([{ action: 'member.manage', allowed: true }])
|
||||
const onlyModern = canManageProjectMembers([{ action: 'project.members.manage', allowed: true }])
|
||||
const both = canManageProjectMembers([
|
||||
{ action: 'member.manage', allowed: true },
|
||||
{ action: 'project.members.manage', allowed: true },
|
||||
])
|
||||
expect(onlyLegacy).toBe(onlyModern)
|
||||
expect(onlyModern).toBe(true)
|
||||
expect(both).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CreatorOwnerMemberDuties member identity', () => {
|
||||
it('falls back to plain member when the creator record is missing', () => {
|
||||
const member = memberDuty({ owner: 'carol', role: 'member' }, undefined)
|
||||
expect(member).toMatchObject({ kind: 'member', label: '项目成员' })
|
||||
expect(member.duties).toContain('基础协作')
|
||||
})
|
||||
|
||||
it('names the source of each member kind distinctly', () => {
|
||||
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice').source).toMatch(/创建项目/)
|
||||
expect(memberDuty({ owner: 'bob', role: 'owner' }, 'alice').source).toMatch(/成员关系/)
|
||||
expect(memberDuty({ owner: 'carol', role: 'member' }, 'alice').source).toBe('成员关系')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DefaultProjectDisabled empty state messaging', () => {
|
||||
it('keeps both helpers pointing the member to wait for an invite', () => {
|
||||
expect(emptyProjectsNotice(true, false)).toContain('等待项目负责人邀请')
|
||||
expect(noProjectError(true, false)).toContain('等待项目负责人邀请')
|
||||
})
|
||||
|
||||
it('keeps the loading notice distinct from a real empty list', () => {
|
||||
expect(emptyProjectsNotice(false, false)).toMatch(/加载/)
|
||||
expect(noProjectError(false, false)).toMatch(/加载/)
|
||||
expect(emptyProjectsNotice(false, false)).not.toBe(noProjectError(false, false))
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { LinkedCapabilities } from './api'
|
||||
|
||||
/** 阶段徽章:只读项目设置里的 stage 字符串;没有就不显示。 */
|
||||
export function projectStage(settings?: Record<string, unknown> | null): string | null {
|
||||
const stage = settings?.stage
|
||||
@@ -41,3 +43,70 @@ export function projectActionError(error: unknown): string {
|
||||
if (/\b503\b/.test(raw)) return '项目服务暂未就绪,请稍后重试或联系项目负责人'
|
||||
return raw.replace(/^Error:\s*/, '')
|
||||
}
|
||||
|
||||
interface ProjectActor {
|
||||
auth?: boolean; isAdmin?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities
|
||||
}
|
||||
|
||||
/** 只决定是否展示创建入口;服务端仍按当前身份最终判定。 */
|
||||
export function canCreateProject(me: ProjectActor | null | undefined): boolean {
|
||||
if (!me) return false
|
||||
if (me.auth === false) return true
|
||||
if (me.linkedCapabilities) return me.linkedCapabilities.projectCreate
|
||||
// Backends without capability exposure keep the server as the only gate.
|
||||
if (me.systemActions === undefined) return true
|
||||
return !!me.isAdmin || me.systemActions.includes('project.create')
|
||||
}
|
||||
|
||||
const CAPABILITY_SOURCE_LABELS: Record<string, string> = {
|
||||
role: '职位角色', 'system-grant': '明确系统授权', 'bootstrap-admin': '初始管理员',
|
||||
}
|
||||
|
||||
export function capabilitySourceLabel(source: string | undefined): string {
|
||||
return (source && CAPABILITY_SOURCE_LABELS[source]) || source || '未知来源'
|
||||
}
|
||||
|
||||
/** 账户区一句话:本人是否能创建项目、来源、以及联动形成的本人项目管理资格。 */
|
||||
export function projectCapabilitySummary(me: ProjectActor | null | undefined): string {
|
||||
const linked = me?.linkedCapabilities
|
||||
if (!linked) return canCreateProject(me) ? '可创建项目' : '未授予创建项目 · 被邀请加入后项目自动出现'
|
||||
if (!linked.projectCreate) return '未授予创建项目 · 被邀请加入后项目自动出现'
|
||||
const conflicts = linked.conflicts?.length ? ` · ${linked.conflicts.length} 项关联被角色上限阻断` : ''
|
||||
return `可创建项目(来源:${capabilitySourceLabel(linked.source)})· 本人创建的项目自动具备设置、成员、项目权限与文档职责管理${conflicts}`
|
||||
}
|
||||
|
||||
/** 空项目列表:未加载不当作空;无创建能力时只提示等待邀请,不诱导新建同名项目。 */
|
||||
export function emptyProjectsNotice(loaded: boolean, canCreate: boolean): string {
|
||||
if (!loaded) return '正在加载项目列表…'
|
||||
if (canCreate) return '还没有项目:从「新建项目」开始。'
|
||||
return '尚无项目:等待项目负责人邀请你加入,加入后会自动出现在这里。'
|
||||
}
|
||||
|
||||
/** 新建对话时没有可用项目:不再自动创建「默认项目」。 */
|
||||
export function noProjectError(loaded: boolean, canCreate: boolean): string {
|
||||
if (!loaded) return '项目列表尚未加载完成,请稍候再试'
|
||||
if (canCreate) return '还没有项目,请先新建项目'
|
||||
return '尚无可用项目,请等待项目负责人邀请你加入'
|
||||
}
|
||||
|
||||
export interface MemberDuty { kind: 'creator' | 'owner' | 'member'; label: string; duties: string; source: string }
|
||||
|
||||
/** 项目内实际职责:成员管理只随不可变创建者;负责人只是界面身份。 */
|
||||
export function memberDuty(member: { owner: string; role: string }, creator: string | undefined): MemberDuty {
|
||||
if (creator && member.owner === creator) {
|
||||
return { kind: 'creator', label: '创建者', duties: '项目设置、成员增删、项目内权限、文档职责分配 + 基础协作',
|
||||
source: '随「创建项目」联动(仅本人创建的项目)' }
|
||||
}
|
||||
if (member.role === 'owner') {
|
||||
return { kind: 'owner', label: '项目负责人', duties: '基础协作:项目阅读与本人对话;不含成员管理',
|
||||
source: '成员关系(界面身份不授予创建者管理权)' }
|
||||
}
|
||||
return { kind: 'member', label: '项目成员', duties: '基础协作:查看与切换项目、两树一网阅读、本人对话', source: '成员关系' }
|
||||
}
|
||||
|
||||
const MEMBER_MANAGE_ACTIONS = new Set(['member.manage', 'project.members.manage'])
|
||||
|
||||
/** 本人自省结果里成员管理是否生效;未知(null)按无权处理。 */
|
||||
export function canManageProjectMembers(actions: { action: string; allowed: boolean }[] | null | undefined): boolean {
|
||||
return !!actions?.some(a => MEMBER_MANAGE_ACTIONS.has(a.action) && a.allowed)
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected, recoveryStatusLabel,
|
||||
} from './recovery'
|
||||
import { humanChangePasswordError, humanRedeemError, requiresCurrentPassword } from './components/LoginPage'
|
||||
|
||||
// 账户链路文案与模式判定(2026-10-14 current_required 加固后前后端契约对齐的
|
||||
// 防漂移锁):改密门必须区分 grant_setup / current_required,恢复提案 409 的
|
||||
// 两种语义必须分开表达。
|
||||
|
||||
describe('requiresCurrentPassword', () => {
|
||||
it('current_required 必须要当前密码,grant_setup 免当前密码', () => {
|
||||
expect(requiresCurrentPassword('current_required')).toBe(true)
|
||||
expect(requiresCurrentPassword('grant_setup')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanChangePasswordError', () => {
|
||||
it('后端 current password incorrect 映射为当前密码不正确', () => {
|
||||
expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确')
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanRedeemError', () => {
|
||||
it('401/409/429 语义分离', () => {
|
||||
expect(humanRedeemError('409: login key already used')).toContain('已被使用')
|
||||
expect(humanRedeemError('401: invalid or expired login key')).toContain('无效或已过期')
|
||||
expect(humanRedeemError('429: too many failed attempts')).toContain('稍后再试')
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryStatusLabel', () => {
|
||||
it('覆盖全部五种提案状态', () => {
|
||||
for (const status of ['pending', 'confirmed', 'completed', 'revoked', 'expired'] as const) {
|
||||
expect(recoveryStatusLabel[status]).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryProposalHint', () => {
|
||||
it('pending/confirmed 给出下一步,终态不给', () => {
|
||||
expect(recoveryProposalHint('pending')).toContain('本人确认')
|
||||
expect(recoveryProposalHint('confirmed')).toContain('新密码')
|
||||
expect(recoveryProposalHint('completed')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryProposalNotice', () => {
|
||||
it('按服务端当前状态给出结果:待确认、已确认与终态(重复响应只回状态)', () => {
|
||||
expect(recoveryProposalNotice('alice', 'pending')).toContain('本人确认')
|
||||
expect(recoveryProposalNotice('alice', 'pending')).toContain('管理员不会收到设密能力')
|
||||
expect(recoveryProposalNotice('alice', 'confirmed')).toContain('已由本人确认')
|
||||
expect(recoveryProposalNotice('alice', 'confirmed')).toContain('管理员不会收到设密能力')
|
||||
for (const status of ['completed', 'revoked', 'expired'] as const) {
|
||||
const notice = recoveryProposalNotice('alice', status)
|
||||
expect(notice).toContain(recoveryStatusLabel[status])
|
||||
expect(notice).toContain('核对下方记录')
|
||||
expect(notice).not.toContain('请该用户')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryAttemptKey', () => {
|
||||
const fresh = () => 'new-key'
|
||||
it('同一账号同一状态版本的重试沿用未确定结果的 key', () => {
|
||||
expect(recoveryAttemptKey({ userId: 'usr_1', stateVersion: 3, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('old-key')
|
||||
})
|
||||
it('无未确定请求、换账号或状态版本变化时生成新 key', () => {
|
||||
expect(recoveryAttemptKey(null, 'usr_1', 3, fresh)).toBe('new-key')
|
||||
expect(recoveryAttemptKey({ userId: 'usr_2', stateVersion: 3, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('new-key')
|
||||
expect(recoveryAttemptKey({ userId: 'usr_1', stateVersion: 2, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('new-key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryProposalRejected', () => {
|
||||
it('4xx 为明确拒绝;网络失败与 5xx 结果未确定,需要先重读状态', () => {
|
||||
expect(recoveryProposalRejected('Error: 403: admin required')).toBe(true)
|
||||
expect(recoveryProposalRejected('Error: 404: not found')).toBe(true)
|
||||
expect(recoveryProposalRejected('Error: 502: bad gateway')).toBe(false)
|
||||
expect(recoveryProposalRejected('TypeError: Failed to fetch')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryProposalErrorMessage', () => {
|
||||
it('OWNER_PROOF_UNAVAILABLE 与活动提案冲突分开表达', () => {
|
||||
expect(recoveryProposalErrorMessage('409: OWNER_PROOF_UNAVAILABLE')).toContain('会话证明')
|
||||
expect(recoveryProposalErrorMessage('409: store: conflict')).toContain('已有进行中的恢复提案')
|
||||
})
|
||||
it('未识别错误剥状态码原样透出', () => {
|
||||
expect(recoveryProposalErrorMessage('500: boom')).toBe('boom')
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
// 恢复提案展示与错误文案(纯函数):管理端 UsersPanel 与本人 MyAccountSettings
|
||||
// 共用,vitest 锁定语义防漂移。后端 409 两种含义按响应体区分:
|
||||
// OWNER_PROOF_UNAVAILABLE=无合格会话证明;其余 conflict=已有活动提案。
|
||||
import type { RecoveryProposal } from './api'
|
||||
|
||||
export const recoveryStatusLabel: Record<RecoveryProposal['status'], string> = {
|
||||
pending: '待本人确认',
|
||||
confirmed: '已确认,待兑换设密',
|
||||
completed: '已完成',
|
||||
revoked: '已撤销',
|
||||
expired: '已过期',
|
||||
}
|
||||
|
||||
/** 管理端视角的下一步指引:提案成立后动作在属主侧(设置 → 账号)。 */
|
||||
export function recoveryProposalHint(status: RecoveryProposal['status']): string {
|
||||
if (status === 'pending') return '等待该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现'
|
||||
if (status === 'confirmed') return '该用户已确认;请提醒其尽快在受限会话中完成新密码设置,到期未用需重新生成'
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 建立提案后的结果提示:以服务端返回或重读的当前状态为准。同一 idempotencyKey
|
||||
* 的重复响应只回状态(可能已是终态),管理员任何时候都拿不到设密能力。 */
|
||||
export function recoveryProposalNotice(owner: string, status: RecoveryProposal['status']): string {
|
||||
if (status === 'pending') return `已为 ${owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现,管理员不会收到设密能力`
|
||||
if (status === 'confirmed') return `${owner} 的恢复提案已由本人确认。只有目标本人可以取得设密能力;管理员不会收到设密能力`
|
||||
return `${owner} 的恢复提案${recoveryStatusLabel[status]}。如需再次恢复,请核对下方记录`
|
||||
}
|
||||
|
||||
export interface RecoveryAttempt { userId: string; stateVersion: number; key: string }
|
||||
|
||||
/** 提案请求标识:同一账号、同一状态版本的显式重试沿用上次未确定结果的 key,
|
||||
* 让响应丢失后的重试落到同一提案而不是重复创建;账号或状态版本变化才换新 key。 */
|
||||
export function recoveryAttemptKey(previous: RecoveryAttempt | null, userId: string, stateVersion: number, fresh: () => string): string {
|
||||
return previous?.userId === userId && previous.stateVersion === stateVersion ? previous.key : fresh()
|
||||
}
|
||||
|
||||
/** 4xx 是服务端明确拒绝(提案未创建);网络失败或 5xx 时提案可能已提交,结果未确定。 */
|
||||
export function recoveryProposalRejected(raw: string): boolean {
|
||||
return /\b4\d\d:/.test(raw)
|
||||
}
|
||||
|
||||
export function recoveryProposalErrorMessage(raw: string): string {
|
||||
if (raw.includes('OWNER_PROOF_UNAVAILABLE')) {
|
||||
return '无法发起:该账号没有可用的合格会话证明(需该用户当前在任一设备保持完整密码登录)。历史来源或已锁死账号请走服务器本地处置'
|
||||
}
|
||||
if (raw.includes('409')) return '该用户已有进行中的恢复提案;请先撤销旧提案或等它完成/过期'
|
||||
if (raw.includes('403')) return '需要管理员身份'
|
||||
if (raw.includes('404')) return '用户不存在或已被移除'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PROJECT_CREATE_LINKED_ACTIONS, buildCapabilityGroups, capabilityGroupChanges, capabilityGroupState, createRolePermissionDraft,
|
||||
ensureLinkedCapabilities, linkedChainBreaks, projectCreateSources, rebaseGrantDraft, rebaseRoleDraft, setCapabilityGroup,
|
||||
type RolePermissionValue,
|
||||
} from './rolePermissions'
|
||||
|
||||
const catalog = [
|
||||
{ actionId: 'project.create', label: '创建项目', group: '项目入口' },
|
||||
{ actionId: 'project.settings', label: '项目设置', group: '项目管理' },
|
||||
{ actionId: 'project.archive', label: '归档项目', group: '项目管理' },
|
||||
{ actionId: 'project.stage.advance', label: '推进项目阶段', group: '项目管理' },
|
||||
{ actionId: 'project.members.manage', label: '管理项目成员', group: '项目管理' },
|
||||
{ actionId: 'project.permissions.manage', label: '管理项目权限', group: '项目管理' },
|
||||
{ actionId: 'document.duties.assign', label: '分配文档三权', group: '文档职责' },
|
||||
{ actionId: 'document.read', label: '读取文档', group: '文档动作' },
|
||||
{ actionId: 'document.author', label: '编制文档', group: '文档动作' },
|
||||
{ actionId: 'document.review', label: '审核文档', group: '文档动作' },
|
||||
{ actionId: 'future.action', label: '未来动作', group: '扩展' },
|
||||
]
|
||||
const off: RolePermissionValue = { allowed: false, capAllowed: false }
|
||||
const values = (overrides: Record<string, RolePermissionValue> = {}) =>
|
||||
({ ...Object.fromEntries(catalog.map(item => [item.actionId, off])), ...overrides })
|
||||
|
||||
describe('CapabilityGroup model', () => {
|
||||
const groups = buildCapabilityGroups(catalog)
|
||||
const create = groups.find(group => group.id === 'project-create')!
|
||||
|
||||
it('groups actions by business capability and keeps unknown actions visible', () => {
|
||||
expect(groups.map(group => group.id)).toEqual(['project-create', 'project-governance', 'document-duties', 'document-read', 'catalog:扩展'])
|
||||
expect(create.actions).toEqual(['project.create'])
|
||||
expect(create.linked).toEqual([...PROJECT_CREATE_LINKED_ACTIONS])
|
||||
const all = groups.flatMap(group => [...group.actions, ...group.linked]).sort()
|
||||
expect(all).toEqual(catalog.map(item => item.actionId).sort())
|
||||
})
|
||||
|
||||
it('enabling project creation links own-project management caps without granting them by default', () => {
|
||||
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
|
||||
expect(draft.values['project.create']).toEqual({ allowed: true, capAllowed: true })
|
||||
for (const id of PROJECT_CREATE_LINKED_ACTIONS) expect(draft.values[id]).toEqual({ allowed: false, capAllowed: true })
|
||||
expect(capabilityGroupState(draft.values, create)).toEqual({ allowed: 'on', capAllowed: 'on', linkedReady: true })
|
||||
})
|
||||
|
||||
it('turning project creation off keeps already linked management caps', () => {
|
||||
const enabled = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
|
||||
const disabled = setCapabilityGroup(createRolePermissionDraft(enabled.values), create, 'capAllowed', false)
|
||||
expect(disabled.values['project.create']).toEqual(off)
|
||||
for (const id of PROJECT_CREATE_LINKED_ACTIONS) expect(disabled.values[id].capAllowed).toBe(true)
|
||||
})
|
||||
|
||||
it('reports mixed group state and the grouped preview with linked additions', () => {
|
||||
const duties = groups.find(group => group.id === 'document-duties')!
|
||||
const draft = setCapabilityGroup(createRolePermissionDraft(values({ 'document.author': { allowed: true, capAllowed: true } })), create, 'capAllowed', true)
|
||||
expect(capabilityGroupState(draft.values, duties).allowed).toBe('mixed')
|
||||
const changes = capabilityGroupChanges(draft, groups)
|
||||
expect(changes.map(change => change.group.id)).toEqual(['project-create'])
|
||||
expect(changes[0].before.capAllowed).toBe('off')
|
||||
expect(changes[0].after.capAllowed).toBe('on')
|
||||
expect(changes[0].linkedAdded).toEqual([...PROJECT_CREATE_LINKED_ACTIONS])
|
||||
})
|
||||
|
||||
it('detects and repairs a broken link from a server baseline before saving', () => {
|
||||
const broken = createRolePermissionDraft(values({ 'project.create': { allowed: true, capAllowed: true } }))
|
||||
expect(linkedChainBreaks(broken.values)).toEqual([...PROJECT_CREATE_LINKED_ACTIONS])
|
||||
expect(linkedChainBreaks(ensureLinkedCapabilities(broken).values)).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves the grouped draft when an optimistic conflict is replayed on newer server values', () => {
|
||||
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
|
||||
const server = values({ 'document.review': { allowed: true, capAllowed: true } })
|
||||
const rebased = rebaseRoleDraft(draft, server)
|
||||
expect(rebased.values['project.create']).toEqual({ allowed: true, capAllowed: true })
|
||||
expect(rebased.values['project.members.manage'].capAllowed).toBe(true)
|
||||
expect(rebased.values['document.review']).toEqual({ allowed: true, capAllowed: true })
|
||||
})
|
||||
|
||||
it('replays explicit grant edits on the latest grants after a version conflict', () => {
|
||||
expect(rebaseGrantDraft(['system.users.view'], ['project.create'], ['system.users.view', 'system.roles.view']))
|
||||
.toEqual(['project.create', 'system.roles.view'])
|
||||
})
|
||||
|
||||
it('names every project creation source', () => {
|
||||
const role = { name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }
|
||||
expect(projectCreateSources(role, ['project.create'])).toEqual(['职位角色「项目经理」', '明确系统授权'])
|
||||
expect(projectCreateSources(null, [])).toEqual([])
|
||||
})
|
||||
|
||||
it('reports each business-group toggle position alongside the linked chain readiness', () => {
|
||||
const governance = groups.find(group => group.id === 'project-governance')!
|
||||
const duties = groups.find(group => group.id === 'document-duties')!
|
||||
expect(capabilityGroupState(values(), governance)).toEqual({ allowed: 'off', capAllowed: 'off', linkedReady: true })
|
||||
expect(capabilityGroupState(values({ 'document.author': { allowed: true, capAllowed: true },
|
||||
'document.review': { allowed: false, capAllowed: true } }), duties)).toEqual({
|
||||
allowed: 'mixed', capAllowed: 'on', linkedReady: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the linked additions accurate when an unrelated group also changes', () => {
|
||||
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'capAllowed', true)
|
||||
draft.values['project.archive'] = { allowed: true, capAllowed: true }
|
||||
const governanceId = 'project-governance'
|
||||
const changes = capabilityGroupChanges(draft, groups)
|
||||
const governance = changes.find(change => change.group.id === governanceId)
|
||||
expect(governance).toBeDefined()
|
||||
expect(governance!.linkedAdded).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps draft baseline identity during non-conflict rebases', () => {
|
||||
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
|
||||
const same = rebaseRoleDraft(draft, draft.values)
|
||||
expect(same.values).toEqual(draft.values)
|
||||
expect(same.baseline).toEqual(draft.values)
|
||||
expect(same.generation).toBeGreaterThan(draft.generation)
|
||||
})
|
||||
})
|
||||
@@ -61,6 +61,161 @@ export function visiblePatch(draft: RolePermissionDraft, visibleActionIds: strin
|
||||
return changes
|
||||
}
|
||||
|
||||
// ---- 业务能力组(KTD1):普通界面按业务组配置,低层动作留给服务端鉴权与审计 ----
|
||||
|
||||
export const PROJECT_CREATE_ACTION = 'project.create'
|
||||
/** 与 Backend catalog.ProjectCreateLinkedActions 同序:创建者在本人项目里的管理资格。 */
|
||||
export const PROJECT_CREATE_LINKED_ACTIONS = [
|
||||
'project.settings', 'project.members.manage', 'project.permissions.manage', 'document.duties.assign',
|
||||
] as const
|
||||
|
||||
interface CapabilityCatalogItem { actionId: string; label: string; group: string; protected?: boolean }
|
||||
|
||||
export interface CapabilityGroup {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
/** 组开关直接控制的动作。 */
|
||||
actions: string[]
|
||||
/** 组内受控项:随主能力联动,不单独勾选。 */
|
||||
linked: string[]
|
||||
}
|
||||
|
||||
const BUSINESS_GROUPS: (Omit<CapabilityGroup, 'actions' | 'linked'> & { actions: readonly string[]; linked?: readonly string[] })[] = [
|
||||
{ id: 'project-create', label: '创建与管理本人项目',
|
||||
description: '创建新项目;本人创建的项目随之具备设置、成员增删、项目内权限与文档职责分配资格,不涉及他人项目',
|
||||
actions: [PROJECT_CREATE_ACTION], linked: PROJECT_CREATE_LINKED_ACTIONS },
|
||||
{ id: 'project-governance', label: '项目归档与阶段', description: '归档/恢复与推进本人创建项目的阶段',
|
||||
actions: ['project.archive', 'project.stage.advance'] },
|
||||
{ id: 'document-duties', label: '文档编制与审签', description: '履行已分配的文档编制、审核、批准与发布职责',
|
||||
actions: ['document.author', 'document.review', 'document.approve', 'document.publish'] },
|
||||
{ id: 'document-read', label: '文档阅读', description: '读取获准的项目文档;项目成员的基础协作另由成员关系提供',
|
||||
actions: ['document.read'] },
|
||||
{ id: 'project-content', label: '追溯与知识', description: '编辑追溯网、发布项目知识与读取业务审计',
|
||||
actions: ['trace.edit', 'knowledge.publish', 'project.audit.read'] },
|
||||
]
|
||||
|
||||
/** 目录里未归入业务组的动作按目录分组兜底,保证新增动作不会从界面消失。 */
|
||||
export function buildCapabilityGroups(catalog: CapabilityCatalogItem[]): CapabilityGroup[] {
|
||||
const known = new Set(catalog.map(item => item.actionId))
|
||||
const claimed = new Set<string>()
|
||||
const groups: CapabilityGroup[] = []
|
||||
for (const group of BUSINESS_GROUPS) {
|
||||
const actions = group.actions.filter(id => known.has(id))
|
||||
if (actions.length === 0) continue
|
||||
const linked = (group.linked ?? []).filter(id => known.has(id))
|
||||
for (const id of [...actions, ...linked]) claimed.add(id)
|
||||
groups.push({ id: group.id, label: group.label, description: group.description, actions, linked })
|
||||
}
|
||||
const rest = new Map<string, CapabilityGroup>()
|
||||
for (const item of catalog) {
|
||||
if (claimed.has(item.actionId)) continue
|
||||
const group = rest.get(item.group) ?? { id: `catalog:${item.group}`, label: item.group, description: '', actions: [], linked: [] }
|
||||
group.actions.push(item.actionId)
|
||||
rest.set(item.group, group)
|
||||
}
|
||||
return [...groups, ...rest.values()]
|
||||
}
|
||||
|
||||
export type GroupLevel = 'on' | 'off' | 'mixed'
|
||||
|
||||
function level(flags: boolean[]): GroupLevel {
|
||||
if (flags.length > 0 && flags.every(Boolean)) return 'on'
|
||||
return flags.some(Boolean) ? 'mixed' : 'off'
|
||||
}
|
||||
|
||||
const OFF: RolePermissionValue = { allowed: false, capAllowed: false }
|
||||
|
||||
export function capabilityGroupState(values: Record<string, RolePermissionValue>, group: CapabilityGroup) {
|
||||
const items = group.actions.map(id => values[id] ?? OFF)
|
||||
return {
|
||||
allowed: level(items.map(item => item.allowed)),
|
||||
capAllowed: level(items.map(item => item.capAllowed)),
|
||||
linkedReady: group.linked.every(id => (values[id] ?? OFF).capAllowed),
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建能力开启(默认或上限)即补齐联动项上限;只开上限,不把管理动作默认授予所有成员项目。 */
|
||||
export function ensureLinkedCapabilities(draft: RolePermissionDraft): RolePermissionDraft {
|
||||
const create = draft.values[PROJECT_CREATE_ACTION]
|
||||
if (!create?.allowed && !create?.capAllowed) return draft
|
||||
let next = draft
|
||||
for (const id of PROJECT_CREATE_LINKED_ACTIONS) {
|
||||
const current = next.values[id]
|
||||
if (!current || current.capAllowed) continue
|
||||
next = updateRolePermission(next, id, { ...current, capAllowed: true })
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** 组开关:沿用「默认不超过上限」耦合;关闭创建不反向删除已有联动资格。 */
|
||||
export function setCapabilityGroup(
|
||||
draft: RolePermissionDraft, group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean,
|
||||
): RolePermissionDraft {
|
||||
let next = draft
|
||||
for (const id of group.actions) {
|
||||
const current = next.values[id] ?? OFF
|
||||
const value = field === 'allowed'
|
||||
? { allowed: checked, capAllowed: checked ? true : current.capAllowed }
|
||||
: { allowed: checked ? current.allowed : false, capAllowed: checked }
|
||||
next = updateRolePermission(next, id, value)
|
||||
}
|
||||
return ensureLinkedCapabilities(next)
|
||||
}
|
||||
|
||||
/** 创建能力已开但联动项上限仍关:断链,需在保存时补齐或由服务端整笔拒绝。 */
|
||||
export function linkedChainBreaks(values: Record<string, RolePermissionValue>): string[] {
|
||||
const create = values[PROJECT_CREATE_ACTION]
|
||||
if (!create?.allowed && !create?.capAllowed) return []
|
||||
return PROJECT_CREATE_LINKED_ACTIONS.filter(id => id in values && !values[id].capAllowed)
|
||||
}
|
||||
|
||||
export interface CapabilityGroupChange {
|
||||
group: CapabilityGroup
|
||||
before: ReturnType<typeof capabilityGroupState>
|
||||
after: ReturnType<typeof capabilityGroupState>
|
||||
/** 本次联动补齐上限的受控项。 */
|
||||
linkedAdded: string[]
|
||||
}
|
||||
|
||||
export function capabilityGroupChanges(draft: RolePermissionDraft, groups: CapabilityGroup[]): CapabilityGroupChange[] {
|
||||
const changed = (id: string) => {
|
||||
const before = draft.baseline[id] ?? OFF
|
||||
const after = draft.values[id] ?? OFF
|
||||
return before.allowed !== after.allowed || before.capAllowed !== after.capAllowed
|
||||
}
|
||||
return groups.flatMap(group => {
|
||||
if (![...group.actions, ...group.linked].some(changed)) return []
|
||||
return [{
|
||||
group,
|
||||
before: capabilityGroupState(draft.baseline, group),
|
||||
after: capabilityGroupState(draft.values, group),
|
||||
linkedAdded: group.linked.filter(id => !(draft.baseline[id] ?? OFF).capAllowed && (draft.values[id] ?? OFF).capAllowed),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
/** 系统授权草稿冲突重放:只把本地相对旧基线的增删应用到最新服务端授权上。 */
|
||||
export function rebaseGrantDraft(baseline: string[], local: string[], server: string[]): string[] {
|
||||
const before = new Set(baseline)
|
||||
const now = new Set(local)
|
||||
const next = new Set(server)
|
||||
for (const id of now) if (!before.has(id)) next.add(id)
|
||||
for (const id of before) if (!now.has(id)) next.delete(id)
|
||||
return [...next].sort()
|
||||
}
|
||||
|
||||
/** 某账号 project.create 的实际来源(角色默认与明确系统授权可并存)。
|
||||
* getUserRole 的角色摘要不带 permissions,缺失按「无角色默认权限」处理。 */
|
||||
export function projectCreateSources(
|
||||
role: { name: string; permissions?: { actionId: string; allowed: boolean }[] } | null | undefined, grants: string[],
|
||||
): string[] {
|
||||
const sources: string[] = []
|
||||
if (role?.permissions?.some(item => item.actionId === PROJECT_CREATE_ACTION && item.allowed)) sources.push(`职位角色「${role.name}」`)
|
||||
if (grants.includes(PROJECT_CREATE_ACTION)) sources.push('明确系统授权')
|
||||
return sources
|
||||
}
|
||||
|
||||
export function validateRolePermissionDraft(draft: RolePermissionDraft): { actionId: string; reason: string } | null {
|
||||
for (const actionId of Object.keys(draft.values).sort()) {
|
||||
const value = draft.values[actionId]
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { auth } from './auth'
|
||||
import {
|
||||
PROJECT_SYNC_INTERVAL_MS, PROJECT_SYNC_RENDER_BUDGET_MS, PROJECT_SYNC_REQUEST_BUDGET_MS,
|
||||
createProjectAutoDiscover, projectSyncDelay, store, type ProjectSyncEnv, type ProjectSyncStatus,
|
||||
} from './store'
|
||||
import type { Conversation, Project } from './contracts'
|
||||
|
||||
const project = (id: string, owner = 'alice'): Project => ({ id, owner, name: id, createdAt: '', updatedAt: '' })
|
||||
const task = (id: string, projectId: string): Conversation => ({ id, projectId, title: id, createdAt: '', updatedAt: '' })
|
||||
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((ok, fail) => { resolve = ok; reject = fail })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
/** 可控的页面环境:可见性、联网与 wake/sleep 事件由测试驱动。 */
|
||||
function fakeEnv() {
|
||||
const env = {
|
||||
visible: true,
|
||||
online: true,
|
||||
handlers: null as null | { wake: () => void; sleep: (status: 'paused' | 'offline') => void },
|
||||
}
|
||||
const port: ProjectSyncEnv = {
|
||||
visible: () => env.visible,
|
||||
online: () => env.online,
|
||||
subscribe: handlers => { env.handlers = handlers; return () => { env.handlers = null } },
|
||||
}
|
||||
return {
|
||||
port,
|
||||
hide() { env.visible = false; env.handlers?.sleep('paused') },
|
||||
show() { env.visible = true; env.handlers?.wake() },
|
||||
offline() { env.online = false; env.handlers?.sleep('offline') },
|
||||
online() { env.online = true; env.handlers?.wake() },
|
||||
focus() { env.handlers?.wake() },
|
||||
}
|
||||
}
|
||||
|
||||
let projects: Project[]
|
||||
let tasks: Conversation[]
|
||||
let requests: { path: string; init?: RequestInit }[]
|
||||
let route: (path: string, init?: RequestInit) => Promise<Response>
|
||||
|
||||
beforeEach(async () => {
|
||||
projects = [project('host-a')]
|
||||
tasks = [task('conv-a', 'host-a')]
|
||||
requests = []
|
||||
const saved = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { saved.set(key, value) },
|
||||
removeItem: (key: string) => { saved.delete(key) } })
|
||||
route = async (path, init) => {
|
||||
if (path === '/api/projects' && !init?.method) return response(projects)
|
||||
if (path === '/api/conversations' && !init?.method) return response(tasks.filter(t => projects.some(p => p.id === t.projectId)))
|
||||
if (path === '/api/conversations/conv-a') return response({ conversation: tasks[0], messages: [], runs: [], activeHitl: [], lastSeq: 0 })
|
||||
if (path === '/api/me') return response({ owner: 'bob', name: 'Bob', auth: true, systemActions: [] })
|
||||
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
|
||||
if (path.endsWith('/artifacts') || path.endsWith('/surfaces')) return response([])
|
||||
if (path.endsWith('/cards')) return response({ cards: [] })
|
||||
return response({})
|
||||
}
|
||||
vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => {
|
||||
requests.push({ path, init })
|
||||
return route(path, init)
|
||||
})
|
||||
vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
|
||||
auth.beginSession()
|
||||
await store.refreshMe()
|
||||
await store.refreshProjects()
|
||||
await store.openTask('conv-a')
|
||||
requests = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
store.stopProjectSync()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function advanceUntil(check: () => boolean, limitMs: number, stepMs = 250): Promise<number> {
|
||||
let elapsed = 0
|
||||
while (!check() && elapsed < limitMs) {
|
||||
await vi.advanceTimersByTimeAsync(stepMs)
|
||||
elapsed += stepMs
|
||||
}
|
||||
return elapsed
|
||||
}
|
||||
|
||||
describe('AutoDiscover project list', () => {
|
||||
it('keeps the visible-page budget inside 5 seconds', () => {
|
||||
expect(PROJECT_SYNC_INTERVAL_MS + PROJECT_SYNC_REQUEST_BUDGET_MS + PROJECT_SYNC_RENDER_BUDGET_MS).toBeLessThanOrEqual(5000)
|
||||
expect(projectSyncDelay(0)).toBe(PROJECT_SYNC_INTERVAL_MS)
|
||||
})
|
||||
|
||||
it('surfaces a project the member was added to within 5 seconds without switching the current work', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = fakeEnv()
|
||||
store.startProjectSync(env.port)
|
||||
projects = [project('host-a'), project('host-b', 'carol')]
|
||||
const elapsed = await advanceUntil(() => store.getState().projects.some(p => p.id === 'host-b'), 5000)
|
||||
expect(store.getState().projects.map(p => p.id)).toEqual(['host-a', 'host-b'])
|
||||
expect(elapsed).toBeLessThanOrEqual(5000)
|
||||
expect(store.getState()).toMatchObject({ currentProjectId: 'host-a', currentConvId: 'conv-a', projectsLoaded: true })
|
||||
expect(requests.map(r => r.path)).not.toContain('/api/conversations/conv-a')
|
||||
expect(store.getState().projectSync.status).toBe('ok')
|
||||
})
|
||||
|
||||
it('clears a removed current project within 5 seconds and keeps the remaining projects', async () => {
|
||||
projects = [project('host-a'), project('host-b', 'carol')]
|
||||
await store.refreshProjects()
|
||||
vi.useFakeTimers()
|
||||
store.startProjectSync(fakeEnv().port)
|
||||
projects = [project('host-b', 'carol')]
|
||||
const elapsed = await advanceUntil(() => store.getState().currentProjectId !== 'host-a', 5000)
|
||||
expect(elapsed).toBeLessThanOrEqual(5000)
|
||||
expect(store.getState()).toMatchObject({ currentProjectId: 'host-b', currentConvId: null })
|
||||
expect(store.getState().projects.map(p => p.id)).toEqual(['host-b'])
|
||||
expect(store.getState().tasks.map(t => t.id)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not re-render or re-open anything when the list is unchanged', async () => {
|
||||
vi.useFakeTimers()
|
||||
store.startProjectSync(fakeEnv().port)
|
||||
const before = store.getState()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 3)
|
||||
expect(requests.filter(r => r.path === '/api/projects')).toHaveLength(3)
|
||||
expect(store.getState().projects).toBe(before.projects)
|
||||
expect(requests.map(r => r.path)).not.toContain('/api/conversations')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RefreshSingleFlight', () => {
|
||||
it('never overlaps requests and folds wake-ups during a request into one immediate follow-up', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = fakeEnv()
|
||||
let concurrent = 0
|
||||
let peak = 0
|
||||
const calls: ReturnType<typeof deferred<void>>[] = []
|
||||
const fetchOnce = vi.fn(() => {
|
||||
concurrent++; peak = Math.max(peak, concurrent)
|
||||
const call = deferred<void>()
|
||||
calls.push(call)
|
||||
return call.promise.finally(() => { concurrent-- })
|
||||
})
|
||||
const sync = createProjectAutoDiscover(fetchOnce, env.port, () => {})
|
||||
sync.start()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||
env.focus(); env.online(); env.show()
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||
calls[0].resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(2)
|
||||
calls[1].resolve()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS - 1)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(3)
|
||||
expect(peak).toBe(1)
|
||||
sync.stop()
|
||||
})
|
||||
|
||||
it('schedules the next round only after the current one finishes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const slow = deferred<void>()
|
||||
const fetchOnce = vi.fn().mockReturnValueOnce(slow.promise).mockResolvedValue(undefined)
|
||||
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, () => {})
|
||||
sync.start()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS + 1500)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||
slow.resolve()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(2)
|
||||
sync.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RefreshBackoff', () => {
|
||||
it('reports a sync failure, backs off exponentially and returns to the 2-second rhythm on success', async () => {
|
||||
vi.useFakeTimers()
|
||||
const statuses: [ProjectSyncStatus, number][] = []
|
||||
let fail = true
|
||||
const fetchOnce = vi.fn(async () => { if (fail) throw new Error('503: unavailable') })
|
||||
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, (status, failures) => statuses.push([status, failures]))
|
||||
sync.start()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(statuses.at(-1)).toEqual(['degraded', 1])
|
||||
await vi.advanceTimersByTimeAsync(3999)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(2)
|
||||
expect(statuses.at(-1)).toEqual(['degraded', 2])
|
||||
fail = false
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(3)
|
||||
expect(statuses.at(-1)).toEqual(['ok', 0])
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(4)
|
||||
expect(projectSyncDelay(10)).toBe(30000)
|
||||
sync.stop()
|
||||
})
|
||||
|
||||
it('aborts a request that exceeds its 2-second budget and counts it as a failure', async () => {
|
||||
vi.useFakeTimers()
|
||||
let signal: AbortSignal | undefined
|
||||
const fetchOnce = vi.fn((s: AbortSignal) => { signal = s; return new Promise<void>(() => {}) })
|
||||
const statuses: ProjectSyncStatus[] = []
|
||||
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, status => statuses.push(status))
|
||||
sync.start()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS + PROJECT_SYNC_REQUEST_BUDGET_MS)
|
||||
expect(signal?.aborted).toBe(true)
|
||||
expect(statuses.at(-1)).toBe('degraded')
|
||||
sync.stop()
|
||||
})
|
||||
|
||||
it('shows the store sync state as degraded while the list endpoint fails', async () => {
|
||||
vi.useFakeTimers()
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/projects' ? Promise.resolve(response({ error: 'down' }, 503)) : previous(path, init)
|
||||
store.startProjectSync(fakeEnv().port)
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
||||
expect(store.getState().projectSync).toEqual({ status: 'degraded', failures: 1 })
|
||||
expect(store.getState().projects.map(p => p.id)).toEqual(['host-a'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('RefreshHiddenPause', () => {
|
||||
it('pauses on a hidden page and resumes immediately when it becomes visible again', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = fakeEnv()
|
||||
const statuses: ProjectSyncStatus[] = []
|
||||
const fetchOnce = vi.fn(async () => {})
|
||||
const sync = createProjectAutoDiscover(fetchOnce, env.port, status => statuses.push(status))
|
||||
sync.start()
|
||||
env.hide()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 10)
|
||||
expect(fetchOnce).not.toHaveBeenCalled()
|
||||
expect(statuses.at(-1)).toBe('paused')
|
||||
env.show()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||
expect(statuses.at(-1)).toBe('ok')
|
||||
sync.stop()
|
||||
})
|
||||
|
||||
it('does not start when the page is already hidden', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = fakeEnv()
|
||||
env.hide()
|
||||
const fetchOnce = vi.fn(async () => {})
|
||||
const sync = createProjectAutoDiscover(fetchOnce, env.port, () => {})
|
||||
sync.start()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 5)
|
||||
expect(fetchOnce).not.toHaveBeenCalled()
|
||||
sync.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RefreshReconnectCompensate', () => {
|
||||
it('stops while offline and re-checks immediately when the network returns', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = fakeEnv()
|
||||
const statuses: ProjectSyncStatus[] = []
|
||||
const fetchOnce = vi.fn(async () => {})
|
||||
const sync = createProjectAutoDiscover(fetchOnce, env.port, status => statuses.push(status))
|
||||
sync.start()
|
||||
env.offline()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 5)
|
||||
expect(fetchOnce).not.toHaveBeenCalled()
|
||||
expect(statuses.at(-1)).toBe('offline')
|
||||
env.online()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||
sync.stop()
|
||||
})
|
||||
|
||||
it('re-checks immediately on focus instead of waiting for the next interval', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = fakeEnv()
|
||||
store.startProjectSync(env.port)
|
||||
projects = [project('host-a'), project('host-b', 'carol')]
|
||||
env.focus()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(store.getState().projects.map(p => p.id)).toContain('host-b')
|
||||
})
|
||||
|
||||
it('drops a late list from the previous identity after a reconnect under another account', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = fakeEnv()
|
||||
const late = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/projects' ? late.promise : previous(path, init)
|
||||
store.startProjectSync(env.port)
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
||||
auth.beginSession()
|
||||
route = previous
|
||||
projects = [project('host-c', 'dave')]
|
||||
await store.refreshProjects()
|
||||
late.resolve(response([project('host-a'), project('alice-secret')]))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(store.getState().projects.map(p => p.id)).toEqual(['host-c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('DefaultProjectDisabled', () => {
|
||||
it('does not create a default project when the member has no projects yet', async () => {
|
||||
projects = []
|
||||
await store.refreshProjects()
|
||||
requests = []
|
||||
await expect(store.createTask()).rejects.toThrow(/等待项目负责人邀请/)
|
||||
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
|
||||
expect(store.getState().toast).toMatch(/等待项目负责人邀请/)
|
||||
})
|
||||
|
||||
it('does not treat a list that has not arrived as empty', async () => {
|
||||
auth.beginSession()
|
||||
const pending = deferred<Response>()
|
||||
route = path => path === '/api/projects' ? pending.promise : Promise.resolve(response({}))
|
||||
void store.refreshProjects()
|
||||
await expect(store.createTask()).rejects.toThrow(/尚未加载/)
|
||||
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
|
||||
pending.resolve(response([]))
|
||||
})
|
||||
|
||||
it('points a creator to explicit project creation instead of creating one silently', async () => {
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/me'
|
||||
? Promise.resolve(response({ owner: 'bob', name: 'Bob', auth: true, systemActions: ['project.create'],
|
||||
linkedCapabilities: { projectCreate: true, source: 'role', linkedActions: [], authorityEpoch: 1 } }))
|
||||
: previous(path, init)
|
||||
await store.refreshMe()
|
||||
projects = []
|
||||
await store.refreshProjects()
|
||||
requests = []
|
||||
await expect(store.createTask()).rejects.toThrow(/请先新建项目/)
|
||||
expect(requests.filter(r => r.path === '/api/projects' && r.init?.method === 'POST')).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces the stage-specific project error before any POST is sent', async () => {
|
||||
projects = [{ ...project('host-archived'), settings: { 'rdm.state': 'archived' } } as Project]
|
||||
await store.refreshProjects()
|
||||
requests = []
|
||||
await expect(store.createTask()).rejects.toThrow(/请先新建项目|归档/)
|
||||
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AutoDiscover sync ownership on lifecycle events', () => {
|
||||
it('surfaces sync start and stop without leaking listeners across re-starts', () => {
|
||||
const env = fakeEnv()
|
||||
const release: Array<() => void> = []
|
||||
const port: ProjectSyncEnv = { ...env.port, subscribe: handlers => {
|
||||
const releaseFn = () => {}
|
||||
release.push(releaseFn)
|
||||
env.port.subscribe(handlers)
|
||||
return () => { releaseFn() }
|
||||
} }
|
||||
store.startProjectSync(port)
|
||||
store.stopProjectSync()
|
||||
store.startProjectSync(port)
|
||||
expect(release.length).toBe(2)
|
||||
store.stopProjectSync()
|
||||
})
|
||||
|
||||
it('keeps the store sync state idle after a clean stop and ready to re-start', () => {
|
||||
store.startProjectSync(fakeEnv().port)
|
||||
store.stopProjectSync()
|
||||
expect(store.getState().projectSync.status).toBe('idle')
|
||||
expect(store.getState().projectSync.failures).toBe(0)
|
||||
})
|
||||
|
||||
it('drops the late response when an identity reset interleaves a sync', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = fakeEnv()
|
||||
const pending = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/projects' ? pending.promise : previous(path, init)
|
||||
store.startProjectSync(env.port)
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
||||
store.resetIdentity()
|
||||
pending.resolve(response([project('host-late')]))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(store.getState().projects.find(p => p.id === 'host-late')).toBeUndefined()
|
||||
store.stopProjectSync()
|
||||
})
|
||||
})
|
||||
+264
-34
@@ -1,9 +1,9 @@
|
||||
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
|
||||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { type AdminZoneSection, api, connectEvents } from './api'
|
||||
import { type AdminZoneSection, type LinkedCapabilities, api, connectEvents } from './api'
|
||||
import { auth } from './auth'
|
||||
import { projectActionError, projectWorkError } from './projectmeta'
|
||||
import { canCreateProject, noProjectError, projectActionError, projectWorkError } from './projectmeta'
|
||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
||||
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
|
||||
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage, type WorkReference } from './outbox'
|
||||
@@ -23,6 +23,7 @@ export function isFontSizeChoice(value: number): value is FontSizeChoice {
|
||||
}
|
||||
import type {
|
||||
ActivityCard,
|
||||
TaskSourceRef,
|
||||
AgentProfile,
|
||||
Artifact,
|
||||
Conversation,
|
||||
@@ -90,11 +91,21 @@ export interface UIState {
|
||||
projectSettingsId: string | null
|
||||
}
|
||||
|
||||
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[] }
|
||||
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities }
|
||||
|
||||
/** KTD9 项目自动发现:可见页每 2 秒串行重查;单次请求预算 2 秒、渲染预留 1 秒,正常链路 5 秒内可见。 */
|
||||
export const PROJECT_SYNC_INTERVAL_MS = 2000
|
||||
export const PROJECT_SYNC_REQUEST_BUDGET_MS = 2000
|
||||
export const PROJECT_SYNC_RENDER_BUDGET_MS = 1000
|
||||
const PROJECT_SYNC_MAX_BACKOFF_MS = 30000
|
||||
export type ProjectSyncStatus = 'idle' | 'ok' | 'degraded' | 'paused' | 'offline'
|
||||
|
||||
interface AppState {
|
||||
contextObs: Record<string, ContextObs>
|
||||
projects: Project[]
|
||||
/** 本身份的项目列表是否已到达过;未到达不等于空列表。 */
|
||||
projectsLoaded: boolean
|
||||
projectSync: { status: ProjectSyncStatus; failures: number }
|
||||
currentProjectId: string | null
|
||||
tasks: Conversation[]
|
||||
me: Me | null
|
||||
@@ -198,7 +209,8 @@ const surfaceActionsInflight: Record<string, boolean> = {}
|
||||
|
||||
let state: AppState = {
|
||||
contextObs: {},
|
||||
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||
projects: [], projectsLoaded: false, projectSync: { status: 'idle', failures: 0 },
|
||||
currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||
serverWorkspaces: [],
|
||||
@@ -232,7 +244,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 +311,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
|
||||
@@ -317,6 +333,21 @@ const actionInflight = new Set<string>()
|
||||
const actionFeedbackOwners: Record<string, symbol> = {}
|
||||
const workActionExpectations = new Map<string, { actionId: string; payload: WorkCardAction }>()
|
||||
|
||||
/** 离开当前项目:关闭项目/会话流并清空项目内投影(不触碰服务端历史)。 */
|
||||
function resetProjectScope() {
|
||||
projectSse?.close(); projectSse = null
|
||||
sse?.close(); sse = null
|
||||
projectSeq = 0
|
||||
projectCardsRequest++
|
||||
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
|
||||
conversationEpoch++; streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||||
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
|
||||
artifacts: [], outbox: [], outboxDraining: false, posting: false })
|
||||
setUI({ previewExpanded: false, filesView: 'list' })
|
||||
}
|
||||
|
||||
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
|
||||
const version = ++detailVersion
|
||||
observations[id] = { ...observations[id], version, seq,
|
||||
@@ -353,12 +384,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 {
|
||||
@@ -421,9 +464,140 @@ export function useStore<T>(selector: (s: AppState) => T): T {
|
||||
return useSyncExternalStore(
|
||||
(cb) => { listeners.add(cb); return () => listeners.delete(cb) },
|
||||
() => selector(state),
|
||||
() => selector(state),
|
||||
)
|
||||
}
|
||||
|
||||
export interface ProjectSyncEnv {
|
||||
visible: () => boolean
|
||||
online: () => boolean
|
||||
/** wake:重新聚焦/页面恢复可见/网络恢复;sleep:页面隐藏或断网。 */
|
||||
subscribe: (handlers: { wake: () => void; sleep: (status: 'paused' | 'offline') => void }) => () => void
|
||||
}
|
||||
|
||||
export function projectSyncDelay(failures: number): number {
|
||||
return failures <= 0 ? PROJECT_SYNC_INTERVAL_MS : Math.min(PROJECT_SYNC_MAX_BACKOFF_MS, PROJECT_SYNC_INTERVAL_MS * 2 ** failures)
|
||||
}
|
||||
|
||||
/**
|
||||
* 单一所有者的串行重查:同一时刻至多一个请求;进行中被唤醒只记一次补偿,完成后立即再查。
|
||||
* 下一轮在本轮结束后才排期(不重叠);失败/超时按指数退避,成功或唤醒补偿后恢复 2 秒节奏。
|
||||
*/
|
||||
export function createProjectAutoDiscover(
|
||||
fetchOnce: (signal: AbortSignal) => Promise<void>,
|
||||
env: ProjectSyncEnv,
|
||||
report: (status: ProjectSyncStatus, failures: number) => void,
|
||||
) {
|
||||
let active = false
|
||||
let inflight = false
|
||||
let pending = false
|
||||
let failures = 0
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let controller: AbortController | null = null
|
||||
let unsubscribe: (() => void) | null = null
|
||||
const clear = () => { if (timer !== null) { clearTimeout(timer); timer = null } }
|
||||
const blocked = (): 'paused' | 'offline' | null => !env.online() ? 'offline' : !env.visible() ? 'paused' : null
|
||||
const schedule = () => {
|
||||
clear()
|
||||
const reason = blocked()
|
||||
if (reason) { report(reason, failures); return }
|
||||
timer = setTimeout(() => { timer = null; void run() }, projectSyncDelay(failures))
|
||||
}
|
||||
const run = async (): Promise<void> => {
|
||||
if (!active) return
|
||||
const reason = blocked()
|
||||
if (reason) { clear(); report(reason, failures); return }
|
||||
if (inflight) { pending = true; return }
|
||||
clear()
|
||||
inflight = true
|
||||
const own = new AbortController()
|
||||
controller = own
|
||||
const budget = setTimeout(() => own.abort(), PROJECT_SYNC_REQUEST_BUDGET_MS)
|
||||
const aborted = new Promise<never>((_, reject) =>
|
||||
own.signal.addEventListener('abort', () => reject(new Error('project sync timeout')), { once: true }))
|
||||
try {
|
||||
await Promise.race([fetchOnce(own.signal), aborted])
|
||||
if (active && controller === own) { failures = 0; report('ok', 0) }
|
||||
} catch {
|
||||
if (active && controller === own) { failures++; report('degraded', failures) }
|
||||
} finally {
|
||||
clearTimeout(budget)
|
||||
inflight = false
|
||||
if (controller === own) controller = null
|
||||
}
|
||||
if (!active) return
|
||||
if (pending) { pending = false; return run() }
|
||||
schedule()
|
||||
}
|
||||
return {
|
||||
start() {
|
||||
if (active) return
|
||||
active = true
|
||||
unsubscribe = env.subscribe({
|
||||
wake: () => { void run() },
|
||||
sleep: status => { clear(); report(status, failures) },
|
||||
})
|
||||
schedule()
|
||||
},
|
||||
stop() {
|
||||
active = false; pending = false; failures = 0
|
||||
clear()
|
||||
controller?.abort(); controller = null
|
||||
unsubscribe?.(); unsubscribe = null
|
||||
report('idle', 0)
|
||||
},
|
||||
trigger: () => run(),
|
||||
}
|
||||
}
|
||||
|
||||
function browserProjectSyncEnv(): ProjectSyncEnv {
|
||||
return {
|
||||
visible: () => typeof document === 'undefined' || document.visibilityState !== 'hidden',
|
||||
online: () => typeof navigator === 'undefined' || navigator.onLine !== false,
|
||||
subscribe: ({ wake, sleep }) => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return () => {}
|
||||
const onVisibility = () => { if (document.visibilityState === 'hidden') sleep('paused'); else wake() }
|
||||
const onOffline = () => sleep('offline')
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
window.addEventListener('focus', wake)
|
||||
window.addEventListener('online', wake)
|
||||
window.addEventListener('offline', onOffline)
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
window.removeEventListener('focus', wake)
|
||||
window.removeEventListener('online', wake)
|
||||
window.removeEventListener('offline', onOffline)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let projectSync: ReturnType<typeof createProjectAutoDiscover> | null = null
|
||||
|
||||
const projectIdsKey = (projects: Project[]) => projects.map(p => p.id).sort().join('\n')
|
||||
|
||||
/** 列表合并只更新投影:不重复 openProject、不切对话、不重建流;仅当前项目失效时清除其状态。 */
|
||||
async function mergeProjectList(projects: Project[]) {
|
||||
if (state.projectsLoaded && JSON.stringify(state.projects) === JSON.stringify(projects)) return
|
||||
const membershipChanged = !state.projectsLoaded || projectIdsKey(state.projects) !== projectIdsKey(projects)
|
||||
set({ projects, projectsLoaded: true, error: null })
|
||||
const current = state.currentProjectId
|
||||
if (current && !projects.some(p => p.id === current)) {
|
||||
const fallback = projects[0]
|
||||
if (fallback) await store.openProject(fallback.id)
|
||||
else {
|
||||
resetProjectScope()
|
||||
set({ currentProjectId: null })
|
||||
setUI({ lastProjectId: null })
|
||||
}
|
||||
store.toast('你已不在原项目中,已清除该项目的入口')
|
||||
} else if (!current) {
|
||||
const next = projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
|
||||
if (next) await store.openProject(next.id)
|
||||
}
|
||||
if (membershipChanged) await store.refreshTasks()
|
||||
}
|
||||
|
||||
export const store = {
|
||||
getState: () => state,
|
||||
getIdentityEpoch: () => identityEpoch,
|
||||
@@ -443,7 +617,7 @@ export const store = {
|
||||
for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key]
|
||||
convRestoreDone = true
|
||||
persistConvId(null)
|
||||
set({ projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||
set({ projects: [], projectsLoaded: false, currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||
contextObs: {}, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
|
||||
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
|
||||
@@ -464,7 +638,7 @@ export const store = {
|
||||
const projects = await api.listProjects()
|
||||
if (epoch !== identityEpoch || request !== projectsRequest) return
|
||||
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
|
||||
set({ projects, currentProjectId: current?.id ?? null, error: null })
|
||||
set({ projects, projectsLoaded: true, currentProjectId: current?.id ?? null, error: null })
|
||||
if (current) await store.openProject(current.id)
|
||||
} catch (e) {
|
||||
if (epoch === identityEpoch && request === projectsRequest) set({ error: projectActionError(e) })
|
||||
@@ -472,6 +646,28 @@ export const store = {
|
||||
if (epoch === identityEpoch && request === projectsRequest) await store.refreshTasks()
|
||||
},
|
||||
|
||||
/** 自动发现的一次重查:与 refreshProjects 共用请求代次,迟到/旧身份响应丢弃;失败抛出供退避。 */
|
||||
async syncProjects(signal?: AbortSignal) {
|
||||
const epoch = identityEpoch
|
||||
const request = ++projectsRequest
|
||||
const projects = await api.listProjects(signal)
|
||||
if (epoch !== identityEpoch || request !== projectsRequest) return
|
||||
await mergeProjectList(projects)
|
||||
},
|
||||
|
||||
startProjectSync(env: ProjectSyncEnv = browserProjectSyncEnv()) {
|
||||
projectSync?.stop()
|
||||
projectSync = createProjectAutoDiscover(signal => store.syncProjects(signal), env, (status, failures) => {
|
||||
if (state.projectSync.status !== status || state.projectSync.failures !== failures) set({ projectSync: { status, failures } })
|
||||
})
|
||||
projectSync.start()
|
||||
},
|
||||
|
||||
stopProjectSync() {
|
||||
projectSync?.stop()
|
||||
projectSync = null
|
||||
},
|
||||
|
||||
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
|
||||
async refreshTasks(includeArchived = state.ui.showArchived) {
|
||||
const epoch = identityEpoch
|
||||
@@ -524,19 +720,7 @@ export const store = {
|
||||
},
|
||||
|
||||
async openProject(projectId: string) {
|
||||
if (state.currentProjectId !== projectId) {
|
||||
projectSse?.close(); projectSse = null
|
||||
sse?.close(); sse = null
|
||||
projectSeq = 0
|
||||
projectCardsRequest++
|
||||
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
|
||||
conversationEpoch++; streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||||
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
|
||||
artifacts: [], outbox: [], outboxDraining: false, posting: false })
|
||||
setUI({ previewExpanded: false, filesView: 'list' })
|
||||
}
|
||||
if (state.currentProjectId !== projectId) resetProjectScope()
|
||||
set({ currentProjectId: projectId })
|
||||
if (store.getState().inboxScope === 'project') {
|
||||
store.loadProjectCards()
|
||||
@@ -547,16 +731,13 @@ export const store = {
|
||||
},
|
||||
|
||||
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。
|
||||
* 空库时主按钮与空状态都写着「点击新建任务开始」,这里先落一个默认项目再开会话。 */
|
||||
* 没有项目时不代建「默认项目」:列表未到达或无创建能力时自动建项目会误触权限与同名冲突。 */
|
||||
async createTask(projectId?: string) {
|
||||
const epoch = identityEpoch
|
||||
try {
|
||||
let pid = projectId ?? state.currentProjectId
|
||||
if (!pid && state.projects.length > 0) pid = state.projects[0].id
|
||||
if (!pid) {
|
||||
const p = await store.createProject('默认项目')
|
||||
pid = p.id
|
||||
}
|
||||
if (!pid) throw new Error(noProjectError(state.projectsLoaded, canCreateProject(state.me)))
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
const error = projectWorkError(state.projects.find(p => p.id === pid))
|
||||
if (error) throw new Error(error)
|
||||
@@ -621,7 +802,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 +900,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 +1065,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 +1262,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 +1274,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 +1479,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 +1593,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 +1679,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 +1687,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)
|
||||
@@ -1516,5 +1744,7 @@ export function mergeStartedRun(runs: Run[], run: Run): Run[] {
|
||||
export function eventTime(e: { ts?: string }, now = Date.now()): number {
|
||||
if (!e.ts) return now
|
||||
const t = Date.parse(e.ts)
|
||||
return Number.isNaN(t) ? now : t
|
||||
// 后端 0 值占位(0001-01-01T00:00:00Z 等)会让状态栏读到巨大耗时,回落 now 让「已工作」保持真实时钟。
|
||||
if (Number.isNaN(t) || t <= 0) return now
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import { credentialLabel } from '../src/components/AccountUsersPanel'
|
||||
|
||||
describe('account directory credential state', () => {
|
||||
it('shows pending trusted-owner initialization before password state', () => {
|
||||
expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待可信 owner 初始化')
|
||||
it('shows whether password is ready without legacy initialization labels', () => {
|
||||
expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待设置密码')
|
||||
expect(credentialLabel({ initializationState: 'initialized', passwordSet: true })).toBe('已设置密码')
|
||||
expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('未设置密码')
|
||||
expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('待设置密码')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// 管理端用户面板两处缺陷的回归锁定:
|
||||
// (1) getUserRole 的 role 摘要不带 permissions(线上实测),projectCreateSources 不得因此抛异常;
|
||||
// 展示来源时用已加载角色列表里同一 roleId 的条目补齐权限,不新增请求。
|
||||
// (2) 一次性登录密码按服务端严格解码要求发送 JSON 对象体。
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { api, type RoleRecord } from '../src/api'
|
||||
import { auth } from '../src/auth'
|
||||
import { ProjectCreateSourceNote, roleWithPermissions } from '../src/components/AccountUsersPanel'
|
||||
import { projectCreateSources } from '../src/rolePermissions'
|
||||
|
||||
const listed: RoleRecord = {
|
||||
roleId: 'role-pm', name: '项目经理', description: '研发', enabled: true, roleVersion: 3, bindingCount: 1,
|
||||
permissions: [
|
||||
{ actionId: 'project.create', allowed: true, capAllowed: true, permissionVersion: 2 },
|
||||
{ actionId: 'document.read', allowed: true, capAllowed: true, permissionVersion: 1 },
|
||||
],
|
||||
}
|
||||
/** getUserRole 的真实响应形状:有角色字段、没有 permissions。 */
|
||||
const summary = { roleId: 'role-pm', name: '项目经理', description: '研发', enabled: true, roleVersion: 3, bindingCount: 1 }
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('角色摘要缺少 permissions 时的创建能力来源', () => {
|
||||
it('把缺失的 permissions 当作空集,不抛异常也不虚构角色来源', () => {
|
||||
expect(projectCreateSources({ name: '项目经理' }, [])).toEqual([])
|
||||
expect(projectCreateSources({ name: '项目经理' }, ['project.create'])).toEqual(['明确系统授权'])
|
||||
expect(projectCreateSources({ ...summary, permissions: undefined }, [])).toEqual([])
|
||||
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={{ name: '项目经理' }} grants={[]} />))
|
||||
.toContain('被加入项目后由成员关系提供基础协作')
|
||||
})
|
||||
|
||||
it('用角色列表里同一 roleId 的权限补齐来源', () => {
|
||||
const resolved = roleWithPermissions(summary, [listed])
|
||||
expect(resolved?.permissions?.some(item => item.actionId === 'project.create')).toBe(true)
|
||||
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={resolved} grants={[]} />))
|
||||
.toContain('创建项目来源:职位角色「项目经理」')
|
||||
})
|
||||
|
||||
it('列表里没有对应条目时按无角色默认权限处理,角色自带权限时不被列表覆盖', () => {
|
||||
expect(projectCreateSources(roleWithPermissions(summary, []), [])).toEqual([])
|
||||
const denied = { ...listed, roleId: 'role-other', permissions: [{ actionId: 'project.create', allowed: false, capAllowed: false, permissionVersion: 1 }] }
|
||||
expect(projectCreateSources(roleWithPermissions({ ...summary, permissions: denied.permissions }, [denied]), [])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('管理员一次性登录密码请求体', () => {
|
||||
it('发送 POST + JSON 对象体,避免服务端 400 EOF', async () => {
|
||||
const issued = { owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true }
|
||||
const request = vi.spyOn(auth, 'fetch').mockResolvedValue(new Response(JSON.stringify(issued)))
|
||||
|
||||
const out = await api.adminIssueOneTimePassword('alice')
|
||||
|
||||
const [url, options] = request.mock.calls[0]
|
||||
expect(url).toBe('/api/admin/users/alice/one-time-password')
|
||||
expect(options?.method).toBe('POST')
|
||||
expect(options?.headers).toEqual({ 'Content-Type': 'application/json' })
|
||||
expect(JSON.parse(String(options?.body))).toEqual({})
|
||||
expect(out).toEqual(issued)
|
||||
})
|
||||
})
|
||||
@@ -329,3 +329,56 @@ describe('conversation work reading', () => {
|
||||
expect(render()).toContain('会覆盖旧草稿,原件保留')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProjectLifecycleConversation work record', () => {
|
||||
it('opens a project lifecycle conversation with a single focused work card and a closed history', () => {
|
||||
snapshot.state!.cards = [card({ title: '整理材料', status: 'active' }), card({
|
||||
id: 'history_1', status: 'done', tone: 'ok', title: '旧一轮记录',
|
||||
body: [{ type: 'markdown', markdown: '项目准备阶段的总结报告' }],
|
||||
updatedAt: '2026-10-12T08:00:00Z',
|
||||
})]
|
||||
const html = renderWork()
|
||||
expect(html).toContain('整理材料')
|
||||
expect(html).not.toContain('项目准备阶段的总结报告')
|
||||
expect(html).toContain('更多工作与成果 · 1 项')
|
||||
})
|
||||
|
||||
it('names a project lifecycle conversation by its own work record when the right work pane is closed', () => {
|
||||
snapshot.state!.workRecord = { cardId: 'card_1', runId: 'run_1', conversationId: 'cnv_1', title: '材料整理' }
|
||||
snapshot.state!.cards = [card()]
|
||||
const html = render()
|
||||
expect(html).toContain('data-conversation-work')
|
||||
expect(html).not.toContain('核对会议结论')
|
||||
})
|
||||
|
||||
it('falls back to a project lifecycle conversation level when the auxiliary work pane already owns the detail', () => {
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
snapshot.state!.ui.rightTab = 'work'
|
||||
snapshot.state!.cards = [card()]
|
||||
const html = render()
|
||||
expect(html).not.toContain('data-conversation-work')
|
||||
expect(html).not.toContain('核对会议结论')
|
||||
})
|
||||
|
||||
it('keeps the project lifecycle conversation summary headline visible regardless of the right pane state', () => {
|
||||
snapshot.state!.cards = [card({ status: 'done', work: { result: 'project-lifecycle-summary-result', resultVersion: 1, readVersion: 0 } })]
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
const html = render()
|
||||
expect(html).toContain('1 项工作有新结果')
|
||||
expect(html).not.toContain('project-lifecycle-summary-result')
|
||||
})
|
||||
|
||||
it('lists only the project lifecycle conversation\'s active work alongside its completed history', () => {
|
||||
snapshot.state!.cards = [
|
||||
card({ status: 'active' }),
|
||||
card({ id: 'other_project', projectId: 'prj_2', conversationId: 'cnv_2', title: '其它项目的工作',
|
||||
body: [{ type: 'kv', kv: [['正在做', '其它项目的工作细节']] }] }),
|
||||
card({ id: 'other_conv', projectId: 'prj_1', conversationId: 'cnv_9', title: '其它会话的工作',
|
||||
body: [{ type: 'kv', kv: [['正在做', '其它会话的工作细节']] }] }),
|
||||
]
|
||||
const html = renderWork()
|
||||
expect(html).toContain('核对会议结论')
|
||||
expect(html).not.toContain('其它项目的工作细节')
|
||||
expect(html).not.toContain('其它会话的工作细节')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// eventTime 取事件上的服务端时间;后端 0 值占位(0001-01-01T00:00:00Z)
|
||||
// 不能让状态栏把「已工作」读成几百年的耗时——非正 epoch 一律回落到墙上时钟。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { eventTime } from '../src/store'
|
||||
|
||||
describe('eventTime', () => {
|
||||
it('returns now when the timestamp is missing', () => {
|
||||
const now = 1_700_000_000_000
|
||||
expect(eventTime({}, now)).toBe(now)
|
||||
expect(eventTime({ ts: '' }, now)).toBe(now)
|
||||
expect(eventTime({ ts: undefined }, now)).toBe(now)
|
||||
})
|
||||
|
||||
it('returns now for unparseable timestamps', () => {
|
||||
const now = 1_700_000_000_000
|
||||
expect(eventTime({ ts: 'not-a-date' }, now)).toBe(now)
|
||||
expect(eventTime({ ts: '2026-13-40T99:99:99Z' }, now)).toBe(now)
|
||||
})
|
||||
|
||||
it('returns now when the parsed time is not a positive epoch (zero-value placeholder)', () => {
|
||||
// 0001-01-01T00:00:00Z is the Go/Unix zero time;过去不能解释为有效事件时间。
|
||||
const now = 1_700_000_000_000
|
||||
expect(eventTime({ ts: '0001-01-01T00:00:00Z' }, now)).toBe(now)
|
||||
// Date.parse('1970-01-01T00:00:00Z') === 0,应被一并回落,否则状态栏会显示从 epoch 至今。
|
||||
expect(eventTime({ ts: '1970-01-01T00:00:00Z' }, now)).toBe(now)
|
||||
})
|
||||
|
||||
it('returns the parsed epoch for a real, parseable timestamp', () => {
|
||||
const now = 1_700_000_000_000
|
||||
expect(eventTime({ ts: '2024-01-02T03:04:05Z' }, now)).toBe(Date.parse('2024-01-02T03:04:05Z'))
|
||||
expect(eventTime({ ts: '2026-10-12T10:00:00Z' }, now)).toBe(Date.parse('2026-10-12T10:00:00Z'))
|
||||
})
|
||||
})
|
||||
+53
-13
@@ -1,7 +1,17 @@
|
||||
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
|
||||
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { auth } from '../src/auth'
|
||||
import {
|
||||
ForcedPasswordModeSection,
|
||||
isForcedPasswordMode,
|
||||
requiresCurrentPassword,
|
||||
submitForcedPassword,
|
||||
} from '../src/components/passwordChangeMode'
|
||||
import { humanChangePasswordError, humanLoginError } from '../src/components/loginErrors'
|
||||
|
||||
describe('humanLoginError(登录)', () => {
|
||||
it('401 统一为用户名或密码不正确', () => {
|
||||
@@ -31,19 +41,49 @@ describe('humanChangePasswordError(改密门)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanRedeemError(可信激活与本人恢复能力兑换)', () => {
|
||||
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
|
||||
expect(humanRedeemError('409: login key already used')).toBe('该能力已被使用过;请由本人核对提案状态,或联系可信 owner 续办初始化')
|
||||
expect(humanRedeemError('401: invalid or expired login key')).toBe('能力无效或已过期;请核对本人提案状态或联系可信 owner 续办初始化')
|
||||
expect(humanRedeemError('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
|
||||
})
|
||||
it('未知错误剥状态码前缀透传', () => {
|
||||
expect(humanRedeemError('400: bad')).toBe('bad')
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanChangePasswordError(登录恢复新增文案)', () => {
|
||||
it('并发凭据变化给出重新登录指引', () => {
|
||||
expect(humanChangePasswordError('401: credentials changed concurrently; retry login')).toBe('登录状态已变化(密码在其他地方被更新),请重新登录')
|
||||
})
|
||||
})
|
||||
|
||||
describe('one_time_password 强制改密流程', () => {
|
||||
afterEach(() => {
|
||||
auth.clear()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('识别 one_time_password 模式且无需当前密码字段', () => {
|
||||
expect(isForcedPasswordMode('one_time_password')).toBe(true)
|
||||
expect(requiresCurrentPassword('one_time_password')).toBe(false)
|
||||
const html = renderToStaticMarkup(createElement(ForcedPasswordModeSection, {
|
||||
mode: 'one_time_password',
|
||||
current: '',
|
||||
onCurrentChange: () => undefined,
|
||||
}))
|
||||
expect(html).not.toContain('forced-current')
|
||||
expect(html).toContain('无需输入当前密码')
|
||||
})
|
||||
|
||||
it('提交 one_time_password 改密时走 /api/auth/password', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
owner: 'alice',
|
||||
name: 'Alice',
|
||||
isAdmin: false,
|
||||
mustChangePassword: false,
|
||||
reloginRequired: true,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
const changeSpy = vi.spyOn(api, 'changePassword')
|
||||
|
||||
await submitForcedPassword('one_time_password', 'should-be-ignored', 'new-password-123')
|
||||
|
||||
expect(changeSpy).toHaveBeenCalledWith('', 'new-password-123')
|
||||
expect(fetchSpy).toHaveBeenCalledWith('/api/auth/password', expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current: '', next: 'new-password-123' }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { auth } from '../src/auth'
|
||||
import { GeneratedCredentialPanel, oneTimePasswordCredential } from '../src/components/oneTimePasswordUi'
|
||||
|
||||
describe('管理员一次性密码发放', () => {
|
||||
afterEach(() => {
|
||||
auth.clear()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('调用精确 one-time-password 端点并使用 POST', async () => {
|
||||
const response = { owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true }
|
||||
const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
const out = await api.adminIssueOneTimePassword('alice')
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
// 服务端严格解码 JSON 对象体:无体无 Content-Type 会被拒为 400 EOF。
|
||||
expect(fetchSpy).toHaveBeenCalledWith('/api/admin/users/alice/one-time-password', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
|
||||
})
|
||||
expect(out).toEqual(response)
|
||||
})
|
||||
|
||||
it('展示后端返回的 oneTimePassword', () => {
|
||||
const credential = oneTimePasswordCredential({ owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true })
|
||||
const html = renderToStaticMarkup(createElement(GeneratedCredentialPanel, {
|
||||
credential,
|
||||
onClose: () => undefined,
|
||||
}))
|
||||
expect(html).toContain('otp-0001')
|
||||
expect(html).toContain('一次性登录密码仅显示一次')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,264 @@
|
||||
// RdmMembersSection rdm.role.options 授权门控:rdm.perm.check 给出 member.manage
|
||||
// 未允许时,不得提前发 rdm.role.options(普通成员被 403);允许后必须发。
|
||||
//
|
||||
// 测试在 node 环境下用最小化的 DOM mock 驱动 React 19 createRoot,让 useEffect 正常落槽;
|
||||
// 不去依赖 happy-dom/jsdom(受网络/包锁约束,allowed_paths 也限制仅 src + test)。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { RdmMembersSection } from '../src/components/adminRdmSections'
|
||||
import { api } from '../src/api'
|
||||
import type { Project } from '../src/contracts'
|
||||
|
||||
// 用 vi.mock 隔离 useStore:组件只用到 select(state).me,其它 store 字段触发不到,
|
||||
// 不会影响此处关注点(避免 host Store 启动监听器、送 SSE 等副作用污染测试)。
|
||||
vi.mock('../src/store', () => {
|
||||
const state = {
|
||||
me: { owner: 'bob', name: 'bob', auth: true },
|
||||
}
|
||||
return {
|
||||
useStore: (select: (value: typeof state) => unknown) => select(state),
|
||||
eventTime: (_e: { ts?: string }, now: number) => now,
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 自定义 React DOM 桩:让 useEffect 在 node 环境落到 commit 阶段 ----
|
||||
// 服务端 React 不运行 useEffect;createRoot 需要一个 DOM-shaped container。
|
||||
// 这里只覆盖 react-dom-client 用到的几个关键入口(addEventListener、tagName、namespaceURI、
|
||||
// ownerDocument.defaultView.HTMLIFrameElement、activeElement、documentElement)。任何
|
||||
// 新字段缺失都会在第一处 React 调用时显式爆出,便于修复而不是静默吞下。
|
||||
class HTMLIFrameElement {}
|
||||
class MockDocument {
|
||||
nodeType = 9
|
||||
defaultView: any
|
||||
HTMLIFrameElement = HTMLIFrameElement
|
||||
activeElement: any = null
|
||||
documentElement: any
|
||||
constructor() {
|
||||
this.defaultView = makeWindow()
|
||||
this.defaultView.document = this
|
||||
this.activeElement = null
|
||||
this.documentElement = this
|
||||
}
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
dispatchEvent() {}
|
||||
contains() { return false }
|
||||
createElement(tag: string) {
|
||||
const el = new MockElement()
|
||||
el.tagName = tag
|
||||
el.nodeName = tag
|
||||
el.ownerDocument = this
|
||||
if (renderObserver) renderObserver.append(tag)
|
||||
return el
|
||||
}
|
||||
createElementNS(_ns: string, tag: string) { return this.createElement(tag) }
|
||||
createTextNode() { return new MockElement() }
|
||||
}
|
||||
|
||||
/** 创建 DOM 节点时把它们的 tag 累积下来,替代 innerHTML 读取(Mock 上 innerHTML 为空)。 */
|
||||
let renderObserver: { tags: string[]; reset(): void; append(t: string): void } | null = null
|
||||
class MockElement {
|
||||
nodeType = 1
|
||||
style: Record<string, string> = {}
|
||||
attributes: Record<string, string> = {}
|
||||
tagName = 'DIV'
|
||||
nodeName = 'DIV'
|
||||
namespaceURI = 'http://www.w3.org/1999/xhtml'
|
||||
ownerDocument: any
|
||||
childNodes: any[] = []
|
||||
parentNode: any = null
|
||||
appendChild(c: any) { this.childNodes.push(c); c.parentNode = this; return c }
|
||||
removeChild(c: any) { const i = this.childNodes.indexOf(c); if (i >= 0) this.childNodes.splice(i, 1); c.parentNode = null; return c }
|
||||
insertBefore(c: any, ref: any) {
|
||||
const i = ref ? this.childNodes.indexOf(ref) : this.childNodes.length
|
||||
this.childNodes.splice(i < 0 ? this.childNodes.length : i, 0, c)
|
||||
c.parentNode = this
|
||||
return c
|
||||
}
|
||||
contains(c: any) {
|
||||
for (let n: any = c; n; n = n.parentNode) if (n === this) return true
|
||||
return false
|
||||
}
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
dispatchEvent() {}
|
||||
focus() {}
|
||||
setAttribute(k: string, v: string) { this.attributes[k] = v }
|
||||
removeAttribute(k: string) { delete this.attributes[k] }
|
||||
cloneNode() { return new MockElement() }
|
||||
/** React 写 `<select>` 时会读 `select.options`,给一个可索引的 length 让 updateOptions 不崩。 */
|
||||
get options() {
|
||||
return this.childNodes
|
||||
}
|
||||
/** React 写入 innerHTML;这里读为空即可(断言用别的字段)。 */
|
||||
get innerHTML() { return '' }
|
||||
get outerHTML() { return '' }
|
||||
get textContent() { return '' }
|
||||
set textContent(_v: string) { /* 允许 commit 阶段写入 */ }
|
||||
get value() { return this.attributes.value ?? '' }
|
||||
set value(v: string) { this.attributes.value = v }
|
||||
get firstChild() { return this.childNodes[0] }
|
||||
get lastChild() { return this.childNodes[this.childNodes.length - 1] }
|
||||
get nextSibling() {
|
||||
if (!this.parentNode) return null
|
||||
const i = this.parentNode.childNodes.indexOf(this)
|
||||
return this.parentNode.childNodes[i + 1] ?? null
|
||||
}
|
||||
get previousSibling() {
|
||||
if (!this.parentNode) return null
|
||||
const i = this.parentNode.childNodes.indexOf(this)
|
||||
return this.parentNode.childNodes[i - 1] ?? null
|
||||
}
|
||||
}
|
||||
function makeWindow() {
|
||||
const w: any = {
|
||||
event: undefined,
|
||||
HTMLIFrameElement,
|
||||
document: undefined as any,
|
||||
navigator: { userAgent: 'node' },
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
function makeContainer(): MockElement {
|
||||
const doc = new MockDocument()
|
||||
const el = doc.createElement('div') as MockElement
|
||||
return el
|
||||
}
|
||||
|
||||
function installDomGlobals() {
|
||||
const win = makeWindow()
|
||||
const doc = new MockDocument()
|
||||
doc.defaultView = win
|
||||
win.document = doc
|
||||
;(globalThis as any).window = win
|
||||
;(globalThis as any).document = doc
|
||||
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
||||
;(globalThis as any).MessageChannel = class { port1 = { postMessage() {} }; port2 = { onmessage: null as any, postMessage() {} } }
|
||||
;(globalThis as any).setImmediate = (cb: (...args: any[]) => void) => Promise.resolve().then(() => cb())
|
||||
}
|
||||
|
||||
let _installed = false
|
||||
function ensureDomGlobals() {
|
||||
if (_installed) return
|
||||
installDomGlobals()
|
||||
_installed = true
|
||||
}
|
||||
|
||||
// ---- 用例辅助 ----
|
||||
|
||||
const project: Project = {
|
||||
id: 'host-1', owner: 'alice', name: '项目P', createdAt: '', updatedAt: '',
|
||||
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' },
|
||||
}
|
||||
|
||||
type CmdHandler = (id: string, params: Record<string, unknown>) => Promise<unknown> | unknown
|
||||
|
||||
/** 直接劫持 api.rdmProjectCommand,用 handlers[id] 分别登记每个命令的响应。 */
|
||||
function spyCommandBus(handlers: Record<string, CmdHandler>) {
|
||||
const calls: Array<[string, Record<string, unknown>]> = []
|
||||
const original = api.rdmProjectCommand.bind(api)
|
||||
const spy = vi.spyOn(api, 'rdmProjectCommand').mockImplementation(async (_proj: Project, id: string, params: Record<string, unknown> = {}) => {
|
||||
const args: [string, Record<string, unknown>] = [id, params]
|
||||
calls.push(args)
|
||||
if (!handlers[id]) throw new Error(`unexpected command: ${id} (no handler registered)`)
|
||||
return handlers[id](id, params)
|
||||
})
|
||||
return {
|
||||
calls,
|
||||
roleOptionsCalls: () => calls.filter(([id]) => id === 'rdm.role.options'),
|
||||
permCheckCalls: () => calls.filter(([id]) => id === 'rdm.perm.check'),
|
||||
memberListCalls: () => calls.filter(([id]) => id === 'rdm.project.member.list'),
|
||||
restore: () => {
|
||||
spy.mockRestore()
|
||||
;(api as any).rdmProjectCommand = original
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let root: Root | null = null
|
||||
let container: MockElement | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
ensureDomGlobals()
|
||||
renderObserver = {
|
||||
tags: [],
|
||||
reset() { this.tags = [] },
|
||||
append(t: string) { this.tags.push(t) },
|
||||
}
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => root!.unmount())
|
||||
root = null
|
||||
}
|
||||
container = null
|
||||
renderObserver = null
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function renderAndFlush(element: ReactNode, flushes: number = 3) {
|
||||
container = makeContainer()
|
||||
await act(async () => {
|
||||
root = createRoot(container as any)
|
||||
root!.render(element)
|
||||
})
|
||||
// 反复冲一轮 microtask flush,让 perm.check 的 .then 落进 commit。
|
||||
for (let i = 0; i < flushes; i++) {
|
||||
await act(async () => { await Promise.resolve() })
|
||||
}
|
||||
}
|
||||
|
||||
/** 助记:累计的 render 标签里「候选专业角色筛选」下拉 = 看到标 'select' + 紧邻的 option 角色字符串。 */
|
||||
function sawCandidateRoleFilter(): boolean {
|
||||
if (!renderObserver) return false
|
||||
// React 渲染时按 tag 序列创建 select/option;候选筛选断言只需确保 select 节点创建了。
|
||||
return renderObserver.tags.includes('select')
|
||||
}
|
||||
|
||||
describe('RdmMembersSection rdm.role.options 授权门控', () => {
|
||||
it('member.manage 未被允许时不得请求 rdm.role.options(普通成员避免 403)', async () => {
|
||||
const bus = spyCommandBus({
|
||||
'rdm.perm.check': () => ({ actions: [{ action: 'member.manage', allowed: false }] }),
|
||||
'rdm.project.member.list': () => ({ members: [], accountDirectoryAvailable: true }),
|
||||
})
|
||||
|
||||
await renderAndFlush(createElement(RdmMembersSection, { project }))
|
||||
|
||||
expect(bus.permCheckCalls().length).toBe(1)
|
||||
expect(bus.roleOptionsCalls().length).toBe(0)
|
||||
})
|
||||
|
||||
it('member.manage 被允许后再请求 rdm.role.options,并把结果写回角色筛选', async () => {
|
||||
const bus = spyCommandBus({
|
||||
'rdm.perm.check': () => ({ actions: [{ action: 'member.manage', allowed: true }] }),
|
||||
'rdm.role.options': () => ({ roles: [
|
||||
{ roleId: 'r-eng', name: '工程师' },
|
||||
{ roleId: 'r-qa', name: '测试' },
|
||||
] }),
|
||||
'rdm.project.member.list': () => ({ members: [], accountDirectoryAvailable: true }),
|
||||
})
|
||||
|
||||
await renderAndFlush(createElement(RdmMembersSection, { project }))
|
||||
|
||||
expect(bus.permCheckCalls().length).toBe(1)
|
||||
expect(bus.roleOptionsCalls().length).toBe(1)
|
||||
})
|
||||
|
||||
it('门控生效前的初始挂载阶段不出现专业角色筛选下拉(不提供误导选项)', async () => {
|
||||
// 用一个永挂起的 perm.check 模拟「自省中」——此时页面上确实只见核对提示,
|
||||
// 候选区不出现;这是顺带的渲染回归保单,防止门控未来误把 options 提前注入。
|
||||
// memberManagementView 在 actions===null 时返回 canManage:false,rdm.role.options 因此不发请求;
|
||||
// 同时 candRoleId 筛选(占位 select)也不应渲染——manage.canManage=false 时整个 ad-create 块被跳掉。
|
||||
spyCommandBus({
|
||||
'rdm.perm.check': () => new Promise<{ actions: { action: string; allowed: boolean }[] }>(() => {}),
|
||||
'rdm.project.member.list': () => new Promise<any>(() => {}),
|
||||
})
|
||||
|
||||
await renderAndFlush(createElement(RdmMembersSection, { project }))
|
||||
|
||||
expect(sawCandidateRoleFilter()).toBe(false)
|
||||
})
|
||||
})
|
||||
+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