Compare commits

...
Author SHA1 Message Date
agent-exec-transport 46a63f0221 agent-exec transport 73562c7c2cf83a3efd62efd0baae1583/1e09e148c1d0 2026-10-19 05:33:07 +00:00
agent-exec-transport 128aafed46 agent-exec transport 3b6054e390f9316a2dab95d1cdd43708/9e6148a528ad 2026-10-19 05:06:19 +00:00
agent-exec-transport 054f134111 agent-exec transport 0d6b4036f23f77daaed447ae4265470e/ec3a3ab90c05 2026-10-19 05:03:09 +00:00
agent-exec-transport c37c33bcac agent-exec transport 33be97509383239c85923e287ead8663/118295d92a69 2026-10-19 04:56:40 +00:00
agent-exec-transport 7d9ac7c093 agent-exec transport 5b222edc742519fdf36f7710ec4e3167/3bafd8f39e76 2026-10-19 04:46:07 +00:00
agent-exec-transport 1d907c16a1 agent-exec transport 8054c941591733dae018547f6a755d2a/bbd4a9613f8b 2026-10-19 04:36:10 +00:00
agent-exec-transport a6ce695d6c agent-exec transport 354d53ff2b4856569e0139fa28015235/4ed3cc851d91 2026-10-19 04:22:39 +00:00
agent-exec-transport d965732106 agent-exec transport 5be3a44afded25495039416fcd4551c0/39cdc052739e 2026-10-19 04:17:29 +00:00
agent-exec-transport 4be7362842 agent-exec transport 719afff63d5bb7be93041751b9e839ce/da1c115ab399 2026-10-19 04:04:38 +00:00
weichen 6db528df10 Merge pull request 'Add U3 account management and recovery UI' (#86) from feature/iam-u3-account-ui into main
governance / governance (push) Successful in 14s
ci / test-and-build (push) Successful in 26s
2026-10-19 01:08:09 +00:00
weichenandCursor 22ef3c4b67 Fold-close the IAM U3 Frontend task card before merge
governance / governance (pull_request) Successful in 15s
ci / test-and-build (pull_request) Successful in 29s
Why: close the card in the same PR before merging.
Evidence: pr-check PR_CHECK_OK on 080c630.
Impact: task card only.
Refs: Frontend PR #86
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 01:07:00 +00:00
weichenandCursor 080c630816 Add the IAM U3 Frontend task card for the PR gate
governance / governance (pull_request) Successful in 14s
ci / test-and-build (pull_request) Successful in 33s
Why: the branch predates Frontend governance adoption and had no task card, so pr-check refused PR #86.
Evidence: card records the agent-exec units, host verification log, CI and independent review of e4041b3.
Impact: task card only.
Refs: Frontend PR #86
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 01:06:46 +00:00
weichen e4041b36b9 Merge IAM U3 account UI history onto Frontend main
governance / governance (pull_request) Successful in 14s
ci / test-and-build (pull_request) Successful in 28s
Why: PR #86 predates IAM U4 and the account-chain UI on main; the U3 behaviour was ported onto main by agent-exec transports d47c493, 72c941c and afd9e75, so this merge only records the U3 branch history.
Evidence: tree equals afd9e75; host tsc -b, vitest 565 passed, build and knip on 72c941c (SELF); independent review blocking item fixed in afd9e75.
Impact: no tree change versus afd9e75.
Refs: Frontend PR #86, base main 1650b4f
2026-10-19 01:04:53 +00:00
agent-exec-transport afd9e7504b agent-exec transport a522bfd9db3df00c549eaa7e63f94562/3f39344cbd2e 2026-10-18 16:57:45 +00:00
agent-exec-transport 72c941c772 agent-exec transport 4a9e9a1c45e7ac50517ea954d95026bb/16e6bae28677 2026-10-18 16:51:49 +00:00
agent-exec-transport d47c49350b agent-exec transport 832aaeb220c114fdce38450a7e3d2dc7/e80375762b4e 2026-10-18 16:31:17 +00:00
weichen 1650b4fff0 Merge pull request 'Add IAM U4 role and permission management UI' (#111) from feature/iam-u4-role-permission-ui into main
governance / governance (push) Successful in 19s
2026-10-18 14:20:29 +00:00
weichen bc9b17a665 Register CI secret cleanup before writing the key
ci / test-and-build (pull_request) Successful in 36s
Why: An early filesystem failure could exit before the UI deploy key cleanup trap existed.

Evidence: Parsed workflow confirms the EXIT trap precedes the first secret write.

Impact: Normal checkout failures remove both transient credential files from the runner.

Refs: IAM U3 Frontend PR #86
2026-10-14 16:24:12 +00:00
weichen a1bd3e2983 Verify the Node archive used by Frontend CI
ci / test-and-build (pull_request) Successful in 1m5s
Why: The CI runner executed a mirror-downloaded toolchain without authenticating its contents.

Evidence: The pinned SHA256 matches the signed Node.js v22.23.3 release manifest and the current mirror archive.

Impact: CI fails before extraction if the Node archive differs from the reviewed release artifact.

Refs: IAM U3 Frontend PR #86
2026-10-14 16:22:50 +00:00
weichen 93ee723131 Fetch private UI in CI with read-only deploy key
ci / test-and-build (pull_request) Successful in 48s
Why: The Gitea job token cannot read the private UI repository and this server lacks HTTPS deploy-token API support.

Evidence: Repository-scoped UI deploy key read was verified; Frontend Actions secret UI_DEPLOY_KEY is listed; prior CI run 3214 confirmed the missing credential gate.

Impact: Exact-pin CI uses a read-only SSH key and pinned host identity, deleting the transient key after checkout.

Refs: IAM U3 Frontend PR #86
2026-10-14 16:14:07 +00:00
weichen ee735f2e2e Use dedicated read access for private UI CI dependency
ci / test-and-build (pull_request) Failing after 0s
Why: Gitea job token can fetch Frontend but receives repository-not-found for private UI.

Evidence: PR CI run 3213 checkout log shows Frontend fetch succeeded and UI fetch failed; Frontend secret listing lacks SUBMODULE_TOKEN.

Impact: CI now requests the dedicated UI read credential and fails clearly until it is provisioned.

Refs: IAM U3 Frontend PR #86
2026-10-14 16:08:41 +00:00
weichen 3203e8d4f3 Accept platform CI server URL
ci / test-and-build (pull_request) Failing after 1s
Why: The runner may inject an internal Gitea URL rather than the public browser URL.

Evidence: PR CI run 3212 exited at checkout preflight before any fetch; updated YAML parses locally.

Impact: Checkout validates the injected URL shape and logs nonsecret event context.

Refs: IAM U3 Frontend PR #86
2026-10-14 16:07:50 +00:00
weichen bed8a5c766 Run Frontend test and build in Gitea CI
ci / test-and-build (pull_request) Failing after 1s
Why: The Frontend repository lacked an exact-HEAD CI gate for U3 delivery.

Evidence: Workflow syntax parsed locally; Frontend and pinned UI tests and build passed locally; platform run pending.

Impact: PR and main checks now fetch fixed Frontend/UI commits and run install, tests, and production build.

Refs: IAM U3 delivery gate
2026-10-14 16:07:12 +00:00
weichen 055476d135 Clarify confirmed recovery proposal notice
Why: A confirmed proposal should not instruct the owner to confirm it again.

Evidence: Frontend tests and production build passed at the changed source.

Impact: Confirmation notice describes the existing state and keeps credential authority with the owner.

Refs: IAM U3 account recovery
2026-10-14 16:03:49 +00:00
weichen 34f6a1bf2f Handle terminal recovery proposal outcomes
Why: A lost proposal response followed by revocation could leave admins with an ambiguous result, and a late response could overwrite a newly selected account.

Evidence: Frontend 272 tests and production build passed; isolated browser replay covered lost response, terminal retry, and account switch.

Impact: Recovery notices reflect current proposal state, and stale account requests cannot update the selected detail.

Refs: IAM U3 account directory and recovery
2026-10-14 16:02:17 +00:00
weichen 2e7e5b7a64 Reconcile U3 account writes and recovery proposals
Why: A successful mutation from a later search page reused an invalid signed cursor and falsely reported a write conflict; a lost recovery-proposal response left a committed proposal hidden and retried with a fresh key.

Evidence: Isolated browser red runs reproduced the stale-page duplicate warning and unknown proposal status; after the fix the combined page-two and lost-response flow passed, as did 272 Frontend tests and a production build.

Impact: Successful writes restart pagination and keep their success status, while uncertain proposal outcomes re-read metadata and reuse the same idempotency key for explicit retry.

Refs: IAM U3 Stage 7; Frontend PR #86
2026-10-14 15:53:46 +00:00
weichen 36ac754300 Merge remote-tracking branch 'origin/main' into feature/iam-u3-account-ui 2026-10-14 15:43:12 +00:00
weichen a961b19ebb Normalize legacy duty fields at the UI boundary
Why: RDM duty.get publishes capitalized domain role keys, so a selected document appeared to have no assigned people in the new stable-ID picker.

Evidence: SELF receipts /tmp/iam-u3-rdm-ui-wire-compat.json and /tmp/iam-u3-frontend-wire-compat-test.json; normal pinned-UI build passed.

Impact: The workbench accepts both existing capitalized and lower-camel role fields without changing the RDM public response contract.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
2026-10-14 05:01:26 +00:00
weichen 010540f203 Treat duplicate recovery responses as status-only
Why: A lost confirmation or grant-rotation response must not imply that the one-time plaintext can be read again.

Evidence: SELF receipt /tmp/iam-u3-frontend-idempotent-test.json and a normal pinned-UI build after the change.

Impact: The owner view shows current proposal status and offers an explicit new-grant action only when the server returns no plaintext.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
2026-10-14 04:58:46 +00:00
weichen 731c9b7776 Close account setup and recovery flows in the workbench
Why: The old user panel accepted initial passwords, exposed administrator-issued recovery keys, and used owner-only candidate selections.

Evidence: SELF receipts /tmp/iam-u3-frontend-normal-combo-build.json, /tmp/iam-u3-frontend-normal-combo-test.json, /tmp/iam-u3-browser-final.json, and /tmp/iam-u3-rdm-ui-browser-replay.json.

Impact: Adds optional profiles, server-backed account search, effective-state controls, metadata-only proposals, owner confirmation and recovery, current-required password mode, and stable-ID RDM pickers. Verified with the Dev-pinned UI component SHA.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
2026-10-14 04:54:05 +00:00
29 changed files with 2580 additions and 299 deletions
+68
View File
@@ -0,0 +1,68 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- name: Check out fixed Frontend and UI commits
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
UI_DEPLOY_KEY: ${{ secrets.UI_DEPLOY_KEY }}
UI_SHA: 5c2b1036dd1aff89ec4eeae0c96493694e33ad4c
run: |
set -euo pipefail
printf 'context server=%s repository=%s sha=%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_SHA}"
[[ "${GITHUB_SERVER_URL}" =~ ^https?://[^/]+/?$ ]]
test "${GITHUB_REPOSITORY}" = "weichen/Agent-System-Frontend"
[[ "${GITHUB_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${UI_SHA}" =~ ^[0-9a-f]{40}$ ]]
test -n "${TOKEN}"
if [ -z "${UI_DEPLOY_KEY}" ]; then
echo "UI_DEPLOY_KEY is required for the private UI dependency" >&2
exit 1
fi
export GIT_TERMINAL_PROMPT=0
mkdir -p "${RUNNER_TEMP}/iam-frontend-ci/frontend" "${RUNNER_TEMP}/iam-frontend-ci/ui"
cd "${RUNNER_TEMP}/iam-frontend-ci/frontend"
git init -q
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "${GITHUB_SHA}"
umask 077
trap 'rm -f "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key" "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"' EXIT
printf '%s' "${UI_DEPLOY_KEY}" > "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key"
printf '%s\n' '[git-ssh.u1680832.nyat.app]:31411 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK55QSxDqMMZ1HIWHSPkQ7fyJkDKbdg98TeIv6SrR3qx' > "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"
export GIT_SSH_COMMAND="ssh -F /dev/null -i ${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts -p 31411"
cd "${RUNNER_TEMP}/iam-frontend-ci/ui"
git init -q
git remote add origin "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git"
git fetch --depth=1 origin "${UI_SHA}"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "${UI_SHA}"
git -C "${RUNNER_TEMP}/iam-frontend-ci/frontend" status --short
git -C "${RUNNER_TEMP}/iam-frontend-ci/ui" status --short
- name: Install Node 22
env:
NODE_ARCHIVE_SHA256: df450af89261115ef9f9e3830c3eeb2cc9213b63c720b1af623cb5dcbe2e02de
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/node.tar.xz" https://npmmirror.com/mirrors/node/v22.23.3/node-v22.23.3-linux-x64.tar.xz
printf '%s %s\n' "${NODE_ARCHIVE_SHA256}" "$RUNNER_TEMP/node.tar.xz" | sha256sum -c -
tar -C /usr/local --strip-components=1 -xJf "$RUNNER_TEMP/node.tar.xz"
- name: Install dependencies
run: |
set -euo pipefail
npm ci --prefix "${RUNNER_TEMP}/iam-frontend-ci/ui"
npm ci --prefix "${RUNNER_TEMP}/iam-frontend-ci/frontend"
- name: Test and build
run: |
set -euo pipefail
cd "${RUNNER_TEMP}/iam-frontend-ci/frontend"
npm test
npm run build
@@ -0,0 +1,45 @@
# IAM U3 账号管理与本人恢复前端 任务卡
任务主记录:本文件随 PR 描述维护。
状态:已完成(合入前闭合);owner:weichen。PR:[Frontend PR #86](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/86)
## 基线与授权
- 权威仓:weichen/Agent-System-Frontend;root_target:main;immediate_target:main
- branch_kind:standalone;initiative_id:无(跨仓任务 Agent-System-Dev `feature/iam-u3-account-directory-recovery` 的前端子交付)
- R0:710ef836(分支创建时的 origin/main;分支早于本仓治理接入,原无任务卡,本卡在收口时补建);工作区:/home/weichen/projects/Agent-System-IAM-U3-Frontend-Combo/frontend(worktree);分支:feature/iam-u3-account-ui
- 父任务信息(仅子任务填写):无
- accepting_requirements:false
- 需求分流:由 Dev U3 主任务承接
- 授权与交付终点:用户已批准 U3 阶段 4–6 与 Frontend 组件阶段 7(HEAD `93ee723`);U4 合入后用户选择 merge_then_accept:同步 main、重跑证据、独立 Review、当前 HEAD CI 通过后合入,用户在部署上验收,问题另立修复。终点为本 PR 合入 main,再由 RDM/Dev 组合固定。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
目标:把 U3 账号界面移植到含 U4 职位权限 UI、账号链路修复(#94/#95)、密码登录与管理员临时重置(#107)、RDM 成员检索(#108)的 main 上,每项能力只保留一套实现(以 main 为基础补入 U3 缺少的行为),并按 U3 RDM 目录消费的命令合同补回成员/职责候选选择器。
非目标:不恢复 U3 登录 Key 标签页(main 已改为纯密码登录);不恢复 main 已删除的 rdm.duty.get 编辑器;不改 Backend/RDM/UI。
## Agent 实施收据
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| iam-u3-frontend-onto-main-r2 | .gitea/workflows/ci.yml、src/、test/ | implementation | cursor→antigravity→pi | 1(e80375762b4e) | cursor / not-observable(新进程,fresh_session=true) | success | d47c493(apply 快进) | 否 | /tmp/cleanup-20261018/render/u3-fe-onto-main-run2.json |
| iam-u3-frontend-rdm-selectors-r1 | src/、test/ | implementation | cursor→antigravity→pi | 1(16e6bae28677) | cursor / not-observable(新进程,fresh_session=true) | success | 72c941c(apply 快进) | 否 | /tmp/cleanup-20261018/render/u3-fe-rdm-selectors-run.json |
| iam-u3-frontend-ci-ui-sha-r1 | .gitea/workflows/ci.yml | repair | cursor→antigravity→pi | 1(3f39344cbd2e) | cursor / not-observable(新进程,fresh_session=true) | success | afd9e75(apply 快进) | 否 | /tmp/cleanup-20261018/render/u3-fe-ci-ui-sha-run.json |
首个单元 r1 因 U3 分支缺 AGENT-EXECUTION.md 返回 invalid-config(未启动 worker);改为在 main 上建同步分支移植。`e4041b3` 以 `-s ours` 记录原分支 `bc9b17a` 历史,树等于 `afd9e75`。
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at |
|---|---|---|---|
| U4 后再同步 | 1650b4fff0163e48a752ecbd86a3c4b18771e287 | e4041b36b97c1d3ac335584644d906b0e8adf55c | 2026-10-18 |
| 合入前 pr-check | 1650b4fff0163e48a752ecbd86a3c4b18771e287 | 080c63081642e5670c8c7ff2646905f5b398c807 | 2026-10-19(PR_CHECK_OK,落后 main 0) |
## 结果与验证
- 结果:每个冲突文件的取舍(worker 记录):api.ts 以 main 为基础,删除 U3 重复方法,redeem 改走 `/api/auth/recovery-grants/redeem`;AccountUsersPanel 保留 main 组件并补入 U3 的写后重读、刷新失败提示、游标重置、409/启停提示与凭据状态列;adminSections 补入 U3 恢复提案幂等重试、创建后重读与终态提示、迟到响应丢弃;LoginPage/AdminZone/MyAccountSettings/SettingsModal 用 main;RDM 选择器按 RDM U3 合同(mode/searchFields/docId/cursor,成员添加带 userId,职责指派/预览带 accountIds)补回。
- 验证:主机在 `72c941c`(配对 UI `5c2b103`)上 `npx tsc -b`、`npx vitest run`(48 文件 565 测试)、`npm run build`、`node scripts/check-knip.mjs` 通过,日志 `/tmp/cleanup-20261018/render/u3-fe-72c941c-verify.log`(SELF)。当前 HEAD `e4041b3` 的 CI `ci / test-and-build` 与 `governance / governance` 通过(GATE)。
- Review:全新只读子代理会话审阅 `1650b4f..72c941c`,阻塞项为 ci.yml 的 UI_SHA 仍是旧 `7c35a78`(复现 tsc 失败),已由 `afd9e75` 改为 `5c2b103` 并经 CI 证明;非阻塞记录:`accountCandidatePager` 的 `issuedFor` 共享闭包在迟到响应下可能丢游标;adminSections 任何 409 都回落为临时重置(main 既有行为)。
- 验收:按 merge_then_accept,用户在部署上验收,问题另立修复。
+419
View File
@@ -0,0 +1,419 @@
// 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)
})
})
+73 -8
View File
@@ -14,8 +14,26 @@ const BASE = ''
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
* 自身命令面 fail-closed。 */
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
export interface PermissionDescriptor { actionId: string; label: string; description: string; group: string; scope: 'system' | 'project'; protected: boolean }
/** category:角色矩阵业务分类(项目与协作/文档工作/项目内容);受保护系统授权为空。 */
export interface PermissionDescriptor { actionId: string; label: string; description: string; group: string; category?: 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[]
@@ -32,6 +50,9 @@ export interface UserRoleView { binding?: UserRoleBinding; role?: RoleRecord }
* 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
@@ -47,12 +68,34 @@ export interface AccountRecord {
enabled?: boolean; stateVersion?: number; isAdmin?: 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
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
}
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
@@ -97,13 +140,35 @@ 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)),
replaceRolePermissions: (roleId: string, expectedRoleVersion: number, changes: { actionId: string; allowed: boolean; capAllowed: boolean }[], idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}/permissions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json<{ receiptId: string; roleVersion: number; authorityEpoch: number }>(r)),
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)),
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' },
@@ -128,19 +193,19 @@ export const api = {
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[] }>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; 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 }
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/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
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 }
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
auth.beginSession()
return me
}),
+244 -25
View File
@@ -1,10 +1,19 @@
import { useEffect, useRef, useState } from 'react'
import {
api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleView,
api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleAssignmentPreview, type UserRoleView,
} from '../api'
import { useStore } from '../store'
import { Collapsible, Icon } from '@ui'
import { hasSystemAction } from './RolePermissionsPanel'
import { cellKind, hasSystemAction } from './RolePermissionsPanel'
import {
ROLE_PERMISSION_STATES, categoryActions, permissionCategories, stateToValue, valueToState,
type RolePermissionStateName, type RolePermissionValue,
} 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'
const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' })
const fields: { key: keyof AccountProfile; label: string }[] = [
@@ -12,6 +21,9 @@ const fields: { key: keyof AccountProfile; label: string }[] = [
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
]
export const credentialLabel = (account: Pick<ManagedAccountRecord, 'initializationState' | 'passwordSet'>) =>
account.initializationState === 'pending' ? '待可信 owner 初始化' : 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}>
<label className="lg-label" htmlFor={`${prefix}-${field.key}`}>{field.label}(可空)</label>
@@ -27,9 +39,20 @@ 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('')
@@ -55,16 +78,27 @@ 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 search = async (pageCursor = cursor) => {
const search = async (pageCursor = cursor, afterWrite = false) => {
const request = ++sequence.current
try {
const page = await api.searchAdminUsers({ query: query.trim(), mode, status,
fields: field === 'default' ? ['owner', 'name', 'userId'] : [field], cursor: pageCursor, limit: 20 })
if (request !== sequence.current) return
setAccounts(page.accounts ?? []); setNextCursor(page.nextCursor ?? '')
} catch (error) { if (request === sequence.current) setErr(String(error)) }
setKnown(current => ({ ...current, ...Object.fromEntries((page.accounts ?? []).map(account => [account.userId, account])) }))
} catch (error) {
if (request === sequence.current) setErr(afterWrite ? `操作已完成,但用户列表暂未刷新:${String(error)}` : String(error))
}
}
const resetPaging = () => { setCursor(''); setTrail([]) }
useEffect(() => {
const timer = window.setTimeout(() => { void search() }, 180)
@@ -76,6 +110,7 @@ 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 }
@@ -90,6 +125,13 @@ 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([])
@@ -99,7 +141,7 @@ export function AccountUsersPanel() {
}
useEffect(() => {
if (!selected || !canViewRoles) { setRoles([]); setRoleLoading(false); return }
if ((!selected && !batchOpen) || !canViewRoles) { setRoles([]); setRoleLoading(false); return }
const request = ++roleSequence.current
setRoleLoading(true); setNextRoleCursor('')
const timer = window.setTimeout(() => {
@@ -111,11 +153,12 @@ export function AccountUsersPanel() {
.catch(error => { if (request === roleSequence.current) { setErr(String(error)); setRoleLoading(false) } })
}, 180)
return () => { window.clearTimeout(timer) }
}, [selected?.userId, roleQuery, roleCursor, canViewRoles])
}, [selected?.userId, batchOpen, roleQuery, roleCursor, canViewRoles])
// 写入成功会推进目录版本,旧页游标随之失效:从第一页重读,刷新失败不改写成功结果。
const refreshFirst = async () => {
setCursor(''); setTrail([]); setNextCursor('')
await search('')
resetPaging(); setNextCursor('')
await search('', true)
}
const create = async () => {
@@ -124,8 +167,9 @@ export function AccountUsersPanel() {
try {
const account = await api.createManagedUser(newOwner.trim(), newProfile)
setNewOwner(''); setNewProfile(emptyProfile())
await refreshFirst(); await open(account)
await open(account)
setNotice(`已创建 ${account.owner};账号资料可空,角色另行分配`)
await refreshFirst()
} catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) }
finally { setBusy(false) }
}
@@ -168,10 +212,162 @@ export function AccountUsersPanel() {
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.adminSetUserEnabled(selected.userId, selected.stateVersion, !selected.enabled)
setSelected(updated); await refreshFirst(); setNotice(updated.enabled ? '账号已启用' : '账号已停用')
} catch (error) { setErr(String(error)) } finally { setBusy(false) }
setSelected(updated); setNotice(updated.enabled ? '账号已启用;旧会话和 Key 不会恢复' : '账号已停用,旧会话、Key 与进行中的恢复提案已撤销')
await refreshFirst()
} catch (error) { setErr(String(error).includes('409') ? '状态版本或最后管理员保护发生冲突,请重新打开用户' : String(error)) }
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)
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)) }
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>
if (!allowed('system.users.view')) return <p className="ad-hint">当前账号没有查看用户的系统权限</p>
return <div>
{allowed('system.users.create') && <div className="ad-create">
@@ -186,7 +382,8 @@ export function AccountUsersPanel() {
{selected ? <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>
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
<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="编辑可选资料">
<ProfileInputs profile={draft} onChange={setDraft} prefix="user-profile" />
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveProfile}>保存资料</button>
@@ -197,12 +394,12 @@ export function AccountUsersPanel() {
</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>}
</div>
{canAssignRoles && <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>}
{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>}
{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
@@ -213,19 +410,41 @@ export function AccountUsersPanel() {
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
</div> : <>
<div className="ad-row rp-filters">
<input className="lg-input" aria-label="搜索用户" placeholder="搜索用户资料" value={query} onChange={event => { setQuery(event.target.value); setCursor(''); setTrail([]) }} />
<select className="lg-input" aria-label="用户匹配方式" value={mode} onChange={event => setMode(event.target.value as typeof mode)}><option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option></select>
<select className="lg-input" aria-label="用户搜索字段" value={field} onChange={event => setField(event.target.value)}>
<input className="lg-input" aria-label="搜索用户" placeholder="搜索用户资料" value={query} onChange={event => { setQuery(event.target.value); resetPaging() }} />
<select className="lg-input" aria-label="用户匹配方式" value={mode} onChange={event => { setMode(event.target.value as typeof mode); resetPaging() }}><option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option></select>
<select className="lg-input" aria-label="用户搜索字段" value={field} onChange={event => { setField(event.target.value); resetPaging() }}>
<option value="default">账号/姓名/ID</option><option value="owner">登录名</option><option value="name">姓名</option><option value="userId">userId</option>
<option value="phone">电话</option><option value="email">邮箱</option><option value="staffNo">工号</option><option value="department">部门</option><option value="notes">备注</option>
</select>
<select className="lg-input" aria-label="用户状态" value={status} onChange={event => setStatus(event.target.value as typeof status)}><option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option></select>
<select className="lg-input" aria-label="用户状态" value={status} onChange={event => { setStatus(event.target.value as typeof status); resetPaging() }}><option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option></select>
</div>
<table className="ad-table"><thead><tr><th>登录名</th><th>姓名</th><th>工号</th><th>状态</th><th></th></tr></thead>
<tbody>{(accounts ?? []).map(account => <tr key={account.userId}><td className="ad-mono">{account.owner}</td><td>{account.profile.name || '—'}</td>
<td>{account.profile.staffNo || '—'}</td><td>{account.enabled ? '有效' : '停用'}</td><td><button className="ad-mini" onClick={() => open(account)}>查看与管理</button></td></tr>)}</tbody></table>
{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>
<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>
<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>
}
+2 -2
View File
@@ -166,9 +166,9 @@ export function humanLoginError(raw: string): string {
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
export function humanRedeemError(raw: string): string {
if (raw.includes('409')) return '该 Key 已被使用过,请联系管理员重新签发'
if (raw.includes('409')) return '该能力已被使用过;请由本人核对提案状态,或联系可信 owner 续办初始化'
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
if (raw.includes('401')) return 'Key 无效或已过期,请联系管理员重新签发'
if (raw.includes('401')) return '能力无效或已过期;请核对本人提案状态或联系可信 owner 续办初始化'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
+504 -151
View File
@@ -1,8 +1,18 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { api, type PermissionDescriptor, type RoleRecord } from '../api'
import {
createRolePermissionDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
type RolePermissionDraft, type RolePermissionValue,
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,
} from '../rolePermissions'
import { Collapsible, Icon } from '@ui'
import { useStore } from '../store'
@@ -10,6 +20,14 @@ 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[] {
@@ -23,11 +41,6 @@ export function filterRoles(
})
}
export function groupPermissionPage(catalog: PermissionDescriptor[], group: string, page: number, size: number): PermissionDescriptor[] {
const grouped = catalog.filter(item => !group || item.group === group)
return grouped.slice(page * size, page * size + size)
}
export function shouldAcceptRoleResponse(activeSequence: number, responseSequence: number): boolean {
return activeSequence === responseSequence
}
@@ -36,6 +49,16 @@ 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 => {
@@ -49,49 +72,315 @@ function roleValue(role: RoleRecord, actionId: string): RolePermissionValue {
return { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }
}
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
}) {
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>
</div>
</Collapsible>}
</section>
}
export function RolePermissionsPanel() {
const me = useStore(state => state.me)
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
const canManageRoles = allowed('system.roles.manage')
const canViewPermissions = allowed('system.permissions.view')
const canManagePermissions = canViewPermissions && allowed('system.permissions.manage')
const 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<Record<string, RolePermissionDraft>>({})
const [drafts, setDrafts] = useState<RoleDraftMap>({})
const [confirmDisable, setConfirmDisable] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [query, setQuery] = useState('')
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
const [group, setGroup] = useState('')
const [category, setCategory] = useState('')
const [actionFilter, setActionFilter] = useState('')
const [roleCursor, setRoleCursor] = useState('')
const [roleTrail, setRoleTrail] = useState<string[]>([])
const [nextRoleCursor, setNextRoleCursor] = useState('')
const [page, setPage] = useState(0)
const [view, setView] = useState<'single' | 'matrix'>('single')
const [view, setView] = useState<'single' | 'matrix'>(() => initialRoleView(
typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? media => window.matchMedia(media).matches : undefined))
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 dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0
const groups = useMemo(() => [...new Set(catalog.map(item => item.group))], [catalog])
const filtered = roles ?? []
const pageItems = useMemo(() => groupPermissionPage(catalog, group, page, 6), [catalog, group, page])
const editablePageItems = pageItems.filter(item => !item.protected)
const 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 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 load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
filters = { query, mode, status, action: actionFilter }) => {
@@ -107,6 +396,7 @@ 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
@@ -114,34 +404,33 @@ 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, descriptors, sequence)
if (nextSelected) selectRole(nextSelected, sequence)
else setSelectedId('')
// DR10:目录失败是部分失败态,需在角色选中(selectRole 会清空提示)之后仍可见。
if (catalogResult.status === 'rejected') setErr(prev => prev || `权限目录加载失败:${catalogResult.reason}`)
}
const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => {
const selectRole = (role: RoleRecord, sequence?: number) => {
const nextSequence = sequence ?? ++requestSequence.current
if (!shouldAcceptRoleResponse(requestSequence.current, nextSequence)) return
setSelectedId(role.roleId)
setName(role.name)
setDescription(role.description)
// DR9:草稿按 roleId 隔离保存;翻页、过滤、视图切换回到该角色时保留,
// 切换角色永不转移他人草稿。仅首次选中时以服务端值为基线。
setDrafts(prev => prev[role.roleId] ? prev
: { ...prev, [role.roleId]: createRolePermissionDraft(roleValues(role, descriptors), nextSequence) })
setNotice('')
setErr('')
}
// DR9:离开管理区须显式决定;带未保存草稿时刷新走浏览器原生确认。
// DR9:离开管理区须显式决定(覆盖所有脏角色);带未保存草稿时刷新走浏览器原生确认。
const dirtyKey = dirtyNames.join('\u0000')
useEffect(() => {
if (!dirty || !selected) { roleDraftLeaveGuard.current = null; return }
const message = `角色「${selected.name}」有未保存的权限修改,离开将放弃这些修改。确定离开吗?`
if (!dirtyNames.length) { roleDraftLeaveGuard.current = null; return }
const message = roleLeaveMessage(dirtyNames)
roleDraftLeaveGuard.current = () => window.confirm(message)
return () => { roleDraftLeaveGuard.current = null }
}, [dirty, selected?.roleId, selected?.name])
}, [dirtyKey])
const dirty = dirtyList.length > 0
useEffect(() => {
if (!dirty) return
const handler = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
@@ -155,6 +444,13 @@ 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)
@@ -164,7 +460,7 @@ export function RolePermissionsPanel() {
setNewName(''); setNewDescription('')
setQuery(''); setStatus('all'); setActionFilter(''); setRoleCursor(''); setRoleTrail([])
setRoles(current => [role, ...(current ?? []).filter(item => item.roleId !== role.roleId)])
selectRole(role, catalog, ++requestSequence.current)
selectRole(role, ++requestSequence.current)
setNotice(`已创建角色 ${role.name}`)
await load('', true, role.roleId, { query: '', mode, status: 'all', action: '' })
setNotice(`已创建角色 ${role.name}`)
@@ -178,15 +474,15 @@ export function RolePermissionsPanel() {
setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
const updated = await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
setDrafts(prev => adoptRoleSnapshot(prev, selected.roleVersion, updated))
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 => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
setRoles(current => mergeRoles(current, [fresh]))
setErr('角色资料版本冲突;已读取最新版本并保留本地名称与描述,请核对后重试')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
@@ -201,105 +497,190 @@ export function RolePermissionsPanel() {
setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
const updated = await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
setDrafts(prev => adoptRoleSnapshot(prev, selected.roleVersion, updated))
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 => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
setRoles(current => mergeRoles(current, [fresh]))
setErr('角色状态版本冲突;已刷新当前状态,请重新确认')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
} finally { setBusy(false) }
}
const rebasePermissionConflict = async (roleId: string, currentDraft: RolePermissionDraft, operationSequence: number) => {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === roleId)
? current.map(role => role.roleId === roleId ? fresh : role) : [fresh, ...(current ?? [])])
// DR9:409 只重放同一角色的草稿;基于最新服务端值回放本地相对基线的变化。
setDrafts(prev => ({ ...prev, [roleId]: rebaseRoleDraft(currentDraft, roleValues(fresh, catalog)) }))
setErr('权限版本冲突;已基于最新版本重放本地草稿,请核对后再次保存')
const editCell = (role: RoleRecord, actionId: string, value: RolePermissionValue) => {
setDrafts(prev => editRoleDraft(prev, role, catalog, actionId, value))
setPreview(null)
}
const savePermissions = async () => {
if (!selected || !draft) return
const roleId = selected.roleId
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 operationSequence = ++requestSequence.current
setRoleLoading(false)
const invalid = validateRolePermissionDraft(draft)
if (invalid) { setErr(`${invalid.actionId} 的默认权限不能超过角色上限`); return }
const full = Object.entries(draft.values).map(([actionId, value]) => ({ actionId, ...value }))
setBusy(true); setErr(''); setNotice('')
try {
await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID())
setDrafts(current => {
if (!(roleId in current)) return current
const next = { ...current }; delete next[roleId]; return next
})
await load(roleCursor, true, selected.roleId); setNotice('角色权限已保存')
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} 个角色的权限设置`)
} catch (error) {
if (String(error).includes('409')) {
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
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)
// DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 掉回 body,则归还。
requestAnimationFrame(() => {
if (savePermsRef.current && !savePermsRef.current.disabled && document.activeElement === document.body) savePermsRef.current.focus()
})
}
} finally { setBusy(false); restoreFocus() }
}
const editValue = (actionId: string, field: 'allowed' | 'capAllowed', checked: boolean) => {
const current = drafts[selectedId]
if (!current) return
const value0 = current.values[actionId] ?? { allowed: false, capAllowed: false }
const value = field === 'allowed'
? { allowed: checked, capAllowed: checked ? true : value0.capAllowed }
: { allowed: checked ? value0.allowed : false, capAllowed: checked }
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: updateRolePermission(prev[selectedId], actionId, value) } : prev)
const cancelAll = () => {
if (!window.confirm(`确定放弃 ${dirtyNames.map(name => `「${name}」`).join('、')} 的全部未保存权限修改吗?`)) return
setDrafts({}); setPreview(null); setErr(''); setNotice('已取消全部未保存的权限修改')
}
const editVisible = (field: 'allowed' | 'capAllowed', checked: boolean) => {
const start = drafts[selectedId]
if (!start) return
let next = start
for (const item of editablePageItems) {
const current = next.values[item.actionId] ?? { allowed: false, capAllowed: false }
const value = field === 'allowed'
? { allowed: checked, capAllowed: checked ? true : current.capAllowed }
: { allowed: checked ? current.allowed : false, capAllowed: checked }
next = updateRolePermission(next, item.actionId, value)
}
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: next } : prev)
const 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">
const matrix = () => <div className="rp-matrix-wrap" role="region" aria-label={`角色矩阵 · ${activeCategory}`} tabIndex={0}>
<table className="ad-table rp-matrix">
<thead><tr><th>角色</th>{pageItems.map(item => <th key={item.actionId}>{item.label}</th>)}</tr></thead>
<thead><tr><th 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' : ''}>
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
{pageItems.map(item => {
const value = role.roleId === selectedId && draft ? draft.values[item.actionId] : roleValue(role, item.actionId)
return <td key={item.actionId}>
{item.protected ? <span className="ad-dim">系统授权</span> : <>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 默认允许`} checked={value?.allowed ?? false}
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} />默认</label>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 角色上限`} checked={value?.capAllowed ?? false}
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} />上限</label>
</>}
</td>
})}
<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>
</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">
@@ -317,9 +698,6 @@ export function RolePermissionsPanel() {
<select className="lg-input" aria-label="角色状态" value={status} disabled={busy} onChange={event => { setStatus(event.target.value as typeof status); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="all">全部状态</option><option value="enabled">启用</option><option value="disabled">停用</option>
</select>
<select className="lg-input" aria-label="权限分组" value={group} onChange={event => { setGroup(event.target.value); setPage(0) }}>
<option value="">全部权限</option>{groups.map(value => <option key={value}>{value}</option>)}
</select>
{canViewPermissions && <select className="lg-input" aria-label="按权限过滤角色" value={actionFilter} disabled={busy}
onChange={event => { setActionFilter(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="">全部角色权限</option>{catalog.filter(item => !item.protected).map(item => <option key={item.actionId} value={item.actionId}>{item.label}</option>)}
@@ -327,60 +705,35 @@ export function RolePermissionsPanel() {
</div>
</Collapsible>
<div className="ad-row rp-view-toggle">
<button className={`ad-mini ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</button>
{canViewPermissions && <button className={`ad-mini ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</button>}
<button className="ad-mini" disabled={page === 0} onClick={() => setPage(value => value - 1)}>上一组</button>
<button className="ad-mini" disabled={(page + 1) * 6 >= catalog.filter(item => !group || item.group === group).length} onClick={() => setPage(value => value + 1)}>下一组</button>
{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" disabled={busy || roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={busy || roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
{view === 'matrix' && canManagePermissions && <>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', true)}>当前页默认全选</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', false)}>当前页默认清除</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', true)}>当前页上限全开</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', false)}>当前页上限关闭</button>
</>}
</div>
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
{view === 'matrix' ? matrix() : <div className="rp-single">
<aside className="rp-role-list">
{(filtered).map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
<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 ? <><table className="ad-table"><thead><tr><th>权限</th><th>默认允许</th><th>角色上限</th><th>说明</th></tr></thead>
<tbody>{pageItems.map(item => {
const value = draft.values[item.actionId] ?? { allowed: false, capAllowed: false }
return <tr key={item.actionId}><td>{item.label}<div className="ad-dim ad-mono">{item.actionId}</div></td>
<td><input type="checkbox" aria-label={`${item.label} 默认允许`} checked={value.allowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} /></td>
<td><input type="checkbox" aria-label={`${item.label} 角色上限`} checked={value.capAllowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} /></td>
<td className="ad-dim">{item.protected ? '受保护系统授权,不能由普通角色授予' : item.description}</td></tr>
})}</tbody></table>
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
{changes.length === 0 ? <p className="ad-hint">没有权限变化</p> : <ul className="rp-diff">{changes.map(change =>
<li key={change.actionId}><span className="ad-mono">{change.actionId}</span>:默认 {change.allowed ? '允许' : '拒绝'},上限 {change.capAllowed ? '允许' : '拒绝'}</li>)}</ul>}
</Collapsible>
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || changes.length === 0 || editablePageItems.length === 0} onClick={savePermissions}>保存权限</button>}
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
</> : <p className="ad-hint">请选择角色</p>}
</section>
{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>}
</div>}
</div>
}
+44 -19
View File
@@ -2,7 +2,7 @@
// 门控集中在 src/rdmManagement.ts;分区可见性由 ProjectSettingsModal 按 store 的
// rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
import { useEffect, useState, useRef, useCallback } from 'react'
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { api } from '../api'
import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta'
@@ -12,12 +12,14 @@ import {
memberAddPayload, memberRemovePayload, memberSetRolePayload,
permCheckPayload, describeSource,
} from '../rdmManagement'
import { dutyConditionNote, linkedCapabilitySummary } from '../permissionExplanations'
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
import {
type MemberMatchMode, type PersonPickItem, type RdmAccount, type RdmMemberPage, type RdmProfRole,
MATCH_MODE_LABELS, accountListParams, accountsToPickItems, appendMemberPage, memberListParams,
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'
@@ -46,6 +48,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
const [pickedItems, setPickedItems] = useState<PersonPickItem[]>([])
const [candRoleId, setCandRoleId] = useState('')
const [candMatch, setCandMatch] = useState<MemberMatchMode>('fuzzy')
const [candFields, setCandFields] = useState<SearchFieldsChoice>('both')
const [role, setRole] = useState<'member' | 'owner'>('member')
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
const rowsRef = useRef<MemberRow[] | null>(null)
@@ -56,7 +59,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
useEffect(() => {
setPicked([]); setPickedItems([]); knownPicks.current.clear()
setCandRoleId(''); setCandMatch('fuzzy'); setMemberQuery(''); setMemberRoleId('')
setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('')
setRoleOptions(null)
let live = true
cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId))
@@ -65,14 +68,19 @@ export function RdmMembersSection({ project }: { project: Project }) {
return () => { live = false }
}, [project])
/** 人员选择器取数适配:rdm.account.list 服务端按 query/roleId/match 先过滤后分页。 */
const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => {
const out = await cmd<{ accounts?: RdmAccount[]; next?: string }>('rdm.account.list',
accountListParams(projectId, { query, cursor, limit, roleId: candRoleId, match: candMatch }))
const items = accountsToPickItems(out.accounts, (rowsRef.current ?? []).map(m => m.owner))
for (const it of items) knownPicks.current.set(it.id, it)
return { items, nextCursor: out.next || undefined }
}, [project, candRoleId, candMatch])
const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
[candMatch, candFields, candRoleId])
/** 人员选择器取数适配:rdm.account.list 服务端按 query/mode/searchFields/roleId 先过滤后分页。 */
const fetchPage = useMemo(() => {
const page = accountCandidatePager(params => cmd<RdmAccountPage>('rdm.account.list', params), projectId,
{ ...candOptions, joinedOwners: () => (rowsRef.current ?? []).map(m => m.owner) })
return async (query: string, cursor: string, limit: number) => {
const out = await page(query, cursor, limit)
for (const it of out.items) knownPicks.current.set(it.id, it)
return out
}
}, [project, candOptions])
/** cursor 空=重载首页(clear 时先清空表格);非空=追加下一页。 */
const loadMembers = useCallback(async (cursor: string, clear: boolean) => {
@@ -120,7 +128,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
const pickedId = picked[0] ?? ''
const pickedItem = pickedItems.find(it => it.id === pickedId)
const pickedValid = !!pickedId && !pickedItem?.disabled && !(rows ?? []).some(m => m.owner === pickedId)
const pickedValid = !!pickedItem && !pickedItem.disabled && !(rows ?? []).some(m => m.owner === pickedItem.owner)
const pickerDisabled = busy || !!writeError
const filtering = memberQuery.trim() !== '' || memberRoleId !== ''
@@ -143,6 +151,11 @@ export function RdmMembersSection({ project }: { project: Project }) {
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m =>
<option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
</select>
<select className="lg-input" aria-label="检索字段" value={candFields}
onChange={e => setCandFields(e.target.value as SearchFieldsChoice)}>
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f =>
<option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
</select>
</div>
<div className="ad-row">
<Picker
@@ -151,21 +164,22 @@ export function RdmMembersSection({ project }: { project: Project }) {
onSelect={onPick}
selectedItems={pickedItems}
fetchPage={fetchPage}
reloadKey={`${projectId}|${candRoleId}|${candMatch}`}
reloadKey={accountSearchKey(projectId, candOptions)}
disabled={pickerDisabled}
placeholder="输入账号或姓名,或浏览候选…"
placeholder={candMatch === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
emptyText="输入后显示匹配账号"
/>
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">项目成员</option>
<option value="owner">项目负责人</option>
</select>
<button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid}
onClick={() => run(`已添加 ${pickedItem ? personLabel({ owner: pickedId, name: pickedItem.title }) : pickedId}`, async () => {
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedId, role))
onClick={() => pickedItem && run(`已添加 ${personLabel({ owner: pickedItem.owner, name: pickedItem.title })}`, async () => {
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedItem.owner, role, pickedItem.id))
setPicked([]); setPickedItems([])
})}>添加成员</button>
</div>
<p className="ad-hint">候选来自系统账号目录(姓名为空时显示账号),无效账号不提供;已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
</div>
<div className="ad-row">
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
@@ -256,7 +270,10 @@ 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 || '—'}</td>
<td className="ad-dim">
{a.reason || '—'}
{dutyConditionNote(a.action) && <span className="ad-dim ad-cond-note">{dutyConditionNote(a.action)}</span>}
</td>
{canManage ? (
<td>
<select className="lg-input" value={overrideOf(a)} style={{ minWidth: 120 }}
@@ -347,6 +364,9 @@ 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>
@@ -372,6 +392,11 @@ 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></>}
+54 -11
View File
@@ -1,7 +1,7 @@
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { api, type RecoveryProposal } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile } from '../contracts'
@@ -9,7 +9,10 @@ import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocB
import { SettingInput, flatten, unflatten } from './SettingsModal'
import type { SettingDesc } from '../contracts'
import { docBridge } from './Workbench'
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery'
import {
recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected,
recoveryStatusLabel, type RecoveryAttempt,
} from '../recovery'
// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码;
// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ----
@@ -29,11 +32,20 @@ export function UsersPanel() {
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) => {
try { setProposals(await api.listAdminRecoveryProposals(userId)) } catch (e) { setErr(String(e)) }
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() }, [])
@@ -60,32 +72,63 @@ export function UsersPanel() {
}
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 {
await api.createAdminRecoveryProposal(propFor.userId, propFor.stateVersion, crypto.randomUUID())
setNotice(`已为 ${propFor.owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现`)
await refreshProposals(propFor.userId)
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) {
if (String(e).includes('409')) {
await resetPassword(propFor)
const message = String(e)
if (message.includes('409')) {
recoveryAttempt.current = null
await resetPassword(target)
return
}
setErr(recoveryProposalErrorMessage(String(e)))
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(''); setBusy(true)
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) }
}
@@ -114,7 +157,7 @@ export function UsersPanel() {
<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={() => { setPropFor(null); setProposals(null) }}>关闭</button>
<button type="button" className="ad-mini" onClick={closeProposals}>关闭</button>
</div>
<p className="ad-hint">前提:该用户当前在任一设备保持完整密码登录(会话证明)。已锁死且无会话的账号无法在线恢复,请走服务器本地处置。</p>
{proposals !== null && proposals.length > 0 && (
+32 -5
View File
@@ -1,14 +1,17 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../../api'
import type { Project } from '../../contracts'
import { projectWorkError } from '../../projectmeta'
import { Btn, Collapsible, EmptyState, Icon } from '@ui'
import { dutyMatrixPayload, dutyPreviewPayload, dutyLegacyPayload } from '../../rdmManagement'
import {
SEARCH_FIELDS, accountCandidatePager, accountSearchKey, dutyMatrixPayload, dutyPreviewPayload, dutyLegacyPayload,
type DutyAccountIds, type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type SearchFieldsChoice,
} from '../../rdmManagement'
import {
absorbServerDocs, dirtyDocs, editCell, savePayload,
type CellDuty, type DutyDocDraft, type DutyMember,
} from '../../duties/draft'
import { DutyByDocumentView, DutyByMemberView, DutyChangePreview, DutyMatrixView } from '../../duties/views'
import { DutyByDocumentView, DutyByMemberView, DutyCandidateSearch, DutyChangePreview, DutyMatrixView } from '../../duties/views'
type ViewName = 'member' | 'document' | 'matrix'
@@ -68,6 +71,9 @@ export function RdmDutiesSection({ project }: { project: Project }) {
const [confirm, setConfirm] = useState(false)
const [legacy, setLegacy] = useState<LegacyOut['findings']>([])
const [legacyOpen, setLegacyOpen] = useState(false)
const [candMode, setCandMode] = useState<MemberMatchMode>('fuzzy')
const [candFields, setCandFields] = useState<SearchFieldsChoice>('both')
const [accountIds, setAccountIds] = useState<DutyAccountIds>({})
const seq = useRef(0)
const op = useRef(0)
const docsRef = useRef(docs)
@@ -117,12 +123,25 @@ export function RdmDutiesSection({ project }: { project: Project }) {
return () => { seq.current += 1 }
}, [project, query, stage, typeKey, assignment, docCursor])
useEffect(() => { setAccountIds({}) }, [project])
const candOptions = useMemo(() => ({ docId, mode: candMode, searchFields: SEARCH_FIELDS[candFields].fields }),
[docId, candMode, candFields])
const fetchCandidates = useMemo(() => accountCandidatePager(
params => api.rdmProjectCommand<RdmAccountPage>(project, 'rdm.account.list', params), projectId, candOptions,
), [project, candOptions])
const onCell = (id: string, owner: string, duty: CellDuty) => {
setPreview([])
setConfirm(false)
setDocs(current => editCell(current, id, owner, duty))
}
const onCandidate = (id: string, candidate: PersonPickItem, duty: Exclude<CellDuty, 'none'>) => {
setAccountIds(current => ({ ...current, [candidate.owner]: candidate.id }))
onCell(id, candidate.owner, duty)
}
const visibleDocs = docs.filter(doc => {
const text = `${doc.title} ${doc.code} ${doc.docId}`.toLowerCase()
if (query && !text.includes(query.trim().toLowerCase())) return false
@@ -132,7 +151,7 @@ export function RdmDutiesSection({ project }: { project: Project }) {
})
const save = async () => {
const payload = savePayload(docsRef.current, confirm)
const payload = savePayload(docsRef.current, confirm, accountIds)
if (payload.issues.length > 0) {
setErr(payload.issues.join(';'))
return
@@ -154,7 +173,7 @@ export function RdmDutiesSection({ project }: { project: Project }) {
return
}
sent = true
await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm).docs))
await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm, accountIds).docs))
if (request !== seq.current) return
setNotice('已保存文档职责')
setPreview([])
@@ -228,6 +247,14 @@ export function RdmDutiesSection({ project }: { project: Project }) {
</div>
{view === 'member' && <DutyByMemberView member={member} members={members} docs={visibleDocs} disabled={disabled} onMember={setMember} onCell={onCell} />}
{view === 'document' && <DutyByDocumentView docId={docId} docs={visibleDocs} members={members} disabled={disabled} onDoc={setDocId} onCell={onCell} />}
{view === 'document' && (() => {
const doc = docs.find(item => item.docId === docId)
return doc ? (
<DutyCandidateSearch doc={doc} mode={candMode} fields={candFields} fetchPage={fetchCandidates}
reloadKey={accountSearchKey(projectId, candOptions)} disabled={disabled}
onMode={setCandMode} onFields={setCandFields} onAssign={(candidate, duty) => onCandidate(doc.docId, candidate, duty)} />
) : null
})()}
{view === 'matrix' && <DutyMatrixView docs={visibleDocs} members={members} disabled={disabled} narrow={narrow} onCell={onCell} />}
<div className="ad-row">
{docCursor && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor('')}>文档首页</button>}
+19 -9
View File
@@ -1,4 +1,6 @@
// 文档职责草稿的唯一存储。三个视图只投影这份数据;单元格值由三权反推。
import { dutyAccountIds, type DutyAccountIds } from '../rdmManagement'
export type CellDuty = 'none' | 'author' | 'reviewer' | 'approver'
interface DutyRoles {
@@ -35,6 +37,7 @@ interface DutySaveItem {
approver: string
expectedDutyVersion: number
confirmInvalidation: boolean
accountIds?: DutyAccountIds
}
export function cellDuty(roles: DutyRoles, owner: string): CellDuty {
@@ -174,7 +177,10 @@ export function dirtyDocs(docs: DutyDocDraft[]): DutyDocDraft[] {
return docs.filter(doc => doc.dirty)
}
export function savePayload(docs: DutyDocDraft[], confirmInvalidation: boolean): { docs: DutySaveItem[]; issues: string[] } {
/** accountIds:候选检索选中的 owner → userId;每份文档只携带其职责涉及的人。 */
export function savePayload(
docs: DutyDocDraft[], confirmInvalidation: boolean, accountIds?: DutyAccountIds,
): { docs: DutySaveItem[]; issues: string[] } {
const dirty = dirtyDocs(docs)
const issues: string[] = []
if (dirty.length === 0) issues.push('没有未保存的职责')
@@ -183,14 +189,18 @@ export function savePayload(docs: DutyDocDraft[], confirmInvalidation: boolean):
for (const issue of saveIssues(doc)) issues.push(`${doc.title}:${issue}`)
}
return {
docs: dirty.slice(0, 20).map(doc => ({
docId: doc.docId,
author: doc.author,
reviewers: doc.reviewers,
approver: doc.approver,
expectedDutyVersion: doc.dutyVersion,
confirmInvalidation: confirmInvalidation && doc.activeRound > 0,
})),
docs: dirty.slice(0, 20).map(doc => {
const ids = dutyAccountIds(doc, accountIds)
return {
docId: doc.docId,
author: doc.author,
reviewers: doc.reviewers,
approver: doc.approver,
expectedDutyVersion: doc.dutyVersion,
confirmInvalidation: confirmInvalidation && doc.activeRound > 0,
...(ids ? { accountIds: ids } : {}),
}
}),
issues,
}
}
+67
View File
@@ -1,5 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Picker } from '@ui'
import type { CellDuty, DutyDocDraft, DutyMember } from './draft'
import { cellDuty, completeness, replacementNote, saveIssues } from './draft'
import {
MATCH_MODE_LABELS, SEARCH_FIELDS,
type MemberMatchMode, type PersonPickItem, type SearchFieldsChoice,
} from '../rdmManagement'
const DUTY_OPTIONS: { value: CellDuty; label: string }[] = [
{ value: 'none', label: '无职责' },
@@ -120,6 +126,67 @@ export function DutyByDocumentView({
)
}
type AssignedDuty = Exclude<CellDuty, 'none'>
/** 文档职责候选检索:rdm.account.list 携带 docId,只有当前有效成员可选;选中后按 userId 指派。 */
export function DutyCandidateSearch({
doc, mode, fields, fetchPage, reloadKey, disabled, onMode, onFields, onAssign,
}: {
doc: DutyDocDraft
mode: MemberMatchMode
fields: SearchFieldsChoice
fetchPage: (query: string, cursor: string, limit: number) => Promise<{ items: PersonPickItem[]; nextCursor?: string }>
reloadKey: string
disabled: boolean
onMode: (mode: MemberMatchMode) => void
onFields: (fields: SearchFieldsChoice) => void
onAssign: (candidate: PersonPickItem, duty: AssignedDuty) => void
}) {
const [picked, setPicked] = useState<PersonPickItem | null>(null)
const [duty, setDuty] = useState<AssignedDuty>('reviewer')
const known = useRef(new Map<string, PersonPickItem>())
useEffect(() => { known.current.clear(); setPicked(null) }, [reloadKey])
const fetchKnown = useCallback(async (query: string, cursor: string, limit: number) => {
const page = await fetchPage(query, cursor, limit)
for (const item of page.items) known.current.set(item.id, item)
return page
}, [fetchPage])
return (
<div className="settings-group">
<h4>搜索候选人</h4>
<div className="ad-row">
<select className="lg-input" aria-label="候选匹配方式" value={mode} onChange={event => onMode(event.target.value as MemberMatchMode)}>
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m => <option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
</select>
<select className="lg-input" aria-label="候选检索字段" value={fields} onChange={event => onFields(event.target.value as SearchFieldsChoice)}>
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f => <option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
</select>
</div>
<div className="ad-row">
<Picker
aria-label="职责候选人"
selected={picked ? [picked.id] : []}
selectedItems={picked ? [picked] : []}
onSelect={ids => setPicked(known.current.get(ids[0] ?? '') ?? null)}
fetchPage={fetchKnown}
reloadKey={reloadKey}
disabled={disabled}
placeholder={mode === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
emptyText="输入后显示当前有效成员"
/>
<select className="lg-input" aria-label="候选人职责" value={duty} onChange={event => setDuty(event.target.value as AssignedDuty)}>
{DUTY_OPTIONS.filter(option => option.value !== 'none').map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<button className="ad-mini" disabled={disabled || !picked || picked.disabled}
onClick={() => { if (picked) { onAssign(picked, duty); setPicked(null) } }}>
{picked ? `指派到 ${doc.title}` : '指派'}
</button>
</div>
{picked && <p className="ad-hint">{picked.title} · {picked.owner}{replacementNote(doc, picked.owner, duty) ? `(${replacementNote(doc, picked.owner, duty)})` : ''}</p>}
</div>
)
}
export function DutyMatrixView({
docs, members, disabled, narrow, onCell,
}: {
+48
View File
@@ -0,0 +1,48 @@
// 项目有效权限说明的纯文案层:只消费服务端字段(/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] ?? ''
}
+106 -22
View File
@@ -102,19 +102,39 @@ export const roleActionPayload = (roleId: string, action: string, def: boolean,
export const roleAssignPayload = (owner: string, roleId: string) => ({ owner, roleId })
export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ roleId, disabled })
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member') =>
({ projectId, member, role })
/** userId 为候选检索选中的稳定账号;服务端按 member 精确查找后比对,不一致即拒。 */
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member', userId?: string) =>
({ projectId, member, role, ...(userId ? { userId } : {}) })
export const memberRemovePayload = (projectId: string, member: string) => ({ projectId, member })
export const memberSetRolePayload = (projectId: string, member: string, role: 'owner' | 'member') => ({ projectId, member, role })
/** 职责账号身份:owner → 候选检索选中的 userId。 */
export type DutyAccountIds = Record<string, string>
/** 只保留本条职责实际涉及的人;为空不携带。 */
export function dutyAccountIds(
roles: { author: string; reviewers: string[]; approver: string }, known: DutyAccountIds | undefined,
): DutyAccountIds | undefined {
if (!known) return undefined
const out: DutyAccountIds = {}
for (const owner of [roles.author, ...roles.reviewers, roles.approver]) {
if (owner && known[owner]) out[owner] = known[owner]
}
return Object.keys(out).length > 0 ? out : undefined
}
export const assignDocDutiesPayload = (
projectId: string, docId: string, author: string, reviewers: string[], approver: string,
extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean },
) => ({
projectId, docId, author, reviewers, approver,
...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}),
...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}),
})
extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean; accountIds?: DutyAccountIds },
) => {
const accountIds = dutyAccountIds({ author, reviewers, approver }, extra?.accountIds)
return {
projectId, docId, author, reviewers, approver,
...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}),
...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}),
...(accountIds ? { accountIds } : {}),
}
}
export const dutyMatrixPayload = (
projectId: string,
@@ -136,7 +156,10 @@ export const dutyMatrixPayload = (
export const dutyPreviewPayload = (
projectId: string,
docs: { docId: string; author: string; reviewers: string[]; approver: string; expectedDutyVersion: number; confirmInvalidation: boolean }[],
docs: {
docId: string; author: string; reviewers: string[]; approver: string
expectedDutyVersion: number; confirmInvalidation: boolean; accountIds?: DutyAccountIds
}[],
) => ({ projectId, docs })
export const dutyLegacyPayload = (projectId: string) => ({ projectId })
@@ -172,12 +195,16 @@ export const commandRequestBody = (params: Record<string, unknown>, source?: Com
// ---- 项目成员页:候选检索 + 成员表检索(R2 契约:服务端先过滤后分页) ----
export type MemberMatchMode = 'fuzzy' | 'exact'
type AccountSearchField = 'owner' | 'name'
export interface RdmProfRole { roleId: string; name: string }
/** rdm.account.list 单条候选。 */
export interface RdmAccount {
owner: string; name?: string; valid: boolean; joined?: boolean
userId: string; owner: string; name?: string; valid: boolean; joined?: boolean
projectRole?: string; profRole?: RdmProfRole | null
}
export interface RdmAccountPage {
accounts?: RdmAccount[]; next?: string; directoryVersion?: string; count?: number
}
/** rdm.project.member.list 单条成员。 */
export interface RdmMemberRow {
owner: string; name?: string; role: string; addedBy: string; addedAt: string
@@ -186,32 +213,78 @@ export interface RdmMemberRow {
export interface RdmMemberPage {
members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean
}
/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库)。 */
/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库);id 为稳定 userId,owner 为提交用账号。 */
export interface PersonPickItem {
id: string; title: string; subtitle?: string; disabled?: boolean; disabledReason?: string
id: string; owner: string; title: string; subtitle?: string; disabled?: boolean; disabledReason?: string
}
export const MEMBER_PAGE_LIMIT = 50
const ACCOUNT_PAGE_LIMIT = 50
export const JOINED_REASON = '已加入项目'
export const INVALID_REASON = '账号无效'
export const NOT_MEMBER_REASON = '非当前项目成员'
export const MATCH_MODE_LABELS: Record<MemberMatchMode, string> = {
fuzzy: '模糊匹配',
exact: '精确账号或姓名',
}
export type SearchFieldsChoice = 'both' | 'owner' | 'name'
export const SEARCH_FIELDS: Record<SearchFieldsChoice, { label: string; fields: AccountSearchField[] }> = {
both: { label: '账号和姓名', fields: ['owner', 'name'] },
owner: { label: '仅账号', fields: ['owner'] },
name: { label: '仅姓名', fields: ['name'] },
}
export const roleOptionsPayload = (projectId: string) => ({ projectId, purpose: 'members' as const })
/** 空串筛选不携带(缺省=不过滤);match 恒携带,服务端据此选择匹配语义。 */
export function accountListParams(projectId: string, opts: {
query?: string; cursor?: string; limit: number; roleId?: string; match?: MemberMatchMode
interface AccountSearchOptions {
mode?: MemberMatchMode
searchFields?: AccountSearchField[]
roleId?: string
/** 携带即按该文档的职责候选检索(仅当前有效成员)。 */
docId?: string
}
/** 只用 mode(match 是服务端旧别名,两者并存且不一致会被拒);空串筛选不携带。 */
export function accountListParams(projectId: string, opts: AccountSearchOptions & {
query?: string; cursor?: string; limit: number
}) {
const query = opts.query?.trim() ?? ''
return {
projectId,
limit: opts.limit,
match: opts.match ?? 'fuzzy',
limit: Math.min(Math.max(1, opts.limit), ACCOUNT_PAGE_LIMIT),
mode: opts.mode ?? 'fuzzy',
...(opts.searchFields && opts.searchFields.length > 0 ? { searchFields: [...opts.searchFields] } : {}),
...(query ? { query } : {}),
...(opts.cursor ? { cursor: opts.cursor } : {}),
...(opts.roleId ? { roleId: opts.roleId } : {}),
...(opts.docId ? { docId: opts.docId } : {}),
}
}
/** Picker reloadKey:项目/文档/匹配方式/检索字段/角色任一变化都清空查询与游标。 */
export function accountSearchKey(projectId: string, opts: AccountSearchOptions): string {
return JSON.stringify([projectId, opts.docId ?? '', opts.mode ?? 'fuzzy', opts.searchFields ?? [], opts.roleId ?? ''])
}
/**
* Picker 取数适配。空 query 不发请求(服务端空查询不枚举);cursor 只在签发它的
* 同一检索条件(query + 选项)下沿用,条件变化即回到首页。
*/
export function accountCandidatePager(
run: (params: ReturnType<typeof accountListParams>) => Promise<RdmAccountPage>,
projectId: string,
opts: AccountSearchOptions & { joinedOwners?: () => Iterable<string> },
) {
const base = accountSearchKey(projectId, opts)
let issuedFor = ''
return async (query: string, cursor: string, limit: number): Promise<{ items: PersonPickItem[]; nextCursor?: string }> => {
const q = query.trim()
if (!q) { issuedFor = ''; return { items: [] } }
const key = `${base}|${q}`
const out = await run(accountListParams(projectId, { ...opts, query: q, cursor: key === issuedFor ? cursor : '', limit }))
issuedFor = out.next ? key : ''
const items = accountsToPickItems(out.accounts, opts.joinedOwners?.() ?? [], opts.docId ? 'duty' : 'member')
return { items, nextCursor: out.next || undefined }
}
}
@@ -237,16 +310,25 @@ export function personLabel(p: { owner: string; name?: string }): string {
return name && name !== p.owner ? `${name} · ${p.owner}` : p.owner
}
/** 候选映射:无效账号不提供;已加入(服务端 joined 或已在成员表)可见但不可选。 */
export function accountsToPickItems(accounts: RdmAccount[] | undefined, joinedOwners: Iterable<string> = []): PersonPickItem[] {
/**
* 候选映射(id=userId;无 userId 的行无法稳定提交,不提供)。无效账号可见但不可选;
* member:已加入(服务端 joined 或已在成员表)不可选;duty:只有当前成员可选。
*/
export function accountsToPickItems(
accounts: RdmAccount[] | undefined, joinedOwners: Iterable<string> = [], purpose: 'member' | 'duty' = 'member',
): PersonPickItem[] {
const joined = new Set(joinedOwners)
return (accounts ?? []).filter(a => a.valid).map(a => {
return (accounts ?? []).filter(a => a.userId && a.owner).map(a => {
const isJoined = a.joined === true || joined.has(a.owner)
const reason = !a.valid ? INVALID_REASON
: purpose === 'member' && isJoined ? JOINED_REASON
: purpose === 'duty' && !isJoined ? NOT_MEMBER_REASON : ''
return {
id: a.owner,
id: a.userId,
owner: a.owner,
title: a.name?.trim() || a.owner,
subtitle: a.owner,
...(isJoined ? { disabled: true, disabledReason: JOINED_REASON } : {}),
...(reason ? { disabled: true, disabledReason: reason } : {}),
}
})
}
@@ -271,6 +353,8 @@ 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
}
}
+39 -1
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from './recovery'
import {
recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected, recoveryStatusLabel,
} from './recovery'
import { humanChangePasswordError, humanRedeemError, requiresCurrentPassword } from './components/LoginPage'
// 账户链路文案与模式判定(2026-10-14 current_required 加固后前后端契约对齐的
@@ -43,6 +45,42 @@ describe('recoveryProposalHint', () => {
})
})
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('会话证明')
+21
View File
@@ -18,6 +18,27 @@ export function recoveryProposalHint(status: RecoveryProposal['status']): string
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 '无法发起:该账号没有可用的合格会话证明(需该用户当前在任一设备保持完整密码登录)。历史来源或已锁死账号请走服务器本地处置'
+143
View File
@@ -0,0 +1,143 @@
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 })),
}
}
+72
View File
@@ -1,5 +1,58 @@
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>
@@ -68,3 +121,22 @@ 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)),
}))
}
+5 -2
View File
@@ -1,7 +1,7 @@
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
import { useSyncExternalStore } from 'react'
import { type AdminZoneSection, api, connectEvents } from './api'
import { type AdminZoneSection, type LinkedCapabilities, api, connectEvents } from './api'
import { auth } from './auth'
import { projectActionError, projectWorkError } from './projectmeta'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
@@ -90,7 +90,10 @@ export interface UIState {
projectSettingsId: string | null
}
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[] }
export interface Me {
owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[]
linkedCapabilities?: LinkedCapabilities
}
interface AppState {
contextObs: Record<string, ContextObs>
+59 -8
View File
@@ -519,28 +519,79 @@ 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; }
.rp-view-toggle .ad-mini.on { background: var(--bg-active); color: var(--text); }
.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-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-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-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-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; }
@media (max-width: 820px) {
.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) {
.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-warn { background: var(--warn-soft); color: var(--warn-text); }
+38
View File
@@ -0,0 +1,38 @@
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 初始化')
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('软件工程师(已停用)')
})
})
+11
View File
@@ -69,5 +69,16 @@ describe('文档职责草稿', () => {
docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'carol',
expectedDutyVersion: 3, confirmInvalidation: true,
})
expect(one.docs[0]).not.toHaveProperty('accountIds')
})
it('候选检索选中的 userId 随预览/批量保存以 accountIds{owner:userId} 携带,只含本文档职责人', () => {
const known = { bob: 'u-bob', erin: 'u-erin' }
const out = savePayload([
doc({ dirty: true }),
doc({ docId: 'd2', dirty: true, author: 'dave', reviewers: ['frank'], approver: 'gina' }),
], false, known)
expect(out.docs[0].accountIds).toEqual({ bob: 'u-bob' })
expect(out.docs[1]).not.toHaveProperty('accountIds')
})
})
+3 -3
View File
@@ -31,10 +31,10 @@ describe('humanChangePasswordError(改密门)', () => {
})
})
describe('humanRedeemError(一次性登录 Key 兑换)', () => {
describe('humanRedeemError(可信激活与本人恢复能力兑换)', () => {
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
expect(humanRedeemError('409: login key already used')).toBe('该 Key 已被使用过,请联系管理员重新签发')
expect(humanRedeemError('401: invalid or expired login key')).toBe('Key 无效或已过期,请联系管理员重新签发')
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('未知错误剥状态码前缀透传', () => {
+63
View File
@@ -0,0 +1,63 @@
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')
})
})
+15
View File
@@ -59,6 +59,21 @@ describe('命令载荷构造(身份只经认证会话——载荷不含任何
expect(memberAddPayload('p1', 'carol', 'owner')).toEqual({ projectId: 'p1', member: 'carol', role: 'owner' })
expect(memberRemovePayload('p1', 'carol')).toEqual({ projectId: 'p1', member: 'carol' })
})
it('成员添加携带候选检索选中的 userId(缺省不携带)', () => {
expect(memberAddPayload('p1', 'carol', 'member', 'u-carol'))
.toEqual({ projectId: 'p1', member: 'carol', role: 'member', userId: 'u-carol' })
expect(memberAddPayload('p1', 'carol', 'owner', '')).not.toHaveProperty('userId')
})
it('文档职责分配/预览携带 accountIds{owner:userId},只含本条职责涉及的人', () => {
const known = { alice: 'u-a', bob: 'u-b', erin: 'u-e' }
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob'], 'dave', { expectedDutyVersion: 2, accountIds: known }))
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave',
expectedDutyVersion: 2, accountIds: { alice: 'u-a', bob: 'u-b' } })
expect(assignDocDutiesPayload('p1', 'd1', 'dave', ['carol'], 'frank', { accountIds: known })).not.toHaveProperty('accountIds')
const doc = { docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave', expectedDutyVersion: 2,
confirmInvalidation: false, accountIds: { alice: 'u-a' } }
expect(dutyPreviewPayload('p1', [doc])).toEqual({ projectId: 'p1', docs: [doc] })
})
it('文档职责与整文审签载荷', () => {
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob', 'carol'], 'dave'))
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob', 'carol'], approver: 'dave' })
+103 -22
View File
@@ -3,28 +3,104 @@
import { describe, expect, it } from 'vitest'
import type { Project } from '../src/contracts'
import {
JOINED_REASON, MATCH_MODE_LABELS, MEMBER_PAGE_LIMIT, RDM_MEMBER_COMMANDS,
accountListParams, accountsToPickItems, appendMemberPage, memberListParams,
personLabel, rdmMembersReady, rdmProjectCommandError, roleOptionsPayload,
type RdmMemberRow,
INVALID_REASON, JOINED_REASON, MATCH_MODE_LABELS, MEMBER_PAGE_LIMIT, NOT_MEMBER_REASON, RDM_MEMBER_COMMANDS,
SEARCH_FIELDS, accountCandidatePager, accountListParams, accountSearchKey, accountsToPickItems, appendMemberPage,
memberListParams, personLabel, rdmMembersReady, rdmProjectCommandError, roleOptionsPayload,
type RdmAccountPage, type RdmMemberRow,
} from '../src/rdmManagement'
const row = (owner: string, name?: string): RdmMemberRow =>
({ owner, name, role: 'member', addedBy: 'admin', addedAt: '' })
describe('accountListParams', () => {
it('passes roleId, match and cursor to rdm.account.list', () => {
expect(accountListParams('p1', { query: ' zhang ', cursor: 'c2', limit: 20, roleId: 'r-eng', match: 'exact' }))
.toEqual({ projectId: 'p1', query: 'zhang', cursor: 'c2', limit: 20, roleId: 'r-eng', match: 'exact' })
it('passes mode, searchFields, roleId and cursor to rdm.account.list', () => {
expect(accountListParams('p1', {
query: ' zhang ', cursor: 'c2', limit: 20, roleId: 'r-eng', mode: 'exact', searchFields: ['name'],
})).toEqual({ projectId: 'p1', query: 'zhang', cursor: 'c2', limit: 20, roleId: 'r-eng', mode: 'exact', searchFields: ['name'] })
})
it('omits empty filters and defaults to fuzzy matching', () => {
expect(accountListParams('p1', { query: ' ', cursor: '', limit: 20, roleId: '' }))
.toEqual({ projectId: 'p1', limit: 20, match: 'fuzzy' })
it('passes docId for document duty candidates', () => {
expect(accountListParams('p1', { query: 'li', limit: 20, docId: 'd1', searchFields: ['owner', 'name'] }))
.toEqual({ projectId: 'p1', query: 'li', limit: 20, mode: 'fuzzy', searchFields: ['owner', 'name'], docId: 'd1' })
})
it('labels both match modes in Chinese', () => {
it('never sends the legacy match alias next to mode', () => {
const params = accountListParams('p1', { query: 'li', limit: 20, mode: 'exact' })
expect(params).not.toHaveProperty('match')
expect(params.mode).toBe('exact')
})
it('omits empty filters, defaults to fuzzy and caps the page at 50', () => {
expect(accountListParams('p1', { query: ' ', cursor: '', limit: 200, roleId: '', docId: '', searchFields: [] }))
.toEqual({ projectId: 'p1', limit: 50, mode: 'fuzzy' })
})
it('labels both match modes and the search field choices in Chinese', () => {
expect(MATCH_MODE_LABELS).toEqual({ fuzzy: '模糊匹配', exact: '精确账号或姓名' })
expect(SEARCH_FIELDS.both.fields).toEqual(['owner', 'name'])
expect(SEARCH_FIELDS.owner.fields).toEqual(['owner'])
expect(SEARCH_FIELDS.name.fields).toEqual(['name'])
})
})
describe('accountCandidatePager', () => {
const recorder = (pages: RdmAccountPage[]) => {
const calls: Record<string, unknown>[] = []
const run = async (params: Record<string, unknown>) => { calls.push(params); return pages.shift() ?? {} }
return { calls, run }
}
const acct = (userId: string, owner: string) => ({ userId, owner, valid: true, joined: false })
it('does not query the directory for an empty search', async () => {
const { calls, run } = recorder([])
const page = accountCandidatePager(run, 'p1', { mode: 'fuzzy' })
expect(await page(' ', '', 20)).toEqual({ items: [] })
expect(calls).toEqual([])
})
it('pages with the opaque next cursor under the same query and options', async () => {
const { calls, run } = recorder([{ accounts: [acct('u1', 'a1')], next: 'n1' }, { accounts: [acct('u2', 'a2')] }])
const page = accountCandidatePager(run, 'p1', { mode: 'exact', searchFields: ['owner'], docId: 'd1' })
const first = await page('zh', '', 20)
expect(first.nextCursor).toBe('n1')
await page('zh', 'n1', 20)
expect(calls).toEqual([
{ projectId: 'p1', query: 'zh', limit: 20, mode: 'exact', searchFields: ['owner'], docId: 'd1' },
{ projectId: 'p1', query: 'zh', limit: 20, mode: 'exact', searchFields: ['owner'], docId: 'd1', cursor: 'n1' },
])
})
it('resets the cursor when the query changes', async () => {
const { calls, run } = recorder([{ accounts: [], next: 'n1' }, { accounts: [] }])
const page = accountCandidatePager(run, 'p1', { mode: 'fuzzy' })
await page('zh', '', 20)
await page('zhang', 'n1', 20)
expect(calls[1]).not.toHaveProperty('cursor')
})
it('resets the cursor when mode or search fields change (new pager, new reload key)', async () => {
const { calls, run } = recorder([{ accounts: [], next: 'n1' }, { accounts: [] }, { accounts: [] }])
await accountCandidatePager(run, 'p1', { mode: 'fuzzy', searchFields: ['owner', 'name'] })('zh', '', 20)
await accountCandidatePager(run, 'p1', { mode: 'exact', searchFields: ['owner', 'name'] })('zh', 'n1', 20)
await accountCandidatePager(run, 'p1', { mode: 'fuzzy', searchFields: ['name'] })('zh', 'n1', 20)
expect(calls[1]).not.toHaveProperty('cursor')
expect(calls[2]).not.toHaveProperty('cursor')
const key = (mode: 'fuzzy' | 'exact', fields: ('owner' | 'name')[], docId?: string) =>
accountSearchKey('p1', { mode, searchFields: fields, docId })
expect(new Set([key('fuzzy', ['owner', 'name']), key('exact', ['owner', 'name']), key('fuzzy', ['name']),
key('fuzzy', ['owner', 'name'], 'd1')]).size).toBe(4)
})
it('maps duty candidates so only current members are selectable', async () => {
const { run } = recorder([{ accounts: [
{ userId: 'u1', owner: 'm1', name: '成员', valid: true, joined: true },
{ userId: 'u2', owner: 'x1', valid: true, joined: false },
] }])
const { items } = await accountCandidatePager(run, 'p1', { docId: 'd1' })('m', '', 20)
expect(items).toEqual([
{ id: 'u1', owner: 'm1', title: '成员', subtitle: 'm1' },
{ id: 'u2', owner: 'x1', title: 'x1', subtitle: 'x1', disabled: true, disabledReason: NOT_MEMBER_REASON },
])
})
})
@@ -52,30 +128,35 @@ describe('roleOptionsPayload', () => {
})
describe('accountsToPickItems', () => {
it('drops invalid accounts', () => {
it('keys candidates by stable userId and drops rows without one', () => {
const items = accountsToPickItems([
{ owner: 'a', name: '甲', valid: true, joined: false },
{ owner: 'gone', name: '离职', valid: false, joined: false },
{ userId: 'u-a', owner: 'a', name: '甲', valid: true, joined: false },
{ userId: '', owner: 'anon', valid: true, joined: false },
])
expect(items.map(it => it.id)).toEqual(['a'])
expect(items).toEqual([{ id: 'u-a', owner: 'a', title: '甲', subtitle: 'a' }])
})
it('shows invalid accounts as not selectable', () => {
const [it] = accountsToPickItems([{ userId: 'u-g', owner: 'gone', name: '离职', valid: false, joined: false }])
expect(it).toMatchObject({ id: 'u-g', disabled: true, disabledReason: INVALID_REASON })
})
it('disables joined candidates with the 已加入项目 reason instead of a badge', () => {
const items = accountsToPickItems([
{ owner: 'a', name: '甲', valid: true, joined: true },
{ owner: 'b', valid: true, joined: false },
{ owner: 'c', name: '丙', valid: true },
{ userId: 'u-a', owner: 'a', name: '甲', valid: true, joined: true },
{ userId: 'u-b', owner: 'b', valid: true, joined: false },
{ userId: 'u-c', owner: 'c', name: '丙', valid: true },
], ['b'])
expect(items).toEqual([
{ id: 'a', title: '甲', subtitle: 'a', disabled: true, disabledReason: JOINED_REASON },
{ id: 'b', title: 'b', subtitle: 'b', disabled: true, disabledReason: '已加入项目' },
{ id: 'c', title: '丙', subtitle: 'c' },
{ id: 'u-a', owner: 'a', title: '甲', subtitle: 'a', disabled: true, disabledReason: JOINED_REASON },
{ id: 'u-b', owner: 'b', title: 'b', subtitle: 'b', disabled: true, disabledReason: '已加入项目' },
{ id: 'u-c', owner: 'c', title: '丙', subtitle: 'c' },
])
expect(items.some(it => 'badge' in it)).toBe(false)
})
it('keeps name as title and account as subtitle so the selection shows both', () => {
const [it] = accountsToPickItems([{ owner: 'zhangsan', name: '张三', valid: true, joined: false }])
const [it] = accountsToPickItems([{ userId: 'u-z', owner: 'zhangsan', name: '张三', valid: true, joined: false }])
expect(it.title).toBe('张三')
expect(it.subtitle).toBe('zhangsan')
})
+133
View File
@@ -0,0 +1,133 @@
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({})
})
})
+95 -9
View File
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest'
import type { PermissionDescriptor, RoleRecord } from '../src/api'
import { describe, expect, it, vi } from 'vitest'
import type { PermissionDescriptor, RolePermissionPreview, RoleRecord } from '../src/api'
import {
filterRoles, groupPermissionPage, hasSystemAction, shouldAcceptRoleResponse,
NARROW_ROLE_QUERY, cellKind, commitRoleBatch, editRoleDraft, filterRoles, hasSystemAction, initialRoleView, memberStatusLabel, previewRoleBatch,
rebaseRoleDrafts, roleCellValue, roleLeaveMessage, roleMembersLabel, 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: [] },
@@ -10,10 +12,14 @@ const roles: RoleRecord[] = [
]
const catalog: PermissionDescriptor[] = [
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', scope: 'system', protected: false },
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', scope: 'project', protected: false },
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', scope: 'project', protected: false },
{ 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 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', () => {
@@ -22,9 +28,10 @@ describe('role permission UI helpers', () => {
expect(filterRoles(roles, '', 'fuzzy', 'disabled').map(role => role.roleId)).toEqual(['role_reviewer'])
})
it('groups and pages matrix columns without changing their stable action IDs', () => {
expect(groupPermissionPage(catalog, '文档动作', 0, 1).map(item => item.actionId)).toEqual(['document.read'])
expect(groupPermissionPage(catalog, '文档动作', 1, 1).map(item => item.actionId)).toEqual(['document.author'])
it('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('drops late results and checks explicit system actions', () => {
@@ -33,4 +40,83 @@ 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')
})
})
+55 -2
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest'
import type { PermissionDescriptor } from '../src/api'
import {
createRolePermissionDraft, mergeRoleDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
type RolePermissionValue,
categoryActions, createRolePermissionDraft, dirtyRoles, linkedByProjectCreate, mergeRoleDraft, permissionCategories, rebaseRoleDraft,
rolePatchEntries, stateToValue, updateRolePermission, validateRolePermissionDraft, valueToState, visiblePatch,
type RolePermissionStateName, type RolePermissionValue,
} from '../src/rolePermissions'
const initial: Record<string, RolePermissionValue> = {
@@ -48,3 +50,54 @@ 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)
})
})