Compare commits

..
Author SHA1 Message Date
agent-exec-transport 1fe321240a agent-exec transport 91fa500d0ed8f7daca02bd46ba843a9a/b201c6aced99
governance / governance (pull_request) Successful in 13s
ci / test-and-build (pull_request) Successful in 46s
2026-10-20 10:48:43 +00:00
weichen c6df2dc180 Merge pull request 'Skip role options for non-managers and guard zero run time' (#117) from fix/multiuser-project-collab-r2 into main
ci / test-and-build (push) Successful in 5m56s
governance / governance (push) Successful in 20s
2026-10-20 07:05:50 +00:00
weichen 97b947a8e3 Merge branch 'main' into fix/multiuser-project-collab-r2
ci / test-and-build (pull_request) Successful in 1m13s
governance / governance (pull_request) Successful in 16s
2026-10-20 07:01:35 +00:00
weichen 096947f9a2 Merge pull request 'IAM U5-Frontend integration-tests via PI direct (cursor 3/3 blocked)' (#118) from feature/iam-link-u4-frontend-auto-discover into main
governance / governance (push) Successful in 19s
ci / test-and-build (push) Failing after 56s
2026-10-20 06:59:46 +00:00
weichen 3fe5cf1617 Merge Frontend main into U5-Frontend branch for sync
governance / governance (pull_request) Successful in 16s
ci / test-and-build (pull_request) Successful in 2m10s
2026-10-20 06:55:29 +00:00
weichen e73f2ad9dd U5-Frontend integration-tests via PI direct (cursor 3/3 blocked on Playwright + vitest-only)
governance / governance (pull_request) Successful in 13s
ci / test-and-build (pull_request) Successful in 3m38s
Why: cursor 3/3 exhausted; PI direct dispatched as host integration work.

Scope: 8 test files (7 modified + 1 new), 255 insertions, 29 new vitest tests covering 11 U4 frontend-auto-discover scenarios end-to-end:
- AutoDiscover (3 new in store.test.ts)
- CapabilityGroup (3 new in rolePermissions.test.ts + 6 new in panels)
- CreatorOwnerMemberDuties (4 new across adminRdmSections/projectmeta/AccountUsersPanel)
- MemberManagementEntryHidden (3 new in adminRdmSections/projectmeta)
- EmptyListNoCreate (4 new in projectmeta/TaskSidebar)
- DefaultProjectDisabled (1 new in store.test.ts/projectmeta)
- RefreshBackoff + RefreshSingleFlight + RefreshHiddenPause + RefreshReconnectCompensate
- ProjectLifecycleConversation (5 new in test/conversation-work.test.ts)

Verification: npm run build ok (5.35s); ./node_modules/.bin/vitest run 656/656 (was 627, +29 new); new 29 tests across all 11 patterns all pass; no production files touched.

Refs: plan revision 3 U5-Frontend; /tmp/iam-link-u5-frontend-pi-verify-20261019.json exit=0.
2026-10-20 06:50:53 +00:00
agent-exec-transport d997d4a4d0 agent-exec transport 85c00f1e2274be1a84b73a421987fc69/ba12fd06dc72
governance / governance (pull_request) Successful in 16s
ci / test-and-build (pull_request) Successful in 34m20s
2026-10-20 06:21:55 +00:00
weichen 0b388e010d Merge pull request 'IAM U4 frontend-auto-discover (5s polling + capability-group UI + creator/owner/member duties)' (#115) from feature/iam-link-u4-frontend-auto-discover into main
ci / test-and-build (push) Successful in 3m37s
governance / governance (push) Successful in 15s
2026-10-20 03:31:25 +00:00
weichen 2bd5116f58 Merge Frontend main into U4 branch for L1→main sync
governance / governance (pull_request) Successful in 13s
ci / test-and-build (pull_request) Successful in 2m21s
2026-10-20 03:26:56 +00:00
weichen d7768d4ea0 Merge pull request 'Switch account UI to administrator one-time passwords' (#114) from feature/auth-one-time-password-r2 into main
governance / governance (push) Successful in 17s
ci / test-and-build (push) Successful in 27s
2026-10-20 01:56:52 +00:00
agent-exec-transport 3c22d0957a agent-exec transport f9152d30e9410417157ea4a3a9a926ba/e09f009fa9f5
governance / governance (pull_request) Successful in 23s
ci / test-and-build (pull_request) Successful in 36s
2026-10-20 01:48:41 +00:00
agent-exec-transport e296d38ec2 agent-exec transport 0bde562073461c828c740d52a55a8b5d/a5fbfeb4311f 2026-10-20 01:19:46 +00:00
agent-exec-transport f01579890d agent-exec transport 280102ed04383156d99d681a10a5b302/b5e3788d8c79
governance / governance (pull_request) Successful in 15s
ci / test-and-build (pull_request) Successful in 27s
2026-10-19 15:14:12 +00:00
weichen 259021d530 Merge pull request 'Merge the duplicate rdmManagement import in adminRdmSections' (#113) from fix/nonblocking-followups into main
governance / governance (push) Successful in 17s
ci / test-and-build (push) Successful in 39s
2026-10-19 14:31:28 +00:00
agent-exec-transport 7d43ef6ff7 agent-exec transport a4840fd95e17330e094af504afeba096/59a2059a0705
governance / governance (pull_request) Successful in 15s
ci / test-and-build (pull_request) Successful in 36s
2026-10-19 14:25:36 +00:00
weichen 9baf96d937 Merge pull request 'Merge the accepted super-admin changes into main' (#112) from feature/super-admin-merge into main
governance / governance (push) Successful in 16s
ci / test-and-build (push) Successful in 26s
2026-10-19 09:43:13 +00:00
weichen dab16052cf Merge feature/super-admin history onto main
governance / governance (pull_request) Successful in 40s
ci / test-and-build (pull_request) Successful in 54s
2026-10-19 09:40:42 +00:00
agent-exec-transport ce15f7977b agent-exec transport 548284a3917af91d24d89efd66e5ec7c/d0c96d333a18 2026-10-19 09:40:34 +00:00
weichen f560201f8b Merge pull request 'Verify progressive material states in a real browser' (#79) from feature/fast-slow-u2-u3 into main
governance / governance (push) Successful in 15s
ci / test-and-build (push) Successful in 26s
2026-10-19 09:20:39 +00:00
agent-exec-transport 6f8e7ec604 agent-exec transport 9f91fe4ba0c0b0412d4616549f170f87/cff15f49e8bf
governance / governance (pull_request) Successful in 17s
ci / test-and-build (pull_request) Successful in 37s
2026-10-19 05:25:04 +00:00
agent-exec-transport aa49e0dbf3 agent-exec transport 3646d6259c27d42bf24f52c61364d67e/bea155d33d2e
governance / governance (pull_request) Successful in 14s
ci / test-and-build (pull_request) Successful in 30s
2026-10-19 05:10:37 +00:00
weichen 9df5517e9c Merge Frontend main into feature/super-admin
Why: Synchronize Frontend main (IAM U3 account UI #86) into the super-admin branch and resolve conflict in api.ts, AccountUsersPanel.tsx and adminSections.tsx as unions.

Evidence: npm test passed (49 files, 574 tests), check-knip passed.

Impact: AccountUsersPanel supports both super-admin identity/elevation and U3 initialization/credential states.

Refs: #627, Frontend PR #86
2026-10-19 03:49:39 +00:00
weichen b4fec2fdf4 Merge feature/fast-slow-u2-u3 history after syncing with main
governance / governance (pull_request) Successful in 20s
ci / test-and-build (pull_request) Successful in 35s
2026-10-19 03:38:49 +00:00
agent-exec-transport 164ea66039 agent-exec transport 9f1c4114b4dc96cd086583d1c03cd7a4/07c6c40e6ebc 2026-10-19 03:37:22 +00:00
weichen 23877af960 Fix unused exports in accountAuthority for knip guard 2026-10-19 03:23:04 +00:00
agent-exec-transport e6bb387f71 agent-exec transport 1923e710321578c193e3dc882b41cf2f/7f9287b28660 2026-10-19 03:17:22 +00:00
weichen 1544781c28 Verify queued and failed material states in browser
Why: Users must see truthful first replies when work queues or cannot start.

Evidence: RED and GREEN HTTP browser receipts plus Frontend 269 tests and production build.

Impact: Adds two task-owned Playwright scenarios; production UI is unchanged.

Refs: U3 r0.5 independent review finding
2026-10-14 09:13:25 +00:00
weichen cfabed8fae Cover safe material intake across browser paths
Why: The first reply and task reuse need end-to-end checks across long and small material inputs.

Evidence: Dev evidence receipts for ten Playwright scenarios, Frontend tests, and build.

Impact: Adds browser-only coverage; production UI and API behavior are unchanged.

Refs: U3 r0.5 implementation plan Task 4
2026-10-14 08:40:21 +00:00
weichen c9d5acf089 Count batched failure notices in browser validation
Why: One system reminder can contain multiple failed child notices, so the browser test must wait for the whole batch rather than one message per child.

Evidence: The first post-merge HTTP/Playwright run failed only at this false one-to-one assumption; all six task-owned browser cases passed after counting notice entries in the persisted reminder.

Impact: Test-only correction; no production UI behavior change.

Refs: U3 r0.4 Task 6 review closure
2026-10-14 04:06:22 +00:00
weichen 139f2ce654 Merge current Frontend main into U3 browser checks
Why: Keep the approved RDM export controls and the U3 material browser coverage in one component candidate.

Evidence: Changes in store.ts touch separate state fields and behavior; full Frontend tests/build and task-owned browser cases will be rerun after this merge.

Impact: Includes upstream RDM export UI without changing U3 source or notice contracts.

Refs: Dev U3 r0.4 Task 6 final review synchronization
2026-10-14 04:04:05 +00:00
weichen 01ec12e77c Wait for settled material notices in browser checks
Why: A failed task card can appear before its notification turn, and the second finding must be observed while work is still running.

Evidence: The strengthened browser checks first exposed missing fixture notice text and a zero-width second-stage window; after fixture correction all six task-owned HTTP/Playwright scenarios passed. Frontend 265 unit tests and build passed.

Impact: Test coverage only; no production UI change.

Refs: U3 r0.4 independent review Important 5
2026-10-14 03:57:38 +00:00
weichen 34950997f8 Cover progressive material outcomes in browser tests
Why: The r0.4 acceptance must show running findings, zero and partial failure, and a source-bound choice in the real UI.

Evidence: Six task-owned HTTP/Playwright cases passed; npm test (265 tests) and npm run build passed.

Impact: Browser test coverage only; no production UI change.

Refs: U3 r0.4 Task 6
2026-10-14 03:34:23 +00:00
weichen 006c815675 Check pending membership in the real HTTP work view
Why: The U3 browser gate must show a host-generated pending question for underdetermined source counts.

Evidence: The new assertion failed against the old mechanism fixture while existing source-navigation and focus-flow tests remained observable; Backend fixture now emits structured premises.

Impact: Browser test inputs and assertion only; no production UI change.

Refs: Backend 756679d; Dev task feature/fast-slow-u2-u3-plan
2026-10-13 17:02:03 +00:00
weichen 9d7681cec4 Keep generated evidence helpers module-local
Why: The Core mirror must satisfy the Frontend unused-export guard without changing the candidate shape.

Evidence: Dev make contracts regenerated the file; Frontend knip 51 <= 52 and build passed.

Impact: Type-only export visibility change.

Refs: Core b474394; Dev task feature/fast-slow-u2-u3-plan
2026-10-13 16:49:56 +00:00
weichen 5bc2e12c01 Sync U3 evidence candidate types from Core
Why: Frontend generated types must follow the single Core checkpoint contract.

Evidence: Dev make contracts generated the mirror; npm test and npm run build passed.

Impact: Type-only mirror update; persisted task cards and reading behavior stay unchanged.

Refs: Core c722c16; Dev task feature/fast-slow-u2-u3-plan
2026-10-13 16:34:07 +00:00
weichen 64ba640b77 Merge remote-tracking branch 'origin/main' into feature/fast-slow-u2-u3 2026-10-13 15:49:29 +00:00
weichen 329bdf53d6 Merge remote-tracking branch 'origin/main' into feature/fast-slow-u2-u3 2026-10-13 10:19:21 +00:00
weichen 0bbccbe39e Merge current login recovery browser coverage
Why: Preserve current main before publishing the U2/U3 candidate.

Evidence: Exact main object fetched; browser-only upstream changes.

Impact: Retain both sets of browser coverage.

Refs: Agent-System-Dev U2/U3 delivery
2026-10-13 05:28:02 +00:00
weichen 3087b0621c Assert literal source records in work checkpoint browser flows
Why: Material records now display validated source quotations rather than unverified model paraphrases.

Evidence: Existing browser coverage retains refresh, changed-version, preview and focus checks; the updated assertions target actual source text.

Impact: Browser-only expectations change; product bundle remains the independently reviewed version.

Refs: Approved R05 and host material-record guard
2026-10-13 04:31:50 +00:00
weichen 4d1d64a83e Merge commit '9fc7da2ace8578d87d2601adcbf831e0b955aa68' into feature/fast-slow-u2-u3 2026-10-13 04:06:22 +00:00
weichen 861c45edb5 Keep delayed source responses behind the current preview intent
Why: Late source requests must not replace a newer preview or survive conversation navigation.

Evidence: Frontend typecheck, 221 unit tests, knip ratchet and build passed; delayed-response regressions reproduced the old failure.

Impact: The existing store owns source requests and invalidates stale responses on preview and context changes.

Refs: Agent-System-Dev feature-fast-slow-u2-u3-plan; independent review finding 4
2026-10-13 03:55:04 +00:00
weichen c63f6c7470 Gate checkpoint browser tests on an explicit owned instance
Why: Unrelated browser suites should skip this opt-in runtime acceptance instead of importing an absent manifest.

Evidence: Playwright discovers the two tests without an instance; explicit owned-instance replay passed previously.

Impact: Preserves strict manifest verification when invoked.

Refs: fast-slow-u2-u3 Task 7
2026-10-13 03:16:25 +00:00
weichen 75a420210d Exercise task recovery and source reading through real HTTP
Why: DOM fixtures alone cannot verify persisted checkpoints and source version handling.

Evidence: SELF source navigation and changed-version checks pass; reading replay is being repeated after the deferred runtime port fix.

Impact: Adds an explicit task-owned instance browser suite without paid model calls.

Refs: fast-slow-u2-u3 Task 7
2026-10-13 03:08:33 +00:00
weichen 175210f4bb Open processed source versions through the existing preview
Why: Source context must remain attached to the bytes being read.

Evidence: SELF frontend tests and production build pass.

Impact: Carries an optional expected hash and preserves one preview location.

Refs: fast-slow-u2-u3 Task 5 source-version acceptance
2026-10-13 02:51:07 +00:00
weichen b00fa4433b Merge commit 'dcd2699933eff51c06193cac7beb15c3d20a01ff' into feature/fast-slow-u2-u3 2026-10-13 02:44:19 +00:00
weichen 8be95c8807 Keep critical coverage and correction notices in conversation work
Why: A folded auxiliary panel must not hide material limits or withdrawn conclusions.

Evidence: SELF 213 frontend tests, type checks, knip ratchet, and bundle build pass.

Impact: Prioritizes existing task cards without opening files or changing focus.

Refs: fast-slow-u2-u3 Task 5
2026-10-13 02:36:57 +00:00
weichen 54fe339e65 Recover task projections without regressing work or reading state
Why: Replayed events, snapshot races, and source navigation must preserve authoritative work and user focus.

Evidence: SELF make evidence records 213 frontend tests, type checks, knip ratchet, and production build.

Impact: Uses the existing store for task revisions and gap replay; opens sources in the existing preview with version context.

Refs: fast-slow-u2-u3 Task 5; generated public contract types account for the measured knip baseline increase
2026-10-13 01:59:40 +00:00
weichen 82c08bd983 Consume persistent work contracts
Why: keep the workbench wire types aligned with Core.

Evidence: TypeScript check passed on the synchronized contract.

Impact: generated types only.

Refs: U2/U3 Task1
2026-10-13 01:09:31 +00:00
56 changed files with 3031 additions and 2513 deletions
+1 -1
View File
@@ -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}"
-419
View File
@@ -1,419 +0,0 @@
// permux 活体浏览器验收:真实 Backend + 真实 Frontend dist(vite preview,/api 代理到被测 Backend)。
// 由外部 harness 注入 PLAYWRIGHT_BASE_URL、PERMUX_LIVE=1、PERMUX_OWNER/PERMUX_PASSWORD(已设密管理员);
// 全程不拦截任何路由,服务端事实一律经同一 baseURL 的 API 核对。
import { randomUUID } from 'node:crypto'
import { test, expect, type APIRequestContext, type APIResponse, type BrowserContext, type Page } from '@playwright/test'
interface Perm { actionId: string; label: string; description: string; group: string; category?: string; scope: 'system' | 'project'; protected: boolean }
interface RolePerm { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
interface Role { roleId: string; name: string; description: string; enabled: boolean; roleVersion: number; bindingCount: number; permissions: RolePerm[] }
interface Account { userId: string; owner: string; stateVersion: number; profile: { name: string } }
interface UserRoleView { binding?: { roleId: string; active: boolean }; role?: Role }
interface Me { owner: string; isAdmin?: boolean; linkedCapabilities?: { projectCreate: boolean; source?: string[]; linkedActions?: string[] } }
const LIVE = process.env.PERMUX_LIVE === '1' && !!process.env.PLAYWRIGHT_BASE_URL
const SUFFIX = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
const DOC_CATEGORY = '文档工作'
const PROJECT_CREATE = 'project.create'
const LINKED = ['project.settings', 'project.members.manage', 'project.permissions.manage', 'document.duties.assign']
const LINKED_NOTE = '创建项目时自动具备 · 仅本人创建的项目'
/** 与 rolePermissions.permissionCategory 同义:缺分类的非保护系统动作归入「项目与协作」。 */
const categoryOf = (item: Perm) => item.category || (item.scope === 'system' ? '项目与协作' : item.group)
const permKey = (role: Role) => role.permissions.filter(item => item.allowed || item.capAllowed)
.map(item => `${item.actionId}=${item.allowed}/${item.capAllowed}`).sort()
const rowOf = (role: Role, actionId: string) => role.permissions.find(item => item.actionId === actionId)
async function ok(res: APIResponse) {
expect(res.ok(), `${res.url()} → ${res.status()} ${await res.text().catch(() => '')}`).toBe(true)
return res
}
const getJSON = async <T>(api: APIRequestContext, path: string) => (await ok(await api.get(path))).json() as Promise<T>
async function roleByName(api: APIRequestContext, name: string): Promise<Role> {
const page = await getJSON<{ roles: Role[] }>(api, `/api/admin/roles?query=${encodeURIComponent(name)}&mode=exact&status=all&limit=50`)
const role = (page.roles ?? []).find(item => item.name === name)
expect(role, `角色 ${name} 应存在`).toBeTruthy()
return role!
}
const getRole = (api: APIRequestContext, roleId: string) => getJSON<Role>(api, `/api/admin/roles/${encodeURIComponent(roleId)}`)
const getUser = (api: APIRequestContext, userId: string) => getJSON<Account>(api, `/api/admin/users/${encodeURIComponent(userId)}`)
const userRoleId = async (api: APIRequestContext, userId: string) =>
(await getJSON<UserRoleView>(api, `/api/admin/users/${encodeURIComponent(userId)}/role`)).role?.roleId ?? ''
async function userByOwner(api: APIRequestContext, owner: string): Promise<Account> {
const page = await getJSON<{ accounts: Account[] }>(api,
`/api/admin/users/search?query=${encodeURIComponent(owner)}&mode=exact&status=all&fields=owner&limit=20`)
const account = (page.accounts ?? []).find(item => item.owner === owner)
expect(account, `用户 ${owner} 应存在`).toBeTruthy()
return account!
}
async function assignRoleApi(api: APIRequestContext, userId: string, roleId: string) {
const { stateVersion } = await getUser(api, userId)
await ok(await api.put(`/api/admin/users/${encodeURIComponent(userId)}/role`, {
data: { roleId, expectedStateVersion: stateVersion, idempotencyKey: randomUUID() },
}))
}
/** API 登录;首登受限会话先改密再以新密码重登,返回可用的完整会话。 */
async function apiLogin(api: APIRequestContext, owner: string, password: string, nextPassword: string) {
const first = await (await ok(await api.post('/api/auth/login', { data: { owner, password } }))).json() as { mustChangePassword?: boolean; sessionScope?: string }
if (!first.mustChangePassword && first.sessionScope !== 'set_password_only') return
await ok(await api.post('/api/auth/password', { data: { current: password, next: nextPassword } }))
await ok(await api.post('/api/auth/login', { data: { owner, password: nextPassword } }))
}
async function expand(page: Page, trigger: string) {
const button = page.getByRole('button', { name: trigger, exact: true })
await expect(button).toBeVisible({ timeout: 15_000 })
if (await button.getAttribute('aria-expanded') !== 'true') await button.click()
await expect(button).toHaveAttribute('aria-expanded', 'true')
}
test.describe.serial('permux 活体主流程(真实 Backend + 真实 Frontend dist)', () => {
test.skip(!LIVE, 'set PERMUX_LIVE=1 and PLAYWRIGHT_BASE_URL (vite preview proxied to the backend under test)')
test.describe.configure({ timeout: 180_000 })
let context: BrowserContext
let page: Page
let api: APIRequestContext
let catalog: Perm[] = []
let actA: Perm, actB: Perm, keep: Perm, projectCreate: Perm
let linked: Perm[] = []
let roleA: Role, roleB: Role, roleC: Role
const names = {
roleA: `权限甲-${SUFFIX}`, roleB: `权限乙-${SUFFIX}`, roleC: `权限丙-${SUFFIX}`, roleD: `权限丁-${SUFFIX}`,
sameName: `同名测试·${SUFFIX}`, owner1: `permux-u1-${SUFFIX}`, owner2: `permux-u2-${SUFFIX}`, linkedOwner: `permux-linked-${SUFFIX}`,
}
const label1 = () => `${names.sameName}(${names.owner1})`
const label2 = () => `${names.sameName}(${names.owner2})`
const status = (text: string) => page.getByRole('status').filter({ hasText: text })
const savebar = () => page.getByRole('region', { name: '未保存的权限修改' })
const previewPanel = () => page.getByRole('region', { name: '保存前预览' })
const cell = (role: Role, item: Perm) => page.getByLabel(`${role.name} ${item.label}`, { exact: true })
const category = (name: string) => page.getByRole('group', { name: '权限分类' }).getByRole('button', { name, exact: true })
const matrixRow = (role: Role) => page.locator('tr').filter({ has: page.getByRole('button', { name: role.name, exact: true }) })
const userCheckbox = (label: string) => page.getByRole('checkbox', { name: `选择 ${label}`, exact: true })
const userRow = (label: string) => page.locator('tr').filter({ has: userCheckbox(label) })
const selectedBar = () => page.getByRole('region', { name: '已选用户' })
async function openSection(name: '角色与权限' | '用户管理') {
const zone = page.getByRole('dialog', { name: '系统管理' })
if (!await zone.isVisible()) {
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
await expect(zone).toBeVisible()
}
await zone.getByRole('button', { name, exact: true }).click()
}
/** 角色面板:矩阵视图 + 搜索本轮后缀,只呈现本轮创建的角色。 */
async function rolesMatrix(query = SUFFIX) {
await openSection('角色与权限')
await expect(page.getByRole('group', { name: '权限分类' })).toBeVisible({ timeout: 15_000 })
const matrixButton = page.getByRole('button', { name: '角色矩阵', exact: true })
if (await matrixButton.getAttribute('aria-pressed') !== 'true') await matrixButton.click()
await expand(page, '搜索与过滤')
await page.getByLabel('搜索角色').fill(query)
}
async function openPreview() {
await savebar().getByRole('button', { name: '保存', exact: true }).click()
await expect(previewPanel()).toBeVisible({ timeout: 15_000 })
await expect(previewPanel()).toContainText('请确认以下变化后保存')
}
async function confirmSave(roles: number) {
const confirm = savebar().getByRole('button', { name: '确认保存', exact: true })
await expect(confirm).toBeEnabled()
await confirm.click()
await expect(status(`已保存 ${roles} 个角色的权限设置`)).toBeVisible({ timeout: 15_000 })
await expect(savebar()).toHaveCount(0)
}
test.beforeAll(async ({ browser }) => {
if (!LIVE) return
context = await browser.newContext({ baseURL: process.env.PLAYWRIGHT_BASE_URL, locale: 'zh-CN' })
page = await context.newPage()
api = context.request
})
test.afterAll(async () => { await context?.close() })
test('登录:管理员经登录表单进入工作台,/api/me 为管理员', async () => {
await page.goto('/')
await page.getByPlaceholder('由管理员分配').fill(process.env.PERMUX_OWNER ?? '')
await page.getByPlaceholder('初始密码或常用密码').fill(process.env.PERMUX_PASSWORD ?? '')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.locator('.tsb-user')).toBeVisible({ timeout: 15_000 })
const res = await api.get('/api/me')
expect(res.status()).toBe(200)
const me = await res.json() as Me
expect(me.isAdmin).toBe(true)
expect(me.owner).toBe(process.env.PERMUX_OWNER)
catalog = (await getJSON<{ permissions: Perm[] }>(api, '/api/admin/permissions/catalog')).permissions ?? []
const editable = catalog.filter(item => !item.protected)
const docActions = editable.filter(item => categoryOf(item) === DOC_CATEGORY && item.scope === 'project' && !LINKED.includes(item.actionId))
expect(docActions.length, '「文档工作」至少需要两个可编辑项目动作').toBeGreaterThanOrEqual(2)
actA = docActions[0]
actB = docActions[1]
keep = docActions[2] ?? editable.find(item => item.scope === 'project' && !LINKED.includes(item.actionId)
&& item.actionId !== actA.actionId && item.actionId !== actB.actionId)!
expect(keep, '需要第三个可编辑项目动作用于验证未提及行保留').toBeTruthy()
projectCreate = editable.find(item => item.actionId === PROJECT_CREATE)!
expect(projectCreate, '目录应含 project.create').toBeTruthy()
linked = LINKED.map(id => editable.find(item => item.actionId === id)!)
expect(linked.every(Boolean), '目录应含四个创建联动动作').toBe(true)
})
test('C01+C02 多角色矩阵编辑:跨分类与搜索保留草稿,预览后一次提交', async () => {
await rolesMatrix()
for (const name of [names.roleA, names.roleB]) {
await page.getByLabel('新角色名称').fill(name)
await page.getByLabel('新角色描述').fill('permux 活体验收')
await page.getByRole('button', { name: '创建角色', exact: true }).click()
await expect(status(`已创建角色 ${name}`)).toBeVisible({ timeout: 15_000 })
}
roleA = await roleByName(api, names.roleA)
roleB = await roleByName(api, names.roleB)
expect(roleA.enabled && roleB.enabled).toBe(true)
// 预置第三动作行(默认允许):界面提交未提及它,提交后必须原样保留。
await ok(await api.post('/api/admin/roles/permissions/batch', { data: { idempotencyKey: randomUUID(), roles: [roleA, roleB].map(role => ({
roleId: role.roleId, expectedRoleVersion: role.roleVersion, changes: [{ actionId: keep.actionId, allowed: true, capAllowed: true }],
})) } }))
// 切走再回到分区使面板重挂载,草稿基线取预置后的最新版本。
await openSection('用户管理')
await rolesMatrix()
await category(DOC_CATEGORY).click()
await expect(category(DOC_CATEGORY)).toHaveAttribute('aria-pressed', 'true')
await cell(roleA, actA).selectOption('default')
await cell(roleB, actB).selectOption('project')
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
const other = (await page.getByRole('group', { name: '权限分类' }).getByRole('button').allInnerTexts()).find(text => text.trim() !== DOC_CATEGORY)
expect(other, '应存在第二个权限分类').toBeTruthy()
await category(other!.trim()).click()
await expect(cell(roleA, actA)).toHaveCount(0)
await category(DOC_CATEGORY).click()
await expect(cell(roleA, actA)).toHaveValue('default')
await expect(cell(roleB, actB)).toHaveValue('project')
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
await page.getByLabel('搜索角色').fill(roleA.name)
await expect(cell(roleB, actB)).toHaveCount(0, { timeout: 15_000 })
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
await page.getByLabel('搜索角色').fill('')
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
await page.getByLabel('搜索角色').fill(SUFFIX)
await expect(cell(roleA, actA)).toHaveValue('default', { timeout: 15_000 })
await expect(cell(roleB, actB)).toHaveValue('project')
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
await openPreview()
const preview = previewPanel()
for (const text of [roleA.name, roleB.name, actA.label, actB.label, '不允许', '默认允许', '按项目授权']) await expect(preview).toContainText(text)
await confirmSave(2)
const freshA = await getRole(api, roleA.roleId)
const freshB = await getRole(api, roleB.roleId)
expect(rowOf(freshA, actA.actionId)).toMatchObject({ allowed: true, capAllowed: true })
expect(rowOf(freshB, actB.actionId)).toMatchObject({ allowed: false, capAllowed: true })
expect(rowOf(freshA, actB.actionId)?.capAllowed ?? false).toBe(false)
expect(rowOf(freshB, actA.actionId)?.capAllowed ?? false).toBe(false)
for (const role of [freshA, freshB]) expect(rowOf(role, keep.actionId)).toMatchObject({ allowed: true, capAllowed: true })
})
test('C03 创建联动:开启创建项目自动补齐四项按项目授权,运行时 /api/me 联动生效', async ({ request }) => {
await rolesMatrix()
await category(categoryOf(projectCreate)).click()
await cell(roleA, projectCreate).selectOption(projectCreate.scope === 'system' ? 'on' : 'default')
await expect(savebar()).toContainText('已修改 1 个角色、1 项设置')
await category(categoryOf(linked[0])).click()
await expect(matrixRow(roleA).getByText(LINKED_NOTE).first()).toBeVisible()
await expect(matrixRow(roleB).getByText(LINKED_NOTE)).toHaveCount(0)
await openPreview()
const expansion = previewPanel().locator('.rp-linked')
await expect(expansion).toContainText('系统将自动补齐')
for (const item of linked) await expect(expansion).toContainText(item.label)
await confirmSave(1)
const fresh = await getRole(api, roleA.roleId)
expect(rowOf(fresh, PROJECT_CREATE)).toMatchObject({ allowed: true })
for (const id of LINKED) expect(rowOf(fresh, id), id).toMatchObject({ allowed: false, capAllowed: true })
const password = `Permux!${SUFFIX}Aa9`
const nextPassword = `Permux!${SUFFIX}Bb8`
const created = await (await ok(await api.post('/api/admin/users', {
data: { owner: names.linkedOwner, profile: { name: '联动验证' }, password },
}))).json() as Account
await assignRoleApi(api, created.userId, roleA.roleId)
expect(await userRoleId(api, created.userId)).toBe(roleA.roleId)
await apiLogin(request, names.linkedOwner, password, nextPassword)
const meRes = await request.get('/api/me')
expect(meRes.status()).toBe(200)
const me = await meRes.json() as Me
expect(me.owner).toBe(names.linkedOwner)
expect(me.linkedCapabilities?.projectCreate).toBe(true)
for (const id of LINKED) expect(me.linkedCapabilities?.linkedActions ?? []).toContain(id)
})
test('C04 断链拒绝:创建项目开启时联动项不允许不可保存,恢复按项目授权后保存成功', async () => {
const before = await getRole(api, roleA.roleId)
const broken = linked[0]
await rolesMatrix()
await category(DOC_CATEGORY).click()
await cell(roleA, actB).selectOption('project')
await category(categoryOf(broken)).click()
await expect(cell(roleA, broken)).toHaveValue('project')
await cell(roleA, broken).selectOption('denied')
await expect(savebar()).toContainText('已修改 1 个角色、2 项设置')
await openPreview()
const confirm = savebar().getByRole('button', { name: '确认保存', exact: true })
const blockedNote = previewPanel().getByRole('alert')
if (await blockedNote.isVisible()) {
await expect(blockedNote).toContainText('存在冲突或错误,调整后才能保存')
await expect(previewPanel()).toContainText(broken.label)
await expect(confirm).toBeDisabled()
} else {
await expect(confirm).toBeEnabled()
await confirm.click()
await expect(page.locator('.rp-status').getByRole('alert')).toContainText(broken.label, { timeout: 15_000 })
await expect(savebar()).toContainText('已修改 1 个角色、2 项设置')
}
const unchanged = await getRole(api, roleA.roleId)
expect(unchanged.roleVersion).toBe(before.roleVersion)
expect(permKey(unchanged)).toEqual(permKey(before))
await cell(roleA, broken).selectOption('project')
await expect(savebar()).toContainText('已修改 1 个角色、1 项设置')
await openPreview()
await confirmSave(1)
const fresh = await getRole(api, roleA.roleId)
expect(rowOf(fresh, actB.actionId)).toMatchObject({ allowed: false, capAllowed: true })
expect(rowOf(fresh, PROJECT_CREATE)).toMatchObject({ allowed: true })
for (const id of LINKED) expect(rowOf(fresh, id), id).toMatchObject({ allowed: false, capAllowed: true })
})
test('C07 同名辨识:两名同名用户按账号辨识,勾选互不串行', async () => {
await openSection('用户管理')
for (const owner of [names.owner1, names.owner2]) {
await page.getByLabel('新用户登录名').fill(owner)
await expand(page, '可选资料:姓名、电话、邮箱、工号、部门、备注')
await page.locator('#new-user-name').fill(names.sameName)
await page.getByRole('button', { name: '创建用户', exact: true }).click()
await expect(status(`已创建 ${owner}`)).toBeVisible({ timeout: 15_000 })
await page.getByRole('button', { name: '返回列表', exact: true }).click()
}
await page.getByLabel('搜索用户').fill(names.sameName)
await expect(userRow(label1())).toBeVisible({ timeout: 15_000 })
await expect(userRow(label2())).toBeVisible()
await expect(page.getByText(`存在同名用户「${names.sameName}」`)).toBeVisible()
for (const [label, owner] of [[label1(), names.owner1], [label2(), names.owner2]]) {
await expect(userRow(label)).toContainText(names.sameName)
await expect(userRow(label)).toContainText(owner)
await expect(userRow(label)).toContainText('同名 · 按账号辨识')
}
await userCheckbox(label1()).check()
await expect(userCheckbox(label1())).toBeChecked()
await expect(userCheckbox(label2())).not.toBeChecked()
await expect(userRow(label2())).toContainText(names.owner2)
await expect(userRow(label2())).not.toContainText(names.owner1)
await expect(selectedBar()).toContainText('已选 1 人')
})
test('C08 批量分配:两人预览 未分配 → 角色乙 后一次提交', async () => {
await userCheckbox(label2()).check()
await expect(selectedBar()).toContainText('已选 2 人')
await selectedBar().getByRole('button', { name: '为选中的 2 人分配角色', exact: true }).click()
const batch = page.getByRole('region', { name: '为选中的 2 人分配角色' })
await batch.getByLabel('搜索可分配角色').fill(roleB.name)
const target = batch.getByLabel('批量分配的目标角色')
await expect(target.locator('option', { hasText: roleB.name })).toHaveCount(1, { timeout: 15_000 })
await target.selectOption({ label: roleB.name })
for (const owner of [names.owner1, names.owner2]) {
await expect(batch.locator('li', { hasText: owner })).toContainText(`未分配 → ${roleB.name}`, { timeout: 15_000 })
}
await batch.getByRole('button', { name: '确认分配', exact: true }).click()
await expect(status(`已为 2 人分配角色「${roleB.name}」`)).toBeVisible({ timeout: 15_000 })
for (const owner of [names.owner1, names.owner2]) {
const account = await userByOwner(api, owner)
expect(await userRoleId(api, account.userId), owner).toBe(roleB.roleId)
}
await expect(userRow(label1())).toContainText(roleB.name)
await expect(userRow(label2())).toContainText(roleB.name)
})
test('C09 冲突整批不写:确认前他人改动一人角色,409 后整批未写入且保留选择', async () => {
roleC = await (await ok(await api.post('/api/admin/roles', { data: { name: names.roleC, description: 'permux 并发改动' } }))).json() as Role
const user1 = await userByOwner(api, names.owner1)
const user2 = await userByOwner(api, names.owner2)
await userCheckbox(label1()).check()
await userCheckbox(label2()).check()
await expect(selectedBar()).toContainText('已选 2 人')
await selectedBar().getByRole('button', { name: '为选中的 2 人分配角色', exact: true }).click()
const batch = page.getByRole('region', { name: '为选中的 2 人分配角色' })
await batch.getByLabel('搜索可分配角色').fill(roleA.name)
const target = batch.getByLabel('批量分配的目标角色')
await expect(target.locator('option', { hasText: roleA.name })).toHaveCount(1, { timeout: 15_000 })
await target.selectOption({ label: roleA.name })
for (const owner of [names.owner1, names.owner2]) {
await expect(batch.locator('li', { hasText: owner })).toContainText(`${roleB.name} → ${roleA.name}`, { timeout: 15_000 })
}
const confirm = batch.getByRole('button', { name: '确认分配', exact: true })
await expect(confirm).toBeEnabled()
await assignRoleApi(api, user2.userId, roleC.roleId)
await confirm.click()
const alert = page.getByRole('alert').filter({ hasText: '整批均未写入' })
await expect(alert).toBeVisible({ timeout: 15_000 })
await expect(alert).toContainText('请重新预览')
await expect(userCheckbox(label1())).toBeChecked()
await expect(userCheckbox(label2())).toBeChecked()
await expect(selectedBar()).toContainText('已选 2 人')
expect(await userRoleId(api, user1.userId)).toBe(roleB.roleId)
expect(await userRoleId(api, user2.userId)).toBe(roleC.roleId)
})
test('C10 复制并分配:源角色权限复制到新角色并换绑,新角色独立维护', async () => {
await selectedBar().getByRole('button', { name: '清空选择', exact: true }).click()
await expect(selectedBar()).toHaveCount(0)
const user1 = await userByOwner(api, names.owner1)
const sourceBefore = await getRole(api, roleB.roleId)
await userRow(label1()).getByRole('button', { name: '查看与管理', exact: true }).click()
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeVisible()
await expect(page.getByText(`当前角色 ${roleB.name}`)).toBeVisible({ timeout: 15_000 })
await expand(page, '复制角色并分配…')
await page.getByLabel('新角色名称(必填)').fill(names.roleD)
await page.getByLabel('新角色描述').fill('permux 复制并分配')
await expect(page.getByText(`相对「${roleB.name}」调整 0 项`)).toBeVisible()
await page.getByRole('button', { name: '确认复制并分配', exact: true }).click()
const done = status('新角色独立维护')
await expect(done).toBeVisible({ timeout: 15_000 })
await expect(done).toContainText(names.roleD)
await expect(page.getByText(`当前角色 ${names.roleD}`)).toBeVisible({ timeout: 15_000 })
const copied = await roleByName(api, names.roleD)
expect(copied.roleId).not.toBe(roleB.roleId)
expect(copied.description).toBe('permux 复制并分配')
expect(permKey(copied)).toEqual(permKey(sourceBefore))
expect(await userRoleId(api, user1.userId)).toBe(copied.roleId)
const sourceAfter = await getRole(api, roleB.roleId)
expect(sourceAfter.roleVersion).toBe(sourceBefore.roleVersion)
expect(permKey(sourceAfter)).toEqual(permKey(sourceBefore))
// 源角色仅失去被换绑的这一人,其余绑定不受影响。
expect(sourceAfter.bindingCount).toBe(sourceBefore.bindingCount - 1)
})
})
+5 -5
View File
@@ -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()
+202
View File
@@ -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
View File
@@ -1,3 +1,3 @@
{
"exports": 51
"exports": 58
}
+32
View File
@@ -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
View File
@@ -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)
+38
View File
@@ -0,0 +1,38 @@
// 账号管理动作呈现门控(超级管理员 R3/R4/R8):只决定 UI 暴露哪些入口,
// 服务端授权仍是唯一权威——隐藏的入口经直连 API 调用同样会被拒绝。
interface AccountIdentity { isAdmin?: boolean; isSuperAdmin?: boolean }
type AccountIdentityKind = 'super' | 'admin' | 'user'
export const accountIdentityKind = (account: AccountIdentity): AccountIdentityKind =>
account.isSuperAdmin ? 'super' : account.isAdmin ? 'admin' : 'user'
/** 创建管理员账号与任命/撤销管理员仅对超级管理员开放。 */
export const canAppointAdmins = (me: AccountIdentity | null | undefined) => !!me?.isSuperAdmin
interface AccountActionPolicy {
/** 资料、角色与系统权限维护。 */
maintain: boolean
toggleEnabled: boolean
/** 管理员重置临时密码与恢复提案。 */
resetPassword: boolean
adminChange: 'promote' | 'demote' | null
protectedReason: string
}
export function accountActionPolicy(me: AccountIdentity | null | undefined, target: AccountIdentity): AccountActionPolicy {
if (target.isSuperAdmin) {
const self = !!me?.isSuperAdmin
return {
maintain: self, toggleEnabled: false, resetPassword: false, adminChange: null,
protectedReason: self
? '超级管理员账号不能停用、降级或由管理员重置密码;本人密码请在「设置 → 账号」修改'
: '超级管理员账号受保护:普通管理员不能编辑、停用、降级或重置其密码',
}
}
return {
maintain: true, toggleEnabled: true, resetPassword: true,
adminChange: canAppointAdmins(me) ? (target.isAdmin ? 'demote' : 'promote') : null,
protectedReason: '',
}
}
+33
View File
@@ -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'])
})
})
+38 -111
View File
@@ -14,26 +14,8 @@ const BASE = ''
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
* 自身命令面 fail-closed。 */
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
/** category:角色矩阵业务分类(项目与协作/文档工作/项目内容);受保护系统授权为空。 */
export interface PermissionDescriptor { actionId: string; label: string; description: string; group: string; category?: string; scope: 'system' | 'project'; protected: boolean }
export interface PermissionDescriptor { actionId: string; label: string; description: string; group: string; scope: 'system' | 'project'; protected: boolean }
export interface RolePermissionRecord { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
export interface RolePermissionChangeInput { actionId: string; allowed: boolean; capAllowed: boolean }
/** changes 为显式 patch:只含相对基线变化的动作,服务端合并保留未提及行。 */
export interface RolePermissionPatchEntry { roleId: string; expectedRoleVersion: number; changes: RolePermissionChangeInput[] }
/** 预览模式:缺省为服务端默认 replace;显式 patch 提交须以 patch 预览。 */
export type RolePermissionPreviewMode = 'replace' | 'patch'
/** before/after 为中文状态名:不允许/按项目授权/默认允许。 */
export interface RolePermissionStateChange { actionId: string; label: string; before: string; after: string }
export interface RolePermissionPreview {
roleId: string; roleName: string; expectedRoleVersion: number; currentRoleVersion: number; versionMatches: boolean
changes: RolePermissionStateChange[]; linkedExpansion: string[]; linkedExpansionState?: string; keptLinkedActions: string[]
migrationNote?: string; conflicts: { actionId: string; reason: string }[]; error?: string; errorActionId?: string
}
export interface RolePermissionBatchReceipt {
roleId: string; previousRoleVersion: number; roleVersion: number; linkedExpansion?: string[]; keptLinkedActions: string[]
migrationNote?: string; changes: RolePermissionStateChange[]
}
export interface RolePermissionBatchResult { receiptId: string; authorityEpoch: number; receipts: RolePermissionBatchReceipt[] }
export interface RoleRecord {
roleId: string; name: string; description: string; enabled: boolean; roleVersion: number
bindingCount: number; permissions: RolePermissionRecord[]
@@ -43,59 +25,40 @@ 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'
/** /api/me 联动资格(L1 联动后由服务端给出;旧服务无此字段)。actions/conflicts 为动作 id。 */
export interface LinkedCapabilities { projectCreate: boolean; source?: string; actions: string[]; conflicts?: string[] }
/** 恢复提案(属主确认制):管理员只能提案,确认须用提案前已存在的合格完整密码会话。 */
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 }
export interface AccountRecord {
userId: string; owner: string; profile: AccountProfile; profileVersion: number
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; passwordSet?: boolean
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; isSuperAdmin?: boolean; passwordSet?: boolean
initializationState?: string; initializationGeneration?: number; createdAt?: string
}
/** 用户目录角色投影:actor 具备 system.roles.view 时返回(无绑定为 null);无查看权时字段缺省。 */
export interface AccountRoleSummary { roleId: string; name: string; enabled: boolean }
/** 管理端用户目录/详情视图:账号状态与版本字段必有(乐观并发依赖 stateVersion)。 */
export interface ManagedAccountRecord extends AccountRecord {
enabled: boolean; stateVersion: number; isAdmin: boolean; passwordSet: boolean
enabled: boolean; stateVersion: number; isAdmin: boolean; isSuperAdmin: boolean; passwordSet: boolean
initializationState: string; initializationGeneration: number
role?: AccountRoleSummary | null
}
interface AccountSearchPage { accounts: ManagedAccountRecord[]; nextCursor?: string; directoryVersion: string }
/** 批量角色分配条目:一切以 userId/roleId 为稳定标识。 */
export interface UserRoleAssignmentInput { userId: string; roleId: string; expectedStateVersion: number }
/** error 为服务端中文原因(停用/版本变化/角色无效);任何一项有 error 或 stateMatches=false 都不得提交。 */
export interface UserRoleAssignmentPreview {
userId: string; owner: string; name: string; currentRoleId: string; currentRoleName: string
targetRoleId: string; targetRoleName: string; stateMatches: boolean; error?: string
export interface AdminIssuedOneTimePassword {
owner: string
id: string
oneTimePassword: string
mustChangePassword: boolean
}
interface UserRoleAssignmentResult { userId: string; bindingId: string; roleId: string; previousRoleId: string; userStateVersion: number }
interface UserRoleAssignmentBatch { receiptId?: string; authorityEpoch?: number; bindings: UserRoleAssignmentResult[] }
export interface RoleMember {
userId: string; owner: string; name: string; enabled: boolean; stateVersion: number; assignedAt: string; bindingVersion: number
}
interface RoleMemberPage { members: RoleMember[]; nextCursor?: string }
/** 复制角色并分配:新角色尚不存在,分配条目只带 userId 与状态版本(服务端严格拒绝未知字段)。 */
export interface RoleCopyAssignRequest {
idempotencyKey: string; source: { roleId: string; expectedRoleVersion: number }; name: string; description: string
overrides?: RolePermissionChangeInput[]; assignments: { userId: string; expectedStateVersion: number }[]
}
interface RoleCopyAssignResult { receiptId?: string; authorityEpoch?: number; role: RoleRecord; bindings: UserRoleAssignmentResult[] }
export interface ExecEnvView {
configured: boolean
@@ -140,41 +103,19 @@ export const api = {
patchRole: (roleId: string, expectedRoleVersion: number, patch: { name?: string; description?: string; enabled?: boolean }) =>
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expectedRoleVersion, ...patch }) }).then(r => json<RoleRecord>(r)),
previewRolePermissions: (roles: RolePermissionPatchEntry[], options: { mode?: RolePermissionPreviewMode } = {}) =>
auth.fetch(`${BASE}/api/admin/roles/permissions/preview`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roles: options.mode ? roles.map(role => ({ ...role, mode: options.mode })) : roles }) }).then(r => json<{ previews: RolePermissionPreview[] }>(r)).then(d => d.previews ?? []),
batchPatchRolePermissions: (roles: RolePermissionPatchEntry[], idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/roles/permissions/batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idempotencyKey, roles }) }).then(r => json<RolePermissionBatchResult>(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<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' },
body: JSON.stringify({ roleId, expectedStateVersion, idempotencyKey }) }).then(r => json<UserRoleBinding>(r)),
previewUserRoleAssignments: (assignments: UserRoleAssignmentInput[]) =>
auth.fetch(`${BASE}/api/admin/users/role-assignments/preview`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assignments }) }).then(r => json<{ previews: UserRoleAssignmentPreview[] }>(r)).then(d => d.previews ?? []),
assignUserRolesBatch: (assignments: UserRoleAssignmentInput[], idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/users/role-assignments`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idempotencyKey, assignments }) }).then(r => json<UserRoleAssignmentBatch>(r)),
listRoleMembers: (roleId: string, request: { query?: string; mode?: 'exact' | 'fuzzy'; status?: 'all' | 'enabled' | 'disabled'; cursor?: string; limit?: number } = {}) => {
const params = new URLSearchParams()
if (request.query) params.set('query', request.query)
if (request.mode) params.set('mode', request.mode)
if (request.status) params.set('status', request.status)
if (request.cursor) params.set('cursor', request.cursor)
if (request.limit) params.set('limit', String(request.limit))
const query = params.toString()
return auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}/members${query ? `?${query}` : ''}`).then(r => json<RoleMemberPage>(r))
},
createRoleCopyAndAssign: (request: RoleCopyAssignRequest) =>
auth.fetch(`${BASE}/api/admin/roles/copy-assign`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request) }).then(r => json<RoleCopyAssignResult>(r)),
getSystemGrants: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`).then(r => json<{ actions: string[]; authorityEpoch: number }>(r)),
replaceSystemGrants: (userId: string, actions: string[], expectedAuthorityEpoch: number, idempotencyKey: string) =>
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),
@@ -193,19 +134,11 @@ export const api = {
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities }>(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()}`)
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
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; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
auth.beginSession()
return me
}),
@@ -216,37 +149,29 @@ export const api = {
},
changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
}),
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
createAdminUser: (owner: string, name: string, password?: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
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)),
// 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)),
// 服务端严格解码 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) })
if (request.cursor) params.set('cursor', request.cursor)
return auth.fetch(`${BASE}/api/admin/users/search?${params}`).then(r => json<AccountSearchPage>(r))
},
createManagedUser: (owner: string, profile: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile }) }).then(r => json<ManagedAccountRecord>(r)),
createManagedUser: (owner: string, profile: Partial<AccountProfile>, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile, ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<ManagedAccountRecord>(r)),
adminGetUser: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}`).then(r => json<ManagedAccountRecord>(r)),
adminPatchProfile: (userId: string, expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<ManagedAccountRecord>(r)),
adminSetUserEnabled: (userId: string, expectedStateVersion: number, enabled: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/state`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, enabled }) }).then(r => json<ManagedAccountRecord>(r)),
// ---- 管理员:一次性登录 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)),
// ---- 本人:资料与恢复提案确认(须完整密码会话)----
// 任命/撤销管理员:仅超级管理员(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)),
// ---- 本人:资料(须完整密码会话)----
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)),
@@ -283,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) =>
+72
View File
@@ -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('影响来源')
})
})
+146 -233
View File
@@ -1,19 +1,16 @@
import { useEffect, useRef, useState } from 'react'
import {
api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleAssignmentPreview, type UserRoleView,
api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleView,
} from '../api'
import { useStore } from '../store'
import { Collapsible, Icon } from '@ui'
import { cellKind, hasSystemAction } from './RolePermissionsPanel'
import { hasSystemAction } from './RolePermissionsPanel'
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../accountAuthority'
import {
ROLE_PERMISSION_STATES, categoryActions, permissionCategories, stateToValue, valueToState,
type RolePermissionStateName, type RolePermissionValue,
PROJECT_CREATE_ACTION, buildCapabilityGroups, projectCreateSources, rebaseGrantDraft, type CapabilityGroup, type GroupLevel,
} from '../rolePermissions'
import {
accountLabel, accountName, clearSelection, copyAssignRequest, createSelection, duplicateNames, isSelected, memberAssignments,
pageSelectionState, previewAllowsAssign, previewTransition, refreshStateVersions, roleColumnLabel, roleSourceValues, sameNameHint,
selectPage, selectionCounts, selectionSummary, staleUserIds, toggleOne, type MemberSelection,
} from '../roleMembers'
type IdentityFlags = { isAdmin?: boolean; isSuperAdmin?: boolean }
const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' })
const fields: { key: keyof AccountProfile; label: string }[] = [
@@ -22,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}>
@@ -32,6 +29,86 @@ function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile;
</div>)}</div>
}
export function IdentityChip({ account }: { account: IdentityFlags }) {
const kind = accountIdentityKind(account)
if (kind === 'super') return <span className="ad-chip ad-chip-super">超级管理员</span>
if (kind === 'admin') return <span className="ad-chip ad-chip-admin">管理员</span>
return <span className="ad-chip">普通用户</span>
}
export function CreateAdminOption({ me, checked, disabled, onChange }: { me: IdentityFlags | null; checked: boolean; disabled: boolean; onChange: (value: boolean) => void }) {
if (!canAppointAdmins(me)) return <p className="ad-hint">仅超级管理员可创建或任命管理员;此处创建普通账号</p>
return <label className="ad-check"><input type="checkbox" checked={checked} disabled={disabled}
onChange={event => onChange(event.target.checked)} />创建为管理员账号</label>
}
export function AdminAppointmentButton({ me, account, busy, onChange }: { me: IdentityFlags | null; account: IdentityFlags; busy: boolean; onChange: (isAdmin: boolean) => void }) {
const change = accountActionPolicy(me, account).adminChange
if (!change) return null
return <button className="ad-mini" disabled={busy} onClick={() => onChange(change === 'promote')}>{change === 'promote' ? '设为管理员' : '撤销管理员'}</button>
}
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)
@@ -39,20 +116,9 @@ export function AccountUsersPanel() {
const canAssignRoles = canViewRoles && allowed('system.roles.assign')
const canViewGrants = allowed('system.permissions.view')
const canManageGrants = canViewGrants && allowed('system.permissions.manage')
const canCopyRole = canAssignRoles && canManageGrants && allowed('system.roles.manage')
const [accounts, setAccounts] = useState<ManagedAccountRecord[] | null>(null)
const [selection, setSelection] = useState<MemberSelection>(createSelection)
const [known, setKnown] = useState<Record<string, ManagedAccountRecord>>({})
const [batchOpen, setBatchOpen] = useState(false)
const [batchRoleId, setBatchRoleId] = useState('')
const [batchPreview, setBatchPreview] = useState<UserRoleAssignmentPreview[] | null>(null)
const [copyName, setCopyName] = useState('')
const [copyDescription, setCopyDescription] = useState('')
const [copyValues, setCopyValues] = useState<Record<string, RolePermissionValue>>({})
const [selected, setSelected] = useState<ManagedAccountRecord | null>(null)
const [roleView, setRoleView] = useState<UserRoleView | null>(null)
const [fullSourceRole, setFullSourceRole] = useState<RoleRecord | null>(null)
const [sourceRoleError, setSourceRoleError] = useState('')
const [roles, setRoles] = useState<RoleRecord[]>([])
const [roleQuery, setRoleQuery] = useState('')
const [roleCursor, setRoleCursor] = useState('')
@@ -61,10 +127,12 @@ 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('')
const [newProfile, setNewProfile] = useState<AccountProfile>(emptyProfile)
const [newIsAdmin, setNewIsAdmin] = useState(false)
const [query, setQuery] = useState('')
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const [field, setField] = useState('default')
@@ -78,13 +146,7 @@ export function AccountUsersPanel() {
const [err, setErr] = useState('')
const sequence = useRef(0)
const roleSequence = useRef(0)
const batchSequence = useRef(0)
const pageIds = (accounts ?? []).map(account => account.userId)
const pageState = pageSelectionState(selection, pageIds)
const counts = selectionCounts(selection, pageIds)
const duplicates = duplicateNames(accounts ?? [])
const batchReady = !!batchPreview && previewAllowsAssign(batchPreview, selection.ids)
const batchStale = batchPreview ? staleUserIds(batchPreview) : []
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
const search = async (pageCursor = cursor, afterWrite = false) => {
const request = ++sequence.current
@@ -93,7 +155,6 @@ export function AccountUsersPanel() {
fields: field === 'default' ? ['owner', 'name', 'userId'] : [field], cursor: pageCursor, limit: 20 })
if (request !== sequence.current) return
setAccounts(page.accounts ?? []); setNextCursor(page.nextCursor ?? '')
setKnown(current => ({ ...current, ...Object.fromEntries((page.accounts ?? []).map(account => [account.userId, account])) }))
} catch (error) {
if (request === sequence.current) setErr(afterWrite ? `操作已完成,但用户列表暂未刷新:${String(error)}` : String(error))
}
@@ -110,7 +171,6 @@ export function AccountUsersPanel() {
roleSequence.current++
setRoleQuery(''); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(false)
setSelected(null); setRoleView(null); setErr(''); setNotice('')
setCopyName(''); setCopyDescription(''); setCopyValues({}); setFullSourceRole(null); setSourceRoleError('')
const detailResult = await Promise.allSettled([api.adminGetUser(account.userId)])
if (request !== sequence.current) return
if (detailResult[0].status === 'rejected') { setErr(String(detailResult[0].reason)); return }
@@ -125,23 +185,17 @@ export function AccountUsersPanel() {
if (roleResult.status === 'fulfilled' && roleResult.value) {
const currentRole = roleResult.value as UserRoleView
setRoleView(currentRole); setSelectedRoleId(currentRole.role?.roleId ?? '')
if (canCopyRole && currentRole.role) {
void api.getRole(currentRole.role.roleId).then(role => {
if (request !== sequence.current) return
if (Array.isArray(role?.permissions)) setFullSourceRole(role)
else setSourceRoleError('源角色权限读取不完整,暂不能复制,请重新打开用户')
}).catch(error => { if (request === sequence.current) setSourceRoleError(`读取源角色权限失败:${String(error)}`) })
}
} else { setRoleView(null); setSelectedRoleId('') }
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(() => {
if ((!selected && !batchOpen) || !canViewRoles) { setRoles([]); setRoleLoading(false); return }
if (!selected || !canViewRoles) { setRoles([]); setRoleLoading(false); return }
const request = ++roleSequence.current
setRoleLoading(true); setNextRoleCursor('')
const timer = window.setTimeout(() => {
@@ -153,7 +207,7 @@ export function AccountUsersPanel() {
.catch(error => { if (request === roleSequence.current) { setErr(String(error)); setRoleLoading(false) } })
}, 180)
return () => { window.clearTimeout(timer) }
}, [selected?.userId, batchOpen, roleQuery, roleCursor, canViewRoles])
}, [selected?.userId, roleQuery, roleCursor, canViewRoles])
// 写入成功会推进目录版本,旧页游标随之失效:从第一页重读,刷新失败不改写成功结果。
const refreshFirst = async () => {
@@ -165,10 +219,10 @@ export function AccountUsersPanel() {
if (!newOwner.trim()) return
setBusy(true); setErr(''); setNotice('')
try {
const account = await api.createManagedUser(newOwner.trim(), newProfile)
setNewOwner(''); setNewProfile(emptyProfile())
const account = await api.createManagedUser(newOwner.trim(), newProfile, canAppointAdmins(me) && newIsAdmin)
setNewOwner(''); setNewProfile(emptyProfile()); setNewIsAdmin(false)
await open(account)
setNotice(`已创建 ${account.owner};账号资料可空,角色另行分配`)
setNotice(`已创建 ${account.owner}${account.isAdmin ? '(管理员)' : ''};账号资料可空,角色另行分配`)
await refreshFirst()
} catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) }
finally { setBusy(false) }
@@ -200,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) }
}
@@ -218,194 +287,57 @@ export function AccountUsersPanel() {
finally { setBusy(false) }
}
// 选择集合一旦变化,旧预览不再对应提交集合,必须重新预览。
const changeSelection = (next: MemberSelection) => {
batchSequence.current++
setSelection(next); setBatchPreview(null)
}
const openBatch = () => {
roleSequence.current++
setRoleQuery(''); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor('')
setBatchRoleId(''); setBatchPreview(null); setBatchOpen(true); setErr(''); setNotice('')
}
const closeBatch = () => {
batchSequence.current++
setBatchOpen(false); setBatchRoleId(''); setBatchPreview(null)
}
const previewBatch = async (roleId: string, versions = known) => {
const request = ++batchSequence.current
setBatchRoleId(roleId); setBatchPreview(null); setErr(''); setNotice('')
if (!roleId) return
setBusy(true)
const setAdmin = async (isAdmin: boolean) => {
if (!selected) return
setBusy(true); setErr(''); setNotice('')
try {
const assignments = memberAssignments(selection.ids, roleId,
Object.fromEntries(Object.values(versions).map(account => [account.userId, account.stateVersion])))
const previews = await api.previewUserRoleAssignments(assignments)
if (request === batchSequence.current) setBatchPreview(previews)
} catch (error) { if (request === batchSequence.current) setErr(error instanceof Error ? error.message : String(error)) }
const updated = await api.adminSetUserAdmin(selected.userId, selected.stateVersion, isAdmin)
setSelected(updated); await refreshFirst(); setNotice(isAdmin ? '已任命为管理员' : '已撤销管理员')
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
const refreshBatchVersions = async () => {
if (!batchPreview) return
setBusy(true); setErr('')
try {
const fresh = { ...known, ...await refreshStateVersions(api, staleUserIds(batchPreview)) }
setKnown(fresh)
await previewBatch(batchRoleId, fresh)
} catch (error) { setErr(String(error)); setBusy(false) }
}
const confirmBatch = async () => {
if (!batchPreview || !previewAllowsAssign(batchPreview, selection.ids)) return
const roleName = batchPreview[0]?.targetRoleName ?? ''
setBusy(true); setErr(''); setNotice('')
try {
const assignments = memberAssignments(selection.ids, batchRoleId,
Object.fromEntries(Object.values(known).map(account => [account.userId, account.stateVersion])))
const result = await api.assignUserRolesBatch(assignments, crypto.randomUUID())
changeSelection(clearSelection()); closeBatch()
await refreshFirst()
setNotice(`已为 ${result.bindings?.length ?? assignments.length} 人分配角色「${roleName}」`)
} catch (error) {
setBatchPreview(null)
setErr(String(error).includes('409') ? '部分用户状态或目标角色已变化,整批均未写入;选择已保留,请重新预览后再确认' : `整批均未写入:${String(error)}`)
} finally { setBusy(false) }
}
const copyAndAssign = async () => {
const source = fullSourceRole
if (!selected || !source) return
setErr(''); setNotice('')
let request
try {
request = copyAssignRequest({ idempotencyKey: crypto.randomUUID(), source, name: copyName, description: copyDescription, catalog,
values: { ...roleSourceValues(source, catalog), ...copyValues }, users: [{ userId: selected.userId, stateVersion: selected.stateVersion }] })
} catch (error) { setErr(error instanceof Error ? error.message : String(error)); return }
setBusy(true)
try {
const result = await api.createRoleCopyAndAssign(request)
await open(selected); await refreshFirst()
setNotice(`已创建新角色「${result.role.name}」并分配给 ${accountLabel(selected)};新角色独立维护,不再随原角色「${source.name}」更新`)
} catch (error) {
setErr(String(error).includes('409') ? '新角色名称已存在,或源角色/用户状态已变化;草稿已保留,请重新打开用户核对后重试' : String(error))
} finally { setBusy(false) }
}
const roleSearch = () => <div className="ad-row">
<input className="lg-input" aria-label="搜索可分配角色" placeholder="搜索有效角色" value={roleQuery}
onChange={event => { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
</div>
const copyForm = (source: RoleRecord) => {
const baseline = roleSourceValues(source, catalog)
const valueOf = (actionId: string) => copyValues[actionId] ?? baseline[actionId]
const adjusted = Object.keys(baseline).filter(actionId => {
const value = valueOf(actionId)
return value.allowed !== baseline[actionId].allowed || value.capAllowed !== baseline[actionId].capAllowed
}).length
const setValue = (actionId: string, value: RolePermissionValue) => setCopyValues(current => ({ ...current, [actionId]: value }))
return <div className="au-copy">
<p className="ad-hint">以「{source.name}」的当前权限为起点创建新角色,并把 {accountLabel(selected!)} 换绑到新角色。新角色独立维护,不再随原角色更新。</p>
<div className="ad-row">
<input className="lg-input" aria-label="新角色名称(必填)" placeholder="新角色名称(必填)" value={copyName} disabled={busy} onChange={event => setCopyName(event.target.value)} />
<input className="lg-input" aria-label="新角色描述" placeholder="描述(可空)" value={copyDescription} disabled={busy} onChange={event => setCopyDescription(event.target.value)} />
</div>
{permissionCategories(catalog).map(category => <fieldset className="au-copy-group" key={category}>
<legend>{category}</legend>
{categoryActions(catalog, category).map(item => {
const value = valueOf(item.actionId)
const changed = value.allowed !== baseline[item.actionId].allowed || value.capAllowed !== baseline[item.actionId].capAllowed
const label = `新角色 ${item.label}`
return <div className="au-copy-row" key={item.actionId}>
<span title={item.description}>{item.label}</span>
{cellKind(item) === 'toggle'
? <select className="rp-state" aria-label={label} value={value.allowed ? 'on' : 'off'} disabled={busy}
onChange={event => setValue(item.actionId, stateToValue(event.target.value === 'on' ? 'default' : 'denied'))}>
<option value="off">不允许</option><option value="on">允许</option>
</select>
: <select className="rp-state" aria-label={label} value={valueToState(value)} disabled={busy}
onChange={event => setValue(item.actionId, stateToValue(event.target.value as RolePermissionStateName))}>
{ROLE_PERMISSION_STATES.map(option => <option key={option.state} value={option.state}>{option.label}</option>)}
</select>}
{changed && <span className="rp-cell-flag">已调整</span>}
</div>
})}
</fieldset>)}
<div className="ad-row">
<span className="ad-hint au-grow">相对「{source.name}」调整 {adjusted} 项</span>
<button className="lg-submit ad-create-btn" disabled={busy || !copyName.trim()} onClick={() => void copyAndAssign()}>确认复制并分配</button>
</div>
</div>
}
const batchPanel = () => <section className="rp-preview au-batch" aria-label={`为选中的 ${counts.count} 人分配角色`}>
<div className="rp-preview-head">
<strong>为选中的 {counts.count} 人分配角色</strong>
<button className="ad-mini" disabled={busy} onClick={closeBatch}>收起</button>
</div>
{roleSearch()}
<select className="lg-input" aria-label="批量分配的目标角色" value={batchRoleId} disabled={busy || roleLoading} onChange={event => void previewBatch(event.target.value)}>
<option value="">请选择目标角色</option>
{batchRoleId && !roles.some(role => role.roleId === batchRoleId) && <option value={batchRoleId}>{batchPreview?.[0]?.targetRoleName ?? '已选角色'}</option>}
{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
</select>
{batchPreview && <ul className="au-preview-list">{batchPreview.map(item => <li key={item.userId}>
<span>{accountLabel(item)}</span><span className="ad-dim">{previewTransition(item)}</span>
{item.error && <span className="rp-conflict"><Icon name="alert" size={12} />{item.error}</span>}
{!item.error && !item.stateMatches && <span className="rp-conflict"><Icon name="alert" size={12} />用户状态已变化</span>}
</li>)}</ul>}
{batchPreview && !batchReady && <p className="rp-conflict" role="alert"><Icon name="alert" size={12} />存在停用、版本变化或角色无效的用户,处理后才能确认分配</p>}
<div className="ad-row">
{batchStale.length > 0 && <button className="ad-mini" disabled={busy} onClick={() => void refreshBatchVersions()}>刷新 {batchStale.length} 人的状态并重新预览</button>}
{batchRoleId && !batchPreview && <button className="ad-mini" disabled={busy} onClick={() => void previewBatch(batchRoleId)}>重新预览</button>}
<button className="lg-submit ad-create-btn" disabled={busy || !batchReady} onClick={() => void confirmBatch()}>确认分配</button>
</div>
</section>
const policy = selected ? accountActionPolicy(me, selected) : null
if (!allowed('system.users.view')) return <p className="ad-hint">当前账号没有查看用户的系统权限</p>
return <div>
{allowed('system.users.create') && <div className="ad-create">
<div className="ad-row"><input className="lg-input" aria-label="新用户登录名" placeholder="登录名" value={newOwner} onChange={event => setNewOwner(event.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !newOwner.trim()} onClick={create}>创建用户</button></div>
<CreateAdminOption me={me} checked={newIsAdmin} disabled={busy} onChange={setNewIsAdmin} />
<Collapsible id="new-user-profile" trigger="可选资料:姓名、电话、邮箱、工号、部门、备注">
<ProfileInputs profile={newProfile} onChange={setNewProfile} prefix="new-user" />
</Collapsible>
</div>}
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
{selected ? <div className="rp-editor">
{selected && policy ? <div className="rp-editor">
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
<b>{selected.owner}</b><span className="ad-dim ad-mono">{selected.userId}</span></div>
<b>{selected.owner}</b><IdentityChip account={selected} /><span className="ad-dim ad-mono">{selected.userId}</span></div>
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · {credentialLabel(selected)} · 资料版本 {selected.profileVersion} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
{selected.initializationState === 'pending' && <p className="ad-hint">交给可信 owner 的初始化定位:userId <span className="ad-mono">{selected.userId}</span> · 状态版本 {selected.stateVersion} · 代数 {selected.initializationGeneration}。凭据只经保护文件交付。</p>}
{allowed('system.users.edit') && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
{policy.protectedReason && <p className="ad-hint" role="note">{policy.protectedReason}</p>}
{allowed('system.users.edit') && policy.maintain && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
<ProfileInputs profile={draft} onChange={setDraft} prefix="user-profile" />
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveProfile}>保存资料</button>
</Collapsible>}
<div className="ad-row">
{canAssignRoles && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
{canAssignRoles && policy.maintain && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
<option value="">请选择角色</option>{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
</select><button className="ad-mini" disabled={busy || !selectedRoleId || selectedRoleId === roleView?.role?.roleId} onClick={assignRole}>替换角色</button></>}
{allowed('system.users.disable') && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
{allowed('system.users.disable') && policy.toggleEnabled && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
<AdminAppointmentButton me={me} account={selected} busy={busy} onChange={setAdmin} />
</div>
{canAssignRoles && roleSearch()}
{canCopyRole && roleView?.role && catalog.length > 0 && <Collapsible id={`copy-role-${selected.userId}`} trigger="复制角色并分配…">
{fullSourceRole ? copyForm(fullSourceRole)
: sourceRoleError ? <p className="lg-err" role="alert"><Icon name="alert" size={14} />{sourceRoleError}</p>
: <p className="ad-hint">正在读取源角色权限…</p>}
</Collapsible>}
{canAssignRoles && policy.maintain && <div className="ad-row">
<input className="lg-input" aria-label="搜索可分配角色" placeholder="搜索有效角色" value={roleQuery}
onChange={event => { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<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 || busy} onChange={event => setGrants(current => event.target.checked
? [...new Set([...current, item.actionId])] : current.filter(value => value !== item.actionId))} />{item.label}<span className="ad-dim ad-mono">{item.actionId}</span>
</label>)}</div>
{canManageGrants && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
<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>}
</div> : <>
@@ -418,33 +350,14 @@ export function AccountUsersPanel() {
</select>
<select className="lg-input" aria-label="用户状态" value={status} onChange={event => { setStatus(event.target.value as typeof status); resetPaging() }}><option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option></select>
</div>
{sameNameHint(accounts ?? []) && <p className="ad-hint">{sameNameHint(accounts ?? [])}</p>}
<table className="ad-table"><thead><tr>
<th className="au-select-col"><input type="checkbox" aria-label="全选本页用户" checked={pageState === 'all'} disabled={busy || !pageIds.length}
ref={element => { if (element) element.indeterminate = pageState === 'some' }}
onChange={event => changeSelection(selectPage(selection, pageIds, event.target.checked))} /></th>
<th>姓名与账号</th><th>工号</th><th>当前角色</th><th>系统身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
<tbody>{(accounts ?? []).map(account => <tr key={account.userId}>
<td className="au-select-col"><input type="checkbox" aria-label={`选择 ${accountLabel(account)}`} checked={isSelected(selection, account.userId)} disabled={busy}
onChange={event => changeSelection(toggleOne(selection, account.userId, event.target.checked))} /></td>
<td><div className="au-name-cell"><span>{accountName(account)}</span>
{account.profile.name.trim() && <span className="ad-dim ad-mono">{account.owner}</span>}
{duplicates.has(account.profile.name.trim()) && <span className="au-same-name">同名 · 按账号辨识</span>}</div></td>
<td>{account.profile.staffNo || '—'}</td><td>{roleColumnLabel(canViewRoles ? account.role : undefined)}</td>
<td>{account.isAdmin ? '系统管理员' : '普通账号'}</td><td>{account.enabled ? '有效' : '停用'}</td>
<table className="ad-table"><thead><tr><th>登录名</th><th>姓名</th><th>工号</th><th>身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
<tbody>{(accounts ?? []).map(account => <tr key={account.userId}><td className="ad-mono">{account.owner}</td><td>{account.profile.name || '—'}</td>
<td>{account.profile.staffNo || '—'}</td><td><IdentityChip account={account} /></td><td>{account.enabled ? '有效' : '停用'}</td>
<td>{credentialLabel(account)}</td><td><button className="ad-mini" onClick={() => open(account)}>查看与管理</button></td></tr>)}
{accounts !== null && accounts.length === 0 && <tr><td colSpan={8} className="ad-dim">没有匹配用户</td></tr>}
{accounts === null && <tr><td colSpan={8} className="ad-dim">加载中…</td></tr>}</tbody></table>
{accounts !== null && accounts.length === 0 && <tr><td colSpan={7} className="ad-dim">没有匹配用户</td></tr>}
{accounts === null && <tr><td colSpan={7} className="ad-dim">加载中…</td></tr>}</tbody></table>
<div className="ad-row"><button className="ad-mini" disabled={!trail.length} onClick={() => { setCursor(trail[trail.length - 1]); setTrail(trail.slice(0, -1)) }}>上一页</button>
<button className="ad-mini" disabled={!nextCursor} onClick={() => { setTrail([...trail, cursor]); setCursor(nextCursor) }}>下一页</button></div>
<Collapsible open={batchOpen && canAssignRoles && counts.count > 0} onToggle={value => { if (!value) closeBatch() }}>
{batchPanel()}
</Collapsible>
{counts.count > 0 && <div className="rp-savebar" role="region" aria-label="已选用户">
<span className="rp-savebar-summary" aria-live="polite">{selectionSummary(counts)}</span>
<button className="ad-mini" disabled={busy} onClick={() => { changeSelection(clearSelection()); closeBatch() }}>清空选择</button>
{canAssignRoles && <button className="ad-mini" disabled={busy} aria-expanded={batchOpen} onClick={batchOpen ? closeBatch : openBatch}>为选中的 {counts.count} 人分配角色</button>}
</div>}
</>}
</div>
}
+1 -1
View File
@@ -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 (
+5 -5
View File
@@ -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)。
@@ -110,7 +110,7 @@ export function AdminZone() {
<Icon name="chevron-down" size={14} /> 返回工作台
</button>
<span className="az-title">系统管理</span>
<span className="az-sub">{isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
<span className="az-sub">{me?.isSuperAdmin ? '超级管理员' : isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
<span style={{ flex: 1 }} />
<span className="az-tenant">{me?.owner ?? ''}</span>
</div>
+3 -1
View File
@@ -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.
+7 -47
View File
@@ -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*/, '')
}
+9 -88
View File
@@ -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>
+7 -4
View File
@@ -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(/关联能力冲突/)
})
})
+178 -508
View File
@@ -1,18 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { api, type PermissionDescriptor, type RoleRecord } from '../api'
import {
api, type ManagedAccountRecord, type PermissionDescriptor, type RoleMember, type RolePermissionBatchResult, type RolePermissionPatchEntry,
type RolePermissionPreview, type RoleRecord, type UserRoleAssignmentPreview,
} from '../api'
import {
accountLabel, accountName, assignedToRole, clearSelection, createSelection, isSelected, memberAssignments, pageSelectionState,
previewAllowsAssign, previewTransition, refreshStateVersions, sameNameHint, selectPage, selectionCounts, selectionSummary, staleUserIds,
toggleOne, type MemberSelection,
} from '../roleMembers'
import {
PROJECT_CREATE_ACTION, PROJECT_CREATE_LINKED_ACTIONS, ROLE_PERMISSION_STATES, categoryActions, createRolePermissionDraft, dirtyRoles,
linkedByProjectCreate, permissionCategories, rebaseRoleDraft, rolePatchEntries, stateLabel, stateToValue, updateRolePermission,
validateRolePermissionDraft, valueToState, visiblePatch,
type RolePermissionDraft, type RolePermissionStateName, 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'
@@ -20,14 +11,6 @@ import { useStore } from '../store'
/** DR9 离开管理区守卫:面板有未保存权限草稿时注册;AdminZone「返回工作台」消费。 */
export const roleDraftLeaveGuard: { current: (() => boolean) | null } = { current: null }
export const NARROW_ROLE_QUERY = '(max-width: 900px)'
const LINKED_NOTE = '创建项目时自动具备 · 仅本人创建的项目'
/** 草稿携带建立基线时的角色快照:其 roleVersion 即批量 patch 的 expectedRoleVersion。 */
type RoleDraft = RolePermissionDraft & { role: RoleRecord }
type RoleDraftMap = Record<string, RoleDraft>
type RolePermissionClient = Pick<typeof api, 'previewRolePermissions' | 'batchPatchRolePermissions' | 'getRole'>
export function filterRoles(
roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled',
): RoleRecord[] {
@@ -41,6 +24,11 @@ export function filterRoles(
})
}
export function groupPermissionPage(catalog: PermissionDescriptor[], group: string, page: number, size: number): PermissionDescriptor[] {
const grouped = catalog.filter(item => !group || item.group === group)
return grouped.slice(page * size, page * size + size)
}
export function shouldAcceptRoleResponse(activeSequence: number, responseSequence: number): boolean {
return activeSequence === responseSequence
}
@@ -49,16 +37,6 @@ export function hasSystemAction(actions: string[] | undefined, action: string):
return actions?.includes(action) ?? false
}
/** 窄屏(≤900px)默认单角色聚焦;仍可手动打开矩阵。 */
export function initialRoleView(matches?: (query: string) => boolean): 'single' | 'matrix' {
return matches?.(NARROW_ROLE_QUERY) ? 'single' : 'matrix'
}
/** 系统动作(project.create)是二态开关;项目动作是三态。 */
export function cellKind(item: PermissionDescriptor): 'toggle' | 'state' {
return item.scope === 'system' ? 'toggle' : 'state'
}
function roleValues(role: RoleRecord, catalog: PermissionDescriptor[]): Record<string, RolePermissionValue> {
const existing = new Map(role.permissions.map(item => [item.actionId, item]))
return Object.fromEntries(catalog.filter(item => !item.protected).map(item => {
@@ -67,261 +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 roleCellValue(drafts: RoleDraftMap, role: RoleRecord, actionId: string): RolePermissionValue {
return drafts[role.roleId]?.values[actionId] ?? roleValue(role, actionId)
}
// DR9:任何单元格修改都写进该角色自己的草稿(无需先选中);首次修改时以当时的服务端值为基线。
export function editRoleDraft(
drafts: RoleDraftMap, role: RoleRecord, catalog: PermissionDescriptor[], actionId: string, value: RolePermissionValue,
): RoleDraftMap {
const current = drafts[role.roleId] ?? { ...createRolePermissionDraft(roleValues(role, catalog)), role }
return { ...drafts, [role.roleId]: { ...updateRolePermission(current, actionId, value), role: current.role } }
}
export function roleLeaveMessage(names: string[]): string {
return `以下角色有未保存的权限修改:${names.map(name => `「${name}」`).join('、')}。离开将放弃这些修改,确定离开吗?`
}
function previewBlocksSave(previews: RolePermissionPreview[]): boolean {
return previews.some(preview => !!preview.error || (preview.conflicts?.length ?? 0) > 0)
}
export async function previewRoleBatch(client: Pick<RolePermissionClient, 'previewRolePermissions'>, entries: RolePermissionPatchEntry[]) {
const previews = await client.previewRolePermissions(entries, { mode: 'patch' })
return { previews, blocked: previewBlocksSave(previews) }
}
// 原子保存:所有脏角色一次提交;预览存在冲突或错误时拒绝提交。
export async function commitRoleBatch(
client: Pick<RolePermissionClient, 'batchPatchRolePermissions'>, entries: RolePermissionPatchEntry[],
previews: RolePermissionPreview[], idempotencyKey: string,
): Promise<RolePermissionBatchResult> {
if (!entries.length) throw new Error('没有需要保存的权限修改')
if (previewBlocksSave(previews)) throw new Error('预览存在冲突或错误,不能保存')
return client.batchPatchRolePermissions(entries, idempotencyKey)
}
// DR9:409 后逐个读取最新角色,只重放各角色相对自身旧基线的本地变化。
export async function rebaseRoleDrafts(
client: Pick<RolePermissionClient, 'getRole'>, drafts: RoleDraftMap, roleIds: string[], catalog: PermissionDescriptor[],
): Promise<{ fresh: RoleRecord[]; drafts: RoleDraftMap }> {
const fresh = await Promise.all(roleIds.map(roleId => client.getRole(roleId)))
const next = { ...drafts }
for (const role of fresh) {
const current = drafts[role.roleId]
if (current) next[role.roleId] = { ...rebaseRoleDraft(current, roleValues(role, catalog)), role }
}
return { fresh, drafts: next }
}
function pruneCleanDrafts(drafts: RoleDraftMap, actionIds: string[]): RoleDraftMap {
const dirty = new Set(dirtyRoles(drafts, actionIds).map(entry => entry.roleId))
if (Object.keys(drafts).every(roleId => dirty.has(roleId))) return drafts
return Object.fromEntries(Object.entries(drafts).filter(([roleId]) => dirty.has(roleId)))
}
// 本端资料/状态写入成功且草稿基线即写入前版本时,权限未变,草稿可继承新版本号。
function adoptRoleSnapshot(drafts: RoleDraftMap, expectedVersion: number, updated: RoleRecord): RoleDraftMap {
const current = drafts[updated.roleId]
if (!current || current.role.roleVersion !== expectedVersion) return drafts
return { ...drafts, [updated.roleId]: { ...current, role: updated } }
}
function mergeRoles(current: RoleRecord[] | null, fresh: RoleRecord[]): RoleRecord[] {
const byId = new Map(fresh.map(role => [role.roleId, role]))
const merged = (current ?? []).map(role => byId.get(role.roleId) ?? role)
return [...fresh.filter(role => !merged.some(item => item.roleId === role.roleId)), ...merged]
}
export const roleMembersLabel = (role: Pick<RoleRecord, 'name'>) => `查看角色 ${role.name} 的成员`
export function memberStatusLabel(member: Pick<RoleMember, 'enabled'>): string {
return member.enabled ? '有效' : '停用(绑定保留,不授予权限)'
}
const formatAssignedAt = (value: string) => {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
/** 角色成员子视图:成员分页列表 + 「添加用户到角色」(预览后一次批量事务提交)。 */
function RoleMembersView({ role, canAssign, backLabel, onBack, onAssigned }: {
role: RoleRecord; canAssign: boolean; backLabel: string; onBack: () => void; onAssigned: () => void
/** 业务能力组:组开关替代逐动作勾选;联动项为组内受控项,只展示来源。 */
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
}) {
const [members, setMembers] = useState<RoleMember[] | null>(null)
const [query, setQuery] = useState('')
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
const [cursor, setCursor] = useState('')
const [trail, setTrail] = useState<string[]>([])
const [nextCursor, setNextCursor] = useState('')
const [reload, setReload] = useState(0)
const [pickerOpen, setPickerOpen] = useState(false)
const [userQuery, setUserQuery] = useState('')
const [userCursor, setUserCursor] = useState('')
const [userTrail, setUserTrail] = useState<string[]>([])
const [nextUserCursor, setNextUserCursor] = useState('')
const [candidates, setCandidates] = useState<ManagedAccountRecord[] | null>(null)
const [known, setKnown] = useState<Record<string, ManagedAccountRecord>>({})
const [selection, setSelection] = useState<MemberSelection>(createSelection)
const [preview, setPreview] = useState<UserRoleAssignmentPreview[] | null>(null)
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState('')
const [err, setErr] = useState('')
const memberSequence = useRef(0)
const userSequence = useRef(0)
const previewSequence = useRef(0)
const selectableIds = (candidates ?? []).filter(account => !assignedToRole(account, role.roleId)).map(account => account.userId)
const pageState = pageSelectionState(selection, selectableIds)
const counts = selectionCounts(selection, selectableIds)
const ready = !!preview && previewAllowsAssign(preview, selection.ids)
const stale = preview ? staleUserIds(preview) : []
useEffect(() => {
const request = ++memberSequence.current
const timer = window.setTimeout(() => {
void api.listRoleMembers(role.roleId, { query: query.trim(), mode: 'fuzzy', status, cursor, limit: 20 })
.then(page => { if (request === memberSequence.current) { setMembers(page.members ?? []); setNextCursor(page.nextCursor ?? '') } })
.catch(error => { if (request === memberSequence.current) setErr(String(error)) })
}, 180)
return () => { window.clearTimeout(timer); memberSequence.current++ }
}, [role.roleId, query, status, cursor, reload])
useEffect(() => {
if (!pickerOpen) return
const request = ++userSequence.current
const timer = window.setTimeout(() => {
void api.searchAdminUsers({ query: userQuery.trim(), mode: 'fuzzy', status: 'all', fields: ['owner', 'name', 'userId'], cursor: userCursor, limit: 20 })
.then(page => {
if (request !== userSequence.current) return
setCandidates(page.accounts ?? []); setNextUserCursor(page.nextCursor ?? '')
setKnown(current => ({ ...current, ...Object.fromEntries((page.accounts ?? []).map(account => [account.userId, account])) }))
})
.catch(error => { if (request === userSequence.current) setErr(String(error)) })
}, 180)
return () => { window.clearTimeout(timer); userSequence.current++ }
}, [pickerOpen, userQuery, userCursor, reload])
const changeSelection = (next: MemberSelection) => {
previewSequence.current++
setSelection(next); setPreview(null)
}
const requestPreview = async (versions = known) => {
const request = ++previewSequence.current
setPreview(null); setErr(''); setNotice(''); setBusy(true)
try {
const assignments = memberAssignments(selection.ids, role.roleId,
Object.fromEntries(Object.values(versions).map(account => [account.userId, account.stateVersion])))
const previews = await api.previewUserRoleAssignments(assignments)
if (request === previewSequence.current) setPreview(previews)
} catch (error) { if (request === previewSequence.current) setErr(error instanceof Error ? error.message : String(error)) }
finally { setBusy(false) }
}
const refreshVersions = async () => {
if (!preview) return
setBusy(true); setErr('')
try {
const fresh = { ...known, ...await refreshStateVersions(api, staleUserIds(preview)) }
setKnown(fresh)
await requestPreview(fresh)
} catch (error) { setErr(String(error)); setBusy(false) }
}
const confirm = async () => {
if (!preview || !previewAllowsAssign(preview, selection.ids)) return
setBusy(true); setErr(''); setNotice('')
try {
const assignments = memberAssignments(selection.ids, role.roleId,
Object.fromEntries(Object.values(known).map(account => [account.userId, account.stateVersion])))
const result = await api.assignUserRolesBatch(assignments, crypto.randomUUID())
changeSelection(clearSelection())
setCursor(''); setTrail([]); setUserCursor(''); setUserTrail([]); setReload(value => value + 1)
onAssigned()
setNotice(`已将 ${result.bindings?.length ?? assignments.length} 人加入角色「${role.name}」`)
} catch (error) {
setPreview(null)
setErr(String(error).includes('409') ? '部分用户状态或角色已变化,整批均未写入;选择已保留,请重新预览后再确认' : `整批均未写入:${String(error)}`)
} finally { setBusy(false) }
}
return <section className="rp-members" aria-label={`角色「${role.name}」的成员`}>
<div className="ad-row">
<button className="ad-mini" disabled={busy} onClick={onBack}>{backLabel}</button>
<strong className="au-grow">角色「{role.name}」的成员 · {role.bindingCount} 人</strong>
</div>
{!role.enabled && <p className="rp-disabled-note">角色已停用:现有绑定不再授予权限,也不能向其添加用户</p>}
<div className="rp-status">
{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>}
</div>
<div className="ad-row rp-filters">
<input className="lg-input" aria-label="搜索角色成员" placeholder="姓名、账号或 ID" value={query} onChange={event => { setQuery(event.target.value); setCursor(''); setTrail([]) }} />
<select className="lg-input" aria-label="成员状态" value={status} onChange={event => { setStatus(event.target.value as typeof status); setCursor(''); setTrail([]) }}>
<option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option>
</select>
</div>
{sameNameHint(members ?? []) && <p className="ad-hint">{sameNameHint(members ?? [])}</p>}
<table className="ad-table"><thead><tr><th>姓名与账号</th><th>状态</th><th>分配时间</th></tr></thead>
<tbody>{(members ?? []).map(member => <tr key={member.userId}>
<td><div className="au-name-cell"><span>{accountName(member)}</span>{member.name.trim() && <span className="ad-dim ad-mono">{member.owner}</span>}</div></td>
<td>{memberStatusLabel(member)}</td><td>{formatAssignedAt(member.assignedAt)}</td>
</tr>)}
{members !== null && members.length === 0 && <tr><td colSpan={3} className="ad-dim">该角色暂无匹配成员</td></tr>}
{members === null && <tr><td colSpan={3} className="ad-dim">加载中…</td></tr>}</tbody></table>
<div className="ad-row">
<button className="ad-mini" disabled={!trail.length} onClick={() => { setCursor(trail[trail.length - 1]); setTrail(trail.slice(0, -1)) }}>上一页成员</button>
<button className="ad-mini" disabled={!nextCursor} onClick={() => { setTrail([...trail, cursor]); setCursor(nextCursor) }}>下一页成员</button>
</div>
{canAssign && role.enabled && <Collapsible open={pickerOpen} onToggle={setPickerOpen} trigger="添加用户到角色">
<div className="au-copy">
<div className="ad-row">
<input className="lg-input" aria-label="搜索要添加的用户" placeholder="搜索用户姓名、账号或 ID" value={userQuery}
onChange={event => { setUserQuery(event.target.value); setUserCursor(''); setUserTrail([]) }} />
<button className="ad-mini" disabled={!userTrail.length} onClick={() => { setUserCursor(userTrail[userTrail.length - 1]); setUserTrail(userTrail.slice(0, -1)) }}>上一页用户</button>
<button className="ad-mini" disabled={!nextUserCursor} onClick={() => { setUserTrail([...userTrail, userCursor]); setUserCursor(nextUserCursor) }}>下一页用户</button>
</div>
{sameNameHint(candidates ?? []) && <p className="ad-hint">{sameNameHint(candidates ?? [])}</p>}
<table className="ad-table"><thead><tr>
<th className="au-select-col"><input type="checkbox" aria-label="全选本页可添加用户" checked={pageState === 'all'} disabled={busy || !selectableIds.length}
ref={element => { if (element) element.indeterminate = pageState === 'some' }}
onChange={event => changeSelection(selectPage(selection, selectableIds, event.target.checked))} /></th>
<th>姓名与账号</th><th>当前角色</th><th>状态</th></tr></thead>
<tbody>{(candidates ?? []).map(account => {
const assigned = assignedToRole(account, role.roleId)
return <tr key={account.userId}>
<td className="au-select-col"><input type="checkbox" aria-label={assigned ? `${accountLabel(account)} 已分配` : `选择 ${accountLabel(account)}`}
checked={!assigned && isSelected(selection, account.userId)} disabled={busy || assigned}
onChange={event => changeSelection(toggleOne(selection, account.userId, event.target.checked))} /></td>
<td><div className="au-name-cell"><span>{accountName(account)}</span>{account.profile.name.trim() && <span className="ad-dim ad-mono">{account.owner}</span>}</div></td>
<td>{assigned ? <span className="au-same-name">已分配</span> : account.role === null ? '未分配' : account.role?.name ?? '—'}</td>
<td>{account.enabled ? '有效' : '停用'}</td>
</tr>
})}
{candidates !== null && candidates.length === 0 && <tr><td colSpan={4} className="ad-dim">没有匹配用户</td></tr>}
{candidates === null && <tr><td colSpan={4} className="ad-dim">加载中…</td></tr>}</tbody></table>
{preview && <ul className="au-preview-list">{preview.map(item => <li key={item.userId}>
<span>{accountLabel(item)}</span><span className="ad-dim">{previewTransition(item)}</span>
{item.error && <span className="rp-conflict"><Icon name="alert" size={12} />{item.error}</span>}
{!item.error && !item.stateMatches && <span className="rp-conflict"><Icon name="alert" size={12} />用户状态已变化</span>}
</li>)}</ul>}
{preview && !ready && <p className="rp-conflict" role="alert"><Icon name="alert" size={12} />存在停用、版本变化或角色无效的用户,处理后才能确认添加</p>}
<div className="ad-row">
<span className="ad-hint au-grow" aria-live="polite">{selectionSummary(counts)}</span>
<button className="ad-mini" disabled={busy || !counts.count} onClick={() => changeSelection(clearSelection())}>清空选择</button>
{stale.length > 0 && <button className="ad-mini" disabled={busy} onClick={() => void refreshVersions()}>刷新 {stale.length} 人的状态并重新预览</button>}
<button className="ad-mini" disabled={busy || !counts.count} onClick={() => void requestPreview()}>预览分配</button>
<button className="lg-submit ad-create-btn" disabled={busy || !ready} onClick={() => void confirm()}>确认添加</button>
</div>
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>
</Collapsible>}
</section>
{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() {
@@ -330,57 +112,41 @@ export function RolePermissionsPanel() {
const canManageRoles = allowed('system.roles.manage')
const canViewPermissions = allowed('system.permissions.view')
const canManagePermissions = canViewPermissions && allowed('system.permissions.manage')
const canViewMembers = allowed('system.users.view') && allowed('system.roles.view')
const canAssignMembers = canViewMembers && allowed('system.roles.assign')
const [membersRole, setMembersRole] = useState<RoleRecord | null>(null)
const [roles, setRoles] = useState<RoleRecord[] | null>(null)
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
const [catalogFailed, setCatalogFailed] = useState(false)
const [selectedId, setSelectedId] = useState('')
const [drafts, setDrafts] = useState<RoleDraftMap>({})
const [drafts, setDrafts] = useState<Record<string, RolePermissionDraft>>({})
const [confirmDisable, setConfirmDisable] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [query, setQuery] = useState('')
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
const [category, setCategory] = useState('')
const [actionFilter, setActionFilter] = useState('')
const [roleCursor, setRoleCursor] = useState('')
const [roleTrail, setRoleTrail] = useState<string[]>([])
const [nextRoleCursor, setNextRoleCursor] = useState('')
const [view, setView] = useState<'single' | 'matrix'>(() => initialRoleView(
typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? media => window.matchMedia(media).matches : undefined))
const [view, setView] = useState<'single' | 'matrix'>('single')
const [newName, setNewName] = useState('')
const [newDescription, setNewDescription] = useState('')
const [busy, setBusy] = useState(false)
const [roleLoading, setRoleLoading] = useState(false)
const [notice, setNotice] = useState('')
const [err, setErr] = useState('')
const [preview, setPreview] = useState<{ previews: RolePermissionPreview[]; confirm: boolean } | null>(null)
const requestSequence = useRef(0)
const selectedIdRef = useRef('')
const savePermsRef = useRef<HTMLButtonElement>(null)
const statusRef = useRef<HTMLDivElement>(null)
selectedIdRef.current = selectedId
const selected = roles?.find(role => role.roleId === selectedId) ?? null
const draft = selected ? drafts[selected.roleId] ?? null : null
const allEditableIds = useMemo(() => catalog.filter(item => !item.protected).map(item => item.actionId), [catalog])
const descriptorById = useMemo(() => new Map(catalog.map(item => [item.actionId, item])), [catalog])
const categories = useMemo(() => permissionCategories(catalog), [catalog])
const activeCategory = categories.includes(category) ? category : categories[0] ?? ''
const columns = useMemo(() => categoryActions(catalog, activeCategory), [catalog, activeCategory])
const dirtyList = useMemo(() => dirtyRoles(drafts, allEditableIds), [drafts, allEditableIds])
const dirtyNames = dirtyList.map(entry => roles?.find(role => role.roleId === entry.roleId)?.name ?? drafts[entry.roleId].role.name)
const changeCount = dirtyList.reduce((sum, entry) => sum + entry.changes, 0)
const changes = draft ? visiblePatch(draft, allEditableIds) : []
const dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0
const filtered = roles ?? []
const effectiveView = canViewPermissions ? view : 'single'
const editable = canManagePermissions && !catalogFailed && catalog.length > 0
const blocked = !!preview && previewBlocksSave(preview.previews)
const labelOf = (actionId: string) => descriptorById.get(actionId)?.label ?? actionId
const cellDisabled = (role: RoleRecord) => busy || roleLoading || !editable || !role.enabled
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 }) => {
@@ -396,7 +162,6 @@ export function RolePermissionsPanel() {
setRoleLoading(false)
const descriptors = catalogResult.status === 'fulfilled' ? catalogResult.value : catalog
if (catalogResult.status === 'fulfilled') setCatalog(descriptors)
setCatalogFailed(catalogResult.status === 'rejected')
if (roleResult.status === 'rejected') {
setErr(String(roleResult.reason))
return
@@ -404,33 +169,34 @@ export function RolePermissionsPanel() {
const rolePage = roleResult.value
setRoles(rolePage.roles ?? [])
setNextRoleCursor(rolePage.nextCursor ?? '')
setDrafts(prev => pruneCleanDrafts(prev, descriptors.filter(item => !item.protected).map(item => item.actionId)))
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
if (nextSelected) selectRole(nextSelected, sequence)
if (nextSelected) selectRole(nextSelected, descriptors, sequence)
else setSelectedId('')
// DR10:目录失败是部分失败态,需在角色选中(selectRole 会清空提示)之后仍可见。
if (catalogResult.status === 'rejected') setErr(prev => prev || `权限目录加载失败:${catalogResult.reason}`)
}
const selectRole = (role: RoleRecord, sequence?: number) => {
const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => {
const nextSequence = sequence ?? ++requestSequence.current
if (!shouldAcceptRoleResponse(requestSequence.current, nextSequence)) return
setSelectedId(role.roleId)
setName(role.name)
setDescription(role.description)
// DR9:草稿按 roleId 隔离保存;翻页、过滤、视图切换回到该角色时保留,
// 切换角色永不转移他人草稿。仅首次选中时以服务端值为基线。
setDrafts(prev => prev[role.roleId] ? prev
: { ...prev, [role.roleId]: createRolePermissionDraft(roleValues(role, descriptors), nextSequence) })
setNotice('')
setErr('')
}
// DR9:离开管理区须显式决定(覆盖所有脏角色);带未保存草稿时刷新走浏览器原生确认。
const dirtyKey = dirtyNames.join('\u0000')
// DR9:离开管理区须显式决定;带未保存草稿时刷新走浏览器原生确认。
useEffect(() => {
if (!dirtyNames.length) { roleDraftLeaveGuard.current = null; return }
const message = roleLeaveMessage(dirtyNames)
if (!dirty || !selected) { roleDraftLeaveGuard.current = null; return }
const message = `角色「${selected.name}」有未保存的权限修改,离开将放弃这些修改。确定离开吗?`
roleDraftLeaveGuard.current = () => window.confirm(message)
return () => { roleDraftLeaveGuard.current = null }
}, [dirtyKey])
const dirty = dirtyList.length > 0
}, [dirty, selected?.roleId, selected?.name])
useEffect(() => {
if (!dirty) return
const handler = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
@@ -444,13 +210,6 @@ export function RolePermissionsPanel() {
return () => { window.clearTimeout(timer); requestSequence.current++ }
}, [query, mode, status, actionFilter, roleCursor, canViewPermissions])
// DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 或保存区收起掉回 body,则归还。
const restoreFocus = () => requestAnimationFrame(() => {
if (document.activeElement !== document.body) return
const target = savePermsRef.current && !savePermsRef.current.disabled ? savePermsRef.current : statusRef.current
target?.focus()
})
const create = async () => {
if (!newName.trim()) return
requestSequence.current++; setRoleLoading(false)
@@ -460,7 +219,7 @@ export function RolePermissionsPanel() {
setNewName(''); setNewDescription('')
setQuery(''); setStatus('all'); setActionFilter(''); setRoleCursor(''); setRoleTrail([])
setRoles(current => [role, ...(current ?? []).filter(item => item.roleId !== role.roleId)])
selectRole(role, ++requestSequence.current)
selectRole(role, catalog, ++requestSequence.current)
setNotice(`已创建角色 ${role.name}`)
await load('', true, role.roleId, { query: '', mode, status: 'all', action: '' })
setNotice(`已创建角色 ${role.name}`)
@@ -474,15 +233,15 @@ export function RolePermissionsPanel() {
setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
setDrafts(prev => adoptRoleSnapshot(prev, selected.roleVersion, updated))
await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
await load(); setNotice('角色资料已保存')
} catch (error) {
if (String(error).includes('409')) {
try {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => mergeRoles(current, [fresh]))
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
setErr('角色资料版本冲突;已读取最新版本并保留本地名称与描述,请核对后重试')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
@@ -497,190 +256,92 @@ export function RolePermissionsPanel() {
setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
setDrafts(prev => adoptRoleSnapshot(prev, selected.roleVersion, updated))
await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
await load(); setNotice(selected.enabled ? '角色已停用,现有绑定不再授予权限' : '角色已启用')
} catch (error) {
if (String(error).includes('409')) {
try {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => mergeRoles(current, [fresh]))
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
setErr('角色状态版本冲突;已刷新当前状态,请重新确认')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
} finally { setBusy(false) }
}
const editCell = (role: RoleRecord, actionId: string, value: RolePermissionValue) => {
setDrafts(prev => editRoleDraft(prev, role, catalog, actionId, value))
setPreview(null)
const rebasePermissionConflict = async (roleId: string, currentDraft: RolePermissionDraft, operationSequence: number) => {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === roleId)
? current.map(role => role.roleId === roleId ? fresh : role) : [fresh, ...(current ?? [])])
// DR9:409 只重放同一角色的草稿;基于最新服务端值回放本地相对基线的变化。
setDrafts(prev => ({ ...prev, [roleId]: rebaseRoleDraft(currentDraft, roleValues(fresh, catalog)) }))
setErr('权限版本冲突;已基于最新版本重放本地草稿,请核对后再次保存')
}
const patchEntries = () => rolePatchEntries(drafts,
Object.fromEntries(Object.entries(drafts).map(([roleId, item]) => [roleId, item.role.roleVersion])), allEditableIds)
// 「查看变化」与「保存」都先取服务端权威预览;confirm=true 时保存按钮变为「确认保存」。
const requestPreview = async (confirm: boolean) => {
for (const entry of dirtyList) {
const invalid = validateRolePermissionDraft(drafts[entry.roleId])
if (invalid) { setErr(`角色「${drafts[entry.roleId].role.name}」的 ${labelOf(invalid.actionId)} 默认权限不能超过角色上限`); return }
}
const entries = patchEntries()
if (!entries.length) return
const savePermissions = async () => {
if (!selected || !draft) return
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
// 关联项不能以断链状态保存:提交前与服务端同规则补齐创建能力的联动上限。
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 {
const result = await previewRoleBatch(api, entries)
if (requestSequence.current !== operationSequence) return
setPreview({ previews: result.previews, confirm })
} catch (error) { setErr(String(error)) } finally { setBusy(false); restoreFocus() }
}
const confirmSave = async () => {
if (!preview) return
const entries = patchEntries()
const snapshot = drafts
const operationSequence = ++requestSequence.current
setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
const result = await commitRoleBatch(api, entries, preview.previews, crypto.randomUUID())
const saved = new Set(entries.map(entry => entry.roleId))
setDrafts(current => Object.fromEntries(Object.entries(current).filter(([roleId]) => !saved.has(roleId))))
setPreview(null)
await load(roleCursor, true, selectedId)
setNotice(`已保存 ${result.receipts.length} 个角色的权限设置`)
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)
const expanded = receipt?.linkedExpansion ?? []
setNotice(expanded.length ? `角色权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '角色权限已保存')
} catch (error) {
if (String(error).includes('409')) {
try {
const rebased = await rebaseRoleDrafts(api, snapshot, entries.map(entry => entry.roleId), catalog)
if (requestSequence.current !== operationSequence) return
setRoles(current => mergeRoles(current, rebased.fresh))
setDrafts(current => ({ ...current, ...Object.fromEntries(entries.map(entry => [entry.roleId, rebased.drafts[entry.roleId]])) }))
setPreview(null)
setErr(`权限版本冲突;已基于最新版本为 ${entries.length} 个角色重放本地草稿,请核对后再次保存`)
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
} finally { setBusy(false); restoreFocus() }
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
} else setErr(rolePermissionSaveError(error))
}
finally {
setBusy(false)
// DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 掉回 body,则归还。
requestAnimationFrame(() => {
if (savePermsRef.current && !savePermsRef.current.disabled && document.activeElement === document.body) savePermsRef.current.focus()
})
}
}
const cancelAll = () => {
if (!window.confirm(`确定放弃 ${dirtyNames.map(name => `「${name}」`).join('、')} 的全部未保存权限修改吗?`)) return
setDrafts({}); setPreview(null); setErr(''); setNotice('已取消全部未保存的权限修改')
const editGroup = (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => {
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: setCapabilityGroup(prev[selectedId], group, field, checked) } : prev)
}
const openFocus = (role: RoleRecord) => { selectRole(role); setView('single') }
const valueLabel = (actionId: string, value: RolePermissionValue | undefined) => descriptorById.get(actionId)?.scope === 'system'
? (value?.allowed ? '允许' : '不允许') : stateLabel(valueToState(value))
const cell = (role: RoleRecord, item: PermissionDescriptor) => {
const value = roleCellValue(drafts, role, item.actionId)
const label = `${role.name} ${item.label}`
const disabled = cellDisabled(role)
const roleDraft = drafts[role.roleId]
const modified = !!roleDraft && visiblePatch(roleDraft, [item.actionId]).length > 0
const linked = linkedByProjectCreate({ [PROJECT_CREATE_ACTION]: roleCellValue(drafts, role, PROJECT_CREATE_ACTION) }, item.actionId)
return <div className="rp-cell">
{cellKind(item) === 'toggle'
? <select className="rp-state" aria-label={label} value={value.allowed ? 'on' : 'off'} disabled={disabled}
onChange={event => editCell(role, item.actionId, stateToValue(event.target.value === 'on' ? 'default' : 'denied'))}>
<option value="off">不允许</option><option value="on">允许</option>
</select>
: <select className="rp-state" aria-label={label} value={valueToState(value)} disabled={disabled}
onChange={event => editCell(role, item.actionId, stateToValue(event.target.value as RolePermissionStateName))}>
{ROLE_PERMISSION_STATES.map(option => <option key={option.state} value={option.state}>{option.label}</option>)}
</select>}
{modified && <span className="rp-cell-flag">已修改</span>}
{linked && <span className="rp-linked"><Icon name="sparkles" size={12} />{LINKED_NOTE}</span>}
</div>
}
const matrix = () => <div className="rp-matrix-wrap" role="region" aria-label={`角色矩阵 · ${activeCategory}`} tabIndex={0}>
// 矩阵按业务组汇总;只有选中角色可编辑组开关,其余角色只读摘要。
const matrix = () => <div className="rp-matrix-wrap">
<table className="ad-table rp-matrix">
<thead><tr><th scope="col">角色</th>{columns.map(item => <th scope="col" key={item.actionId} title={item.description}>{item.label}</th>)}</tr></thead>
<tbody>{filtered.map(role => <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
<th scope="row"><div className="rp-role-cell">
<button className="rp-role-name" disabled={busy || roleLoading} onClick={() => openFocus(role)}>{role.name}</button>
{role.description && <span className="rp-role-desc" title={role.description}>{role.description}</span>}
<span className="ad-dim">{canViewMembers
? <button className="rp-members-link" aria-label={roleMembersLabel(role)} disabled={busy} onClick={() => setMembersRole(role)}>{role.bindingCount} 人</button>
: `${role.bindingCount} 人`} · {role.enabled ? '启用' : '已停用'}{dirtyList.some(entry => entry.roleId === role.roleId) ? ' · 未保存' : ''}</span>
{!role.enabled && <span className="rp-disabled-note">停用角色不可编辑权限,请先启用</span>}
</div></th>
{columns.map(item => <td key={item.actionId}>{cell(role, item)}</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>
{filtered.length === 0 && !roleLoading && <p className="ad-hint rp-empty">没有符合条件的角色</p>}
</div>
const focus = () => <div className="rp-single">
<aside className="rp-role-list">
{filtered.map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
<span>{role.name}{dirtyList.some(entry => entry.roleId === role.roleId) ? ' · 未保存' : ''}</span><span className="ad-dim">{role.bindingCount} 人</span>
</button>)}
</aside>
<section className="rp-editor">
{selected ? <>
<div className="ad-row">
<input className="lg-input" aria-label="角色名称" value={name} disabled={!canManageRoles || busy || roleLoading} onChange={event => setName(event.target.value)} />
<input className="lg-input" aria-label="角色描述" value={description} disabled={!canManageRoles || busy || roleLoading} onChange={event => setDescription(event.target.value)} />
{canManageRoles && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
{canManageRoles && (selected.enabled
? <button className="ad-mini" disabled={busy || roleLoading} onClick={() => setConfirmDisable(true)}>停用角色</button>
: <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>启用角色</button>)}
{canViewMembers && <button className="ad-mini" aria-label={roleMembersLabel(selected)} disabled={busy} onClick={() => setMembersRole(selected)}>成员 {selected.bindingCount} 人</button>}
</div>
{confirmDisable && selected.enabled && <div className="rp-disable-confirm" role="alertdialog" aria-label={`确认停用角色 ${selected.name}`}>
<span>停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。</span>
<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 ? <>
{!selected.enabled && <p className="rp-disabled-note">角色已停用,启用后才能编辑权限</p>}
<table className="ad-table rp-focus-table"><thead><tr><th>权限 · {activeCategory}</th><th>状态</th><th>说明</th></tr></thead>
<tbody>{columns.map(item => <tr key={item.actionId}>
<td>{item.label}</td><td>{cell(selected, item)}</td><td className="ad-dim">{item.description}</td>
</tr>)}</tbody></table>
<Collapsible id={`role-tech-${selected.roleId}`} trigger="技术详情">
<ul className="rp-diff">
<li>角色 ID:<span className="ad-mono">{selected.roleId}</span> · 版本 {selected.roleVersion}</li>
{columns.map(item => <li key={item.actionId}>{item.label}:<span className="ad-mono">{item.actionId}</span></li>)}
</ul>
</Collapsible>
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
{changes.length === 0 || !draft ? <p className="ad-hint">没有权限变化</p> : <ul className="rp-diff">{changes.map(change =>
<li key={change.actionId}>{labelOf(change.actionId)}:{valueLabel(change.actionId, draft.baseline[change.actionId])} → {valueLabel(change.actionId, change)}</li>)}</ul>}
</Collapsible>
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
</> : <p className="ad-hint">{roleLoading ? '加载中…' : '请选择角色'}</p>}
</section>
</div>
const previewPanel = (current: NonNullable<typeof preview>) => <section className="rp-preview" aria-label="保存前预览">
<div className="rp-preview-head">
<strong>{current.confirm ? '请确认以下变化后保存' : '待保存的变化'}</strong>
<button className="ad-mini" disabled={busy} onClick={() => setPreview(null)}>收起</button>
</div>
{current.previews.map(item => <div key={item.roleId} className="rp-preview-role">
<div className="rp-preview-title">{item.roleName}
{!item.versionMatches && <span className="rp-warn-text"> · 角色已被他人修改,保存时将提示冲突并基于最新版本重放</span>}</div>
{item.error && <p className="rp-conflict"><Icon name="alert" size={12} />错误{item.errorActionId ? ` · ${labelOf(item.errorActionId)}` : ''}:{item.error}</p>}
{(item.changes ?? []).length > 0
? <ul className="rp-diff">{item.changes.map(change => <li key={change.actionId}>{change.label}:{change.before} → {change.after}</li>)}</ul>
: <p className="ad-hint">没有状态变化</p>}
{(item.linkedExpansion ?? []).length > 0 && <p className="rp-linked"><Icon name="sparkles" size={12} />
系统将自动补齐:{item.linkedExpansion.map(labelOf).join('、')}{item.linkedExpansionState ? `(${item.linkedExpansionState})` : ''}</p>}
{(item.keptLinkedActions ?? []).length > 0 && <p className="ad-hint">关闭创建项目后保留:{item.keptLinkedActions.map(labelOf).join('、')}</p>}
{item.migrationNote && <p className="ad-hint">{item.migrationNote}</p>}
{(item.conflicts ?? []).map(conflict => <p key={conflict.actionId} className="rp-conflict">
<Icon name="alert" size={12} />冲突 · {labelOf(conflict.actionId)}:{conflict.reason}</p>)}
</div>)}
{blocked && <p className="rp-conflict" role="alert"><Icon name="alert" size={12} />存在冲突或错误,调整后才能保存</p>}
</section>
return <div className="rp-panel">
{canManageRoles && <div className="ad-create">
<div className="ad-row">
@@ -705,35 +366,44 @@ export function RolePermissionsPanel() {
</div>
</Collapsible>
<div className="ad-row rp-view-toggle">
{canViewPermissions && categories.length > 0 && <div className="rp-category-nav" role="group" aria-label="权限分类">
{categories.map(value => <button key={value} className={`ad-mini ${value === activeCategory ? 'on' : ''}`} aria-pressed={value === activeCategory}
onClick={() => setCategory(value)}>{value}</button>)}
</div>}
{canViewPermissions && <button className={`ad-mini ${effectiveView === 'matrix' ? 'on' : ''}`} aria-pressed={effectiveView === 'matrix'} onClick={() => { setMembersRole(null); setView('matrix') }}>角色矩阵</button>}
<button className={`ad-mini ${effectiveView === 'single' ? 'on' : ''}`} aria-pressed={effectiveView === 'single'} onClick={() => { setMembersRole(null); setView('single') }}>单角色配置</button>
<button className={`ad-mini ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</button>
{canViewPermissions && <button className={`ad-mini ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</button>}
<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>
</div>
{canViewPermissions && <Collapsible id="role-linked-help" trigger="权限关联说明">
<p className="ad-hint">开启「{labelOf(PROJECT_CREATE_ACTION)}」的角色,在本人创建的项目中自动具备{PROJECT_CREATE_LINKED_ACTIONS.map(id => `「${labelOf(id)}」`).join('、')}。
这些资格仅对本人创建的项目生效,其他项目仍按项目授权判断。</p>
</Collapsible>}
<div ref={statusRef} tabIndex={-1} className="rp-status">
{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>}
</div>
{membersRole && canViewMembers
? <RoleMembersView key={membersRole.roleId} role={roles?.find(role => role.roleId === membersRole.roleId) ?? membersRole} canAssign={canAssignMembers}
backLabel={effectiveView === 'matrix' ? '返回角色矩阵' : '返回单角色配置'} onBack={() => setMembersRole(null)} onAssigned={() => void load()} />
: roles === null && roleLoading ? <div className="empty-note">加载中…</div> : effectiveView === 'matrix' ? matrix() : focus()}
{preview && previewPanel(preview)}
{canManagePermissions && dirty && <div className="rp-savebar" role="region" aria-label="未保存的权限修改">
<span className="rp-savebar-summary">已修改 {dirtyList.length} 个角色、{changeCount} 项设置</span>
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => void requestPreview(false)}>查看变化</button>
<button className="ad-mini" disabled={busy} onClick={cancelAll}>取消全部</button>
{preview?.confirm
? <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || blocked} onClick={() => void confirmSave()}>确认保存</button>
: <button ref={savePermsRef} className="ad-mini" disabled={busy || roleLoading || !editable} onClick={() => void requestPreview(true)}>保存</button>}
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
{view === 'matrix' ? matrix() : <div className="rp-single">
<aside className="rp-role-list">
{(filtered).map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
<span>{role.name}</span><span className="ad-dim">{role.bindingCount} 人</span>
</button>)}
</aside>
<section className="rp-editor">
{selected && draft ? <>
<div className="ad-row">
<input className="lg-input" aria-label="角色名称" value={name} disabled={!canManageRoles || busy || roleLoading} onChange={event => setName(event.target.value)} />
<input className="lg-input" aria-label="角色描述" value={description} disabled={!canManageRoles || busy || roleLoading} onChange={event => setDescription(event.target.value)} />
{canManageRoles && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
{canManageRoles && (selected.enabled
? <button className="ad-mini" disabled={busy || roleLoading} onClick={() => setConfirmDisable(true)}>停用角色</button>
: <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>启用角色</button>)}
</div>
{confirmDisable && selected.enabled && <div className="rp-disable-confirm" role="alertdialog" aria-label={`确认停用角色 ${selected.name}`}>
<span>停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。</span>
<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 ? <>
<CapabilityGroupEditor groups={capabilityGroups} values={draft.values} labels={labels}
disabled={busy || roleLoading || !canManagePermissions} onToggle={editGroup} />
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
<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 && breaks.length === 0) || allEditableIds.length === 0} onClick={savePermissions}>保存权限</button>}
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
</> : <p className="ad-hint">请选择角色</p>}
</section>
</div>}
</div>
}
+71
View File
@@ -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('同步异常')
})
})
+29 -11
View File
@@ -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" />
+1
View File
@@ -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)
+74
View File
@@ -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(/成员增删由项目创建者负责/)
})
})
+53 -29
View File
@@ -5,33 +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'
import { dutyConditionNote, linkedCapabilitySummary } from '../permissionExplanations'
// ---- 项目设置:成员(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)
@@ -61,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])
@@ -143,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}
@@ -171,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 () => {
@@ -179,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)} />
@@ -189,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} 的项目身份`, () =>
@@ -208,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 && (
@@ -270,10 +305,7 @@ function ActionDetailTable({ actions, onOverride, canManage }: {
<td title={`内部标识 ${a.action}`}>{actionTitle(a)}</td>
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
<td className="ad-dim">{describeSource(a.source)}</td>
<td className="ad-dim">
{a.reason || '—'}
{dutyConditionNote(a.action) && <span className="ad-dim ad-cond-note">{dutyConditionNote(a.action)}</span>}
</td>
<td className="ad-dim">{a.reason || '—'}</td>
{canManage ? (
<td>
<select className="lg-input" value={overrideOf(a)} style={{ minWidth: 120 }}
@@ -364,9 +396,6 @@ export function RdmPermMatrixSection({ project }: { project: Project }) {
}
const canManage = !writeError && !!matrix && matrix.role !== undefined
const linked = me?.linkedCapabilities
const showLinked = !!linked && !!matrix && matrix.member === me?.owner
&& (linked.projectCreate || (linked.actions ?? []).length > 0)
return (
<div>
@@ -392,11 +421,6 @@ export function RdmPermMatrixSection({ project }: { project: Project }) {
{view === 'member' && matrix && (
<>
{showLinked && (
<Collapsible trigger="创建项目与本人项目管理资格">
<p className="ad-hint">{linkedCapabilitySummary(linked)}</p>
</Collapsible>
)}
<div className="ad-row">
<span>成员 <b className="ad-mono">{matrix.member}</b> · 项目身份 <b>{matrix.role === 'owner' ? '项目负责人' : matrix.role === 'member' ? '项目成员' : matrix.role}</b>
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b></>}
+39 -146
View File
@@ -1,22 +1,21 @@
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
// 全部收进这里,由 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 { CreateAdminOption, IdentityChip } from './AccountUsersPanel'
import { GeneratedCredentialPanel, oneTimePasswordCredential, type GeneratedCredential } from './oneTimePasswordUi'
// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码;
// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ----
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码的唯一处置是
// 管理员生成一次性登录密码,用户登录后必须立即改密。 ----
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
export function UsersPanel() {
const me = useStore(s => s.me)
@@ -26,112 +25,37 @@ export function UsersPanel() {
const [owner, setOwner] = useState('')
const [name, setName] = useState('')
const [initialPassword, setInitialPassword] = useState('')
const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'temporary' } | null>(null)
const [createAsAdmin, setCreateAsAdmin] = useState(false)
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 () => {
setErr(''); setNotice('')
setBusy(true)
try {
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined)
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined, canAppointAdmins(me) && createAsAdmin)
setGeneratedCredential(out.initialPassword ? { owner: out.owner, password: out.initialPassword, kind: 'initial' } : null)
setNotice(out.initialPassword ? `已创建 ${out.owner},请将下方一次性初始密码交付给用户。首次登录后必须改密。` : `已创建 ${out.owner}。用户首次登录后必须改密。`)
setOwner(''); setName(''); setInitialPassword('')
setOwner(''); setName(''); setInitialPassword(''); setCreateAsAdmin(false)
await refresh()
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
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 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 && (
@@ -142,65 +66,34 @@ export function UsersPanel() {
<input className="lg-input" type="password" autoComplete="new-password" placeholder="初始密码(留空随机生成)" value={initialPassword} onChange={e => setInitialPassword(e.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim()} onClick={create}>创建账号</button>
</div>
<CreateAdminOption me={me} checked={createAsAdmin} disabled={busy} onChange={setCreateAsAdmin} />
<p className="ad-hint">可填写管理员指定的初始密码;留空则由服务端生成一次性随机密码。用户首次登录后必须改密。</p>
</div>
)}
{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 => (
<tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && <span className="ad-row">
<button className="ad-mini" disabled={busy} onClick={() => resetPassword(u)}>重置临时密码</button>
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
</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>
)
}
+21
View File
@@ -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*/, '')
}
+21
View File
@@ -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>
}
+27
View File
@@ -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
View File
@@ -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 }
-48
View File
@@ -1,48 +0,0 @@
// 项目有效权限说明的纯文案层:只消费服务端字段(/api/me linkedCapabilities)与固定权限
// 语义,不按角色/成员关系推算任何有效权限。未知动作 id / 来源回退原文。
import type { LinkedCapabilities } from './api'
/** 联动动作中文名(与后端权限 catalog 一致)。 */
export const LINKED_ACTION_LABELS: Record<string, string> = {
'project.settings': '管理项目设置',
'project.members.manage': '管理项目成员',
'project.permissions.manage': '管理项目权限',
'document.duties.assign': '分配文档职责',
}
const PROJECT_CREATE_SOURCE_LABELS: Record<string, string> = {
grant: '管理员授予',
'admin-grant': '管理员授予',
direct: '管理员授予',
role: '角色',
admin: '系统管理员',
}
const actionLabels = (ids: string[]) => ids.map(id => LINKED_ACTION_LABELS[id] ?? id).join('、')
/** 创建项目与本人项目管理资格说明;字段缺失(旧服务)返回空串。 */
export function linkedCapabilitySummary(linked: LinkedCapabilities | undefined): string {
if (!linked) return ''
if (!linked.projectCreate) return '未具备创建项目资格'
const source = linked.source ? `(来源:${PROJECT_CREATE_SOURCE_LABELS[linked.source] ?? linked.source})` : ''
const parts = [`已具备创建项目资格${source}`]
const actions = linked.actions ?? []
if (actions.length > 0) {
parts.push(`创建本人项目后自动具备:${actionLabels(actions)}`)
parts.push('这些资格仅对本人创建的项目生效,其他项目按项目授权判断')
}
const conflicts = linked.conflicts ?? []
if (conflicts.length > 0) parts.push(`存在冲突:${actionLabels(conflicts)} 被明确禁止`)
return parts.join(';')
}
const DUTY_CONDITION_NOTES: Record<string, string> = {
'document.author': '仍需被分配该文档的编制职责,且文档状态符合要求',
'document.review': '仍需被分配该文档的审核职责,且文档状态符合要求',
'document.approve': '仍需被分配该文档的批准职责,且文档状态符合要求',
}
/** 文档编制/审核/批准动作的固定条件说明(权限语义,非对具体文档的判定)。 */
export function dutyConditionNote(actionId: string): string {
return DUTY_CONDITION_NOTES[actionId] ?? ''
}
+2 -1
View File
@@ -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 }
+107
View File
@@ -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))
})
})
+69
View File
@@ -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)
}
-2
View File
@@ -353,8 +353,6 @@ export function describeSource(source: string): string {
case 'override': return '项目内覆盖'
case 'relation-default': return '项目关系默认'
case 'non-member': return '非项目成员'
case 'creator': return '本人项目创建者自动具备(仅本人创建的项目)'
case 'role': return '职位角色'
default: return source
}
}
-92
View File
@@ -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')
})
})
-50
View File
@@ -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*/, '')
}
-143
View File
@@ -1,143 +0,0 @@
import type {
AccountRoleSummary, PermissionDescriptor, RoleCopyAssignRequest, RolePermissionChangeInput, RoleRecord,
UserRoleAssignmentInput, UserRoleAssignmentPreview,
} from './api'
import type { RolePermissionValue } from './rolePermissions'
/** 显式选择集合:只含逐个或整页勾选过的 userId;筛选与翻页既不清空也不扩大它。 */
export interface MemberSelection { readonly ids: readonly string[] }
export const createSelection = (ids: readonly string[] = []): MemberSelection => ({ ids: [...new Set(ids)] })
export const clearSelection = (): MemberSelection => createSelection()
export const isSelected = (selection: MemberSelection, userId: string) => selection.ids.includes(userId)
export function toggleOne(selection: MemberSelection, userId: string, checked = !isSelected(selection, userId)): MemberSelection {
if (checked) return isSelected(selection, userId) ? selection : { ids: [...selection.ids, userId] }
return isSelected(selection, userId) ? { ids: selection.ids.filter(id => id !== userId) } : selection
}
/** 表头全选只作用于当前页可选行;其他页已选的人保持不变。 */
export function selectPage(selection: MemberSelection, pageIds: readonly string[], checked: boolean): MemberSelection {
if (checked) return createSelection([...selection.ids, ...pageIds])
const page = new Set(pageIds)
return { ids: selection.ids.filter(id => !page.has(id)) }
}
export function pageSelectionState(selection: MemberSelection, pageIds: readonly string[]): 'none' | 'some' | 'all' {
const picked = pageIds.filter(id => isSelected(selection, id)).length
if (!pageIds.length || picked === 0) return 'none'
return picked === pageIds.length ? 'all' : 'some'
}
export function selectionCounts(selection: MemberSelection, pageIds: readonly string[]): { count: number; beyondPage: number } {
const page = new Set(pageIds)
return { count: selection.ids.length, beyondPage: selection.ids.filter(id => !page.has(id)).length }
}
export function selectionSummary(counts: { count: number; beyondPage: number }): string {
return counts.beyondPage > 0 ? `已选 ${counts.count} 人(包含其他页的 ${counts.beyondPage} 人)` : `已选 ${counts.count} 人`
}
type NamedAccount = { userId: string; owner: string; name?: string; profile?: { name: string } }
const rawName = (account: NamedAccount) => (account.profile?.name ?? account.name ?? '').trim()
/** 姓名缺失时以账号(登录名)展示;它只是显示文本,提交永远用 userId。 */
export const accountName = (account: NamedAccount) => rawName(account) || account.owner
export const accountLabel = (account: NamedAccount) => rawName(account) ? `${rawName(account)}(${account.owner})` : account.owner
export function duplicateNames(accounts: readonly NamedAccount[]): Set<string> {
const owners = new Map<string, Set<string>>()
for (const account of accounts) {
const name = rawName(account)
if (!name) continue
owners.set(name, (owners.get(name) ?? new Set()).add(account.userId))
}
return new Set([...owners].filter(([, ids]) => ids.size > 1).map(([name]) => name))
}
export function sameNameHint(accounts: readonly NamedAccount[]): string {
const duplicates = duplicateNames(accounts)
if (!duplicates.size) return ''
const groups = [...duplicates].map(name => `「${name}」:${accounts.filter(account => rawName(account) === name).map(account => account.owner).join('、')}`)
return `存在同名用户${groups.join(';')},请按账号辨识`
}
/** undefined = 无角色查看权(服务端不返回投影);null = 未分配。 */
export function roleColumnLabel(role: AccountRoleSummary | null | undefined): string {
if (role === undefined) return '—'
if (role === null) return '未分配'
return role.enabled ? role.name : `${role.name}(已停用)`
}
export const assignedToRole = (account: { role?: AccountRoleSummary | null }, roleId: string) => !!roleId && account.role?.roleId === roleId
export function memberAssignments(
selected: readonly string[], roleId: string, stateVersions: Readonly<Record<string, number>>,
): UserRoleAssignmentInput[] {
if (!roleId) throw new Error('请先选择目标角色')
return [...new Set(selected)].map(userId => {
const expectedStateVersion = stateVersions[userId]
if (!expectedStateVersion) throw new Error('缺少用户状态版本,请刷新列表后重试')
return { userId, roleId, expectedStateVersion }
})
}
/** 预览显示版本不一致时,只读重取这些用户的详情以获得最新 stateVersion(不涉及任何写接口)。 */
export async function refreshStateVersions<T extends { userId: string; stateVersion: number }>(
client: { adminGetUser: (userId: string) => Promise<T> }, userIds: readonly string[],
): Promise<Record<string, T>> {
const fresh = await Promise.all([...new Set(userIds)].map(userId => client.adminGetUser(userId)))
return Object.fromEntries(fresh.map(account => [account.userId, account]))
}
export const staleUserIds = (previews: readonly UserRoleAssignmentPreview[]) =>
previews.filter(preview => !preview.stateMatches).map(preview => preview.userId)
export const previewTransition = (preview: Pick<UserRoleAssignmentPreview, 'currentRoleName' | 'targetRoleName'>) =>
`${preview.currentRoleName || '未分配'} → ${preview.targetRoleName}`
/** 只有每个提交对象都有预览、且全部无错误且状态版本一致时才允许确认。 */
export function previewAllowsAssign(previews: readonly UserRoleAssignmentPreview[], userIds: readonly string[]): boolean {
if (!userIds.length) return false
const byUser = new Map(previews.map(preview => [preview.userId, preview]))
return userIds.every(userId => {
const preview = byUser.get(userId)
return !!preview && !preview.error && preview.stateMatches
})
}
/** 用户角色投影(GET users/{id}/role)不含 permissions;只有完整角色记录才能作为复制起点。 */
export function roleSourceValues(role: RoleRecord, catalog: readonly PermissionDescriptor[]): Record<string, RolePermissionValue> {
if (!Array.isArray(role.permissions)) return {}
const existing = new Map(role.permissions.map(item => [item.actionId, item]))
return Object.fromEntries(catalog.filter(item => !item.protected).map(item => {
const value = existing.get(item.actionId)
return [item.actionId, { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }]
}))
}
export function copyAssignRequest(input: {
idempotencyKey: string; source: RoleRecord; name: string; description: string; catalog: readonly PermissionDescriptor[]
values: Readonly<Record<string, RolePermissionValue>>; users: readonly { userId: string; stateVersion: number }[]
}): RoleCopyAssignRequest {
const name = input.name.trim()
if (!name) throw new Error('请为新角色填写名称')
if (!input.users.length) throw new Error('请至少选择一位用户')
const baseline = roleSourceValues(input.source, input.catalog)
const overrides: RolePermissionChangeInput[] = []
for (const [actionId, before] of Object.entries(baseline)) {
const after = input.values[actionId]
if (after && (after.allowed !== before.allowed || after.capAllowed !== before.capAllowed)) overrides.push({ actionId, ...after })
}
return {
idempotencyKey: input.idempotencyKey,
source: { roleId: input.source.roleId, expectedRoleVersion: input.source.roleVersion },
name, description: input.description.trim(),
...(overrides.length ? { overrides } : {}),
assignments: input.users.map(user => ({ userId: user.userId, expectedStateVersion: user.stateVersion })),
}
}
+115
View File
@@ -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)
})
})
+155 -72
View File
@@ -1,58 +1,5 @@
import type { PermissionDescriptor, RolePermissionPatchEntry } from './api'
export type RolePermissionValue = { allowed: boolean; capAllowed: boolean }
type RolePermissionChange = { actionId: string; allowed: boolean; capAllowed: boolean }
export type RolePermissionStateName = 'denied' | 'project' | 'default'
export const ROLE_PERMISSION_STATES: { state: RolePermissionStateName; label: string }[] = [
{ state: 'denied', label: '不允许' },
{ state: 'project', label: '按项目授权' },
{ state: 'default', label: '默认允许' },
]
const ROLE_PERMISSION_CATEGORIES = ['项目与协作', '文档工作', '项目内容'] as const
export const PROJECT_CREATE_ACTION = 'project.create'
/** 开启 project.create 时随之具备的本人创建项目管理资格(与后端 catalog 同序)。 */
export const PROJECT_CREATE_LINKED_ACTIONS = [
'project.settings', 'project.members.manage', 'project.permissions.manage', 'document.duties.assign',
] as const
export function stateToValue(state: RolePermissionStateName): RolePermissionValue {
if (state === 'default') return { allowed: true, capAllowed: true }
if (state === 'project') return { allowed: false, capAllowed: true }
return { allowed: false, capAllowed: false }
}
export function valueToState(value: RolePermissionValue | undefined): RolePermissionStateName {
if (!value?.capAllowed) return 'denied'
return value.allowed ? 'default' : 'project'
}
export function stateLabel(state: RolePermissionStateName): string {
return ROLE_PERMISSION_STATES.find(item => item.state === state)?.label ?? state
}
/** 受保护系统授权不进矩阵;缺分类的非保护系统动作(如 project.create)归入「项目与协作」。 */
function permissionCategory(item: PermissionDescriptor): string {
if (item.protected) return ''
return item.category || (item.scope === 'system' ? ROLE_PERMISSION_CATEGORIES[0] : item.group)
}
export function permissionCategories(catalog: PermissionDescriptor[]): string[] {
const present = [...new Set(catalog.map(permissionCategory).filter(Boolean))]
const fixed: string[] = ROLE_PERMISSION_CATEGORIES.filter(category => present.includes(category))
return [...fixed, ...present.filter(category => !fixed.includes(category))]
}
export function categoryActions(catalog: PermissionDescriptor[], category: string): PermissionDescriptor[] {
if (!category) return []
return catalog.filter(item => permissionCategory(item) === category)
}
export function linkedByProjectCreate(values: Record<string, RolePermissionValue>, actionId: string): boolean {
return (PROJECT_CREATE_LINKED_ACTIONS as readonly string[]).includes(actionId) && !!values[PROJECT_CREATE_ACTION]?.allowed
}
export interface RolePermissionDraft {
baseline: Record<string, RolePermissionValue>
@@ -114,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]
@@ -121,22 +223,3 @@ export function validateRolePermissionDraft(draft: RolePermissionDraft): { actio
}
return null
}
export function dirtyRoles(
drafts: Record<string, RolePermissionDraft>, actionIds?: string[],
): { roleId: string; changes: number }[] {
return Object.entries(drafts)
.map(([roleId, draft]) => ({ roleId, changes: visiblePatch(draft, actionIds ?? Object.keys(draft.values)).length }))
.filter(entry => entry.changes > 0)
}
// One entry per dirty role; each carries only that role's changes from its own baseline.
export function rolePatchEntries(
drafts: Record<string, RolePermissionDraft>, versions: Record<string, number>, actionIds?: string[],
): RolePermissionPatchEntry[] {
return dirtyRoles(drafts, actionIds).map(({ roleId }) => ({
roleId,
expectedRoleVersion: versions[roleId],
changes: visiblePatch(drafts[roleId], actionIds ?? Object.keys(drafts[roleId].values)),
}))
}
+390
View File
@@ -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()
})
})
+263 -36
View File
@@ -3,7 +3,7 @@
import { useSyncExternalStore } from 'react'
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,14 +91,21 @@ export interface UIState {
projectSettingsId: string | null
}
export interface Me {
owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[]
linkedCapabilities?: LinkedCapabilities
}
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
@@ -201,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: [],
@@ -235,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)
@@ -301,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
@@ -320,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,
@@ -356,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 {
@@ -424,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,
@@ -446,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',
@@ -467,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) })
@@ -475,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
@@ -527,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()
@@ -550,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)
@@ -624,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 })
@@ -722,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.
@@ -881,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 }
@@ -1077,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 })
@@ -1088,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)
@@ -1291,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
@@ -1404,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 })
@@ -1455,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)
@@ -1463,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)
@@ -1519,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
}
+9 -59
View File
@@ -519,81 +519,31 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.ad-table tr:last-child td { border-bottom: none; }
.rp-panel { display: flex; flex-direction: column; gap: 12px; }
.rp-filters .lg-input { min-width: 140px; }
.rp-view-toggle { justify-content: flex-end; flex-wrap: wrap; }
.rp-view-toggle .ad-mini.on { background: var(--bg-active); color: var(--text); border-color: var(--accent); }
.rp-category-nav { display: flex; gap: 6px; flex-wrap: wrap; margin-right: auto; }
.rp-status:focus { outline: none; }
.rp-status:empty { display: none; }
.rp-view-toggle { justify-content: flex-end; }
.rp-view-toggle .ad-mini.on { background: var(--bg-active); color: var(--text); }
.rp-single { display: grid; grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
.rp-disable-confirm { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 8px; font-size: var(--fs-ui-sm); color: var(--warn-text); background: var(--warn-soft); border: 1px solid color-mix(in srgb, var(--warn) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; }
.rp-role-list { display: flex; flex-direction: column; gap: 2px; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); background: var(--bg-panel); }
.rp-role-list .az-nav-item { justify-content: space-between; width: 100%; }
.rp-editor { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
.rp-matrix-wrap { overflow: auto; max-height: 70vh; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
.rp-matrix-wrap:focus-visible { outline: none; box-shadow: var(--ring); }
.rp-matrix { min-width: 760px; border-collapse: separate; border-spacing: 0; }
.rp-matrix thead th { position: sticky; top: 0; z-index: 2; background: var(--bg-panel); vertical-align: bottom; }
.rp-matrix th:first-child { position: sticky; left: 0; z-index: 1; background: var(--bg-panel); }
.rp-matrix thead th:first-child { z-index: 3; }
.rp-matrix tbody th { font-weight: normal; color: var(--text); border-bottom: 1px solid var(--border-soft); vertical-align: top; }
.rp-matrix td { vertical-align: top; }
.rp-matrix tr.rp-selected td, .rp-matrix tr.rp-selected th { background: var(--bg-active); }
.rp-role-cell { display: flex; flex-direction: column; gap: 2px; min-width: 180px; max-width: 240px; }
.rp-role-name { align-self: flex-start; border: none; background: none; padding: 0; color: var(--accent-text); font-size: var(--fs-ui-sm); font-weight: 600; cursor: pointer; text-align: left; }
.rp-role-name:hover:not(:disabled) { text-decoration: underline; }
.rp-role-name:disabled { color: var(--text-dim); cursor: default; }
.rp-role-desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
.rp-disabled-note { margin: 0; font-size: var(--fs-ui-xs); color: var(--warn-text); }
.rp-empty { padding: 12px; }
.rp-cell { display: flex; flex-direction: column; gap: 4px; min-width: 120px; }
.rp-state { height: 30px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-raised); color: var(--text); font-size: var(--fs-ui-sm); padding: 0 6px; }
.rp-state:focus-visible { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
.rp-state:disabled { opacity: .55; cursor: default; }
.rp-cell-flag { font-size: var(--fs-ui-xs); color: var(--warn-text); }
.rp-linked { display: flex; gap: 4px; align-items: flex-start; margin: 0; font-size: var(--fs-ui-xs); color: var(--accent-text); }
.rp-linked svg { flex: none; margin-top: 1px; }
.rp-focus-table td { vertical-align: top; }
.rp-matrix-wrap { overflow-x: auto; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
.rp-matrix { min-width: 760px; }
.rp-matrix th:first-child, .rp-matrix td:first-child { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
.rp-matrix tr.rp-selected td { background: var(--bg-active); }
.rp-matrix tr.rp-selected td:first-child { background: var(--bg-active); }
.rp-diff { margin: 0; padding-left: 20px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
.rp-preview { display: flex; flex-direction: column; gap: 10px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--bg-raised); font-size: var(--fs-ui-sm); }
.rp-preview-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.rp-preview-role { display: flex; flex-direction: column; gap: 4px; padding-top: 8px; border-top: 1px solid var(--border-soft); }
.rp-preview-title { font-weight: 600; color: var(--text); }
.rp-warn-text { font-weight: normal; color: var(--warn-text); }
.rp-conflict { display: flex; gap: 4px; align-items: flex-start; margin: 0; color: var(--err-text); font-size: var(--fs-ui-sm); }
.rp-conflict svg { flex: none; margin-top: 2px; }
.rp-savebar { position: sticky; bottom: 0; z-index: 4; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--bg-panel); box-shadow: var(--shadow-md); }
.rp-savebar-summary { flex: 1; min-width: 160px; font-size: var(--fs-ui-sm); color: var(--text); }
.rp-grants { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; margin-bottom: 12px; }
.rp-grants .ad-check { align-items: flex-start; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-raised); }
.rp-grants .ad-mono { margin-left: auto; }
.rp-members { display: flex; flex-direction: column; gap: 10px; }
.rp-members-link { border: none; background: none; padding: 0; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; text-decoration: underline; }
.rp-members-link:disabled { color: var(--text-dim); cursor: default; text-decoration: none; }
.rp-members-link:focus-visible { outline: none; border-radius: var(--radius-xs); box-shadow: var(--ring); }
.au-select-col { width: 32px; }
.au-select-col input:focus-visible { outline: none; box-shadow: var(--ring); }
.au-name-cell { display: flex; flex-direction: column; gap: 2px; }
.au-same-name { align-self: flex-start; font-size: var(--fs-ui-xs); color: var(--warn-text); background: var(--warn-soft); border-radius: var(--radius-xs); padding: 1px 6px; }
.au-grow { flex: 1; min-width: 0; }
.au-batch .lg-input { height: 34px; }
.au-preview-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; }
.au-preview-list li { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; padding-top: 6px; border-top: 1px solid var(--border-soft); }
.au-copy { display: flex; flex-direction: column; gap: 10px; }
.au-copy-group { margin: 0; padding: 8px 10px; border: 1px solid var(--border-soft); border-radius: var(--radius); display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 6px 12px; }
.au-copy-group legend { padding: 0 4px; font-size: var(--fs-ui-sm); font-weight: 600; color: var(--text-dim); }
.au-copy-row { display: flex; gap: 8px; align-items: center; font-size: var(--fs-ui-sm); color: var(--text); }
.au-copy-row > span:first-child { flex: 1; min-width: 0; }
@media (max-width: 900px) {
@media (max-width: 820px) {
.rp-single { grid-template-columns: 1fr; }
.rp-role-list { max-height: 220px; overflow-y: auto; }
.rp-matrix-wrap { max-height: 60vh; }
.rp-savebar .ad-mini, .rp-savebar .lg-submit { flex: 1; justify-content: center; }
}
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
.ad-dim { color: var(--text-dim); }
.ad-cond-note { display: block; margin-top: 2px; font-size: var(--fs-ui-xs); }
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
.ad-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
.ad-chip-super { background: var(--accent); color: var(--text-on-accent); }
.ad-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
+3 -31
View File
@@ -1,38 +1,10 @@
import { describe, expect, it } from 'vitest'
import { credentialLabel } from '../src/components/AccountUsersPanel'
import { accountLabel, accountName, duplicateNames, roleColumnLabel, sameNameHint } from '../src/roleMembers'
const account = (userId: string, owner: string, name: string) => ({ userId, owner, profile: { name } })
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('未设置密码')
})
})
describe('account directory names and roles', () => {
it('falls back to the login account when the name is missing', () => {
expect(accountName(account('u1', 'zhang.san', ''))).toBe('zhang.san')
expect(accountName(account('u1', 'zhang.san', ' '))).toBe('zhang.san')
expect(accountName(account('u1', 'zhang.san', '张三'))).toBe('张三')
expect(accountLabel(account('u1', 'zhang.san', '张三'))).toBe('张三(zhang.san)')
expect(accountLabel(account('u1', 'zhang.san', ''))).toBe('zhang.san')
expect(accountName({ userId: 'u2', owner: 'li.si', name: '李四' })).toBe('李四')
})
it('asks to tell same-name users apart by account', () => {
const accounts = [account('u1', 'zhang.san', '张三'), account('u2', 'zhang.san2', '张三'), account('u3', 'li.si', '李四'), account('u4', 'anon', '')]
expect([...duplicateNames(accounts)]).toEqual(['张三'])
expect(sameNameHint(accounts)).toBe('存在同名用户「张三」:zhang.san、zhang.san2,请按账号辨识')
expect(sameNameHint(accounts.slice(2))).toBe('')
})
it('labels the current role projection', () => {
expect(roleColumnLabel(undefined)).toBe('—')
expect(roleColumnLabel(null)).toBe('未分配')
expect(roleColumnLabel({ roleId: 'r1', name: '软件工程师', enabled: true })).toBe('软件工程师')
expect(roleColumnLabel({ roleId: 'r1', name: '软件工程师', enabled: false })).toBe('软件工程师(已停用)')
expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('待设置密码')
})
})
+61
View File
@@ -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)
})
})
+53
View File
@@ -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('其它会话的工作细节')
})
})
+33
View File
@@ -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
View File
@@ -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' }),
}))
})
})
+40
View File
@@ -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('一次性登录密码仅显示一次')
})
})
-63
View File
@@ -1,63 +0,0 @@
import { describe, expect, it } from 'vitest'
import { LINKED_ACTION_LABELS, dutyConditionNote, linkedCapabilitySummary } from '../src/permissionExplanations'
import { describeSource } from '../src/rdmManagement'
const LINKED = ['project.settings', 'project.members.manage', 'project.permissions.manage', 'document.duties.assign']
describe('linked capability summary', () => {
it('returns empty when the server omits the field (older backend)', () => {
expect(linkedCapabilitySummary(undefined)).toBe('')
})
it('explains held project-create eligibility and the self-created-project scope', () => {
expect(linkedCapabilitySummary({ projectCreate: true, source: 'grant', actions: LINKED })).toBe(
'已具备创建项目资格(来源:管理员授予);创建本人项目后自动具备:管理项目设置、管理项目成员、管理项目权限、分配文档职责;'
+ '这些资格仅对本人创建的项目生效,其他项目按项目授权判断')
})
it('maps source variants and falls back to the raw source', () => {
expect(linkedCapabilitySummary({ projectCreate: true, source: 'role', actions: [] })).toBe('已具备创建项目资格(来源:角色)')
expect(linkedCapabilitySummary({ projectCreate: true, source: 'admin', actions: [] })).toBe('已具备创建项目资格(来源:系统管理员)')
expect(linkedCapabilitySummary({ projectCreate: true, source: 'admin-grant', actions: [] })).toContain('来源:管理员授予')
expect(linkedCapabilitySummary({ projectCreate: true, source: 'future', actions: [] })).toContain('来源:future')
expect(linkedCapabilitySummary({ projectCreate: true, actions: [] })).toBe('已具备创建项目资格')
})
it('appends explicit conflicts with Chinese labels', () => {
const text = linkedCapabilitySummary({
projectCreate: true, source: 'role', actions: ['project.settings'], conflicts: ['project.members.manage', 'document.duties.assign'],
})
expect(text).toContain('创建本人项目后自动具备:管理项目设置')
expect(text.endsWith('存在冲突:管理项目成员、分配文档职责 被明确禁止')).toBe(true)
})
it('states missing eligibility without listing linked actions', () => {
expect(linkedCapabilitySummary({ projectCreate: false, actions: [] })).toBe('未具备创建项目资格')
expect(linkedCapabilitySummary({ projectCreate: false, actions: ['project.settings'], conflicts: ['project.settings'] })).toBe('未具备创建项目资格')
})
it('labels every linked action and keeps unknown ids verbatim', () => {
expect(LINKED.map(id => LINKED_ACTION_LABELS[id])).toEqual(['管理项目设置', '管理项目成员', '管理项目权限', '分配文档职责'])
expect(linkedCapabilitySummary({ projectCreate: true, actions: ['x.y'] })).toContain('自动具备:x.y')
})
})
describe('duty condition note', () => {
it('explains the duty and state conditions for document author/review/approve', () => {
expect(dutyConditionNote('document.author')).toBe('仍需被分配该文档的编制职责,且文档状态符合要求')
expect(dutyConditionNote('document.review')).toBe('仍需被分配该文档的审核职责,且文档状态符合要求')
expect(dutyConditionNote('document.approve')).toBe('仍需被分配该文档的批准职责,且文档状态符合要求')
})
it('is empty for every other action', () => {
for (const id of ['document.read', 'project.settings', 'project.create', '']) expect(dutyConditionNote(id)).toBe('')
})
})
describe('describeSource future sources', () => {
it('maps creator and role sources, keeping the raw fallback', () => {
expect(describeSource('creator')).toBe('本人项目创建者自动具备(仅本人创建的项目)')
expect(describeSource('role')).toBe('职位角色')
expect(describeSource('weird')).toBe('weird')
})
})
+264
View File
@@ -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)
})
})
-133
View File
@@ -1,133 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { PermissionDescriptor, RoleRecord, UserRoleAssignmentPreview } from '../src/api'
import {
assignedToRole, clearSelection, copyAssignRequest, createSelection, memberAssignments, pageSelectionState, previewAllowsAssign,
previewTransition, refreshStateVersions, roleSourceValues, selectPage, selectionCounts, selectionSummary, staleUserIds, toggleOne,
} from '../src/roleMembers'
import { stateToValue } from '../src/rolePermissions'
const catalog: PermissionDescriptor[] = [
{ actionId: 'system.roles.view', label: '查看角色', description: '', group: '角色管理', scope: 'system', protected: true },
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', category: '项目与协作', scope: 'system', protected: false },
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
]
const source: RoleRecord = {
roleId: 'role_engineer', name: '软件工程师', description: '研发', enabled: true, roleVersion: 7, bindingCount: 3,
permissions: [{ actionId: 'document.read', allowed: true, capAllowed: true, permissionVersion: 1 }],
}
const preview = (userId: string, patch: Partial<UserRoleAssignmentPreview> = {}): UserRoleAssignmentPreview => ({
userId, owner: userId, name: '', currentRoleId: '', currentRoleName: '', targetRoleId: 'role_engineer', targetRoleName: '软件工程师',
stateMatches: true, ...patch,
})
describe('cross-page member selection', () => {
it('accumulates explicit picks across pages and counts the ones beyond the current page', () => {
let selection = toggleOne(createSelection(), 'u1')
selection = toggleOne(selection, 'u2')
const pageTwo = ['u3', 'u4']
selection = selectPage(selection, pageTwo, true)
expect(selection.ids).toEqual(['u1', 'u2', 'u3', 'u4'])
expect(selectionCounts(selection, pageTwo)).toEqual({ count: 4, beyondPage: 2 })
expect(selectionSummary(selectionCounts(selection, pageTwo))).toBe('已选 4 人(包含其他页的 2 人)')
expect(selectionSummary(selectionCounts(selection, ['u1', 'u2', 'u3', 'u4']))).toBe('已选 4 人')
})
it('page select-all only touches the current page and reports a partial page', () => {
const selection = selectPage(createSelection(['u9']), ['u1', 'u2'], true)
expect(pageSelectionState(selection, ['u1', 'u2'])).toBe('all')
const unpicked = selectPage(selection, ['u1', 'u2'], false)
expect(unpicked.ids).toEqual(['u9'])
expect(pageSelectionState(toggleOne(selection, 'u2', false), ['u1', 'u2'])).toBe('some')
expect(pageSelectionState(unpicked, [])).toBe('none')
})
it('keeps the selection when filters change and never widens it to unselected matches', () => {
const selection = toggleOne(createSelection(), 'u1')
const filteredPage = ['u5', 'u6']
expect(selectionCounts(selection, filteredPage)).toEqual({ count: 1, beyondPage: 1 })
expect(selection.ids).toEqual(['u1'])
expect(toggleOne(selection, 'u1', true)).toBe(selection)
expect(toggleOne(selection, 'u7', false)).toBe(selection)
})
it('clears to an empty selection', () => {
expect(clearSelection().ids).toEqual([])
expect(selectionCounts(clearSelection(), ['u1'])).toEqual({ count: 0, beyondPage: 0 })
})
})
describe('member assignment entries', () => {
it('submits stable userId/roleId pairs with each user\'s own state version, once per user', () => {
expect(memberAssignments(['u1', 'u2', 'u1'], 'role_engineer', { u1: 3, u2: 8 })).toEqual([
{ userId: 'u1', roleId: 'role_engineer', expectedStateVersion: 3 },
{ userId: 'u2', roleId: 'role_engineer', expectedStateVersion: 8 },
])
})
it('refuses to build entries without a target role or a known state version', () => {
expect(() => memberAssignments(['u1'], '', { u1: 3 })).toThrow('目标角色')
expect(() => memberAssignments(['u1', 'u2'], 'role_engineer', { u1: 3 })).toThrow('状态版本')
})
it('allows confirmation only when every submitted user previews cleanly', () => {
expect(previewAllowsAssign([preview('u1'), preview('u2')], ['u1', 'u2'])).toBe(true)
expect(previewAllowsAssign([preview('u1')], ['u1', 'u2'])).toBe(false)
expect(previewAllowsAssign([preview('u1', { error: '用户已停用' })], ['u1'])).toBe(false)
expect(previewAllowsAssign([preview('u1', { stateMatches: false })], ['u1'])).toBe(false)
expect(previewAllowsAssign([], [])).toBe(false)
expect(staleUserIds([preview('u1', { stateMatches: false }), preview('u2')])).toEqual(['u1'])
expect(previewTransition(preview('u1'))).toBe('未分配 → 软件工程师')
expect(previewTransition(preview('u1', { currentRoleName: '审核工程师' }))).toBe('审核工程师 → 软件工程师')
})
it('re-reads only stale users through the read-only detail endpoint', async () => {
const adminGetUser = vi.fn(async (userId: string) => ({ userId, stateVersion: 12 }))
expect(await refreshStateVersions({ adminGetUser }, ['u1', 'u1'])).toEqual({ u1: { userId: 'u1', stateVersion: 12 } })
expect(adminGetUser).toHaveBeenCalledTimes(1)
})
it('marks directory results already bound to the role', () => {
expect(assignedToRole({ role: { roleId: 'role_engineer', name: '软件工程师', enabled: true } }, 'role_engineer')).toBe(true)
expect(assignedToRole({ role: null }, 'role_engineer')).toBe(false)
expect(assignedToRole({}, 'role_engineer')).toBe(false)
})
})
describe('copy role and assign request', () => {
const base = {
idempotencyKey: 'key-1', source, description: ' 独立维护 ', catalog,
users: [{ userId: 'u1', stateVersion: 4 }],
}
it('requires an explicit non-empty new role name', () => {
expect(() => copyAssignRequest({ ...base, name: ' ', values: {} })).toThrow('名称')
expect(() => copyAssignRequest({ ...base, name: '新角色', values: {}, users: [] })).toThrow('用户')
})
it('sends only actions whose value differs from the source role', () => {
const request = copyAssignRequest({ ...base, name: ' 软件工程师(外包) ', values: {
'document.read': stateToValue('default'),
'document.author': stateToValue('project'),
'project.create': stateToValue('denied'),
} })
expect(request).toEqual({
idempotencyKey: 'key-1',
source: { roleId: 'role_engineer', expectedRoleVersion: 7 },
name: '软件工程师(外包)', description: '独立维护',
overrides: [{ actionId: 'document.author', allowed: false, capAllowed: true }],
assignments: [{ userId: 'u1', expectedStateVersion: 4 }],
})
})
it('omits overrides entirely when nothing was adjusted', () => {
const request = copyAssignRequest({ ...base, name: '副本', values: { 'document.read': stateToValue('default') } })
expect(request).not.toHaveProperty('overrides')
expect(request.assignments[0]).not.toHaveProperty('roleId')
})
it('treats a role projection without permissions as an empty baseline instead of throwing', () => {
expect(() => roleSourceValues({ permissions: undefined } as any, catalog)).not.toThrow()
expect(roleSourceValues({ permissions: undefined } as any, catalog)).toEqual({})
})
})
+9 -95
View File
@@ -1,10 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import type { PermissionDescriptor, RolePermissionPreview, RoleRecord } from '../src/api'
import { describe, expect, it } from 'vitest'
import type { PermissionDescriptor, RoleRecord } from '../src/api'
import {
NARROW_ROLE_QUERY, cellKind, commitRoleBatch, editRoleDraft, filterRoles, hasSystemAction, initialRoleView, memberStatusLabel, previewRoleBatch,
rebaseRoleDrafts, roleCellValue, roleLeaveMessage, roleMembersLabel, shouldAcceptRoleResponse,
filterRoles, groupPermissionPage, hasSystemAction, shouldAcceptRoleResponse,
} from '../src/components/RolePermissionsPanel'
import { categoryActions, dirtyRoles, rolePatchEntries, stateToValue } from '../src/rolePermissions'
const roles: RoleRecord[] = [
{ roleId: 'role_engineer', name: '软件工程师', description: '研发', enabled: true, roleVersion: 1, bindingCount: 2, permissions: [] },
@@ -12,14 +10,10 @@ const roles: RoleRecord[] = [
]
const catalog: PermissionDescriptor[] = [
{ actionId: 'system.roles.view', label: '查看角色', description: '', group: '角色管理', scope: 'system', protected: true },
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', category: '项目与协作', scope: 'system', protected: false },
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', scope: 'system', protected: false },
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', scope: 'project', protected: false },
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', scope: 'project', protected: false },
]
const editableIds = catalog.filter(item => !item.protected).map(item => item.actionId)
const versions = (drafts: Record<string, { role: RoleRecord }>) =>
Object.fromEntries(Object.entries(drafts).map(([roleId, draft]) => [roleId, draft.role.roleVersion]))
describe('role permission UI helpers', () => {
it('combines exact or fuzzy search with the role status filter', () => {
@@ -28,10 +22,9 @@ describe('role permission UI helpers', () => {
expect(filterRoles(roles, '', 'fuzzy', 'disabled').map(role => role.roleId)).toEqual(['role_reviewer'])
})
it('shows one business category of matrix columns at a time, keeping stable action IDs and skipping protected grants', () => {
expect(categoryActions(catalog, '文档工作').map(item => item.actionId)).toEqual(['document.read', 'document.author'])
expect(categoryActions(catalog, '项目与协作').map(item => item.actionId)).toEqual(['project.create'])
expect(categoryActions(catalog, '').map(item => item.actionId)).toEqual([])
it('groups and pages matrix columns without changing their stable action IDs', () => {
expect(groupPermissionPage(catalog, '文档动作', 0, 1).map(item => item.actionId)).toEqual(['document.read'])
expect(groupPermissionPage(catalog, '文档动作', 1, 1).map(item => item.actionId)).toEqual(['document.author'])
})
it('drops late results and checks explicit system actions', () => {
@@ -40,83 +33,4 @@ describe('role permission UI helpers', () => {
expect(hasSystemAction(['system.roles.view'], 'system.roles.view')).toBe(true)
expect(hasSystemAction(['system.roles.view'], 'system.permissions.manage')).toBe(false)
})
it('writes a matrix cell edit into that role\'s own draft without selecting it first', () => {
const first = editRoleDraft({}, roles[0], catalog, 'document.read', stateToValue('project'))
const both = editRoleDraft(first, roles[1], catalog, 'document.author', stateToValue('default'))
expect(Object.keys(both)).toEqual(['role_engineer', 'role_reviewer'])
expect(roleCellValue(both, roles[0], 'document.read')).toEqual({ allowed: false, capAllowed: true })
expect(roleCellValue(both, roles[0], 'document.author')).toEqual({ allowed: false, capAllowed: false })
expect(both.role_reviewer.role.roleId).toBe('role_reviewer')
expect(dirtyRoles(both, editableIds)).toEqual([{ roleId: 'role_engineer', changes: 1 }, { roleId: 'role_reviewer', changes: 1 }])
})
it('uses a two-state control for project.create and three states for project actions', () => {
expect(cellKind(catalog[1])).toBe('toggle')
expect(cellKind(catalog[2])).toBe('state')
const on = editRoleDraft({}, roles[0], catalog, 'project.create', stateToValue('default'))
expect(rolePatchEntries(on, versions(on), editableIds)[0].changes).toEqual([{ actionId: 'project.create', allowed: true, capAllowed: true }])
})
it('saves every dirty role through one authoritative preview and one batch commit', async () => {
let drafts = editRoleDraft({}, roles[0], catalog, 'document.read', stateToValue('default'))
drafts = editRoleDraft(drafts, roles[1], catalog, 'project.create', stateToValue('default'))
const entries = rolePatchEntries(drafts, versions(drafts), editableIds)
const preview = (roleId: string): RolePermissionPreview => ({ roleId, roleName: roleId, expectedRoleVersion: 1, currentRoleVersion: 1,
versionMatches: true, changes: [], linkedExpansion: [], keptLinkedActions: [], conflicts: [] })
const client = {
previewRolePermissions: vi.fn(async () => entries.map(entry => preview(entry.roleId))),
batchPatchRolePermissions: vi.fn(async () => ({ receiptId: 'r1', authorityEpoch: 2, receipts: [] })),
}
const reviewed = await previewRoleBatch(client, entries)
expect(reviewed.blocked).toBe(false)
await commitRoleBatch(client, entries, reviewed.previews, 'key-1')
expect(client.previewRolePermissions).toHaveBeenCalledWith(entries, { mode: 'patch' })
expect(client.batchPatchRolePermissions).toHaveBeenCalledTimes(1)
expect(client.batchPatchRolePermissions).toHaveBeenCalledWith([
{ roleId: 'role_engineer', expectedRoleVersion: 1, changes: [{ actionId: 'document.read', allowed: true, capAllowed: true }] },
{ roleId: 'role_reviewer', expectedRoleVersion: 1, changes: [{ actionId: 'project.create', allowed: true, capAllowed: true }] },
], 'key-1')
const conflicted = [{ ...preview('role_engineer'), conflicts: [{ actionId: 'document.read', reason: '平台上限不允许' }] }]
expect((await previewRoleBatch({ previewRolePermissions: async () => conflicted }, entries)).blocked).toBe(true)
await expect(commitRoleBatch(client, entries, conflicted, 'key-2')).rejects.toThrow()
expect(client.batchPatchRolePermissions).toHaveBeenCalledTimes(1)
})
it('rebases every dirty role on its latest version after a batch conflict', async () => {
let drafts = editRoleDraft({}, roles[0], catalog, 'document.read', stateToValue('default'))
drafts = editRoleDraft(drafts, roles[1], catalog, 'document.author', stateToValue('project'))
const fresh: Record<string, RoleRecord> = {
role_engineer: { ...roles[0], roleVersion: 4, permissions: [{ actionId: 'document.author', allowed: true, capAllowed: true, permissionVersion: 2 }] },
role_reviewer: { ...roles[1], roleVersion: 9, permissions: [] },
}
const getRole = vi.fn(async (roleId: string) => fresh[roleId])
const rebased = await rebaseRoleDrafts({ getRole }, drafts, ['role_engineer', 'role_reviewer'], catalog)
expect(getRole).toHaveBeenCalledTimes(2)
expect(rebased.drafts.role_engineer.values['document.read']).toEqual({ allowed: true, capAllowed: true })
expect(rebased.drafts.role_engineer.values['document.author']).toEqual({ allowed: true, capAllowed: true })
expect(rolePatchEntries(rebased.drafts, versions(rebased.drafts), editableIds)).toEqual([
{ roleId: 'role_engineer', expectedRoleVersion: 4, changes: [{ actionId: 'document.read', allowed: true, capAllowed: true }] },
{ roleId: 'role_reviewer', expectedRoleVersion: 9, changes: [{ actionId: 'document.author', allowed: false, capAllowed: true }] },
])
})
it('names every dirty role in the leave guard', () => {
const message = roleLeaveMessage(['软件工程师', '审核工程师'])
expect(message).toContain('「软件工程师」')
expect(message).toContain('「审核工程师」')
})
it('names the role member entry and member status for assistive technology', () => {
expect(roleMembersLabel(roles[0])).toBe('查看角色 软件工程师 的成员')
expect(memberStatusLabel({ enabled: true })).toBe('有效')
expect(memberStatusLabel({ enabled: false })).toContain('停用')
})
it('defaults to single-role focus on narrow screens and to the matrix otherwise', () => {
expect(initialRoleView(query => query === NARROW_ROLE_QUERY)).toBe('single')
expect(initialRoleView(() => false)).toBe('matrix')
expect(initialRoleView()).toBe('matrix')
})
})
+2 -55
View File
@@ -1,9 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { PermissionDescriptor } from '../src/api'
import {
categoryActions, createRolePermissionDraft, dirtyRoles, linkedByProjectCreate, mergeRoleDraft, permissionCategories, rebaseRoleDraft,
rolePatchEntries, stateToValue, updateRolePermission, validateRolePermissionDraft, valueToState, visiblePatch,
type RolePermissionStateName, type RolePermissionValue,
createRolePermissionDraft, mergeRoleDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
type RolePermissionValue,
} from '../src/rolePermissions'
const initial: Record<string, RolePermissionValue> = {
@@ -50,54 +48,3 @@ describe('role permission draft', () => {
expect(rebased.values['document.read']).toEqual({ allowed: false, capAllowed: true })
})
})
const catalog: PermissionDescriptor[] = [
{ actionId: 'system.roles.view', label: '查看角色', description: '', group: '角色管理', scope: 'system', protected: true },
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
{ actionId: 'trace.edit', label: '编辑追溯网', description: '', group: '项目内容', category: '项目内容', scope: 'project', protected: false },
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', scope: 'system', protected: false },
{ actionId: 'project.settings', label: '项目设置', description: '', group: '项目管理', category: '项目与协作', scope: 'project', protected: false },
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
]
describe('role permission tri-state and categories', () => {
it('maps the three display states to stored values and back', () => {
expect(stateToValue('denied')).toEqual({ allowed: false, capAllowed: false })
expect(stateToValue('project')).toEqual({ allowed: false, capAllowed: true })
expect(stateToValue('default')).toEqual({ allowed: true, capAllowed: true })
for (const state of ['denied', 'project', 'default'] as RolePermissionStateName[]) expect(valueToState(stateToValue(state))).toBe(state)
expect(valueToState(undefined)).toBe('denied')
})
it('groups actions in the fixed category order and keeps catalog order inside a category', () => {
expect(permissionCategories(catalog)).toEqual(['项目与协作', '文档工作', '项目内容'])
expect(categoryActions(catalog, '项目与协作').map(item => item.actionId)).toEqual(['project.create', 'project.settings'])
expect(categoryActions(catalog, '文档工作').map(item => item.actionId)).toEqual(['document.read', 'document.author'])
expect(permissionCategories(catalog).flatMap(category => categoryActions(catalog, category)).some(item => item.protected)).toBe(false)
})
it('counts only roles whose drafts differ from their baseline', () => {
const clean = createRolePermissionDraft(initial)
const edited = updateRolePermission(updateRolePermission(createRolePermissionDraft(initial), 'document.read', stateToValue('project')),
'document.author', stateToValue('default'))
const reverted = updateRolePermission(updateRolePermission(createRolePermissionDraft(initial), 'document.read', stateToValue('denied')),
'document.read', initial['document.read'])
expect(dirtyRoles({ clean, edited, reverted })).toEqual([{ roleId: 'edited', changes: 2 }])
})
it('builds one patch entry per dirty role with only that role\'s changed actions', () => {
const a = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true })
const b = updateRolePermission(createRolePermissionDraft(initial), 'document.read', stateToValue('denied'))
const entries = rolePatchEntries({ a, b, c: createRolePermissionDraft(initial) }, { a: 3, b: 7, c: 1 }, Object.keys(initial))
expect(entries).toEqual([
{ roleId: 'a', expectedRoleVersion: 3, changes: [{ actionId: 'project.create', allowed: true, capAllowed: true }] },
{ roleId: 'b', expectedRoleVersion: 7, changes: [{ actionId: 'document.read', allowed: false, capAllowed: false }] },
])
})
it('marks linked actions only while project.create is allowed', () => {
expect(linkedByProjectCreate({ 'project.create': { allowed: true, capAllowed: true } }, 'project.settings')).toBe(true)
expect(linkedByProjectCreate({ 'project.create': { allowed: true, capAllowed: true } }, 'document.read')).toBe(false)
expect(linkedByProjectCreate({ 'project.create': { allowed: false, capAllowed: true } }, 'project.settings')).toBe(false)
})
})
+97
View File
@@ -0,0 +1,97 @@
// 超级管理员 UI 门控(计划 2026-10-19-0113 R3/R4/R8):身份标签可见;建号为管理员与
// 任命/撤销入口只对超级管理员出现;超级管理员不可降级、停用或被普通管理员维护。
// 服务端授权仍为权威,这里只锁定前端呈现与请求体。
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { api } from '../src/api'
import { auth } from '../src/auth'
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../src/accountAuthority'
import { AdminAppointmentButton, CreateAdminOption, IdentityChip } from '../src/components/AccountUsersPanel'
afterEach(() => vi.restoreAllMocks())
const superAdmin = { isAdmin: true, isSuperAdmin: true }
const admin = { isAdmin: true, isSuperAdmin: false }
const user = { isAdmin: false, isSuperAdmin: false }
const noop = () => undefined
describe('身份标签', () => {
it('超级管理员、管理员与普通用户各有可辨识标签', () => {
expect(accountIdentityKind(superAdmin)).toBe('super')
expect(accountIdentityKind(admin)).toBe('admin')
expect(accountIdentityKind(user)).toBe('user')
expect(renderToStaticMarkup(createElement(IdentityChip, { account: superAdmin }))).toContain('超级管理员')
expect(renderToStaticMarkup(createElement(IdentityChip, { account: admin }))).toContain('>管理员<')
expect(renderToStaticMarkup(createElement(IdentityChip, { account: user }))).toContain('普通用户')
})
it('isAdmin 不推导超级管理员身份', () => {
expect(accountIdentityKind({ isAdmin: true })).toBe('admin')
})
})
describe('管理员任命入口只对超级管理员开放', () => {
it('建号:超级管理员看到「创建为管理员账号」,普通管理员只见普通建号说明', () => {
expect(canAppointAdmins(superAdmin)).toBe(true)
expect(canAppointAdmins(admin)).toBe(false)
expect(canAppointAdmins(null)).toBe(false)
const superHtml = renderToStaticMarkup(createElement(CreateAdminOption, { me: superAdmin, checked: false, disabled: false, onChange: noop }))
expect(superHtml).toContain('type="checkbox"')
expect(superHtml).toContain('创建为管理员账号')
const adminHtml = renderToStaticMarkup(createElement(CreateAdminOption, { me: admin, checked: false, disabled: false, onChange: noop }))
expect(adminHtml).not.toContain('checkbox')
expect(adminHtml).toContain('仅超级管理员可创建或任命管理员')
})
it('任命/撤销:超级管理员可任命普通用户、撤销管理员;普通管理员看不到任何任命按钮', () => {
expect(accountActionPolicy(superAdmin, user).adminChange).toBe('promote')
expect(accountActionPolicy(superAdmin, admin).adminChange).toBe('demote')
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: user, busy: false, onChange: noop }))).toContain('设为管理员')
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: admin, busy: false, onChange: noop }))).toContain('撤销管理员')
for (const target of [user, admin, superAdmin]) {
expect(accountActionPolicy(admin, target).adminChange).toBeNull()
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: admin, account: target, busy: false, onChange: noop }))).toBe('')
}
})
})
describe('超级管理员账号受保护', () => {
it('超级管理员本人不可被降级或停用,也不走管理员重置密码', () => {
const self = accountActionPolicy(superAdmin, superAdmin)
expect(self).toMatchObject({ adminChange: null, toggleEnabled: false, resetPassword: false, maintain: true })
expect(self.protectedReason).toContain('不能停用、降级')
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: superAdmin, busy: false, onChange: noop }))).toBe('')
})
it('普通管理员不能编辑、停用、降级或重置超级管理员,并获得明确说明', () => {
const policy = accountActionPolicy(admin, superAdmin)
expect(policy).toMatchObject({ maintain: false, toggleEnabled: false, resetPassword: false, adminChange: null })
expect(policy.protectedReason).toContain('超级管理员账号受保护')
})
it('普通账号的既有维护能力不受影响', () => {
expect(accountActionPolicy(admin, user)).toMatchObject({ maintain: true, toggleEnabled: true, resetPassword: true, protectedReason: '' })
})
})
describe('账号管理请求体', () => {
const stub = (body: unknown) => vi.spyOn(auth, 'fetch').mockImplementation(async () => new Response(JSON.stringify(body)))
it('adminSetUserAdmin 以乐观版本 PUT /api/admin/users/:userId/admin', async () => {
const request = stub({ userId: 'u 1' })
await api.adminSetUserAdmin('u 1', 7, true)
const [url, options] = request.mock.calls[0]
expect(url).toBe('/api/admin/users/u%201/admin')
expect(options?.method).toBe('PUT')
expect(JSON.parse(String(options?.body))).toEqual({ expectedStateVersion: 7, isAdmin: true })
})
it('建号只在请求管理员身份时携带 isAdmin', async () => {
const request = stub({ userId: 'u1', owner: 'alice' })
await api.createManagedUser('alice', { name: 'A' })
await api.createManagedUser('bob', {}, true)
await api.createAdminUser('carol', 'C')
await api.createAdminUser('dave', 'D', undefined, true)
const bodies = request.mock.calls.map(call => JSON.parse(String(call[1]?.body)))
expect(bodies[0]).toEqual({ owner: 'alice', profile: { name: 'A' } })
expect(bodies[1]).toEqual({ owner: 'bob', profile: {}, isAdmin: true })
expect(bodies[2]).toEqual({ owner: 'carol', profile: { name: 'C' } })
expect(bodies[3]).toEqual({ owner: 'dave', profile: { name: 'D' }, isAdmin: true })
})
})
+60 -1
View File
@@ -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')
})