Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e611c5de0 | ||
|
|
ec1ced3050 | ||
|
|
b02ff83dfc | ||
|
|
1d7f2a3e1b | ||
|
|
18fc958def | ||
|
|
16a059381c | ||
|
|
e4cd8f1a57 | ||
|
|
9f7f4a164b | ||
|
|
a13d041159 | ||
|
|
f6442cc501 | ||
|
|
e2a670e2aa | ||
|
|
1fe321240a | ||
|
|
c6df2dc180 | ||
|
|
97b947a8e3 | ||
|
|
096947f9a2 | ||
|
|
3fe5cf1617 | ||
|
|
e73f2ad9dd | ||
|
|
d997d4a4d0 | ||
|
|
0b388e010d | ||
|
|
2bd5116f58 | ||
|
|
d7768d4ea0 | ||
|
|
3c22d0957a | ||
|
|
e296d38ec2 | ||
|
|
f01579890d | ||
|
|
259021d530 | ||
|
|
7d43ef6ff7 | ||
|
|
9baf96d937 | ||
|
|
dab16052cf | ||
|
|
ce15f7977b | ||
|
|
f560201f8b | ||
|
|
6f8e7ec604 | ||
|
|
aa49e0dbf3 | ||
|
|
9df5517e9c | ||
|
|
b4fec2fdf4 | ||
|
|
164ea66039 | ||
|
|
23877af960 | ||
|
|
e6bb387f71 | ||
|
|
6db528df10 | ||
|
|
22ef3c4b67 | ||
|
|
080c630816 | ||
|
|
e4041b36b9 | ||
|
|
afd9e7504b | ||
|
|
72c941c772 | ||
|
|
d47c49350b | ||
|
|
1650b4fff0 | ||
|
|
4d7dffeb4b | ||
|
|
ac45aa71aa | ||
|
|
cf07aef3d1 | ||
|
|
d562d6a95c | ||
|
|
05609ff61c | ||
|
|
5c50943731 | ||
|
|
f2b5532b7b | ||
|
|
20c20b7959 | ||
|
|
9d0ae331c7 | ||
|
|
1fc9dabc13 | ||
|
|
70f0d2d41f | ||
|
|
bd736f528c | ||
|
|
bc9b17a665 | ||
|
|
a1bd3e2983 | ||
|
|
93ee723131 | ||
|
|
ee735f2e2e | ||
|
|
3203e8d4f3 | ||
|
|
bed8a5c766 | ||
|
|
055476d135 | ||
|
|
34f6a1bf2f | ||
|
|
2e7e5b7a64 | ||
|
|
36ac754300 | ||
|
|
1544781c28 | ||
|
|
cfabed8fae | ||
|
|
a961b19ebb | ||
|
|
010540f203 | ||
|
|
731c9b7776 | ||
|
|
c9d5acf089 | ||
|
|
139f2ce654 | ||
|
|
01ec12e77c | ||
|
|
34950997f8 | ||
|
|
006c815675 | ||
|
|
9d7681cec4 | ||
|
|
5bc2e12c01 | ||
|
|
64ba640b77 | ||
|
|
329bdf53d6 | ||
|
|
0bbccbe39e | ||
|
|
3087b0621c | ||
|
|
4d1d64a83e | ||
|
|
861c45edb5 | ||
|
|
c63f6c7470 | ||
|
|
75a420210d | ||
|
|
175210f4bb | ||
|
|
b00fa4433b | ||
|
|
8be95c8807 | ||
|
|
54fe339e65 | ||
|
|
82c08bd983 |
@@ -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: fadabceadeec210d52cda7e9cec71e988d2e4482
|
||||
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,用户在部署上验收,问题另立修复。
|
||||
@@ -0,0 +1,623 @@
|
||||
// IAM U4 阶段 7 确定性浏览器切片(DR6–DR10):真实 React/Picker 交互 +
|
||||
// 完全受控的 HTTP 应答,不依赖任何账号或数据库。活体隔离产品层由
|
||||
// Agent-System-RDM scripts/iam-u4-role-acceptance.sh 以真实 Backend/RDM
|
||||
// 授权路径复用同一场景(live 模式不拦截路由)。
|
||||
import { test, expect, type Page, type Route } from '@playwright/test'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>(ok => { resolve = ok })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; systemActions?: string[] }
|
||||
interface Perm { actionId: string; label: string; description: string; group: 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; profile: { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
|
||||
profileVersion: number; enabled: boolean; stateVersion: number; isAdmin: boolean; passwordSet: boolean
|
||||
initializationState: string; initializationGeneration: number
|
||||
}
|
||||
|
||||
const FULL_ACTIONS = ['system.users.view', 'system.users.create', 'system.users.edit', 'system.users.disable',
|
||||
'system.roles.view', 'system.roles.manage', 'system.roles.assign', 'system.permissions.view', 'system.permissions.manage']
|
||||
|
||||
function perm(actionId: string, label: string, group: string, scope: 'system' | 'project', extra: Partial<Perm> = {}): Perm {
|
||||
return { actionId, label, description: `${label}说明`, group, scope, protected: false, ...extra }
|
||||
}
|
||||
|
||||
const CATALOG: Perm[] = [
|
||||
perm('project.create', '创建项目', '项目入口', 'system'),
|
||||
perm('project.member.manage', '管理项目成员', '项目入口', 'project'),
|
||||
perm('project.override.set', '设置项目覆盖', '项目入口', 'project'),
|
||||
perm('document.read', '读取文档', '文档动作', 'project'),
|
||||
perm('document.author', '编制文档', '文档动作', 'project'),
|
||||
perm('document.approve', '批准文档', '文档动作', 'project'),
|
||||
perm('export.create', '创建导出', '平台动作', 'project'),
|
||||
perm('audit.read', '读取审计', '平台动作', 'project'),
|
||||
perm('system.users.manage', '管理用户账号', '系统动作', 'system', { protected: true }),
|
||||
]
|
||||
|
||||
function emptyProfile(name = '') {
|
||||
return { name, phone: '', email: '', staffNo: '', department: '', notes: '' }
|
||||
}
|
||||
|
||||
function account(userId: string, owner: string, profile = emptyProfile(), overrides: Partial<Account> = {}): Account {
|
||||
return { userId, owner, profile, profileVersion: 1, enabled: true, stateVersion: 3, isAdmin: false, passwordSet: true, initializationState: 'done', initializationGeneration: 0, ...overrides }
|
||||
}
|
||||
|
||||
function rp(actionId: string, allowed = false, capAllowed = false): RolePerm {
|
||||
return { actionId, allowed, capAllowed, permissionVersion: 1 }
|
||||
}
|
||||
|
||||
/** 可变角色/绑定状态:一个 fixture 贯穿用例内全部请求,支持版本化 409。 */
|
||||
function world(me: Me) {
|
||||
const roles: Role[] = [
|
||||
{ roleId: 'role_eng', name: '软件工程师', description: '研发职位', enabled: true, roleVersion: 3, bindingCount: 2, permissions: [rp('project.create', true, true), rp('document.read', true, true)] },
|
||||
{ roleId: 'role_ops', name: '运维工程师', description: '运维职位', enabled: true, roleVersion: 1, bindingCount: 0, permissions: [] },
|
||||
{ roleId: 'role_review', name: '审核工程师', description: '质量职位', enabled: false, roleVersion: 2, bindingCount: 1, permissions: [rp('document.read', true, true), rp('document.approve', false, true)] },
|
||||
]
|
||||
const bindings: Record<string, string> = { u1: 'role_eng', u3: 'role_eng' }
|
||||
const users: Account[] = [
|
||||
account('u1', 'alice', { ...emptyProfile('张三'), phone: '13800000001', staffNo: 'A001' }),
|
||||
account('u2', 'bob'),
|
||||
account('u3', 'carol', { ...emptyProfile('李四'), email: 'carol@example.com' }),
|
||||
]
|
||||
const grants: Record<string, { actions: string[]; authorityEpoch: number }> = {}
|
||||
const calls: { method: string; path: string; query: Record<string, string>; body?: Record<string, unknown> }[] = []
|
||||
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
|
||||
const failNext = new Map<string, number>()
|
||||
let grantEpoch = 5
|
||||
const PAGE = 2
|
||||
|
||||
const visible = (q: URLSearchParams): Role[] => roles.filter(role => {
|
||||
const status = q.get('status') ?? 'all'
|
||||
if (status === 'enabled' && !role.enabled) return false
|
||||
if (status === 'disabled' && role.enabled) return false
|
||||
const action = q.get('action') ?? ''
|
||||
if (action && !role.permissions.some(item => item.actionId === action && item.allowed)) return false
|
||||
const query = (q.get('query') ?? '').trim().toLocaleLowerCase()
|
||||
if (!query) return true
|
||||
const fields = [role.name, role.description, role.roleId].map(value => value.toLocaleLowerCase())
|
||||
return (q.get('mode') ?? 'fuzzy') === 'exact' ? fields.some(value => value === query) : fields.some(value => value.includes(query))
|
||||
})
|
||||
|
||||
const recount = () => {
|
||||
for (const role of roles) role.bindingCount = Object.values(bindings).filter(roleId => roleId === role.roleId).length
|
||||
}
|
||||
|
||||
const notFound = () => ({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'not found' }) })
|
||||
const deny = (route: Route, status: number) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify({ error: `code:${status}` }) })
|
||||
const ok = (route: Route, payload: unknown) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(payload) })
|
||||
|
||||
return {
|
||||
roles, bindings, users, grants, calls,
|
||||
hold(key: string) {
|
||||
const pending = { started: deferred(), release: deferred() }
|
||||
holds.set(key, pending)
|
||||
return pending
|
||||
},
|
||||
failNextTimes(path: string, times: number) { failNext.set(path, times) },
|
||||
async route(route: Route) {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const path = decodeURIComponent(url.pathname)
|
||||
const method = request.method()
|
||||
const raw = request.postData()
|
||||
calls.push({ method, path, query: Object.fromEntries(url.searchParams), body: raw ? JSON.parse(raw) as Record<string, unknown> : undefined })
|
||||
const hold = holds.get(`${method} ${path}`)
|
||||
if (hold) { holds.delete(`${method} ${path}`); hold.started.resolve(); await hold.release.promise }
|
||||
const fails = failNext.get(path) ?? 0
|
||||
if (fails > 0) { failNext.set(path, fails - 1); return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'boom' }) }) }
|
||||
|
||||
if (path === '/api/health') return ok(route, { ok: true })
|
||||
if (path === '/api/me') return ok(route, me)
|
||||
if (path === '/api/projects') return ok(route, [])
|
||||
if (path === '/api/settings') return ok(route, { schema: [], defaults: {}, user: {}, project: {} })
|
||||
if (path === '/api/commands') return ok(route, [])
|
||||
if (path === '/api/admin-zone/sections') return ok(route, [])
|
||||
if (path.endsWith('/cards')) return ok(route, { cards: [] })
|
||||
if (path === '/api/admin/permissions/catalog') return ok(route, { permissions: CATALOG })
|
||||
if (path === '/api/admin/roles' && method === 'GET') {
|
||||
const list = visible(url.searchParams)
|
||||
const start = Number(url.searchParams.get('cursor') ?? 0) || 0
|
||||
const page = list.slice(start, start + PAGE)
|
||||
return ok(route, { roles: page, nextCursor: start + PAGE < list.length ? String(start + PAGE) : '' })
|
||||
}
|
||||
if (path === '/api/admin/roles' && method === 'POST') {
|
||||
const body = request.postDataJSON() as { name: string; description: string }
|
||||
const role: Role = { roleId: `role_${body.name}`, name: body.name, description: body.description, enabled: true, roleVersion: 1, bindingCount: 0, permissions: [] }
|
||||
roles.unshift(role)
|
||||
return ok(route, role)
|
||||
}
|
||||
const roleMatch = path.match(/^\/api\/admin\/roles\/([^/]+)(\/permissions)?$/)
|
||||
if (roleMatch) {
|
||||
const role = roles.find(item => item.roleId === roleMatch[1])
|
||||
if (!role) return route.fulfill(notFound())
|
||||
if (!roleMatch[2]) {
|
||||
if (method === 'GET') return ok(route, role)
|
||||
const body = request.postDataJSON() as { expectedRoleVersion: number; name?: string; description?: string; enabled?: boolean }
|
||||
if (body.expectedRoleVersion !== role.roleVersion) return deny(route, 409)
|
||||
if (body.name !== undefined) role.name = body.name
|
||||
if (body.description !== undefined) role.description = body.description
|
||||
if (body.enabled !== undefined) role.enabled = body.enabled
|
||||
role.roleVersion += 1
|
||||
return ok(route, role)
|
||||
}
|
||||
if (method === 'PUT') {
|
||||
const body = request.postDataJSON() as { expectedRoleVersion: number; changes: { actionId: string; allowed: boolean; capAllowed: boolean }[] }
|
||||
if (body.expectedRoleVersion !== role.roleVersion) return deny(route, 409)
|
||||
role.permissions = body.changes.map(change => ({ ...change, permissionVersion: 1 }))
|
||||
role.roleVersion += 1
|
||||
return ok(route, { receiptId: 'r1', roleVersion: role.roleVersion, authorityEpoch: 6 })
|
||||
}
|
||||
}
|
||||
if (path === '/api/admin/users/search' && method === 'GET') {
|
||||
const q = url.searchParams
|
||||
const query = (q.get('query') ?? '').trim().toLocaleLowerCase()
|
||||
const fields = (q.get('fields') ?? 'owner,name,userId').split(',')
|
||||
const status = q.get('status') ?? 'all'
|
||||
const list = users.filter(item => {
|
||||
if (status === 'enabled' && !item.enabled) return false
|
||||
if (status === 'disabled' && item.enabled) return false
|
||||
if (!query) return true
|
||||
const values = (fields.includes('default') ? ['owner', 'name', 'userId'] : fields)
|
||||
.map(field => field === 'userId' ? item.userId : field === 'owner' ? item.owner : item.profile[field as 'name'] ?? '')
|
||||
return (q.get('mode') ?? 'fuzzy') === 'exact' ? values.some(value => value.toLowerCase() === query) : values.some(value => value.toLowerCase().includes(query))
|
||||
})
|
||||
return ok(route, { accounts: list, nextCursor: '', directoryVersion: 'd1' })
|
||||
}
|
||||
if (path === '/api/admin/users' && method === 'POST') {
|
||||
const body = request.postDataJSON() as { owner: string; profile: Account['profile'] }
|
||||
if (users.some(item => item.owner === body.owner)) return deny(route, 409)
|
||||
const created = account(`u-${body.owner}`, body.owner, { ...emptyProfile(), ...body.profile })
|
||||
users.push(created)
|
||||
return ok(route, created)
|
||||
}
|
||||
const userMatch = path.match(/^\/api\/admin\/users\/([^/]+)(\/role|\/profile|\/state|\/system-grants)?$/)
|
||||
if (userMatch) {
|
||||
const user = users.find(item => item.userId === userMatch[1])
|
||||
if (!user) return route.fulfill(notFound())
|
||||
const sub = userMatch[2] ?? ''
|
||||
if (sub === '' && method === 'GET') return ok(route, user)
|
||||
if (sub === '/profile' && method === 'PATCH') {
|
||||
const body = request.postDataJSON() as { expectedProfileVersion: number; patch: Record<string, string> }
|
||||
if (body.expectedProfileVersion !== user.profileVersion) return deny(route, 409)
|
||||
user.profile = { ...user.profile, ...body.patch }
|
||||
user.profileVersion += 1
|
||||
return ok(route, user)
|
||||
}
|
||||
if (sub === '/state' && method === 'PUT') {
|
||||
const body = request.postDataJSON() as { expectedStateVersion: number; enabled: boolean }
|
||||
if (body.expectedStateVersion !== user.stateVersion) return deny(route, 409)
|
||||
user.enabled = body.enabled
|
||||
user.stateVersion += 1
|
||||
return ok(route, user)
|
||||
}
|
||||
if (sub === '/role' && method === 'GET') {
|
||||
const role = roles.find(item => item.roleId === bindings[user.userId])
|
||||
return ok(route, role ? {
|
||||
binding: { bindingId: `b-${user.userId}`, userId: user.userId, roleId: role.roleId, active: true, bindingVersion: 1, assignedBy: 'root', assignedAt: '', userStateVersion: user.stateVersion, authorityEpoch: grantEpoch },
|
||||
role,
|
||||
} : {})
|
||||
}
|
||||
if (sub === '/role' && method === 'PUT') {
|
||||
const body = request.postDataJSON() as { roleId: string; expectedStateVersion: number }
|
||||
if (body.expectedStateVersion !== user.stateVersion) return deny(route, 409)
|
||||
bindings[user.userId] = body.roleId
|
||||
recount()
|
||||
return ok(route, { bindingId: `b-${user.userId}`, userId: user.userId, roleId: body.roleId, active: true, bindingVersion: 2, assignedBy: 'root', assignedAt: '', userStateVersion: user.stateVersion, authorityEpoch: grantEpoch })
|
||||
}
|
||||
if (sub === '/system-grants' && method === 'GET') {
|
||||
return ok(route, grants[user.userId] ?? { actions: [], authorityEpoch: grantEpoch })
|
||||
}
|
||||
if (sub === '/system-grants' && method === 'PUT') {
|
||||
const body = request.postDataJSON() as { actions: string[]; expectedAuthorityEpoch: number }
|
||||
if (body.expectedAuthorityEpoch !== (grants[user.userId]?.authorityEpoch ?? grantEpoch)) return deny(route, 409)
|
||||
const hadManage = (grants[user.userId]?.actions ?? []).includes('system.permissions.manage')
|
||||
if (hadManage && !body.actions.includes('system.permissions.manage')) {
|
||||
return route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ error: 'cannot remove the final permission manager' }) })
|
||||
}
|
||||
grants[user.userId] = { actions: body.actions, authorityEpoch: ++grantEpoch }
|
||||
return ok(route, { receiptId: 'g1', authorityEpoch: grantEpoch })
|
||||
}
|
||||
}
|
||||
// 未点名端点(profiles/plugins/conversations/workspaces 等启动探测)按空列表兜底。
|
||||
return route.fulfill({ contentType: 'application/json', body: '[]' })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type World = ReturnType<typeof world>
|
||||
|
||||
async function openAdmin(page: Page, me: Me): Promise<World> {
|
||||
const w = world(me)
|
||||
await page.route('**/api/**', route => { void w.route(route) })
|
||||
await page.goto('/')
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
|
||||
return w
|
||||
}
|
||||
|
||||
const openAdminAsAdmin = (page: Page) => openAdmin(page, { owner: 'root', name: '管理员', auth: true, isAdmin: true, systemActions: [...FULL_ACTIONS] })
|
||||
|
||||
async function openRolesSection(page: Page) {
|
||||
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
|
||||
// 面板加载带 180ms 防抖,等待首笔角色清单返回后再交互。
|
||||
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '搜索与过滤' }).click()
|
||||
}
|
||||
|
||||
async function openUsersSection(page: Page) {
|
||||
await page.getByRole('button', { name: '用户管理', exact: true }).click()
|
||||
await expect(page.locator('tr', { hasText: 'alice' })).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
const engRow = (page: Page) => page.locator('.rp-role-list button', { hasText: '软件工程师' })
|
||||
const reviewRow = (page: Page) => page.locator('.rp-role-list button', { hasText: '审核工程师' })
|
||||
|
||||
test('DR10 细粒度管理员:仅用户查看权时看不到角色分区,也不发出角色请求', async ({ page }) => {
|
||||
const w = await openAdmin(page, { owner: 'mgr', name: '用户管理员', auth: true, isAdmin: false, systemActions: ['system.users.view'] })
|
||||
await expect(page.getByRole('button', { name: '用户管理', exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '角色与权限', exact: true })).toHaveCount(0)
|
||||
await openUsersSection(page)
|
||||
await page.locator('tr', { hasText: 'alice' }).getByRole('button', { name: '查看与管理' }).click()
|
||||
await expect(page.getByText(/当前角色 无角色查看权限/)).toBeVisible()
|
||||
await expect(page.getByLabel('用户生效角色')).toHaveCount(0)
|
||||
expect(w.calls.filter(call => call.path.endsWith('/role'))).toEqual([])
|
||||
expect(w.calls.filter(call => call.path === '/api/admin/permissions/catalog')).toEqual([])
|
||||
})
|
||||
|
||||
test('DR10 权限管理者无查看权:出现守护提示且不拉取目录', async ({ page }) => {
|
||||
const w = await openAdmin(page, { owner: 'mgr', name: '权限管理者', auth: true, isAdmin: false, systemActions: ['system.users.view', 'system.permissions.manage'] })
|
||||
await openUsersSection(page)
|
||||
await page.locator('tr', { hasText: 'alice' }).getByRole('button', { name: '查看与管理' }).click()
|
||||
await expect(page.getByText('需要同时具备系统权限查看权,才能安全编辑现有授权')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '明确系统权限' })).toHaveCount(0)
|
||||
expect(w.calls.filter(call => call.path === '/api/admin/permissions/catalog')).toEqual([])
|
||||
})
|
||||
|
||||
test('DR6 创建带描述的角色,后续修改保持稳定 roleId', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
await openRolesSection(page)
|
||||
await page.getByLabel('新角色名称').fill('数据工程师')
|
||||
await page.getByLabel('新角色描述').fill('数据管道职位')
|
||||
await page.getByRole('button', { name: '创建角色', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('已创建角色 数据工程师')
|
||||
await page.getByLabel('角色描述', { exact: true }).fill('数据管道与质量职位')
|
||||
await page.getByRole('button', { name: '保存资料', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('角色资料已保存')
|
||||
expect(w.calls.filter(call => call.method === 'POST' && call.path === '/api/admin/roles')).toHaveLength(1)
|
||||
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path.startsWith('/api/admin/roles/'))
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0].path).toBe('/api/admin/roles/role_数据工程师')
|
||||
})
|
||||
|
||||
test('DR6 停用角色需显式确认;取消不发出任何请求', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
await openRolesSection(page)
|
||||
await engRow(page).click()
|
||||
await page.getByRole('button', { name: '停用角色', exact: true }).click()
|
||||
const dialog = page.getByRole('alertdialog', { name: '确认停用角色 软件工程师' })
|
||||
await expect(dialog).toContainText('软件工程师')
|
||||
await expect(dialog).toContainText('2 个用户绑定')
|
||||
await expect(dialog).toContainText('不再授予权限')
|
||||
await dialog.getByRole('button', { name: '取消', exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
expect(w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')).toEqual([])
|
||||
await page.getByRole('button', { name: '停用角色', exact: true }).click()
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('角色已停用,现有绑定不再授予权限')
|
||||
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0].body).toMatchObject({ expectedRoleVersion: 3, enabled: false })
|
||||
})
|
||||
|
||||
test('DR6 停用版本冲突:刷新最新状态并保留重试上下文', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
await openRolesSection(page)
|
||||
await engRow(page).click()
|
||||
// 并发方先推进版本,首次确认停用必然 409。
|
||||
w.roles.find(role => role.roleId === 'role_eng')!.roleVersion = 4
|
||||
await page.getByRole('button', { name: '停用角色', exact: true }).click()
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
|
||||
await expect(page.getByRole('alert')).toContainText('角色状态版本冲突')
|
||||
await page.getByRole('button', { name: '停用角色', exact: true }).click()
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('角色已停用')
|
||||
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')
|
||||
expect(patches).toHaveLength(2)
|
||||
expect(patches[0].body).toMatchObject({ expectedRoleVersion: 3 })
|
||||
expect(patches[1].body).toMatchObject({ expectedRoleVersion: 4 })
|
||||
expect(w.calls.some(call => call.method === 'GET' && call.path === '/api/admin/roles/role_eng')).toBe(true)
|
||||
})
|
||||
|
||||
test('DR9 过滤、翻页与视图切换按 roleId 保留草稿且永不串角色', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
await openRolesSection(page)
|
||||
await engRow(page).click()
|
||||
await page.getByLabel('编制文档 默认允许').check()
|
||||
await expect(page.getByText('变更预览(1)')).toBeVisible()
|
||||
// 切到第二页的审核工程师:不带走软件工程师的草稿。
|
||||
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
|
||||
await reviewRow(page).click()
|
||||
await expect(page.getByText('变更预览(0)')).toBeVisible()
|
||||
await expect(page.getByLabel('编制文档 默认允许')).not.toBeChecked()
|
||||
// 切回软件工程师:草稿原样保留。
|
||||
await page.getByRole('button', { name: '上一页角色', exact: true }).click()
|
||||
await engRow(page).click()
|
||||
await expect(page.getByText('变更预览(1)')).toBeVisible()
|
||||
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
|
||||
// 状态过滤触发重载:草稿仍在。
|
||||
await page.getByLabel('角色状态').selectOption('disabled')
|
||||
await expect(reviewRow(page)).toBeVisible()
|
||||
await page.getByLabel('角色状态').selectOption('all')
|
||||
await engRow(page).click()
|
||||
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
|
||||
// 单角色 ↔ 矩阵切换保留;矩阵中当前角色的勾选状态一致。
|
||||
await page.getByRole('button', { name: '角色矩阵', exact: true }).click()
|
||||
await expect(page.getByLabel('软件工程师 编制文档 默认允许')).toBeChecked()
|
||||
await page.getByRole('button', { name: '单角色配置', exact: true }).click()
|
||||
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
|
||||
expect(w.calls.filter(call => call.path === '/api/admin/roles/role_ops')).toEqual([])
|
||||
})
|
||||
|
||||
test('DR9 离开管理区须显式决定;确认离开后草稿被放弃', async ({ page }) => {
|
||||
await openAdminAsAdmin(page)
|
||||
await openRolesSection(page)
|
||||
await engRow(page).click()
|
||||
await page.getByLabel('编制文档 默认允许').check()
|
||||
const dialogs: string[] = []
|
||||
let leave = false
|
||||
page.on('dialog', async dialog => {
|
||||
dialogs.push(dialog.message())
|
||||
if (dialog.type() === 'confirm') leave ? await dialog.accept() : await dialog.dismiss()
|
||||
else await dialog.accept()
|
||||
})
|
||||
await page.getByRole('button', { name: '返回工作台' }).click()
|
||||
expect(dialogs).toHaveLength(1)
|
||||
expect(dialogs[0]).toContain('软件工程师')
|
||||
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
|
||||
leave = true
|
||||
await page.getByRole('button', { name: '返回工作台' }).click()
|
||||
await expect(page.getByRole('dialog', { name: '系统管理' })).toHaveCount(0)
|
||||
// 重新进入后草稿不复存在(离开即放弃)。
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||
await openRolesSection(page)
|
||||
await engRow(page).click()
|
||||
await expect(page.getByText('变更预览(0)')).toBeVisible()
|
||||
})
|
||||
|
||||
test('DR9 409 只重放同一角色草稿;另一角色草稿不受影响', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
await openRolesSection(page)
|
||||
await engRow(page).click()
|
||||
await page.getByLabel('编制文档 默认允许').check()
|
||||
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
|
||||
await reviewRow(page).click()
|
||||
await page.getByLabel('批准文档 默认允许').check()
|
||||
// 切回软件工程师再保存:并发方已推进版本并改变服务端基线,保存触发 409 rebase。
|
||||
await page.getByRole('button', { name: '上一页角色', exact: true }).click()
|
||||
await engRow(page).click()
|
||||
const eng = w.roles.find(role => role.roleId === 'role_eng')!
|
||||
eng.roleVersion = 4
|
||||
eng.permissions = [rp('project.create', false, false), rp('document.read', true, true)]
|
||||
await page.getByRole('button', { name: '保存权限', exact: true }).click()
|
||||
await expect(page.getByRole('alert')).toContainText('权限版本冲突;已基于最新版本重放本地草稿')
|
||||
expect(w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/roles/role_review')).toEqual([])
|
||||
// 重放后:本地对编制文档的修改保留;再次保存基于最新版本成功。
|
||||
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
|
||||
await page.getByRole('button', { name: '保存权限', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('角色权限已保存')
|
||||
const puts = w.calls.filter(call => call.method === 'PUT' && call.path === '/api/admin/roles/role_eng/permissions')
|
||||
expect(puts).toHaveLength(2)
|
||||
expect(puts[0].body).toMatchObject({ expectedRoleVersion: 3 })
|
||||
expect(puts[1].body).toMatchObject({ expectedRoleVersion: 4 })
|
||||
expect((puts[1].body!.changes as { actionId: string; allowed: boolean }[]).find(change => change.actionId === 'document.author')).toMatchObject({ allowed: true, capAllowed: true })
|
||||
// 审核工程师的草稿未被动过:切回后仍在。
|
||||
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
|
||||
await reviewRow(page).click()
|
||||
await expect(page.getByLabel('批准文档 默认允许')).toBeChecked()
|
||||
})
|
||||
|
||||
test('DR9 矩阵批量编辑只作用于当前可见页', async ({ page }) => {
|
||||
await openAdminAsAdmin(page)
|
||||
await openRolesSection(page)
|
||||
await page.getByRole('button', { name: '角色矩阵', exact: true }).click()
|
||||
await page.locator('.rp-matrix button', { hasText: '软件工程师' }).click()
|
||||
await page.getByRole('button', { name: '当前页默认全选', exact: true }).click()
|
||||
for (const label of ['创建项目', '管理项目成员', '设置项目覆盖', '读取文档', '编制文档', '批准文档']) {
|
||||
await expect(page.getByLabel(`软件工程师 ${label} 默认允许`)).toBeChecked()
|
||||
}
|
||||
await page.getByRole('button', { name: '下一组', exact: true }).click()
|
||||
await expect(page.getByLabel('软件工程师 创建导出 默认允许')).not.toBeChecked()
|
||||
await page.getByRole('button', { name: '上一组', exact: true }).click()
|
||||
await page.getByRole('button', { name: '当前页上限关闭', exact: true }).click()
|
||||
await expect(page.getByLabel('软件工程师 创建项目 默认允许')).not.toBeChecked()
|
||||
// 变更预览在单角色视图:批量编辑按 roleId 保留,切回可见 2 项相对基线变化。
|
||||
await page.getByRole('button', { name: '单角色配置', exact: true }).click()
|
||||
await expect(page.getByText('变更预览(2)')).toBeVisible()
|
||||
})
|
||||
|
||||
test('DR10 角色清单失败不拖垮目录加载,可经检索重试恢复', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
w.failNextTimes('/api/admin/roles', 1)
|
||||
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
|
||||
await expect(page.getByRole('alert')).toContainText('500')
|
||||
// 目录请求独立发出并成功(互不拖垮)。
|
||||
expect(w.calls.some(call => call.path === '/api/admin/permissions/catalog')).toBe(true)
|
||||
await page.getByRole('button', { name: '搜索与过滤' }).click()
|
||||
await page.getByLabel('搜索角色').fill('软件')
|
||||
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByRole('alert')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('DR10 权限目录失败不影响角色列表呈现', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
w.failNextTimes('/api/admin/permissions/catalog', 1)
|
||||
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
|
||||
await expect(page.getByRole('alert')).toContainText('权限目录加载失败')
|
||||
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible()
|
||||
expect(w.calls.some(call => call.path === '/api/admin/roles')).toBe(true)
|
||||
})
|
||||
|
||||
test('DR10 键盘完成核心流程,保存后焦点稳定且状态可被播报', async ({ page }) => {
|
||||
await openAdminAsAdmin(page)
|
||||
await openRolesSection(page)
|
||||
await engRow(page).focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByLabel('角色名称', { exact: true })).toHaveValue('软件工程师')
|
||||
await page.getByLabel('编制文档 默认允许').focus()
|
||||
await page.keyboard.press('Space')
|
||||
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
|
||||
await page.getByRole('button', { name: '保存权限', exact: true }).focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByRole('status')).toContainText('角色权限已保存')
|
||||
const focused = await page.evaluate(() => (document.activeElement as HTMLElement | null)?.textContent ?? '')
|
||||
expect(focused).toContain('保存权限')
|
||||
})
|
||||
|
||||
test('DR10 窄屏矩阵横向可导航且可操作', async ({ page }) => {
|
||||
await openAdminAsAdmin(page)
|
||||
await page.setViewportSize({ width: 480, height: 800 })
|
||||
await openRolesSection(page)
|
||||
// 窄屏下视图切换按钮存在持续的布局微抖动,Playwright 稳定性检查无法通过;
|
||||
// 用 DOM 事件切换视图,随后的横向滚动与勾选仍是真实交互。
|
||||
await page.getByRole('button', { name: '角色矩阵', exact: true }).dispatchEvent('click')
|
||||
const wrap = page.locator('.rp-matrix-wrap')
|
||||
await expect(wrap).toBeVisible()
|
||||
const scroll = await wrap.evaluate(el => ({ sw: el.scrollWidth, cw: el.clientWidth }))
|
||||
expect(scroll.sw).toBeGreaterThan(scroll.cw)
|
||||
await page.getByLabel('软件工程师 创建项目 默认允许').check()
|
||||
await expect(page.getByLabel('软件工程师 创建项目 默认允许')).toBeChecked()
|
||||
})
|
||||
|
||||
test('DR7 两人共享同一角色;换绑后仅一个生效角色且计数随之变化', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
await openUsersSection(page)
|
||||
const openUser = async (owner: string) => {
|
||||
await page.locator('tr', { hasText: owner }).getByRole('button', { name: '查看与管理' }).click()
|
||||
await expect(page.getByRole('button', { name: '返回列表' })).toBeVisible()
|
||||
}
|
||||
await openUser('bob')
|
||||
await page.getByLabel('用户生效角色').selectOption({ label: '软件工程师' })
|
||||
await page.getByRole('button', { name: '替换角色', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('用户角色已替换')
|
||||
expect(w.bindings.u2).toBe('role_eng')
|
||||
await page.getByRole('button', { name: '返回列表', exact: true }).click()
|
||||
await openUser('alice')
|
||||
await expect(page.getByText(/当前角色 软件工程师/)).toBeVisible()
|
||||
await page.getByLabel('用户生效角色').selectOption({ label: '运维工程师' })
|
||||
await page.getByRole('button', { name: '替换角色', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('用户角色已替换')
|
||||
expect(w.bindings.u1).toBe('role_ops')
|
||||
expect(w.roles.find(role => role.roleId === 'role_eng')!.bindingCount).toBe(2) // bob + carol
|
||||
// 用户角色视图始终单角色:GET /role 只返回一个 binding。
|
||||
expect(w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/users/u1/role').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('DR8 资料可全空创建,并支持按电话等字段检索', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
await openUsersSection(page)
|
||||
await page.getByLabel('新用户登录名').fill('dave')
|
||||
await page.getByRole('button', { name: '创建用户', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('已创建 dave')
|
||||
const created = w.calls.find(call => call.method === 'POST' && call.path === '/api/admin/users')
|
||||
expect(created!.body!.profile).toEqual(emptyProfile())
|
||||
await page.getByRole('button', { name: '返回列表', exact: true }).click()
|
||||
await page.getByLabel('用户搜索字段').selectOption({ label: '电话' })
|
||||
await page.getByLabel('搜索用户').fill('13800000001')
|
||||
await expect(page.locator('tr', { hasText: 'alice' })).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator('tr', { hasText: 'carol' })).toHaveCount(0)
|
||||
const searches = w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/users/search')
|
||||
expect(searches.at(-1)!.query.fields).toContain('phone')
|
||||
})
|
||||
|
||||
test('DR10 加载与空态:加载期间无结果无报错,过滤为空给出选择提示', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
|
||||
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '搜索与过滤' }).click()
|
||||
const pending = w.hold('GET /api/admin/roles')
|
||||
await page.getByLabel('搜索角色').fill('不存在的角色')
|
||||
await pending.started.promise
|
||||
await expect(page.locator('.rp-role-list button').first()).toBeDisabled()
|
||||
await expect(page.getByRole('alert')).toHaveCount(0)
|
||||
pending.release.resolve()
|
||||
await expect(page.locator('.rp-role-list button')).toHaveCount(0)
|
||||
await expect(page.getByText('请选择角色')).toBeVisible()
|
||||
})
|
||||
|
||||
// ── 活体层(IAM_E2E_LIVE=1):同一文件在真实 Backend/RDM 组合上运行真实授权路径。
|
||||
// 由 Agent-System-RDM scripts/iam-u4-role-acceptance.sh 物化冻结候选源码、
|
||||
// 构建内嵌产品、全新 SQLite 数据根与随机回环端口后驱动;不拦截任何路由。
|
||||
const LIVE = process.env.IAM_E2E_LIVE === '1'
|
||||
|
||||
async function liveLogin(page: Page) {
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('由管理员分配').fill(process.env.IAM_E2E_OWNER ?? '')
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(process.env.IAM_E2E_PASSWORD ?? '')
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await expect(page.locator('.tsb-user')).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
async function openAdminZoneLive(page: Page) {
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
|
||||
}
|
||||
|
||||
test('LIVE 真实登录加载真实权限目录,创建、配置并停用角色', async ({ page }) => {
|
||||
test.skip(!LIVE, 'live 层由 iam-u4-role-acceptance.sh 驱动')
|
||||
test.setTimeout(90_000)
|
||||
await liveLogin(page)
|
||||
await openAdminZoneLive(page)
|
||||
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
|
||||
await page.getByRole('button', { name: '搜索与过滤' }).click()
|
||||
await expect(page.getByLabel('新角色名称')).toBeVisible({ timeout: 15_000 })
|
||||
// 真实空库无角色:先经真实 Backend 创建,选中后权限表才渲染真实目录。
|
||||
await page.getByLabel('新角色名称').fill('数据工程师')
|
||||
await page.getByLabel('新角色描述').fill('活体验收职位')
|
||||
await page.getByRole('button', { name: '创建角色', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('已创建角色 数据工程师', { timeout: 15_000 })
|
||||
// 真实目录的首页可能全是受保护系统授权(复选框禁用):逐组翻到可编辑动作。
|
||||
const firstDefault = page.locator('input[aria-label$="默认允许"]:not([disabled])').first()
|
||||
for (let pageIdx = 0; (await firstDefault.count()) === 0 && pageIdx < 15; pageIdx++) {
|
||||
await page.getByRole('button', { name: '下一组', exact: true }).click()
|
||||
await page.waitForTimeout(150)
|
||||
}
|
||||
await expect(firstDefault).toBeVisible({ timeout: 15_000 })
|
||||
await firstDefault.check()
|
||||
await page.getByRole('button', { name: '保存权限', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('角色权限已保存', { timeout: 15_000 })
|
||||
await page.getByRole('button', { name: '停用角色', exact: true }).click()
|
||||
await expect(page.getByRole('alertdialog')).toContainText('数据工程师')
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('角色已停用,现有绑定不再授予权限', { timeout: 15_000 })
|
||||
})
|
||||
|
||||
test('LIVE DR13 非成员账号被拒读取项目业务内容;创建者放行', async ({ request }) => {
|
||||
test.skip(!LIVE, 'live 层由 iam-u4-role-acceptance.sh 驱动')
|
||||
const base = process.env.PLAYWRIGHT_BASE_URL!
|
||||
const projectId = process.env.IAM_E2E_PROJECT_ID
|
||||
test.expect(projectId).toBeTruthy()
|
||||
const memberList = async (owner: string, password: string) => {
|
||||
await request.post(`${base}/api/auth/login`, { data: { owner, password } })
|
||||
const res = await request.post(`${base}/api/commands/rdm.project.member.list`, {
|
||||
data: { params: { projectId } },
|
||||
})
|
||||
const body = await res.json().catch(() => null)
|
||||
return { ok: res.ok(), body }
|
||||
}
|
||||
const observer = await memberList(process.env.IAM_E2E_OBSERVER_OWNER ?? '', process.env.IAM_E2E_OBSERVER_PASSWORD ?? '')
|
||||
const denied = !observer.ok || observer.body?.receipt?.state === 'rejected'
|
||||
expect(denied, `observer 应被拒绝:${JSON.stringify(observer.body).slice(0, 240)}`).toBe(true)
|
||||
const creator = await memberList(process.env.IAM_E2E_OWNER ?? '', process.env.IAM_E2E_PASSWORD ?? '')
|
||||
const allowed = creator.ok && creator.body?.receipt?.state !== 'rejected'
|
||||
expect(allowed, `创建者应放行:${JSON.stringify(creator.body).slice(0, 240)}`).toBe(true)
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
// 管理员随机临时密码与登录入口回归:管理员重置 → 用户密码登录 → 强制改密;
|
||||
// 登录页不再提供一次性 Key 登录 tab。Key 的分发/管理 API 与非登录页流程保留。
|
||||
// 管理员发放一次性登录密码与登录入口回归:管理员生成一次性密码
|
||||
// → 用户密码登录 → 强制改密;登录页不再提供一次性 Key 登录 tab。
|
||||
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
|
||||
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
|
||||
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
|
||||
@@ -51,17 +51,17 @@ async function adminLogin(page: Page) {
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test('管理员随机重置:临时密码登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
|
||||
test('管理员生成一次性登录密码:用户登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
|
||||
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
|
||||
test.setTimeout(120_000)
|
||||
// 1) 管理员进入用户管理,为目标用户生成随机临时密码(明文仅显示一次)。
|
||||
// 1) 管理员进入用户管理,为目标用户生成一次性登录密码(明文仅显示一次)。
|
||||
await adminLogin(page)
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||
const zone = page.getByRole('dialog', { name: '系统管理' })
|
||||
await expect(zone).toBeVisible()
|
||||
const row = zone.locator('tr', { hasText: USER })
|
||||
await row.getByRole('button', { name: '重置临时密码' }).click()
|
||||
await row.getByRole('button', { name: '生成一次性登录密码' }).click()
|
||||
const temporaryPassword = zone.locator('input[aria-label="一次性密码"]')
|
||||
await expect(temporaryPassword).toBeVisible({ timeout: 10_000 })
|
||||
const password = await temporaryPassword.inputValue()
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { readFileSync, readlinkSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
const manifestPath=process.env.WORK_CHECKPOINT_HTTP_MANIFEST
|
||||
test.skip(!manifestPath,'Run explicitly with a task-owned mechanism instance manifest')
|
||||
const manifest=manifestPath?JSON.parse(readFileSync(manifestPath,'utf8')):null
|
||||
if(manifest&&(manifest.mode!=='mechanism'||!manifest.dataDir.includes('fast-slow-u2-u3-')||readlinkSync(`/proc/${manifest.pid}/exe`)!==manifest.binary||createHash('sha256').update(readFileSync(manifest.binary)).digest('hex')!==manifest.binarySHA256))throw new Error('Owned mechanism instance verification failed')
|
||||
|
||||
type Scenario='normal'|'fast-ack'|'queue-hold'|'slow-stages'|'zero-failure'|'partial-failure'|'choice'
|
||||
async function setup(request:any,scenario:Scenario='normal',awaitTask=true,options:{text?:string,small?:boolean,startFailure?:boolean}={}){
|
||||
const project=await(await request.post(manifest.base+'/api/projects',{data:{name:'browser-'+Date.now()}})).json()
|
||||
const conv=await(await request.post(manifest.base+`/api/projects/${project.id}/conversations`,{data:{title:'材料整理',profileId:'checkpoint-eval'}})).json()
|
||||
const ids:string[]=[]
|
||||
for(let i=0;i<(options.small?1:3);i++){
|
||||
const marker=i===0&&scenario!=='normal'?`[fixture:${scenario}]\n`:''
|
||||
const res=await request.post(manifest.base+`/api/projects/${project.id}/uploads`,{multipart:{file:{name:`来源${i+1}.txt`,mimeType:'text/plain',buffer:Buffer.from(marker+'上线六月。验收条件需要补齐,预算50万元尚未审批。交付包含12项需求,其中3项依赖外部接口。第一期先完成8项核心需求。\n\n'+(options.small?'':('整理记录说明:保留日期、金额、限制和待确认事项。\n\n').repeat(options.text?250:100)))}}})
|
||||
expect(res.ok()).toBeTruthy();ids.push((await res.json()).artifactId)
|
||||
}
|
||||
if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:true}})).status()).toBe(204)
|
||||
let sendBody:any
|
||||
try{
|
||||
const sent=await request.post(manifest.base+`/api/conversations/${conv.id}/messages`,{data:{text:options.text??'',attachmentIds:ids,requestId:'browser-'+Date.now()}})
|
||||
expect(sent.ok()).toBeTruthy()
|
||||
sendBody=await sent.json()
|
||||
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${sendBody.run.id}`)).json()).run.status).toBe('completed')
|
||||
}finally{
|
||||
if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:false}})).status()).toBe(204)
|
||||
}
|
||||
if(awaitTask)await expect.poll(async()=>{
|
||||
const out=await(await request.get(manifest.base+`/api/conversations/${conv.id}/cards`)).json()
|
||||
return out.cards.find((c:any)=>c.task)?.task.executionState
|
||||
}).toBe(scenario.endsWith('failure')?'failed':'completed')
|
||||
return {project,conv,ids,sendRunId:sendBody.run.id}
|
||||
}
|
||||
async function waitForFailedWorkCard(request:any,seed:any){
|
||||
await expect.poll(async()=>{
|
||||
const out=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()
|
||||
const card=out.cards.find((c:any)=>c.task)
|
||||
return card?.task.executionState==='failed'&&card.status==='failed'&&Boolean(card.work)
|
||||
}).toBe(true)
|
||||
}
|
||||
async function expectOnlySendRun(request:any,seed:any){
|
||||
const current=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}`)).json()
|
||||
expect(current.runs.filter((r:any)=>r.kind==='conversation').map((r:any)=>r.id)).toEqual([seed.sendRunId])
|
||||
}
|
||||
async function open(page:any,seed:any){
|
||||
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
|
||||
await page.goto('/')
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
|
||||
}
|
||||
test('attachment-only intake gives a safe first reply before sourced findings',async({page,request})=>{
|
||||
const seed=await setup(request,'fast-ack',false)
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
|
||||
expect(cards.find((c:any)=>c.task).task.checkpointSeq??0).toBe(0)
|
||||
await open(page,seed)
|
||||
const reply=page.getByText(/已收到 3 份材料.*待确认/)
|
||||
await expect(reply).toBeVisible()
|
||||
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
|
||||
await page.setViewportSize({width:390,height:844})
|
||||
await expect(reply).toBeVisible()
|
||||
if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT.replace(/\.png$/, '-mobile.png'),fullPage:true})
|
||||
await page.reload()
|
||||
await expect(reply).toBeVisible()
|
||||
})
|
||||
test('text with long material keeps the user focus in the host reply',async({page,request})=>{
|
||||
const seed=await setup(request,'fast-ack',false,{text:'重点核对预算审批'})
|
||||
await open(page,seed)
|
||||
await expect(page.getByText(/你提出的工作重点已带入整理任务.*待确认/)).toBeVisible()
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
|
||||
})
|
||||
test('running and completed resends reuse one material card',async({page,request})=>{
|
||||
const seed=await setup(request,'slow-stages',false)
|
||||
const resend=async(key:string)=>{
|
||||
const response=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:seed.ids,requestId:key}})
|
||||
expect(response.ok()).toBeTruthy()
|
||||
const body=await response.json()
|
||||
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${body.run.id}`)).json()).run.status).toBe('completed')
|
||||
}
|
||||
await resend('active-resend-'+Date.now())
|
||||
await open(page,seed)
|
||||
await expect(page.getByText(/这批材料仍在整理.*待确认/)).toBeVisible()
|
||||
await expect.poll(async()=>{
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
return cards.find((c:any)=>c.task)?.task.executionState
|
||||
}).toBe('completed')
|
||||
await resend('completed-resend-'+Date.now())
|
||||
await expect(page.getByText(/这批材料已整理完成.*待确认/)).toBeVisible()
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
|
||||
})
|
||||
test('text with a small attachment retains the ordinary reply path',async({page,request})=>{
|
||||
const seed=await setup(request,'normal',true,{text:'请先解释材料要点',small:true})
|
||||
await open(page,seed)
|
||||
await expect(page.getByText('已收到材料,正在后台整理;请结合阶段发现继续工作。').first()).toBeVisible()
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
|
||||
})
|
||||
test('real HTTP task recovery, source navigation and changed-version limitation',async({page,request})=>{
|
||||
const seed=await setup(request);const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message));await open(page,seed)
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐')
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('材料未说明具体归属')
|
||||
await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
|
||||
await page.getByRole('button',{name:'查看依据',exact:true}).first().click()
|
||||
await page.getByRole('button',{name:/查看来源/}).first().click()
|
||||
await expect(page.locator('.preview-pane')).toBeVisible();await expect(page.getByText('任务处理时引用的来源版本;下方是本项发现引用的片段')).toBeVisible()
|
||||
const card=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.find((c:any)=>c.task)
|
||||
const source=card.task.findings[0].sources[0]
|
||||
const update=await request.put(manifest.base+`/api/artifacts/${source.artifactId}/content`,{data:'上线七月。文件内容已经更新。',headers:{'Content-Type':'text/plain'}});expect(update.ok()).toBeTruthy()
|
||||
const old=await request.get(manifest.base+`/api/artifacts/${source.artifactId}/content?expectedHash=${source.contentHash}`);expect(old.status()).toBe(409)
|
||||
await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('依据文件已更新')
|
||||
await expect(page.locator('[data-conversation-work] .acard-findings').first()).not.toContainText('上线六月')
|
||||
expect(errors).toEqual([])
|
||||
if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT,fullPage:true})
|
||||
})
|
||||
test('background replay does not steal the current file, focus or viewport',async({page,request})=>{
|
||||
const seed=await setup(request);await open(page,seed)
|
||||
await page.getByRole('button',{name:'查看依据',exact:true}).first().click();await page.getByRole('button',{name:/查看来源/}).first().click();await expect(page.locator('.preview-pane')).toBeVisible()
|
||||
const tabs=await page.locator('.tab-strip').textContent()
|
||||
const composer=page.locator('textarea').last();await composer.fill('正在记录自己的想法');await composer.focus()
|
||||
const focus=await composer.evaluate((el:any)=>document.activeElement===el);expect(focus).toBe(true)
|
||||
const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'重点核对预算',attachmentIds:seed.ids,requestId:'another-'+Date.now()}});expect(sent.ok()).toBeTruthy()
|
||||
await expect.poll(async()=>{
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
return cards.filter((c:any)=>c.task?.executionState==='completed').length
|
||||
}).toBe(2)
|
||||
await expect(composer).toBeFocused();await expect(composer).toHaveValue('正在记录自己的想法')
|
||||
expect(await page.locator('.tab-strip').textContent()).toBe(tabs)
|
||||
})
|
||||
test('two sourced stages appear while the material task is still running',async({page,request})=>{
|
||||
const seed=await setup(request,'slow-stages',false)
|
||||
await open(page,seed)
|
||||
const cards=async()=>(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('running')
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
|
||||
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState==='running'&&card.task.checkpointSeq>=2&&card.task.findings?.length>1}).toBe(true)
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐')
|
||||
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('completed')
|
||||
})
|
||||
for(const scenario of ['zero-failure','partial-failure'] as const){
|
||||
test(`${scenario} shows the saved scope without a deliverable choice`,async({page,request})=>{
|
||||
const seed=await setup(request,scenario)
|
||||
await waitForFailedWorkCard(request,seed)
|
||||
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
|
||||
await page.goto('/')
|
||||
const work=page.locator('[data-conversation-work]')
|
||||
await expect(work).toContainText('执行失败')
|
||||
if(scenario==='zero-failure')await expect(work).toContainText('暂无阶段结论记录')
|
||||
else await expect(work).toContainText('上线六月')
|
||||
await expect(page.locator('.work-choice')).toHaveCount(0)
|
||||
await expect(page.locator('.hitl-choice')).toHaveCount(0)
|
||||
await expectOnlySendRun(request,seed)
|
||||
})
|
||||
}
|
||||
test('completed sourced work offers only its grounded suggestions',async({page,request})=>{
|
||||
const seed=await setup(request,'choice')
|
||||
await open(page,seed)
|
||||
const work=page.locator('[data-conversation-work]')
|
||||
await expect(work.locator('.work-choice')).toHaveCount(3)
|
||||
for(const label of ['会议摘要','验收清单','联调计划'])await expect(work.locator('.work-choice').filter({hasText:label})).toHaveCount(1)
|
||||
await expect(work.locator('.work-decision legend')).toContainText('接下来需要做什么?')
|
||||
await page.reload()
|
||||
await expect(work.locator('.work-choice')).toHaveCount(3)
|
||||
})
|
||||
test('failed material registration reports no started task or unsupported finding',async({page,request})=>{
|
||||
const seed=await setup(request,'normal',false,{startFailure:true})
|
||||
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
|
||||
await page.goto('/')
|
||||
await expect(page.getByText(/后台整理未能启动/).first()).toBeVisible()
|
||||
const conversation=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}`)).json()
|
||||
expect(conversation.messages.some((m:any)=>m.role==='assistant'&&(m.parts??[]).some((p:any)=>p.type==='text'&&p.text?.includes('未能启动')))).toBe(true)
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
expect(cards.filter((c:any)=>c.task)).toHaveLength(0)
|
||||
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
|
||||
})
|
||||
test('saturated material slots show queued status without claiming execution',async({page,request})=>{
|
||||
const seed=await setup(request,'queue-hold',false)
|
||||
const sendAnother=async(label:string,hold:boolean)=>{
|
||||
const uploaded=await request.post(manifest.base+`/api/projects/${seed.project.id}/uploads`,{
|
||||
multipart:{file:{name:`${label}.txt`,mimeType:'text/plain',buffer:Buffer.from((hold?'[fixture:queue-hold]\n':'')+'上线六月。验收条件需要补齐。'+label)}}
|
||||
})
|
||||
expect(uploaded.ok()).toBeTruthy()
|
||||
const artifactId=(await uploaded.json()).artifactId
|
||||
const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:[artifactId],requestId:'queue-'+label}})
|
||||
expect(sent.ok()).toBeTruthy()
|
||||
const runId=(await sent.json()).run.id
|
||||
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${runId}`)).json()).run.status).toBe('completed')
|
||||
}
|
||||
await sendAnother('占用二',true)
|
||||
await sendAnother('占用三',true)
|
||||
await sendAnother('等待四',false)
|
||||
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
|
||||
await page.goto('/')
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('排队中')
|
||||
await expect(page.getByText(/整理任务正在排队.*待确认/).first()).toBeVisible()
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.filter((c:any)=>c.task)
|
||||
expect(cards).toHaveLength(4)
|
||||
expect(cards.filter((c:any)=>c.task.executionState==='queued')).toHaveLength(1)
|
||||
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
|
||||
})
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"exports": 51
|
||||
"exports": 58
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadSignedInWorkspace } from './App'
|
||||
import { store } from './store'
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
function stubLoad() {
|
||||
const order: string[] = []
|
||||
vi.spyOn(store, 'refreshMe').mockImplementation(async () => { order.push('me') })
|
||||
vi.spyOn(store, 'refreshProfiles').mockImplementation(async () => {})
|
||||
vi.spyOn(store, 'refreshPlugins').mockImplementation(async () => {})
|
||||
vi.spyOn(store, 'refreshProjects').mockImplementation(async () => { order.push('projects') })
|
||||
vi.spyOn(store, 'startProjectSync').mockImplementation(() => { order.push('sync') })
|
||||
return order
|
||||
}
|
||||
|
||||
describe('AutoDiscover ownership after sign-in', () => {
|
||||
it('checks projects immediately on sign-in, then hands re-checks to the single sync owner', async () => {
|
||||
const order = stubLoad()
|
||||
await loadSignedInWorkspace({ current: true })
|
||||
expect(order).toEqual(['me', 'projects', 'sync'])
|
||||
expect(store.startProjectSync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not start a sync owner for a page that was torn down during loading', async () => {
|
||||
const order = stubLoad()
|
||||
const active = { current: true }
|
||||
vi.mocked(store.refreshProjects).mockImplementation(async () => { order.push('projects'); active.current = false })
|
||||
await loadSignedInWorkspace(active)
|
||||
expect(store.startProjectSync).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+13
-8
@@ -25,6 +25,16 @@ const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
|
||||
const RIGHT_MIN = 280, RIGHT_MAX = 560
|
||||
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
|
||||
|
||||
/** 放行后的首轮装载:首次项目列表就是登录即时核对;其后由唯一的自动发现所有者接管重查。 */
|
||||
export async function loadSignedInWorkspace(active: { current: boolean }) {
|
||||
await store.refreshMe()
|
||||
if (!active.current) return
|
||||
void store.refreshProfiles()
|
||||
void store.refreshPlugins()
|
||||
await store.refreshProjects()
|
||||
if (active.current) store.startProjectSync()
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const ui = useStore(s => s.ui)
|
||||
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
|
||||
@@ -114,14 +124,9 @@ export function App() {
|
||||
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
|
||||
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
|
||||
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
|
||||
let active = true
|
||||
store.refreshMe().then(() => {
|
||||
if (!active) return
|
||||
void store.refreshProjects()
|
||||
void store.refreshProfiles()
|
||||
void store.refreshPlugins()
|
||||
})
|
||||
return () => { active = false }
|
||||
const active = { current: true }
|
||||
void loadSignedInWorkspace(active)
|
||||
return () => { active.current = false; store.stopProjectSync() }
|
||||
}, [authState])
|
||||
|
||||
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// 账号管理动作呈现门控(超级管理员 R3/R4/R8):只决定 UI 暴露哪些入口,
|
||||
// 服务端授权仍是唯一权威——隐藏的入口经直连 API 调用同样会被拒绝。
|
||||
interface AccountIdentity { isAdmin?: boolean; isSuperAdmin?: boolean }
|
||||
|
||||
type AccountIdentityKind = 'super' | 'admin' | 'user'
|
||||
|
||||
export const accountIdentityKind = (account: AccountIdentity): AccountIdentityKind =>
|
||||
account.isSuperAdmin ? 'super' : account.isAdmin ? 'admin' : 'user'
|
||||
|
||||
/** 创建管理员账号与任命/撤销管理员仅对超级管理员开放。 */
|
||||
export const canAppointAdmins = (me: AccountIdentity | null | undefined) => !!me?.isSuperAdmin
|
||||
|
||||
interface AccountActionPolicy {
|
||||
/** 资料、角色与系统权限维护。 */
|
||||
maintain: boolean
|
||||
toggleEnabled: boolean
|
||||
/** 管理员重置临时密码与恢复提案。 */
|
||||
resetPassword: boolean
|
||||
adminChange: 'promote' | 'demote' | null
|
||||
protectedReason: string
|
||||
}
|
||||
|
||||
export function accountActionPolicy(me: AccountIdentity | null | undefined, target: AccountIdentity): AccountActionPolicy {
|
||||
if (target.isSuperAdmin) {
|
||||
const self = !!me?.isSuperAdmin
|
||||
return {
|
||||
maintain: self, toggleEnabled: false, resetPassword: false, adminChange: null,
|
||||
protectedReason: self
|
||||
? '超级管理员账号不能停用、降级或由管理员重置密码;本人密码请在「设置 → 账号」修改'
|
||||
: '超级管理员账号受保护:普通管理员不能编辑、停用、降级或重置其密码',
|
||||
}
|
||||
}
|
||||
return {
|
||||
maintain: true, toggleEnabled: true, resetPassword: true,
|
||||
adminChange: canAppointAdmins(me) ? (target.isAdmin ? 'demote' : 'promote') : null,
|
||||
protectedReason: '',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from './api'
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
describe('AutoDiscover list request', () => {
|
||||
it('passes the abort signal so an over-budget project list request is cancelled', async () => {
|
||||
const seen: (RequestInit | undefined)[] = []
|
||||
vi.stubGlobal('fetch', async (_path: string, init?: RequestInit) => {
|
||||
seen.push(init)
|
||||
return new Response('[]', { status: 200 })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
await api.listProjects(controller.signal)
|
||||
await api.listProjects()
|
||||
expect(seen[0]?.signal).toBe(controller.signal)
|
||||
expect(seen[1]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads the linked creation capability from the identity response', async () => {
|
||||
vi.stubGlobal('fetch', async () => new Response(JSON.stringify({ owner: 'bob', name: 'Bob', auth: true,
|
||||
linkedCapabilities: { projectCreate: true, source: 'system-grant', linkedActions: ['project.members.manage'], authorityEpoch: 4 } }), { status: 200 }))
|
||||
const me = await api.me()
|
||||
expect(me.linkedCapabilities).toMatchObject({ projectCreate: true, source: 'system-grant' })
|
||||
})
|
||||
|
||||
it('returns the linked expansion from a role permission write receipt', async () => {
|
||||
vi.stubGlobal('fetch', async () => new Response(JSON.stringify({ receiptId: 'r1', roleVersion: 3, authorityEpoch: 9,
|
||||
linkedExpansion: ['project.members.manage'] }), { status: 200 }))
|
||||
const receipt = await api.replaceRolePermissions('role_pm', 2, [{ actionId: 'project.create', allowed: true, capAllowed: true }], 'key')
|
||||
expect(receipt.linkedExpansion).toEqual(['project.members.manage'])
|
||||
})
|
||||
})
|
||||
+97
-43
@@ -14,26 +14,50 @@ 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 }
|
||||
export interface RolePermissionRecord { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
|
||||
export interface RoleRecord {
|
||||
roleId: string; name: string; description: string; enabled: boolean; roleVersion: number
|
||||
bindingCount: number; permissions: RolePermissionRecord[]
|
||||
}
|
||||
interface RoleListPage { roles: RoleRecord[]; nextCursor?: string }
|
||||
export interface UserRoleBinding {
|
||||
bindingId: string; userId: string; roleId: string; active: boolean; bindingVersion: number
|
||||
assignedBy: string; assignedAt: string; userStateVersion: number; authorityEpoch: number
|
||||
}
|
||||
/** getUserRole 返回的角色摘要:实测不含 permissions,权限补齐由角色列表条目提供。 */
|
||||
export interface UserRoleSummary extends Omit<RoleRecord, 'permissions'> { permissions?: RolePermissionRecord[] }
|
||||
export interface UserRoleView { binding?: UserRoleBinding; role?: UserRoleSummary }
|
||||
/** Backend 计算的本人 project.create 联动(/api/me linkedCapabilities)。 */
|
||||
export interface LinkedCapabilities {
|
||||
projectCreate: boolean; source?: string; linkedActions: string[]; conflicts?: string[]; authorityEpoch: number
|
||||
}
|
||||
/** 角色/系统授权写入回执:linkedExpansion 为服务端为 project.create 联动补齐的动作。 */
|
||||
interface PolicyWriteReceipt { receiptId: string; authorityEpoch: number; linkedExpansion?: string[]; migrationNote?: string }
|
||||
|
||||
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
|
||||
* passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码,
|
||||
* passwordChangeMode 由服务端从会话派生;grant_setup/one_time_password 免当前密码,
|
||||
* current_required 必须校验当前密码。 */
|
||||
export type PasswordChangeMode = 'grant_setup' | 'current_required'
|
||||
|
||||
/** 恢复提案(属主确认制):管理员只能提案,确认须用提案前已存在的合格完整密码会话。 */
|
||||
export interface RecoveryProposal {
|
||||
id: string; userId: string; owner: string
|
||||
status: 'pending' | 'confirmed' | 'completed' | 'revoked' | 'expired'
|
||||
version: number; generation: number; createdBy: string
|
||||
createdAt: string; expiresAt: string
|
||||
}
|
||||
export type PasswordChangeMode = 'grant_setup' | 'current_required' | 'one_time_password'
|
||||
|
||||
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
|
||||
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
|
||||
export interface AccountRecord {
|
||||
userId: string; owner: string; profile: AccountProfile; profileVersion: number
|
||||
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; passwordSet?: boolean
|
||||
initializationState?: string; initializationGeneration?: number
|
||||
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; isSuperAdmin?: boolean; passwordSet?: boolean
|
||||
initializationState?: string; initializationGeneration?: number; createdAt?: string
|
||||
}
|
||||
/** 管理端用户目录/详情视图:账号状态与版本字段必有(乐观并发依赖 stateVersion)。 */
|
||||
export interface ManagedAccountRecord extends AccountRecord {
|
||||
enabled: boolean; stateVersion: number; isAdmin: boolean; isSuperAdmin: boolean; passwordSet: boolean
|
||||
initializationState: string; initializationGeneration: number
|
||||
}
|
||||
interface AccountSearchPage { accounts: ManagedAccountRecord[]; nextCursor?: string; directoryVersion: string }
|
||||
export interface AdminIssuedOneTimePassword {
|
||||
owner: string
|
||||
id: string
|
||||
oneTimePassword: string
|
||||
mustChangePassword: boolean
|
||||
}
|
||||
|
||||
export interface ExecEnvView {
|
||||
@@ -59,15 +83,49 @@ async function json<T>(res: Response): Promise<T> {
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export type ConversationView = Conversation & { /** 无项目对话:服务端内部项目只承载这一条会话,不出现在项目列表里。 */ scratch?: boolean }
|
||||
|
||||
export const api = {
|
||||
listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json<Project[]>(r)),
|
||||
permissionCatalog: () => auth.fetch(`${BASE}/api/admin/permissions/catalog`).then(r => json<{ permissions: PermissionDescriptor[] }>(r)).then(d => d.permissions ?? []),
|
||||
listRoles: (request: { query?: string; mode?: 'exact' | 'fuzzy'; status?: 'all' | 'enabled' | 'disabled'; action?: string; 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.action) params.set('action', request.action)
|
||||
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${query ? `?${query}` : ''}`).then(r => json<RoleListPage>(r))
|
||||
},
|
||||
getRole: (roleId: string) => auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`).then(r => json<RoleRecord>(r)),
|
||||
createRole: (name: string, description: string) => auth.fetch(`${BASE}/api/admin/roles`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description }),
|
||||
}).then(r => json<RoleRecord>(r)),
|
||||
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<PolicyWriteReceipt & { roleVersion: number }>(r)),
|
||||
getUserRole: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`).then(r => json<UserRoleView>(r)),
|
||||
assignUserRole: (userId: string, roleId: string, expectedStateVersion: number, idempotencyKey: string) =>
|
||||
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ roleId, expectedStateVersion, idempotencyKey }) }).then(r => json<UserRoleBinding>(r)),
|
||||
getSystemGrants: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`).then(r => json<{ actions: string[]; authorityEpoch: number }>(r)),
|
||||
replaceSystemGrants: (userId: string, actions: string[], expectedAuthorityEpoch: number, idempotencyKey: string) =>
|
||||
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ actions, expectedAuthorityEpoch, idempotencyKey }) }).then(r => json<PolicyWriteReceipt>(r)),
|
||||
explainSystemAction: (action: string) => auth.fetch(`${BASE}/api/authz/explain?action=${encodeURIComponent(action)}`).then(r => json<{ allowed: boolean; actionId: string; source?: string; reason?: string; authorityEpoch: number }>(r)),
|
||||
listProjects: (signal?: AbortSignal) => auth.fetch(`${BASE}/api/projects`, signal ? { signal } : undefined).then(r => json<Project[]>(r)),
|
||||
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
||||
createScratchSession: (title?: string) => auth.fetch(`${BASE}/api/scratch-sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(title ? { title } : {}) }).then(r => json<{ project: Project; conversation: ConversationView }>(r)),
|
||||
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
||||
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
|
||||
|
||||
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
|
||||
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<ConversationView[]>(r)),
|
||||
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
|
||||
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<Conversation[]>(r)),
|
||||
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<ConversationView[]>(r)),
|
||||
setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean; allowCrossRead?: boolean; crossPrivate?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
||||
setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
||||
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
|
||||
@@ -79,19 +137,11 @@ export const api = {
|
||||
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||||
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
|
||||
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities }>(r)),
|
||||
// ---- 认证与会话(管理员发放制,R2)----
|
||||
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
|
||||
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 => {
|
||||
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; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
@@ -102,27 +152,29 @@ export const api = {
|
||||
},
|
||||
changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
|
||||
}),
|
||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
|
||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
|
||||
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
|
||||
createAdminUser: (owner: string, name: string, password?: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
|
||||
adminResetUserPassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<{ owner: string; temporaryPassword: string; mustChangePassword: boolean }>(r)),
|
||||
// ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除;
|
||||
// list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)----
|
||||
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
|
||||
adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)),
|
||||
// ---- 管理员:恢复提案(属主确认制,替换已 410 的直接签发 Key 入口)----
|
||||
createAdminRecoveryProposal: (userId: string, expectedStateVersion: number, idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, idempotencyKey }) }).then(r => json<RecoveryProposal>(r)),
|
||||
listAdminRecoveryProposals: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
|
||||
adminRevokeRecoveryProposal: (userId: string, id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<RecoveryProposal>(r)),
|
||||
// ---- 本人:资料与恢复提案确认(须完整密码会话)----
|
||||
// isAdmin 仅超级管理员可用(R3/R4),普通管理员携带时由服务端拒绝。
|
||||
createAdminUser: (owner: string, name: string, password?: string, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}), ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
|
||||
// 服务端严格解码 JSON 对象体:无 Content-Type 与请求体的 POST 会被拒为 400 EOF。
|
||||
adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<AdminIssuedOneTimePassword>(r)),
|
||||
searchAdminUsers: (request: { query: string; mode: 'exact' | 'fuzzy'; status: 'all' | 'enabled' | 'disabled'; fields: string[]; cursor?: string; limit?: number }) => {
|
||||
const params = new URLSearchParams({ query: request.query, mode: request.mode, status: request.status,
|
||||
fields: request.fields.join(','), limit: String(request.limit ?? 20) })
|
||||
if (request.cursor) params.set('cursor', request.cursor)
|
||||
return auth.fetch(`${BASE}/api/admin/users/search?${params}`).then(r => json<AccountSearchPage>(r))
|
||||
},
|
||||
createManagedUser: (owner: string, profile: Partial<AccountProfile>, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile, ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
adminGetUser: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}`).then(r => json<ManagedAccountRecord>(r)),
|
||||
adminPatchProfile: (userId: string, expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
adminSetUserEnabled: (userId: string, expectedStateVersion: number, enabled: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/state`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, enabled }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
// 任命/撤销管理员:仅超级管理员(R3);超级管理员本身不可降级(R5)。
|
||||
adminSetUserAdmin: (userId: string, expectedStateVersion: number, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/admin`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, isAdmin }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
// ---- 本人:资料(须完整密码会话)----
|
||||
meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json<AccountRecord>(r)),
|
||||
patchMeProfile: (expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(r)),
|
||||
listMyRecoveryProposals: () => auth.fetch(`${BASE}/api/me/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
|
||||
getMyRecoveryProposal: (id: string) => auth.fetch(`${BASE}/api/me/recovery-proposals/${encodeURIComponent(id)}`).then(r => json<RecoveryProposal>(r)),
|
||||
confirmMyRecoveryProposal: (id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
|
||||
rotateMyRecoveryGrant: (id: string, expectedVersion: number, expectedGeneration: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/rotate-grant`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion, expectedGeneration }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
|
||||
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
|
||||
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
|
||||
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
|
||||
@@ -139,7 +191,7 @@ export const api = {
|
||||
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
|
||||
|
||||
conversationDetail: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`).then(r =>
|
||||
json<{ conversation: Conversation; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number; resumeSeq?: number }>(r)),
|
||||
json<{ conversation: ConversationView; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number; resumeSeq?: number }>(r)),
|
||||
|
||||
runEventsPage: (convId: string, runId: string, after: number, signal?: AbortSignal) =>
|
||||
auth.fetch(`${BASE}/api/conversations/${encodeURIComponent(convId)}/runs/${encodeURIComponent(runId)}/events?after=${after}&limit=500`, { signal })
|
||||
@@ -159,6 +211,8 @@ export const api = {
|
||||
auth.fetch(`${BASE}/api/hitl/${cardId}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(resp) })
|
||||
.then(r => json<{ resolved: boolean; pivoted: boolean; pivotText: string }>(r)),
|
||||
|
||||
taskSource: (cardId:string,sourceId:string) => auth.fetch(`${BASE}/api/cards/${encodeURIComponent(cardId)}/sources/${encodeURIComponent(sourceId)}`).then(r=>json<{source:import('./contracts').TaskSourceRef;versionState:string;artifact?:Artifact}>(r)),
|
||||
listCardsSnapshot: (conversationId: string): Promise<{cards: ActivityCard[]; replaySeq?: number}> => auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{cards: ActivityCard[]; replaySeq?: number}>(r)),
|
||||
listCards: (conversationId: string) =>
|
||||
auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []),
|
||||
cardAction: (cardId: string, actionId: string, payload?: string | WorkCardAction) =>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { PermissionDescriptor } from '../api'
|
||||
import { GrantChangePreview, GrantGroupEditor, ProjectCreateSourceNote, toggleGrantGroup } from './AccountUsersPanel'
|
||||
import { buildCapabilityGroups } from '../rolePermissions'
|
||||
|
||||
const descriptor = (actionId: string, label: string, group: string, scope: 'system' | 'project' = 'system'): PermissionDescriptor =>
|
||||
({ actionId, label, description: '', group, scope, protected: scope === 'system' && actionId !== 'project.create' })
|
||||
const catalog = [
|
||||
descriptor('system.users.view', '查看用户', '用户管理'),
|
||||
descriptor('system.users.create', '创建用户', '用户管理'),
|
||||
descriptor('system.roles.view', '查看角色', '角色管理'),
|
||||
descriptor('project.create', '创建项目', '项目入口'),
|
||||
descriptor('project.members.manage', '管理项目成员', '项目管理', 'project'),
|
||||
]
|
||||
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
|
||||
|
||||
describe('CapabilityGroup explicit grants', () => {
|
||||
it('grants system capabilities by group instead of one checkbox per action', () => {
|
||||
const html = renderToStaticMarkup(<GrantGroupEditor catalog={catalog} grants={['system.users.view']} disabled={false} onChange={vi.fn()} />)
|
||||
expect(html.match(/type="checkbox"/g)).toHaveLength(3)
|
||||
expect(html).toContain('授予 用户管理')
|
||||
expect(html).toContain('用户管理(部分)')
|
||||
expect(html).toContain('联动本人创建项目的设置、成员、项目权限与文档职责管理')
|
||||
expect(html).not.toContain('管理项目成员')
|
||||
})
|
||||
|
||||
it('toggles every action of a group together', () => {
|
||||
const users = buildCapabilityGroups(catalog.filter(item => item.scope === 'system')).find(group => group.id === 'catalog:用户管理')!
|
||||
expect(toggleGrantGroup(['system.roles.view'], users, true)).toEqual(['system.roles.view', 'system.users.create', 'system.users.view'])
|
||||
expect(toggleGrantGroup(['system.users.view', 'system.roles.view'], users, false)).toEqual(['system.roles.view'])
|
||||
})
|
||||
|
||||
it('previews the grant difference with its source and the single affected user', () => {
|
||||
const html = renderToStaticMarkup(<GrantChangePreview baseline={['system.users.view']} grants={['project.create']} labels={labels} owner="bob" />)
|
||||
expect(html).toContain('影响来源:明确系统授权 · 影响 1 名用户(bob)')
|
||||
expect(html).toContain('新增:创建项目(联动本人创建项目的管理资格)')
|
||||
expect(html).toContain('撤销:查看用户')
|
||||
const revoke = renderToStaticMarkup(<GrantChangePreview baseline={['project.create']} grants={[]} labels={labels} owner="bob" />)
|
||||
expect(revoke).toContain('已创建项目的管理资格不被反向删除')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CreatorOwnerMemberDuties capability source', () => {
|
||||
it('shows where an account gets project creation and what it links to', () => {
|
||||
const role = { name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }
|
||||
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={role} grants={['project.create']} />))
|
||||
.toContain('创建项目来源:职位角色「项目经理」 + 明确系统授权')
|
||||
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={null} grants={[]} />))
|
||||
.toContain('被加入项目后由成员关系提供基础协作')
|
||||
})
|
||||
|
||||
it('lists each project creation source separately when only one is present', () => {
|
||||
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={{ name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }} grants={[]} />))
|
||||
.toContain('创建项目来源:职位角色「项目经理」')
|
||||
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={null} grants={['project.create']} />))
|
||||
.toContain('创建项目来源:明确系统授权')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CapabilityGroup empty preview', () => {
|
||||
it('renders a no-change preview when baseline equals grants', () => {
|
||||
const html = renderToStaticMarkup(<GrantChangePreview baseline={['system.users.view']} grants={['system.users.view']} labels={labels} owner="bob" />)
|
||||
expect(html).toContain('系统授权没有变化')
|
||||
})
|
||||
|
||||
it('still names the affected user when nothing visible changes', () => {
|
||||
const html = renderToStaticMarkup(<GrantChangePreview baseline={[]} grants={[]} labels={labels} owner="bob" />)
|
||||
expect(html).toContain('系统授权没有变化')
|
||||
expect(html).not.toContain('影响来源')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleView,
|
||||
} from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { Collapsible, Icon } from '@ui'
|
||||
import { hasSystemAction } from './RolePermissionsPanel'
|
||||
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../accountAuthority'
|
||||
import {
|
||||
PROJECT_CREATE_ACTION, buildCapabilityGroups, projectCreateSources, rebaseGrantDraft, type CapabilityGroup, type GroupLevel,
|
||||
} from '../rolePermissions'
|
||||
|
||||
type IdentityFlags = { isAdmin?: boolean; isSuperAdmin?: boolean }
|
||||
|
||||
const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' })
|
||||
const fields: { key: keyof AccountProfile; label: string }[] = [
|
||||
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
|
||||
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
|
||||
]
|
||||
|
||||
export const credentialLabel = (account: Pick<ManagedAccountRecord, 'initializationState' | 'passwordSet'>) =>
|
||||
account.passwordSet ? '已设置密码' : '待设置密码'
|
||||
|
||||
function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile; onChange: (value: AccountProfile) => void; prefix: string }) {
|
||||
return <div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
|
||||
<label className="lg-label" htmlFor={`${prefix}-${field.key}`}>{field.label}(可空)</label>
|
||||
<input id={`${prefix}-${field.key}`} className="lg-input" value={profile[field.key]}
|
||||
onChange={event => onChange({ ...profile, [field.key]: event.target.value })} />
|
||||
</div>)}</div>
|
||||
}
|
||||
|
||||
export function IdentityChip({ account }: { account: IdentityFlags }) {
|
||||
const kind = accountIdentityKind(account)
|
||||
if (kind === 'super') return <span className="ad-chip ad-chip-super">超级管理员</span>
|
||||
if (kind === 'admin') return <span className="ad-chip ad-chip-admin">管理员</span>
|
||||
return <span className="ad-chip">普通用户</span>
|
||||
}
|
||||
|
||||
export function CreateAdminOption({ me, checked, disabled, onChange }: { me: IdentityFlags | null; checked: boolean; disabled: boolean; onChange: (value: boolean) => void }) {
|
||||
if (!canAppointAdmins(me)) return <p className="ad-hint">仅超级管理员可创建或任命管理员;此处创建普通账号</p>
|
||||
return <label className="ad-check"><input type="checkbox" checked={checked} disabled={disabled}
|
||||
onChange={event => onChange(event.target.checked)} />创建为管理员账号</label>
|
||||
}
|
||||
|
||||
export function AdminAppointmentButton({ me, account, busy, onChange }: { me: IdentityFlags | null; account: IdentityFlags; busy: boolean; onChange: (isAdmin: boolean) => void }) {
|
||||
const change = accountActionPolicy(me, account).adminChange
|
||||
if (!change) return null
|
||||
return <button className="ad-mini" disabled={busy} onClick={() => onChange(change === 'promote')}>{change === 'promote' ? '设为管理员' : '撤销管理员'}</button>
|
||||
}
|
||||
|
||||
function grantLevel(grants: string[], group: CapabilityGroup): GroupLevel {
|
||||
const held = group.actions.filter(id => grants.includes(id)).length
|
||||
return held === 0 ? 'off' : held === group.actions.length ? 'on' : 'mixed'
|
||||
}
|
||||
|
||||
export function toggleGrantGroup(grants: string[], group: CapabilityGroup, checked: boolean): string[] {
|
||||
return checked ? [...new Set([...grants, ...group.actions])].sort() : grants.filter(id => !group.actions.includes(id))
|
||||
}
|
||||
|
||||
/** 明确系统授权按业务组授予;创建项目组说明联动形成的本人项目管理资格。 */
|
||||
export function GrantGroupEditor({ catalog, grants, disabled, onChange }: {
|
||||
catalog: PermissionDescriptor[]
|
||||
grants: string[]
|
||||
disabled: boolean
|
||||
onChange: (grants: string[]) => void
|
||||
}) {
|
||||
const groups = buildCapabilityGroups(catalog.filter(item => item.scope === 'system'))
|
||||
return <div className="rp-grants">{groups.map(group => {
|
||||
const state = grantLevel(grants, group)
|
||||
return <label className="ad-check" key={group.id} title={`内部动作 ${group.actions.join('、')}`}>
|
||||
<input type="checkbox" aria-label={`授予 ${group.label}`} checked={state === 'on'} disabled={disabled}
|
||||
onChange={event => onChange(toggleGrantGroup(grants, group, event.target.checked))} />
|
||||
{group.label}{state === 'mixed' ? '(部分)' : ''}
|
||||
{group.actions.includes(PROJECT_CREATE_ACTION) && <span className="ad-dim">:联动本人创建项目的设置、成员、项目权限与文档职责管理,不涉及他人项目</span>}
|
||||
</label>
|
||||
})}</div>
|
||||
}
|
||||
|
||||
/** 授权差异预览:受影响来源为明确系统授权,影响人数为当前账号。 */
|
||||
export function GrantChangePreview({ baseline, grants, labels, owner }: {
|
||||
baseline: string[]; grants: string[]; labels: Record<string, string>; owner: string
|
||||
}) {
|
||||
const added = grants.filter(id => !baseline.includes(id))
|
||||
const removed = baseline.filter(id => !grants.includes(id))
|
||||
if (added.length === 0 && removed.length === 0) return <p className="ad-hint">系统授权没有变化</p>
|
||||
const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、')
|
||||
return <div>
|
||||
<p className="ad-hint" role="note">影响来源:明确系统授权 · 影响 1 名用户({owner})</p>
|
||||
<ul className="rp-diff">
|
||||
{added.length > 0 && <li>新增:{names(added)}{added.includes(PROJECT_CREATE_ACTION) ? '(联动本人创建项目的管理资格)' : ''}</li>}
|
||||
{removed.length > 0 && <li>撤销:{names(removed)}{removed.includes(PROJECT_CREATE_ACTION) ? '(仅停止未来创建,已创建项目的管理资格不被反向删除)' : ''}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
/** 账号的创建能力来源与联动说明(角色默认与明确授权可并存)。 */
|
||||
export function ProjectCreateSourceNote({ role, grants }: {
|
||||
role: { name: string; permissions?: { actionId: string; allowed: boolean }[] } | null | undefined; grants: string[]
|
||||
}) {
|
||||
const sources = projectCreateSources(role, grants)
|
||||
if (sources.length === 0) return <p className="ad-hint">未获得创建项目能力:被加入项目后由成员关系提供基础协作</p>
|
||||
return <p className="ad-hint">创建项目来源:{sources.join(' + ')} · 联动形成本人创建项目的设置、成员、项目权限与文档职责管理资格</p>
|
||||
}
|
||||
|
||||
/** getUserRole 的角色摘要不带 permissions:用已加载角色列表里同一 roleId 的条目补齐,
|
||||
* 列表里也没有时按无角色默认权限处理(不新增请求,也不改写已有权限)。 */
|
||||
export function roleWithPermissions(role: UserRoleView['role'], roles: RoleRecord[]): UserRoleView['role'] {
|
||||
if (!role || role.permissions) return role
|
||||
return roles.find(item => item.roleId === role.roleId) ?? role
|
||||
}
|
||||
|
||||
export function AccountUsersPanel() {
|
||||
const me = useStore(state => state.me)
|
||||
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
|
||||
const canViewRoles = allowed('system.roles.view')
|
||||
const canAssignRoles = canViewRoles && allowed('system.roles.assign')
|
||||
const canViewGrants = allowed('system.permissions.view')
|
||||
const canManageGrants = canViewGrants && allowed('system.permissions.manage')
|
||||
const [accounts, setAccounts] = useState<ManagedAccountRecord[] | null>(null)
|
||||
const [selected, setSelected] = useState<ManagedAccountRecord | null>(null)
|
||||
const [roleView, setRoleView] = useState<UserRoleView | null>(null)
|
||||
const [roles, setRoles] = useState<RoleRecord[]>([])
|
||||
const [roleQuery, setRoleQuery] = useState('')
|
||||
const [roleCursor, setRoleCursor] = useState('')
|
||||
const [roleTrail, setRoleTrail] = useState<string[]>([])
|
||||
const [nextRoleCursor, setNextRoleCursor] = useState('')
|
||||
const [roleLoading, setRoleLoading] = useState(false)
|
||||
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
|
||||
const [grants, setGrants] = useState<string[]>([])
|
||||
const [grantBaseline, setGrantBaseline] = useState<string[]>([])
|
||||
const [authorityEpoch, setAuthorityEpoch] = useState(0)
|
||||
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
|
||||
const [newOwner, setNewOwner] = useState('')
|
||||
const [newProfile, setNewProfile] = useState<AccountProfile>(emptyProfile)
|
||||
const [newIsAdmin, setNewIsAdmin] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||||
const [field, setField] = useState('default')
|
||||
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||||
const [cursor, setCursor] = useState('')
|
||||
const [trail, setTrail] = useState<string[]>([])
|
||||
const [nextCursor, setNextCursor] = useState('')
|
||||
const [selectedRoleId, setSelectedRoleId] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const sequence = useRef(0)
|
||||
const roleSequence = useRef(0)
|
||||
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
|
||||
|
||||
const search = async (pageCursor = cursor, afterWrite = false) => {
|
||||
const request = ++sequence.current
|
||||
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(afterWrite ? `操作已完成,但用户列表暂未刷新:${String(error)}` : String(error))
|
||||
}
|
||||
}
|
||||
const resetPaging = () => { setCursor(''); setTrail([]) }
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => { void search() }, 180)
|
||||
return () => { window.clearTimeout(timer); sequence.current++ }
|
||||
}, [query, mode, field, status, cursor])
|
||||
|
||||
const open = async (account: ManagedAccountRecord) => {
|
||||
const request = ++sequence.current
|
||||
roleSequence.current++
|
||||
setRoleQuery(''); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(false)
|
||||
setSelected(null); setRoleView(null); setErr(''); setNotice('')
|
||||
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 }
|
||||
const detail = detailResult[0].value
|
||||
setSelected(detail); setDraft(detail.profile)
|
||||
const [roleResult, catalogResult, grantsResult] = await Promise.allSettled([
|
||||
canViewRoles ? api.getUserRole(account.userId) : Promise.resolve(null),
|
||||
canViewGrants ? api.permissionCatalog() : Promise.resolve(null),
|
||||
canViewGrants ? api.getSystemGrants(account.userId) : Promise.resolve(null),
|
||||
])
|
||||
if (request !== sequence.current) return
|
||||
if (roleResult.status === 'fulfilled' && roleResult.value) {
|
||||
const currentRole = roleResult.value as UserRoleView
|
||||
setRoleView(currentRole); setSelectedRoleId(currentRole.role?.roleId ?? '')
|
||||
} else { setRoleView(null); setSelectedRoleId('') }
|
||||
if (catalogResult.status === 'fulfilled' && catalogResult.value) setCatalog(catalogResult.value)
|
||||
else setCatalog([])
|
||||
if (grantsResult.status === 'fulfilled' && grantsResult.value) {
|
||||
const actions = [...(grantsResult.value.actions ?? [])].sort()
|
||||
setGrants(actions); setGrantBaseline(actions); setAuthorityEpoch(grantsResult.value.authorityEpoch)
|
||||
} else { setGrants([]); setGrantBaseline([]); setAuthorityEpoch(0) }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected || !canViewRoles) { setRoles([]); setRoleLoading(false); return }
|
||||
const request = ++roleSequence.current
|
||||
setRoleLoading(true); setNextRoleCursor('')
|
||||
const timer = window.setTimeout(() => {
|
||||
void api.listRoles({ query: roleQuery.trim(), mode: 'fuzzy', status: 'enabled', cursor: roleCursor, limit: 50 })
|
||||
.then(page => {
|
||||
if (request !== roleSequence.current) return
|
||||
setRoles(page.roles ?? []); setNextRoleCursor(page.nextCursor ?? ''); setRoleLoading(false)
|
||||
})
|
||||
.catch(error => { if (request === roleSequence.current) { setErr(String(error)); setRoleLoading(false) } })
|
||||
}, 180)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [selected?.userId, roleQuery, roleCursor, canViewRoles])
|
||||
|
||||
// 写入成功会推进目录版本,旧页游标随之失效:从第一页重读,刷新失败不改写成功结果。
|
||||
const refreshFirst = async () => {
|
||||
resetPaging(); setNextCursor('')
|
||||
await search('', true)
|
||||
}
|
||||
|
||||
const create = async () => {
|
||||
if (!newOwner.trim()) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const account = await api.createManagedUser(newOwner.trim(), newProfile, canAppointAdmins(me) && newIsAdmin)
|
||||
setNewOwner(''); setNewProfile(emptyProfile()); setNewIsAdmin(false)
|
||||
await open(account)
|
||||
setNotice(`已创建 ${account.owner}${account.isAdmin ? '(管理员)' : ''};账号资料可空,角色另行分配`)
|
||||
await refreshFirst()
|
||||
} catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
if (!selected) return
|
||||
const patch: Partial<AccountProfile> = {}
|
||||
for (const item of fields) if (draft[item.key] !== selected.profile[item.key]) patch[item.key] = draft[item.key]
|
||||
if (!Object.keys(patch).length) { setNotice('资料没有变化'); return }
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const updated = await api.adminPatchProfile(selected.userId, selected.profileVersion, patch)
|
||||
setSelected(updated); setDraft(updated.profile); await refreshFirst(); setNotice('用户资料已保存')
|
||||
} catch (error) { setErr(String(error).includes('409') ? '资料版本冲突,请重新打开用户' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const assignRole = async () => {
|
||||
if (!selected || !selectedRoleId) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
await api.assignUserRole(selected.userId, selectedRoleId, selected.stateVersion, crypto.randomUUID())
|
||||
await open(selected); await refreshFirst(); setNotice('用户角色已替换')
|
||||
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const saveGrants = async () => {
|
||||
if (!selected) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
const userId = selected.userId
|
||||
try {
|
||||
const result = await api.replaceSystemGrants(userId, grants, authorityEpoch, crypto.randomUUID())
|
||||
setAuthorityEpoch(result.authorityEpoch); setGrantBaseline(grants)
|
||||
const expanded = result.linkedExpansion ?? []
|
||||
setNotice(expanded.length ? `系统权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '系统权限已保存')
|
||||
} catch (error) {
|
||||
const raw = String(error)
|
||||
if (raw.includes('final permission manager')) setErr('不能撤销最后一个有效权限管理者')
|
||||
else if (raw.includes('409')) {
|
||||
// 版本冲突:读取最新授权并重放本地增删,草稿不丢。
|
||||
try {
|
||||
const fresh = await api.getSystemGrants(userId)
|
||||
const server = [...(fresh.actions ?? [])].sort()
|
||||
setGrants(rebaseGrantDraft(grantBaseline, grants, server)); setGrantBaseline(server); setAuthorityEpoch(fresh.authorityEpoch)
|
||||
setErr('系统权限版本冲突;已基于最新授权重放本地修改,请核对后再次保存')
|
||||
} catch (refreshError) { setErr(`${String(refreshError)};本地草稿已保留`) }
|
||||
} else setErr(`${raw.replace(/^Error:\s*/, '')};本地草稿已保留`)
|
||||
}
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const toggleState = async () => {
|
||||
if (!selected) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const updated = await api.adminSetUserEnabled(selected.userId, selected.stateVersion, !selected.enabled)
|
||||
setSelected(updated); setNotice(updated.enabled ? '账号已启用;旧会话和 Key 不会恢复' : '账号已停用,旧会话、Key 与进行中的恢复提案已撤销')
|
||||
await refreshFirst()
|
||||
} catch (error) { setErr(String(error).includes('409') ? '状态版本或最后管理员保护发生冲突,请重新打开用户' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const setAdmin = async (isAdmin: boolean) => {
|
||||
if (!selected) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const updated = await api.adminSetUserAdmin(selected.userId, selected.stateVersion, isAdmin)
|
||||
setSelected(updated); await refreshFirst(); setNotice(isAdmin ? '已任命为管理员' : '已撤销管理员')
|
||||
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const policy = selected ? accountActionPolicy(me, selected) : null
|
||||
|
||||
if (!allowed('system.users.view')) return <p className="ad-hint">当前账号没有查看用户的系统权限</p>
|
||||
return <div>
|
||||
{allowed('system.users.create') && <div className="ad-create">
|
||||
<div className="ad-row"><input className="lg-input" aria-label="新用户登录名" placeholder="登录名" value={newOwner} onChange={event => setNewOwner(event.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !newOwner.trim()} onClick={create}>创建用户</button></div>
|
||||
<CreateAdminOption me={me} checked={newIsAdmin} disabled={busy} onChange={setNewIsAdmin} />
|
||||
<Collapsible id="new-user-profile" trigger="可选资料:姓名、电话、邮箱、工号、部门、备注">
|
||||
<ProfileInputs profile={newProfile} onChange={setNewProfile} prefix="new-user" />
|
||||
</Collapsible>
|
||||
</div>}
|
||||
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||
{selected && policy ? <div className="rp-editor">
|
||||
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
|
||||
<b>{selected.owner}</b><IdentityChip account={selected} /><span className="ad-dim ad-mono">{selected.userId}</span></div>
|
||||
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · {credentialLabel(selected)} · 资料版本 {selected.profileVersion} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
|
||||
{policy.protectedReason && <p className="ad-hint" role="note">{policy.protectedReason}</p>}
|
||||
{allowed('system.users.edit') && policy.maintain && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
|
||||
<ProfileInputs profile={draft} onChange={setDraft} prefix="user-profile" />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveProfile}>保存资料</button>
|
||||
</Collapsible>}
|
||||
<div className="ad-row">
|
||||
{canAssignRoles && policy.maintain && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
|
||||
<option value="">请选择角色</option>{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
|
||||
</select><button className="ad-mini" disabled={busy || !selectedRoleId || selectedRoleId === roleView?.role?.roleId} onClick={assignRole}>替换角色</button></>}
|
||||
{allowed('system.users.disable') && policy.toggleEnabled && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
|
||||
<AdminAppointmentButton me={me} account={selected} busy={busy} onChange={setAdmin} />
|
||||
</div>
|
||||
{canAssignRoles && policy.maintain && <div className="ad-row">
|
||||
<input className="lg-input" aria-label="搜索可分配角色" placeholder="搜索有效角色" value={roleQuery}
|
||||
onChange={event => { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
|
||||
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
|
||||
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
|
||||
</div>}
|
||||
{(canViewRoles || canViewGrants) && <ProjectCreateSourceNote role={roleWithPermissions(roleView?.role, roles)} grants={grantBaseline} />}
|
||||
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
|
||||
<GrantGroupEditor catalog={catalog} grants={grants} disabled={!canManageGrants || !policy.maintain || busy} onChange={setGrants} />
|
||||
<GrantChangePreview baseline={grantBaseline} grants={grants} labels={labels} owner={selected.owner} />
|
||||
{canManageGrants && policy.maintain && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
|
||||
</Collapsible>}
|
||||
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
|
||||
</div> : <>
|
||||
<div className="ad-row rp-filters">
|
||||
<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); resetPaging() }}><option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option></select>
|
||||
</div>
|
||||
<table className="ad-table"><thead><tr><th>登录名</th><th>姓名</th><th>工号</th><th>身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
|
||||
<tbody>{(accounts ?? []).map(account => <tr key={account.userId}><td className="ad-mono">{account.owner}</td><td>{account.profile.name || '—'}</td>
|
||||
<td>{account.profile.staffNo || '—'}</td><td><IdentityChip account={account} /></td><td>{account.enabled ? '有效' : '停用'}</td>
|
||||
<td>{credentialLabel(account)}</td><td><button className="ad-mini" onClick={() => open(account)}>查看与管理</button></td></tr>)}
|
||||
{accounts !== null && accounts.length === 0 && <tr><td colSpan={7} className="ad-dim">没有匹配用户</td></tr>}
|
||||
{accounts === null && <tr><td colSpan={7} className="ad-dim">加载中…</td></tr>}</tbody></table>
|
||||
<div className="ad-row"><button className="ad-mini" disabled={!trail.length} onClick={() => { setCursor(trail[trail.length - 1]); setTrail(trail.slice(0, -1)) }}>上一页</button>
|
||||
<button className="ad-mini" disabled={!nextCursor} onClick={() => { setTrail([...trail, cursor]); setCursor(nextCursor) }}>下一页</button></div>
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import { Icon } from '@ui'
|
||||
import { humanChangePasswordError } from './LoginPage'
|
||||
import { humanChangePasswordError } from './loginErrors'
|
||||
|
||||
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -10,7 +10,8 @@ import { EmptyState, Icon, type IconName } from '@ui'
|
||||
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
|
||||
import { ExecEnvPanel } from './ExecEnvPanel'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
import { RdmRolesPanel } from './adminRdmSections'
|
||||
import { AccountUsersPanel } from './AccountUsersPanel'
|
||||
import { RolePermissionsPanel, roleDraftLeaveGuard, hasSystemAction } from './RolePermissionsPanel'
|
||||
import type { AdminZoneSection } from '../api'
|
||||
|
||||
interface AdminSectionDef {
|
||||
@@ -18,6 +19,8 @@ interface AdminSectionDef {
|
||||
label: string
|
||||
icon: IconName
|
||||
desc: string
|
||||
requiredAction?: string
|
||||
rootOnly?: boolean
|
||||
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
|
||||
render: () => React.ReactNode
|
||||
}
|
||||
@@ -27,16 +30,30 @@ function ProfilesPanelWrapper() {
|
||||
return <ProfilesSection profiles={profiles} />
|
||||
}
|
||||
|
||||
/** 账号目录(按系统权限开放)+ 登录凭据管理(根管理员专属)。 */
|
||||
function UsersSection() {
|
||||
const isAdmin = useStore(s => !!s.me?.isAdmin)
|
||||
return <>
|
||||
<AccountUsersPanel />
|
||||
{isAdmin && <>
|
||||
<div className="az-section-head" style={{ marginTop: 18 }}>
|
||||
<h3>登录凭据</h3>
|
||||
<p>建号并发放初始密码、生成一次性登录密码(仅根管理员)</p>
|
||||
</div>
|
||||
<UsersPanel />
|
||||
</>}
|
||||
</>
|
||||
}
|
||||
|
||||
const SECTIONS: AdminSectionDef[] = [
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
|
||||
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
|
||||
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
|
||||
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员可发放登录凭据', requiredAction: 'system.users.view', render: () => <UsersSection /> },
|
||||
{ key: 'roles', label: '角色与权限', icon: 'shield', desc: '职位角色、默认权限与上限;支持单角色和角色矩阵配置', requiredAction: 'system.roles.view', render: () => <RolePermissionsPanel /> },
|
||||
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', rootOnly: true, render: () => <ProfilesPanelWrapper /> },
|
||||
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
|
||||
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => <SystemSettingsPanel /> },
|
||||
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
|
||||
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', render: () => <ExecEnvPanel /> },
|
||||
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
|
||||
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', rootOnly: true, render: () => <SystemSettingsPanel /> },
|
||||
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', rootOnly: true, render: () => <PluginsSection /> },
|
||||
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', rootOnly: true, render: () => <ExecEnvPanel /> },
|
||||
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', rootOnly: true, render: () => <PluginWorkspacesPanel /> },
|
||||
]
|
||||
|
||||
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
|
||||
@@ -64,14 +81,15 @@ function DeclaredSection({ section }: { section: AdminZoneSection }) {
|
||||
export function AdminZone() {
|
||||
const me = useStore(s => s.me)
|
||||
const declared = useStore(s => s.adminSections)
|
||||
const rdmRolesAvailable = useStore(s => s.rdmRolesAvailable)
|
||||
const [sectionKey, setSectionKey] = useState('users')
|
||||
const isAdmin = !!me?.isAdmin
|
||||
const systemActions = me?.systemActions ?? []
|
||||
const canOpenSystem = isAdmin || systemActions.length > 0
|
||||
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
|
||||
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
|
||||
if (!isAdmin && declared.length === 0) return null
|
||||
if (!canOpenSystem && declared.length === 0) return null
|
||||
const sections: AdminSectionDef[] = [
|
||||
...(isAdmin ? SECTIONS.filter(s => s.key !== 'rdm-roles' || rdmRolesAvailable) : []),
|
||||
...SECTIONS.filter(section => section.rootOnly ? isAdmin : isAdmin || hasSystemAction(systemActions, section.requiredAction ?? '')),
|
||||
...declared.map(s => ({
|
||||
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
|
||||
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
|
||||
@@ -84,11 +102,15 @@ export function AdminZone() {
|
||||
return (
|
||||
<div className="az" role="dialog" aria-label="系统管理">
|
||||
<div className="az-head">
|
||||
<button className="btn ghost" onClick={() => store.setUI({ adminZoneOpen: false })}>
|
||||
<button className="btn ghost" onClick={() => {
|
||||
// DR9:带未保存权限草稿离开须显式决定;守卫返回 false 表示用户取消。
|
||||
if (roleDraftLeaveGuard.current && !roleDraftLeaveGuard.current()) return
|
||||
store.setUI({ adminZoneOpen: false })
|
||||
}}>
|
||||
<Icon name="chevron-down" size={14} /> 返回工作台
|
||||
</button>
|
||||
<span className="az-title">系统管理</span>
|
||||
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
|
||||
<span className="az-sub">{me?.isSuperAdmin ? '超级管理员' : isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="az-tenant">{me?.owner ?? ''}</span>
|
||||
</div>
|
||||
|
||||
@@ -637,14 +637,14 @@ function ConversationSession() {
|
||||
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="companion-setting"><span>读取同项目的其他对话</span><button className="btn ghost"
|
||||
{!conversation?.scratch && <div className="companion-setting"><span>读取同项目的其他对话</span><button className="btn ghost"
|
||||
title={conversation?.allowCrossRead === false
|
||||
? '本会话不读取其他会话记录(点击开启)'
|
||||
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
|
||||
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
|
||||
{conversation?.allowCrossRead === false ? '关闭' : '允许'}
|
||||
</button>
|
||||
<span className="companion-setting-note">实际读取范围还需项目设置允许</span></div>
|
||||
<span className="companion-setting-note">实际读取范围还需项目设置允许</span></div>}
|
||||
<label className="companion-setting"><span>这次工作的要求</span><SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} /></label>
|
||||
</Collapsible>
|
||||
<span title={connected ? '已连接' : '正在重新连接'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
|
||||
|
||||
@@ -10,6 +10,7 @@ export function ConversationWork() {
|
||||
const cards = useStore(s => s.cards)
|
||||
const conversationId = useStore(s => s.currentConvId)
|
||||
const projectId = useStore(s => s.currentProjectId)
|
||||
const scratch = useStore(s => !!s.tasks.find(t => t.id === s.currentConvId)?.scratch)
|
||||
const messages = useStore(s => s.messages)
|
||||
const actionStates = useStore(s => s.cardActionStates)
|
||||
const rightCollapsed = useStore(s => s.ui.rightCollapsed)
|
||||
@@ -29,7 +30,9 @@ export function ConversationWork() {
|
||||
if (card.kind === 'confirm' && card.anchorMessageId
|
||||
&& messages.some(message => message.role === 'user' && message.id === card.anchorMessageId)) continue
|
||||
const summary = activitySummary(card)
|
||||
if (card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
|
||||
const taskNotice = card.task?.limitations?.length || card.task?.phase.id === 'correction'
|
||||
|| card.task?.findings?.some(f => f.status === 'active' && (f.kind === 'needs_user' || f.corrects))
|
||||
if (taskNotice || card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
|
||||
else remaining.push(card)
|
||||
}
|
||||
// Preserve the host's newest-first order for completed results and history.
|
||||
@@ -54,7 +57,7 @@ export function ConversationWork() {
|
||||
onPin: (card: ActivityCard, pinned: boolean) => { void store.cardPin(card, pinned) },
|
||||
onDismiss: (card: ActivityCard) => { void store.cardDismiss(card) },
|
||||
onLocate: (card: ActivityCard) => { store.locateMessage(card.anchorMessageId) },
|
||||
onArchive: (card: ActivityCard) => { void store.cardToProject(card) },
|
||||
...(scratch ? {} : { onArchive: (card: ActivityCard) => { void store.cardToProject(card) } }),
|
||||
}
|
||||
const view = (card: ActivityCard) => <ActivityCardView key={card.id} card={card} callbacks={callbacks} docBridge={docBridge} />
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
|
||||
// LoginPage —— 用户名+密码唯一入口;
|
||||
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
|
||||
// grant_setup 免当前密码(激活/恢复 Key 兑换),current_required 必须校验当前密码
|
||||
// grant_setup 与 one_time_password 免当前密码,current_required 必须校验当前密码
|
||||
//(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, type PasswordChangeMode } from '../api'
|
||||
import { Icon, BrandMark } from '@ui'
|
||||
import { ForcedPasswordModeSection, isForcedPasswordMode, requiresCurrentPassword, submitForcedPassword } from './passwordChangeMode'
|
||||
import { humanChangePasswordError, humanLoginError } from './loginErrors'
|
||||
|
||||
function Err({ msg }: { msg: string }) {
|
||||
if (!msg) return null
|
||||
@@ -74,12 +76,6 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
)
|
||||
}
|
||||
|
||||
/** current_required 模式必须由用户输入当前密码(后端 CompleteForcedPasswordChecked
|
||||
* 校验);grant_setup(激活/恢复 Key 兑换的受限会话)免当前密码。 */
|
||||
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
|
||||
return mode === 'current_required'
|
||||
}
|
||||
|
||||
/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me
|
||||
* 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */
|
||||
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
|
||||
@@ -96,8 +92,8 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
api.me().then(me => {
|
||||
if (!active) return
|
||||
if (me.owner !== owner || me.sessionScope !== 'set_password_only' ||
|
||||
(me.passwordChangeMode !== 'grant_setup' && me.passwordChangeMode !== 'current_required')) {
|
||||
setErr('当前设密凭据不再有效,请重新登录;恢复场景请先核对「设置 → 账号」中的恢复提案状态。')
|
||||
!isForcedPasswordMode(me.passwordChangeMode)) {
|
||||
setErr('当前设密凭据不再有效,请重新登录。')
|
||||
return
|
||||
}
|
||||
setMode(me.passwordChangeMode)
|
||||
@@ -113,7 +109,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
if (next.length < 8) { setErr('新密码至少 8 位'); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
|
||||
await submitForcedPassword(mode, current, next)
|
||||
setDone(true)
|
||||
} catch (e) {
|
||||
setErr(humanChangePasswordError(String(e)))
|
||||
@@ -136,12 +132,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||
<h2 className="lg-title">设置新密码</h2>
|
||||
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续</p>
|
||||
{mode === 'current_required' && <>
|
||||
<label className="lg-label" htmlFor="forced-current">当前密码</label>
|
||||
<input id="forced-current" className="lg-input" type="password" value={current}
|
||||
onChange={e => setCurrent(e.target.value)} autoComplete="current-password" />
|
||||
</>}
|
||||
{mode === 'grant_setup' && <p className="lg-hint">身份经可信激活/恢复能力确认,此次无需输入当前密码。</p>}
|
||||
<ForcedPasswordModeSection mode={mode} current={current} onCurrentChange={setCurrent} />
|
||||
<label className="lg-label">新密码(≥8 位)</label>
|
||||
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
|
||||
<label className="lg-label">确认新密码</label>
|
||||
@@ -155,34 +146,3 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
|
||||
export function humanLoginError(raw: string): string {
|
||||
if (raw.includes('401')) return '用户名或密码不正确'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
|
||||
export function humanRedeemError(raw: string): string {
|
||||
if (raw.includes('409')) return '该 Key 已被使用过,请联系管理员重新签发'
|
||||
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return 'Key 无效或已过期,请联系管理员重新签发'
|
||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
|
||||
* 不能沿用登录的「用户名或密码不正确」一刀切。
|
||||
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
|
||||
export function humanChangePasswordError(raw: string): string {
|
||||
if (raw.includes('current password incorrect')) return '当前密码不正确'
|
||||
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
|
||||
if (raw.includes('session expired')) return '会话已过期,请重新登录'
|
||||
if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
|
||||
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return '认证失败,请重新登录'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
// 我的账号与恢复(设置 → 账号):可选资料编辑 + 恢复提案的属主确认视图。
|
||||
// 管理员只能建立提案;确认必须使用提案之前已存在、来源可信的完整密码会话
|
||||
//(backend eligibleRecoveryProof 契约)。确认产出的一次性 grant 只在本次响应
|
||||
// 出现,可就地兑换进受限设密会话(完成前不放行工作台)。
|
||||
// 我的账号(设置 → 账号):可选资料编辑 + 忘记密码指引。忘记密码只有一个处置:
|
||||
// 由管理员生成一次性登录密码,用户用它登录后进入强制改密门,设置新密码才放行。
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, type AccountProfile, type AccountRecord, type RecoveryProposal } from '../api'
|
||||
import { api, type AccountProfile, type AccountRecord } from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { Collapsible, Icon, SettingsGroup } from '@ui'
|
||||
import { recoveryStatusLabel } from '../recovery'
|
||||
|
||||
const fields: { key: keyof AccountProfile; label: string }[] = [
|
||||
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
|
||||
@@ -17,8 +14,6 @@ export function MyAccountSettings() {
|
||||
const me = useStore(s => s.me)
|
||||
const [account, setAccount] = useState<AccountRecord | null>(null)
|
||||
const [draft, setDraft] = useState<AccountProfile | null>(null)
|
||||
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
|
||||
const [grant, setGrant] = useState<{ proposalId: string; value: string } | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -27,9 +22,9 @@ export function MyAccountSettings() {
|
||||
useEffect(() => {
|
||||
if (!me?.auth) return
|
||||
const seq = ++sequence.current
|
||||
Promise.all([api.meProfile(), api.listMyRecoveryProposals()]).then(([profile, statuses]) => {
|
||||
api.meProfile().then(profile => {
|
||||
if (seq !== sequence.current) return
|
||||
setAccount(profile); setDraft(profile.profile); setProposals(statuses)
|
||||
setAccount(profile); setDraft(profile.profile)
|
||||
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
|
||||
return () => { sequence.current++ }
|
||||
}, [me?.owner, me?.auth])
|
||||
@@ -44,74 +39,14 @@ export function MyAccountSettings() {
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const updated = await api.patchMeProfile(account.profileVersion, patch)
|
||||
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存;资料不会成为恢复身份的证明')
|
||||
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存')
|
||||
} catch (error) { setErr(String(error).includes('409') ? '资料版本已变化,请关闭设置后重新打开' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const refreshProposal = async (id: string) => {
|
||||
const updated = await api.getMyRecoveryProposal(id)
|
||||
setProposals(current => current?.map(item => item.id === id ? updated : item) ?? [updated])
|
||||
return updated
|
||||
}
|
||||
|
||||
const confirm = async (proposal: RecoveryProposal) => {
|
||||
setBusy(true); setErr(''); setNotice(''); setGrant(null)
|
||||
try {
|
||||
const result = await api.confirmMyRecoveryProposal(proposal.id, proposal.version)
|
||||
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
|
||||
if (!result.grant) {
|
||||
setErr('提案已确认,但一次性明文不可读回。若仍保有原本人会话,可显式重新生成设密能力。')
|
||||
return
|
||||
}
|
||||
setGrant({ proposalId: proposal.id, value: result.grant })
|
||||
setNotice('本人确认成功。设密能力只在此响应可用,请立即兑换并设置新密码。')
|
||||
} catch (error) {
|
||||
const current = await refreshProposal(proposal.id).catch(() => null)
|
||||
setErr(current?.status === 'confirmed'
|
||||
? '确认结果待核验:提案已确认,但明文不能读回。若仍保有原本人会话,可显式重新生成设密能力。'
|
||||
: `确认未完成或结果未知:${String(error)}。请核对当前提案状态。`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const rotate = async (proposal: RecoveryProposal) => {
|
||||
setBusy(true); setErr(''); setNotice(''); setGrant(null)
|
||||
try {
|
||||
const result = await api.rotateMyRecoveryGrant(proposal.id, proposal.version, proposal.generation)
|
||||
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
|
||||
if (!result.grant) {
|
||||
setErr('重新生成已提交,但一次性明文不可读回。请核对当前提案状态,再由本人显式操作。')
|
||||
return
|
||||
}
|
||||
setGrant({ proposalId: proposal.id, value: result.grant })
|
||||
setNotice('已重新生成设密能力;旧能力与派生受限会话已失效。')
|
||||
} catch (error) {
|
||||
await refreshProposal(proposal.id).catch(() => null)
|
||||
setErr(`重新生成结果待核验:${String(error)}。请核对提案状态,再由本人显式操作。`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const redeem = async () => {
|
||||
if (!grant) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
await api.redeemLoginKey(grant.value)
|
||||
location.reload() // 新 HttpOnly 受限会话由应用启动门送到设密页
|
||||
} catch (error) {
|
||||
const session = await api.me().catch(() => null)
|
||||
if (session?.sessionScope === 'set_password_only' && session.passwordChangeMode === 'grant_setup') {
|
||||
location.reload()
|
||||
return
|
||||
}
|
||||
await refreshProposal(grant.proposalId).catch(() => null)
|
||||
setGrant(null)
|
||||
setErr(`兑换结果待核验:${String(error)}。不会自动重复兑换;若仍保有原本人会话,请显式重新生成能力。`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return <SettingsGroup title="我的账号与恢复">
|
||||
return <SettingsGroup title="我的账号">
|
||||
{account && draft ? <>
|
||||
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空,也不会作为本人恢复证明。</p>
|
||||
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空。</p>
|
||||
<Collapsible id={`my-profile-${account.userId}`} trigger="编辑可选资料">
|
||||
<div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
|
||||
<label className="lg-label" htmlFor={`my-profile-${field.key}`}>{field.label}(可空)</label>
|
||||
@@ -121,21 +56,7 @@ export function MyAccountSettings() {
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={save}>保存我的资料</button></div>
|
||||
</Collapsible>
|
||||
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
|
||||
|
||||
<p className="ad-hint">忘记密码的恢复:由管理员发起提案,本人在此用当前登录会话确认后获得一次性 Key 兑换设密;历史未知来源账号无法在线证明。</p>
|
||||
{(proposals ?? []).map(proposal => <div className="ad-create" key={proposal.id}>
|
||||
<div className="ad-row"><span className="ad-mono">{proposal.id}</span><b>{recoveryStatusLabel[proposal.status]}</b>
|
||||
<span className="ad-dim">到期 {new Date(proposal.expiresAt).toLocaleString()}</span></div>
|
||||
<div className="ad-row">
|
||||
{proposal.status === 'pending' && <button className="ad-mini" disabled={busy} onClick={() => confirm(proposal)}>由本人确认</button>}
|
||||
{proposal.status === 'confirmed' && <button className="ad-mini" disabled={busy} onClick={() => rotate(proposal)}>显式重新生成设密能力</button>}
|
||||
</div>
|
||||
</div>)}
|
||||
{proposals !== null && proposals.length === 0 && <p className="ad-hint">当前没有恢复提案。</p>}
|
||||
{grant && <div className="ad-reset">
|
||||
<p className="ad-hint">设密能力仅保存在当前页面内存,离开后不可读回;不自动复制到剪贴板。</p>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={redeem}>立即兑换并设置新密码</button>
|
||||
</div>}
|
||||
<p className="ad-hint">忘记密码:请联系管理员生成一次性登录密码。用它登录后必须立即设置新密码(强制改密),原密码随生成即失效。</p>
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -24,6 +24,7 @@ export function PreviewPane() {
|
||||
})
|
||||
: undefined
|
||||
const art = active ? artifacts.find(a => a.id === active.id) : undefined
|
||||
const contentUrl=art?api.artifactContentUrl(art.id)+(active?.sourceContext?.expectedHash?`?expectedHash=${encodeURIComponent(active.sourceContext.expectedHash)}`:''):''
|
||||
const textKind = !!art && isTextKind(art)
|
||||
const [content, setContent] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -35,12 +36,12 @@ export function PreviewPane() {
|
||||
setContent(null)
|
||||
if (!art || !isTextKind(art)) return
|
||||
let cancelled = false
|
||||
fetch(api.artifactContentUrl(art.id))
|
||||
.then(r => r.text())
|
||||
fetch(contentUrl)
|
||||
.then(r => {if(!r.ok)throw new Error('source unavailable');return r.text()})
|
||||
.then(text => { if (!cancelled) setContent(text) })
|
||||
.catch(() => { if (!cancelled) setContent('(读取失败)') })
|
||||
return () => { cancelled = true }
|
||||
}, [art?.id])
|
||||
}, [art?.id,contentUrl])
|
||||
|
||||
if (!active) return null
|
||||
if (active.id.startsWith('surface:')) {
|
||||
@@ -96,6 +97,7 @@ export function PreviewPane() {
|
||||
await api.saveArtifactContent(art.id, content ?? '')
|
||||
setDirty(false)
|
||||
store.toast('已保存')
|
||||
const conv=store.getState().currentConvId;if(conv)void store.refreshCards(conv)
|
||||
}}>保存</Btn>}
|
||||
</>
|
||||
)}
|
||||
@@ -108,6 +110,7 @@ export function PreviewPane() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{active.sourceContext&&<div className="acard-status-note" role="note"><p>{active.sourceContext.notice}</p>{active.sourceContext.quote&&<blockquote>{active.sourceContext.quote}</blockquote>}</div>}
|
||||
<div className="preview-pane-body">
|
||||
{editing && textKind ? (
|
||||
<textarea className="search-input preview-pane-editor" value={content ?? ''}
|
||||
@@ -118,7 +121,7 @@ export function PreviewPane() {
|
||||
id: art.id,
|
||||
name: art.name,
|
||||
mime: art.mime,
|
||||
contentUrl: api.artifactContentUrl(art.id),
|
||||
contentUrl,
|
||||
downloadUrl: api.artifactDownloadUrl(art.id),
|
||||
}} />
|
||||
) : <div className="empty-note" style={{ padding: 14 }}>这个文件已不在当前项目里</div>}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { CapabilityChangePreview, CapabilityGroupEditor, rolePermissionSaveError } from './RolePermissionsPanel'
|
||||
import {
|
||||
buildCapabilityGroups, capabilityGroupChanges, createRolePermissionDraft, linkedChainBreaks, setCapabilityGroup,
|
||||
type RolePermissionValue,
|
||||
} from '../rolePermissions'
|
||||
|
||||
const catalog = [
|
||||
{ actionId: 'project.create', label: '创建项目', group: '项目入口' },
|
||||
{ actionId: 'project.settings', label: '项目设置', group: '项目管理' },
|
||||
{ actionId: 'project.members.manage', label: '管理项目成员', group: '项目管理' },
|
||||
{ actionId: 'project.permissions.manage', label: '管理项目权限', group: '项目管理' },
|
||||
{ actionId: 'document.duties.assign', label: '分配文档三权', group: '文档职责' },
|
||||
{ actionId: 'document.author', label: '编制文档', group: '文档动作' },
|
||||
{ actionId: 'document.review', label: '审核文档', group: '文档动作' },
|
||||
{ actionId: 'document.approve', label: '批准文档', group: '文档动作' },
|
||||
]
|
||||
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
|
||||
const off: RolePermissionValue = { allowed: false, capAllowed: false }
|
||||
const values = Object.fromEntries(catalog.map(item => [item.actionId, off]))
|
||||
const groups = buildCapabilityGroups(catalog)
|
||||
|
||||
describe('CapabilityGroup role editor', () => {
|
||||
it('replaces the per-capability checkbox grid with one default/cap pair per business group', () => {
|
||||
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={values} labels={labels} disabled={false} onToggle={vi.fn()} />)
|
||||
expect(html.match(/type="checkbox"/g)).toHaveLength(groups.length * 2)
|
||||
expect(html).toContain('创建与管理本人项目 默认授予')
|
||||
expect(html).toContain('文档编制与审签 角色上限')
|
||||
expect(html).not.toContain('编制文档 默认允许')
|
||||
expect(html).not.toContain('管理项目成员 默认授予')
|
||||
})
|
||||
|
||||
it('shows linked own-project management as controlled items with their association source', () => {
|
||||
const create = groups.find(group => group.id === 'project-create')!
|
||||
const linked = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true).values
|
||||
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={linked} labels={labels} disabled onToggle={vi.fn()} />)
|
||||
expect(html).toContain('管理项目成员:已联动(来源:创建项目,仅本人创建的项目)')
|
||||
expect(html).toContain('分配文档三权:已联动')
|
||||
const before = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={values} labels={labels} disabled onToggle={vi.fn()} />)
|
||||
expect(before).toContain('未联动(开启创建项目后自动补齐)')
|
||||
})
|
||||
|
||||
it('marks a partially granted group instead of hiding the difference', () => {
|
||||
const partial = { ...values, 'document.author': { allowed: true, capAllowed: true } }
|
||||
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={partial} labels={labels} disabled={false} onToggle={vi.fn()} />)
|
||||
expect(html).toContain('默认授予(部分)')
|
||||
})
|
||||
|
||||
it('previews the grouped difference with the affected source and affected user count', () => {
|
||||
const create = groups.find(group => group.id === 'project-create')!
|
||||
const draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
|
||||
const html = renderToStaticMarkup(<CapabilityChangePreview changes={capabilityGroupChanges(draft, groups)} breaks={linkedChainBreaks(draft.values)}
|
||||
labels={labels} roleName="项目经理" bindingCount={7} />)
|
||||
expect(html).toContain('影响来源:职位角色「项目经理」 · 影响 7 名绑定用户')
|
||||
expect(html).toContain('创建与管理本人项目:默认 拒绝 → 允许')
|
||||
expect(html).toContain('联动补齐 项目设置、管理项目成员、管理项目权限、分配文档三权(仅本人创建的项目)')
|
||||
})
|
||||
|
||||
it('keeps the draft and names a linked conflict the server rejected as a whole', () => {
|
||||
expect(rolePermissionSaveError(new Error('422: linked capability conflict project.members.manage')))
|
||||
.toMatch(/关联能力冲突.*整笔拒绝.*本地草稿已保留/)
|
||||
expect(rolePermissionSaveError(new Error('500: boom'))).toContain('本地草稿已保留')
|
||||
})
|
||||
|
||||
it('keeps every action label visible inside the linked-source list once project creation opens', () => {
|
||||
const create = groups.find(group => group.id === 'project-create')!
|
||||
const draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
|
||||
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={draft.values} labels={labels} disabled onToggle={vi.fn()} />)
|
||||
expect(html).toContain('管理项目成员:已联动(来源:创建项目,仅本人创建的项目)')
|
||||
expect(html).toContain('项目设置:已联动')
|
||||
expect(html).toContain('管理项目权限:已联动')
|
||||
expect(html).toContain('分配文档三权:已联动')
|
||||
})
|
||||
|
||||
it('splits every preview row by business group when multiple groups change together', () => {
|
||||
const create = groups.find(group => group.id === 'project-create')!
|
||||
const duties = groups.find(group => group.id === 'document-duties')!
|
||||
let draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
|
||||
draft = setCapabilityGroup(draft, duties, 'allowed', true)
|
||||
const html = renderToStaticMarkup(<CapabilityChangePreview changes={capabilityGroupChanges(draft, groups)} breaks={linkedChainBreaks(draft.values)}
|
||||
labels={labels} roleName="项目经理" bindingCount={7} />)
|
||||
expect(html).toContain('创建与管理本人项目:默认 拒绝 → 允许')
|
||||
expect(html).toContain('文档编制与审签:默认 拒绝 → 允许')
|
||||
})
|
||||
|
||||
it('treats a 403 the same as a 400 for linked-capability rejection phrasing', () => {
|
||||
expect(rolePermissionSaveError(new Error('403: linked capability conflict'))).toMatch(/关联能力冲突/)
|
||||
expect(rolePermissionSaveError(new Error('500: unrelated boom'))).toContain('本地草稿已保留')
|
||||
expect(rolePermissionSaveError(new Error('500: unrelated boom'))).not.toMatch(/关联能力冲突/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,409 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, type PermissionDescriptor, type RoleRecord } from '../api'
|
||||
import {
|
||||
buildCapabilityGroups, capabilityGroupChanges, capabilityGroupState, createRolePermissionDraft, ensureLinkedCapabilities,
|
||||
linkedChainBreaks, rebaseRoleDraft, setCapabilityGroup, validateRolePermissionDraft, visiblePatch,
|
||||
type CapabilityGroup, type CapabilityGroupChange, type GroupLevel, type RolePermissionDraft, type RolePermissionValue,
|
||||
} from '../rolePermissions'
|
||||
import { Collapsible, Icon } from '@ui'
|
||||
import { useStore } from '../store'
|
||||
|
||||
/** DR9 离开管理区守卫:面板有未保存权限草稿时注册;AdminZone「返回工作台」消费。 */
|
||||
export const roleDraftLeaveGuard: { current: (() => boolean) | null } = { current: null }
|
||||
|
||||
export function filterRoles(
|
||||
roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled',
|
||||
): RoleRecord[] {
|
||||
const normalized = query.trim().toLocaleLowerCase()
|
||||
return roles.filter(role => {
|
||||
if (status === 'enabled' && !role.enabled) return false
|
||||
if (status === 'disabled' && role.enabled) return false
|
||||
if (!normalized) return true
|
||||
const fields = [role.name, role.description, role.roleId].map(value => value.toLocaleLowerCase())
|
||||
return mode === 'exact' ? fields.some(value => value === normalized) : fields.some(value => value.includes(normalized))
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function hasSystemAction(actions: string[] | undefined, action: string): boolean {
|
||||
return actions?.includes(action) ?? false
|
||||
}
|
||||
|
||||
function roleValues(role: RoleRecord, catalog: PermissionDescriptor[]): Record<string, RolePermissionValue> {
|
||||
const existing = new Map(role.permissions.map(item => [item.actionId, item]))
|
||||
return Object.fromEntries(catalog.filter(item => !item.protected).map(item => {
|
||||
const value = existing.get(item.actionId)
|
||||
return [item.actionId, { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }]
|
||||
}))
|
||||
}
|
||||
|
||||
const LEVEL_TEXT: Record<GroupLevel, string> = { on: '允许', off: '拒绝', mixed: '部分' }
|
||||
|
||||
function linkedStatus(values: Record<string, RolePermissionValue>, actionId: string, createOn: boolean): string {
|
||||
if (values[actionId]?.capAllowed) return '已联动(来源:创建项目,仅本人创建的项目)'
|
||||
return createOn ? '待联动补齐(保存时随创建项目一并补齐)' : '未联动(开启创建项目后自动补齐)'
|
||||
}
|
||||
|
||||
/** 业务能力组:组开关替代逐动作勾选;联动项为组内受控项,只展示来源。 */
|
||||
export function CapabilityGroupEditor({ groups, values, labels, disabled, onToggle }: {
|
||||
groups: CapabilityGroup[]
|
||||
values: Record<string, RolePermissionValue>
|
||||
labels: Record<string, string>
|
||||
disabled: boolean
|
||||
onToggle: (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => void
|
||||
}) {
|
||||
return <div className="rp-groups">{groups.map(group => {
|
||||
const state = capabilityGroupState(values, group)
|
||||
const createOn = state.allowed !== 'off' || state.capAllowed !== 'off'
|
||||
return <section key={group.id} className="settings-group" aria-label={`能力组 ${group.label}`}>
|
||||
<div className="ad-row">
|
||||
<b title={`内部动作 ${group.actions.join('、')}`}>{group.label}</b>
|
||||
<label className="ad-check"><input type="checkbox" aria-label={`${group.label} 默认授予`} checked={state.allowed === 'on'} disabled={disabled}
|
||||
onChange={event => onToggle(group, 'allowed', event.target.checked)} />默认授予{state.allowed === 'mixed' ? '(部分)' : ''}</label>
|
||||
<label className="ad-check"><input type="checkbox" aria-label={`${group.label} 角色上限`} checked={state.capAllowed === 'on'} disabled={disabled}
|
||||
onChange={event => onToggle(group, 'capAllowed', event.target.checked)} />角色上限{state.capAllowed === 'mixed' ? '(部分)' : ''}</label>
|
||||
</div>
|
||||
{group.description && <p className="ad-hint">{group.description}</p>}
|
||||
{group.linked.length > 0 && <ul className="rp-diff" aria-label={`${group.label} 联动项`}>{group.linked.map(id =>
|
||||
<li key={id}>{labels[id] ?? id}:{linkedStatus(values, id, createOn)}</li>)}</ul>}
|
||||
</section>
|
||||
})}</div>
|
||||
}
|
||||
|
||||
/** 变更预览:按业务组列出前后差异、联动补齐项、影响来源与受影响人数。 */
|
||||
export function CapabilityChangePreview({ changes, breaks, labels, roleName, bindingCount }: {
|
||||
changes: CapabilityGroupChange[]
|
||||
breaks: string[]
|
||||
labels: Record<string, string>
|
||||
roleName: string
|
||||
bindingCount: number
|
||||
}) {
|
||||
if (changes.length === 0 && breaks.length === 0) return <p className="ad-hint">没有权限变化</p>
|
||||
const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、')
|
||||
return <div>
|
||||
<p className="ad-hint" role="note">影响来源:职位角色「{roleName}」 · 影响 {bindingCount} 名绑定用户</p>
|
||||
{breaks.length > 0 && <p className="ad-hint" role="note">关联待补齐:{names(breaks)}——保存时随「创建项目」一并补齐上限</p>}
|
||||
<ul className="rp-diff">{changes.map(change => <li key={change.group.id}>
|
||||
{change.group.label}:默认 {LEVEL_TEXT[change.before.allowed]} → {LEVEL_TEXT[change.after.allowed]},上限 {LEVEL_TEXT[change.before.capAllowed]} → {LEVEL_TEXT[change.after.capAllowed]}
|
||||
{change.linkedAdded.length > 0 && <>;联动补齐 {names(change.linkedAdded)}(仅本人创建的项目)</>}
|
||||
</li>)}</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function rolePermissionSaveError(error: unknown): string {
|
||||
const raw = String(error)
|
||||
if (/\b(400|403|422)\b/.test(raw) && /(link|conflict|关联|冲突|cap)/i.test(raw)) {
|
||||
return `关联能力冲突,服务端已整笔拒绝,未保存任何修改;本地草稿已保留,请调整冲突项后重试(${raw.replace(/^Error:\s*/, '')})`
|
||||
}
|
||||
return `${raw.replace(/^Error:\s*/, '')};本地草稿已保留`
|
||||
}
|
||||
|
||||
export function RolePermissionsPanel() {
|
||||
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 [roles, setRoles] = useState<RoleRecord[] | null>(null)
|
||||
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [drafts, setDrafts] = useState<Record<string, RolePermissionDraft>>({})
|
||||
const [confirmDisable, setConfirmDisable] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||||
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||||
const [actionFilter, setActionFilter] = useState('')
|
||||
const [roleCursor, setRoleCursor] = useState('')
|
||||
const [roleTrail, setRoleTrail] = useState<string[]>([])
|
||||
const [nextRoleCursor, setNextRoleCursor] = useState('')
|
||||
const [view, setView] = useState<'single' | 'matrix'>('single')
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newDescription, setNewDescription] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [roleLoading, setRoleLoading] = useState(false)
|
||||
const [notice, setNotice] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const requestSequence = useRef(0)
|
||||
const selectedIdRef = useRef('')
|
||||
const savePermsRef = useRef<HTMLButtonElement>(null)
|
||||
selectedIdRef.current = selectedId
|
||||
|
||||
const selected = roles?.find(role => role.roleId === selectedId) ?? null
|
||||
const draft = selected ? drafts[selected.roleId] ?? null : null
|
||||
const allEditableIds = useMemo(() => catalog.filter(item => !item.protected).map(item => item.actionId), [catalog])
|
||||
const dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0
|
||||
const filtered = roles ?? []
|
||||
const capabilityGroups = useMemo(() => buildCapabilityGroups(catalog.filter(item => !item.protected)), [catalog])
|
||||
const labels = useMemo(() => Object.fromEntries(catalog.map(item => [item.actionId, item.label])), [catalog])
|
||||
const changes = draft ? capabilityGroupChanges(draft, capabilityGroups) : []
|
||||
const breaks = draft ? linkedChainBreaks(draft.values) : []
|
||||
|
||||
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
|
||||
filters = { query, mode, status, action: actionFilter }) => {
|
||||
const sequence = ++requestSequence.current
|
||||
setRoleLoading(true)
|
||||
setErr('')
|
||||
const [roleResult, catalogResult] = await Promise.allSettled([
|
||||
api.listRoles({ query: filters.query.trim(), mode: filters.mode, status: filters.status,
|
||||
action: filters.action || undefined, cursor: pageCursor, limit: 50 }),
|
||||
canViewPermissions && (refreshCatalog || catalog.length === 0) ? api.permissionCatalog() : Promise.resolve(catalog),
|
||||
])
|
||||
if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return
|
||||
setRoleLoading(false)
|
||||
const descriptors = catalogResult.status === 'fulfilled' ? catalogResult.value : catalog
|
||||
if (catalogResult.status === 'fulfilled') setCatalog(descriptors)
|
||||
if (roleResult.status === 'rejected') {
|
||||
setErr(String(roleResult.reason))
|
||||
return
|
||||
}
|
||||
const rolePage = roleResult.value
|
||||
setRoles(rolePage.roles ?? [])
|
||||
setNextRoleCursor(rolePage.nextCursor ?? '')
|
||||
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
|
||||
if (nextSelected) selectRole(nextSelected, descriptors, sequence)
|
||||
else setSelectedId('')
|
||||
// DR10:目录失败是部分失败态,需在角色选中(selectRole 会清空提示)之后仍可见。
|
||||
if (catalogResult.status === 'rejected') setErr(prev => prev || `权限目录加载失败:${catalogResult.reason}`)
|
||||
}
|
||||
|
||||
const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => {
|
||||
const 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:离开管理区须显式决定;带未保存草稿时刷新走浏览器原生确认。
|
||||
useEffect(() => {
|
||||
if (!dirty || !selected) { roleDraftLeaveGuard.current = null; return }
|
||||
const message = `角色「${selected.name}」有未保存的权限修改,离开将放弃这些修改。确定离开吗?`
|
||||
roleDraftLeaveGuard.current = () => window.confirm(message)
|
||||
return () => { roleDraftLeaveGuard.current = null }
|
||||
}, [dirty, selected?.roleId, selected?.name])
|
||||
useEffect(() => {
|
||||
if (!dirty) return
|
||||
const handler = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [dirty])
|
||||
|
||||
useEffect(() => {
|
||||
setNextRoleCursor(''); setRoleLoading(true)
|
||||
const timer = window.setTimeout(() => { void load(roleCursor) }, 180)
|
||||
return () => { window.clearTimeout(timer); requestSequence.current++ }
|
||||
}, [query, mode, status, actionFilter, roleCursor, canViewPermissions])
|
||||
|
||||
const create = async () => {
|
||||
if (!newName.trim()) return
|
||||
requestSequence.current++; setRoleLoading(false)
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const role = await api.createRole(newName.trim(), newDescription.trim())
|
||||
setNewName(''); setNewDescription('')
|
||||
setQuery(''); setStatus('all'); setActionFilter(''); setRoleCursor(''); setRoleTrail([])
|
||||
setRoles(current => [role, ...(current ?? []).filter(item => item.roleId !== role.roleId)])
|
||||
selectRole(role, catalog, ++requestSequence.current)
|
||||
setNotice(`已创建角色 ${role.name}`)
|
||||
await load('', true, role.roleId, { query: '', mode, status: 'all', action: '' })
|
||||
setNotice(`已创建角色 ${role.name}`)
|
||||
} catch (error) { setErr(String(error)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const saveMetadata = async () => {
|
||||
if (!selected) return
|
||||
const roleId = selected.roleId
|
||||
const operationSequence = ++requestSequence.current
|
||||
setRoleLoading(false)
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
|
||||
await load(); setNotice('角色资料已保存')
|
||||
} catch (error) {
|
||||
if (String(error).includes('409')) {
|
||||
try {
|
||||
const fresh = await api.getRole(roleId)
|
||||
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
|
||||
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
|
||||
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
|
||||
setErr('角色资料版本冲突;已读取最新版本并保留本地名称与描述,请核对后重试')
|
||||
} catch (refreshError) { setErr(String(refreshError)) }
|
||||
} else setErr(String(error))
|
||||
}
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const setEnabled = async () => {
|
||||
if (!selected) return
|
||||
const roleId = selected.roleId
|
||||
const operationSequence = ++requestSequence.current
|
||||
setRoleLoading(false)
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
|
||||
await load(); setNotice(selected.enabled ? '角色已停用,现有绑定不再授予权限' : '角色已启用')
|
||||
} catch (error) {
|
||||
if (String(error).includes('409')) {
|
||||
try {
|
||||
const fresh = await api.getRole(roleId)
|
||||
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
|
||||
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
|
||||
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
|
||||
setErr('角色状态版本冲突;已刷新当前状态,请重新确认')
|
||||
} catch (refreshError) { setErr(String(refreshError)) }
|
||||
} else setErr(String(error))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const rebasePermissionConflict = async (roleId: string, currentDraft: RolePermissionDraft, operationSequence: number) => {
|
||||
const fresh = await api.getRole(roleId)
|
||||
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
|
||||
setRoles(current => current?.some(role => role.roleId === roleId)
|
||||
? current.map(role => role.roleId === roleId ? fresh : role) : [fresh, ...(current ?? [])])
|
||||
// DR9:409 只重放同一角色的草稿;基于最新服务端值回放本地相对基线的变化。
|
||||
setDrafts(prev => ({ ...prev, [roleId]: rebaseRoleDraft(currentDraft, roleValues(fresh, catalog)) }))
|
||||
setErr('权限版本冲突;已基于最新版本重放本地草稿,请核对后再次保存')
|
||||
}
|
||||
|
||||
const savePermissions = async () => {
|
||||
if (!selected || !draft) return
|
||||
const roleId = selected.roleId
|
||||
const operationSequence = ++requestSequence.current
|
||||
setRoleLoading(false)
|
||||
// 关联项不能以断链状态保存:提交前与服务端同规则补齐创建能力的联动上限。
|
||||
const prepared = ensureLinkedCapabilities(draft)
|
||||
const invalid = validateRolePermissionDraft(prepared)
|
||||
if (invalid) { setErr(`${labels[invalid.actionId] ?? invalid.actionId} 的默认权限不能超过角色上限`); return }
|
||||
const full = Object.entries(prepared.values).map(([actionId, value]) => ({ actionId, ...value }))
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const receipt = await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID())
|
||||
setDrafts(current => {
|
||||
if (!(roleId in current)) return current
|
||||
const next = { ...current }; delete next[roleId]; return next
|
||||
})
|
||||
await load(roleCursor, true, selected.roleId)
|
||||
const expanded = receipt?.linkedExpansion ?? []
|
||||
setNotice(expanded.length ? `角色权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '角色权限已保存')
|
||||
} catch (error) {
|
||||
if (String(error).includes('409')) {
|
||||
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
|
||||
} else setErr(rolePermissionSaveError(error))
|
||||
}
|
||||
finally {
|
||||
setBusy(false)
|
||||
// DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 掉回 body,则归还。
|
||||
requestAnimationFrame(() => {
|
||||
if (savePermsRef.current && !savePermsRef.current.disabled && document.activeElement === document.body) savePermsRef.current.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const editGroup = (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => {
|
||||
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: setCapabilityGroup(prev[selectedId], group, field, checked) } : prev)
|
||||
}
|
||||
|
||||
// 矩阵按业务组汇总;只有选中角色可编辑组开关,其余角色只读摘要。
|
||||
const matrix = () => <div className="rp-matrix-wrap">
|
||||
<table className="ad-table rp-matrix">
|
||||
<thead><tr><th>角色</th>{capabilityGroups.map(group => <th key={group.id} title={group.description}>{group.label}</th>)}</tr></thead>
|
||||
<tbody>{filtered.map(role => {
|
||||
const values = role.roleId === selectedId && draft ? draft.values : roleValues(role, catalog)
|
||||
const editable = role.roleId === selectedId && !!draft && canManagePermissions
|
||||
return <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
|
||||
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
|
||||
{capabilityGroups.map(group => {
|
||||
const state = capabilityGroupState(values, group)
|
||||
return <td key={group.id}>{editable ? <>
|
||||
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${group.label} 默认授予`} checked={state.allowed === 'on'}
|
||||
disabled={busy || roleLoading} onChange={event => editGroup(group, 'allowed', event.target.checked)} />默认</label>
|
||||
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${group.label} 角色上限`} checked={state.capAllowed === 'on'}
|
||||
disabled={busy || roleLoading} onChange={event => editGroup(group, 'capAllowed', event.target.checked)} />上限</label>
|
||||
</> : <span className="ad-dim">默认{LEVEL_TEXT[state.allowed]} · 上限{LEVEL_TEXT[state.capAllowed]}</span>}</td>
|
||||
})}
|
||||
</tr>
|
||||
})}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
return <div className="rp-panel">
|
||||
{canManageRoles && <div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="新角色名称" placeholder="角色名称" value={newName} onChange={event => setNewName(event.target.value)} />
|
||||
<input className="lg-input" aria-label="新角色描述" placeholder="描述(可空)" value={newDescription} onChange={event => setNewDescription(event.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || roleLoading || !newName.trim()} onClick={create}>创建角色</button>
|
||||
</div>
|
||||
</div>}
|
||||
<Collapsible id="role-permission-filters" trigger="搜索与过滤">
|
||||
<div className="ad-row rp-filters">
|
||||
<input className="lg-input" aria-label="搜索角色" placeholder="角色名称、描述或 ID" value={query} disabled={busy} onChange={event => { setQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
|
||||
<select className="lg-input" aria-label="角色匹配方式" value={mode} disabled={busy} onChange={event => { setMode(event.target.value as typeof mode); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
||||
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
|
||||
</select>
|
||||
<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>
|
||||
{canViewPermissions && <select className="lg-input" aria-label="按权限过滤角色" value={actionFilter} disabled={busy}
|
||||
onChange={event => { setActionFilter(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
||||
<option value="">全部角色权限</option>{catalog.filter(item => !item.protected).map(item => <option key={item.actionId} value={item.actionId}>{item.label}</option>)}
|
||||
</select>}
|
||||
</div>
|
||||
</Collapsible>
|
||||
<div className="ad-row rp-view-toggle">
|
||||
<button className={`ad-mini ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</button>
|
||||
{canViewPermissions && <button className={`ad-mini ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</button>}
|
||||
<button className="ad-mini" disabled={busy || roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
|
||||
<button className="ad-mini" disabled={busy || roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
|
||||
</div>
|
||||
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||
{view === 'matrix' ? matrix() : <div className="rp-single">
|
||||
<aside className="rp-role-list">
|
||||
{(filtered).map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
|
||||
<span>{role.name}</span><span className="ad-dim">{role.bindingCount} 人</span>
|
||||
</button>)}
|
||||
</aside>
|
||||
<section className="rp-editor">
|
||||
{selected && draft ? <>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="角色名称" value={name} disabled={!canManageRoles || busy || roleLoading} onChange={event => setName(event.target.value)} />
|
||||
<input className="lg-input" aria-label="角色描述" value={description} disabled={!canManageRoles || busy || roleLoading} onChange={event => setDescription(event.target.value)} />
|
||||
{canManageRoles && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
|
||||
{canManageRoles && (selected.enabled
|
||||
? <button className="ad-mini" disabled={busy || roleLoading} onClick={() => setConfirmDisable(true)}>停用角色</button>
|
||||
: <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>启用角色</button>)}
|
||||
</div>
|
||||
{confirmDisable && selected.enabled && <div className="rp-disable-confirm" role="alertdialog" aria-label={`确认停用角色 ${selected.name}`}>
|
||||
<span>停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。</span>
|
||||
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
|
||||
<button className="ad-mini" disabled={busy} onClick={() => setConfirmDisable(false)}>取消</button>
|
||||
</div>}
|
||||
{canViewPermissions ? <>
|
||||
<CapabilityGroupEditor groups={capabilityGroups} values={draft.values} labels={labels}
|
||||
disabled={busy || roleLoading || !canManagePermissions} onToggle={editGroup} />
|
||||
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
|
||||
<CapabilityChangePreview changes={changes} breaks={breaks} labels={labels} roleName={selected.name} bindingCount={selected.bindingCount} />
|
||||
</Collapsible>
|
||||
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || (changes.length === 0 && breaks.length === 0) || allEditableIds.length === 0} onClick={savePermissions}>保存权限</button>}
|
||||
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
|
||||
</> : <p className="ad-hint">请选择角色</p>}
|
||||
</section>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { TaskSidebar } from './TaskSidebar'
|
||||
|
||||
const mocked = vi.hoisted(() => ({ state: {} as Record<string, unknown> }))
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (select: (value: Record<string, unknown>) => unknown) => select(mocked.state),
|
||||
store: { openInbox: vi.fn(), openFiles: vi.fn(), focusPending: vi.fn(), setUI: vi.fn(), toggleLeft: vi.fn() },
|
||||
}))
|
||||
|
||||
const member = { owner: 'bob', name: 'Bob', auth: true, systemActions: [],
|
||||
linkedCapabilities: { projectCreate: false, linkedActions: [], authorityEpoch: 1 } }
|
||||
const creator = { ...member, linkedCapabilities: { projectCreate: true, source: 'role', linkedActions: [], authorityEpoch: 1 } }
|
||||
|
||||
function render(patch: Record<string, unknown>) {
|
||||
mocked.state = {
|
||||
projects: [], projectsLoaded: true, projectSync: { status: 'ok', failures: 0 }, tasks: [], me: member,
|
||||
adminSections: [], artifacts: [], cards: [], projectCards: [], serverWorkspaces: [],
|
||||
rdmAuditAvailable: false, rdmCockpitAvailable: false, currentConvId: null, currentProjectId: null,
|
||||
ui: { theme: 'light', showArchived: false },
|
||||
...patch,
|
||||
}
|
||||
return renderToStaticMarkup(<TaskSidebar />)
|
||||
}
|
||||
|
||||
beforeEach(() => { mocked.state = {} })
|
||||
|
||||
describe('EmptyListNoCreate sidebar', () => {
|
||||
it('shows a waiting-for-invite state without inviting a same-name project for a member without creation', () => {
|
||||
const html = render({})
|
||||
expect(html).toContain('尚无项目:等待项目负责人邀请你加入,加入后会自动出现在这里。')
|
||||
expect(html).not.toContain('新建项目')
|
||||
expect(html).toMatch(/<button class="tsb-newtask-main" disabled=""/)
|
||||
})
|
||||
|
||||
it('shows a loading state instead of an empty state before the list arrives', () => {
|
||||
const html = render({ projectsLoaded: false })
|
||||
expect(html).toContain('正在加载项目列表…')
|
||||
expect(html).not.toContain('等待项目负责人邀请')
|
||||
})
|
||||
|
||||
it('points a creator to explicit project creation', () => {
|
||||
expect(render({ me: creator })).toContain('还没有项目:从「新建项目」开始。')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AutoDiscover sidebar status', () => {
|
||||
it('surfaces a degraded or offline project sync instead of silently showing stale projects', () => {
|
||||
expect(render({ projectSync: { status: 'degraded', failures: 2 } })).toContain('项目列表同步异常,正在自动重试')
|
||||
expect(render({ projectSync: { status: 'offline', failures: 0 } })).toContain('网络已断开,恢复后自动同步项目列表')
|
||||
expect(render({})).not.toContain('同步异常')
|
||||
})
|
||||
|
||||
it('keeps conversation creation available once a discovered project is current', () => {
|
||||
const html = render({ projects: [{ id: 'host-b', name: '项目P', owner: 'carol' }], currentProjectId: 'host-b' })
|
||||
expect(html).toMatch(/<button class="tsb-newtask-main" title="在当前项目新建对话/)
|
||||
expect(html).toContain('从「新建对话」开始')
|
||||
})
|
||||
|
||||
it('falls back to a project-less sidebar when the current discovery has no project', () => {
|
||||
const html = render({ projects: [], currentProjectId: null })
|
||||
expect(html).toContain('尚无项目')
|
||||
expect(html).not.toContain('同步异常')
|
||||
})
|
||||
|
||||
it('does not render a sync notice when the listing succeeded', () => {
|
||||
expect(render({ projectSync: { status: 'ok', failures: 0 } })).not.toContain('同步异常')
|
||||
expect(render({ projectSync: { status: 'idle', failures: 0 } })).not.toContain('同步异常')
|
||||
})
|
||||
})
|
||||
@@ -3,11 +3,12 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { auth } from '../auth'
|
||||
import { api } from '../api'
|
||||
import { api, type ConversationView } from '../api'
|
||||
import { Icon } from '@ui'
|
||||
import type { Conversation } from '../contracts'
|
||||
import { relativeTime, filterTasks } from '../tasklist'
|
||||
import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta'
|
||||
import {
|
||||
canCreateProject, emptyProjectsNotice, projectCapabilitySummary, projectStage, projectStateLabel, projectWorkError,
|
||||
} from '../projectmeta'
|
||||
import { buildWorkspaceEntries } from '../workspaces'
|
||||
|
||||
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
|
||||
@@ -53,12 +54,15 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const currentConvId = useStore(s => s.currentConvId)
|
||||
const currentProjectId = useStore(s => s.currentProjectId)
|
||||
const scratchMode = useStore(s => !!s.scratchMode)
|
||||
const showArchived = useStore(s => s.ui.showArchived)
|
||||
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||||
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
|
||||
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
|
||||
const rdmCockpitAvailable = useStore(s => s.rdmCockpitAvailable)
|
||||
const theme = useStore(s => s.ui.theme)
|
||||
const projectsLoaded = useStore(s => !!s.projectsLoaded)
|
||||
const syncStatus = useStore(s => s.projectSync?.status ?? 'idle')
|
||||
const pendingBadge = useStore(s =>
|
||||
s.cards.filter(c => c.status === 'action_required').length +
|
||||
s.projectCards.filter(c => c.status === 'action_required').length)
|
||||
@@ -70,13 +74,15 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
|
||||
const createConversation = async (projectId?: string) => {
|
||||
try {
|
||||
if (projectId && scratchMode) await store.openProject(projectId)
|
||||
await store.createTask(projectId)
|
||||
onConversationOpen?.()
|
||||
} catch { /* store owns the actionable failure toast */ }
|
||||
}
|
||||
|
||||
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable, rdmCockpit: rdmCockpitAvailable })
|
||||
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
|
||||
const scratchTasks = tasks.filter(t => t.scratch)
|
||||
const visible = filterTasks(scratchMode ? scratchTasks : tasks.filter(t => t.projectId === currentProjectId && !t.scratch), q)
|
||||
const pinned = visible.filter(t => t.pinned)
|
||||
const rest = visible.filter(t => !t.pinned)
|
||||
const currentProject = projects.find(p => p.id === currentProjectId)
|
||||
@@ -84,17 +90,21 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
const workError = currentProject ? projectWorkError(currentProject) : null
|
||||
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
|
||||
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
|
||||
const canCreate = canCreateProject(me)
|
||||
const noProjects = projects.length === 0
|
||||
const syncNotice = syncStatus === 'degraded' ? '项目列表同步异常,正在自动重试'
|
||||
: syncStatus === 'offline' ? '网络已断开,恢复后自动同步项目列表' : null
|
||||
|
||||
const rowActions = (t: Conversation) => (
|
||||
const rowActions = (t: ConversationView) => (
|
||||
<span className="tsb-row-acts">
|
||||
<button className="tsb-row-act" title="置顶 / 取消置顶" onClick={e => { e.stopPropagation(); store.pinTask(t.id, !t.pinned) }}><Icon name="pin" size={13} /></button>
|
||||
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
|
||||
<Icon name={t.archived ? 'undo' : 'archive'} size={13} />
|
||||
</button>
|
||||
<button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
|
||||
{!t.scratch && <button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
|
||||
onClick={e => { e.stopPropagation(); store.setCrossFlags(t.id, { crossPrivate: !t.crossPrivate }) }}>
|
||||
<Icon name="shield" size={13} />
|
||||
</button>
|
||||
</button>}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -107,13 +117,13 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
onConversationOpen?.()
|
||||
}
|
||||
|
||||
const taskRow = (t: Conversation) => (
|
||||
const taskRow = (t: ConversationView) => (
|
||||
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
|
||||
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
|
||||
<button className="tsb-task-open" onClick={() => { store.openTask(t.id); onConversationOpen?.() }}>
|
||||
<Icon name="message" size={14} />
|
||||
<span className="tsb-task-t" title={t.title}>{t.title || '未命名对话'}</span>
|
||||
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
|
||||
{t.crossPrivate && !t.scratch && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
|
||||
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
|
||||
</button>
|
||||
{rowActions(t)}
|
||||
@@ -128,9 +138,9 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
<div className="tsb-brand">
|
||||
<button className="tsb-projrow" title="切换项目"
|
||||
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'project' ? null : { kind: 'project', anchor: el }) }}>
|
||||
<Icon name="folder" size={15} />
|
||||
<span className="tsb-projrow-name">{currentProject?.name ?? '选择项目'}</span>
|
||||
{stage && <span className="tsb-projrow-stage">{stage}</span>}
|
||||
<Icon name={scratchMode ? 'message' : 'folder'} size={15} />
|
||||
<span className="tsb-projrow-name">{scratchMode ? '无项目对话' : currentProject?.name ?? '选择项目'}</span>
|
||||
{stage && !scratchMode && <span className="tsb-projrow-stage">{stage}</span>}
|
||||
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
||||
</button>
|
||||
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={onCollapse}><Icon name="panel-left" /></button>
|
||||
@@ -139,7 +149,9 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
{/* 主入口创建会话;项目创建保留在次级菜单。 */}
|
||||
<div className="tsb-actions">
|
||||
<div className="tsb-newtask">
|
||||
<button className="tsb-newtask-main" title="在当前项目新建对话(⌘N)" onClick={() => createConversation()}>
|
||||
<button className="tsb-newtask-main" disabled={noProjects && !scratchMode}
|
||||
title={scratchMode ? '新建无项目对话(⌘N)' : noProjects ? emptyProjectsNotice(projectsLoaded, canCreate) : '在当前项目新建对话(⌘N)'}
|
||||
onClick={() => createConversation()}>
|
||||
<Icon name="plus" size={15} />新建对话
|
||||
</button>
|
||||
<button className="tsb-newtask-caret" title="更多创建选项"
|
||||
@@ -147,12 +159,12 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
</div>
|
||||
<div className="tsb-search">
|
||||
<Icon name="search" size={14} />
|
||||
<input aria-label="搜索本项目会话" placeholder="搜索对话…" value={q} onChange={e => setQ(e.target.value)} />
|
||||
<input aria-label={scratchMode ? '搜索无项目对话' : '搜索本项目会话'} placeholder="搜索对话…" value={q} onChange={e => setQ(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tsb-orgrow">
|
||||
<span className="tsb-seclabel" style={{ padding: 0 }}>本项目会话</span>
|
||||
<span className="tsb-seclabel" style={{ padding: 0 }}>{scratchMode ? '无项目对话' : '本项目会话'}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className={`tsb-iconbtn ${showArchived ? 'active' : ''}`} title={showArchived ? '隐藏归档' : '查看归档'}
|
||||
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}><Icon name="archive" size={15} /></button>
|
||||
@@ -160,7 +172,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
|
||||
{/* 中段(方案:会话与搜索)。项目切换在顶部,文件与工作区沉底。 */}
|
||||
<div className="tsb-scroll">
|
||||
{creatingProject && (
|
||||
{syncNotice && <div className="tsb-tool-note" role="status">{syncNotice}</div>}
|
||||
{canCreate && creatingProject && (
|
||||
<div className="tsb-newproject">
|
||||
<input className="tsb-search" autoFocus value={newProjectName}
|
||||
onChange={e => setNewProjectName(e.target.value)}
|
||||
@@ -185,7 +198,9 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
)}
|
||||
|
||||
{rest.length === 0 && pinned.length === 0 && (
|
||||
<div className="tsb-empty">{workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
|
||||
<div className="tsb-empty">{scratchMode ? '开始一个通用对话;无项目对话彼此独立,不会使用项目文档与知识。'
|
||||
: noProjects ? emptyProjectsNotice(projectsLoaded, canCreate)
|
||||
: workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
|
||||
)}
|
||||
{rest.map(t => taskRow(t))}
|
||||
</div>
|
||||
@@ -204,7 +219,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
<kbd>{entry.shortcut}</kbd>
|
||||
</button>
|
||||
))}
|
||||
{currentProject && (
|
||||
{currentProject && !scratchMode && (
|
||||
<button className="tsb-tool" onClick={() => store.openProjectSettings(currentProject.id)}>
|
||||
<span className="tsb-tool-icon"><Icon name="settings" size={14} /></span>
|
||||
<span className="tsb-tool-name">项目设置</span>
|
||||
@@ -228,25 +243,37 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
|
||||
{/* 弹出菜单们 */}
|
||||
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||
<MenuItem icon="message" onClick={() => { void store.enterScratchMode(); onConversationOpen?.(); setMenu(null) }}>
|
||||
无项目对话
|
||||
<kbd>{scratchMode ? '当前 · ' : ''}{scratchTasks.filter(t => !t.archived).length} 会话</kbd>
|
||||
</MenuItem>
|
||||
{projects.map(p => {
|
||||
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
|
||||
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
|
||||
const mine = !!p.owner && p.owner === me?.owner
|
||||
return (
|
||||
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
|
||||
{p.name}
|
||||
<kbd>{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd>
|
||||
<kbd>{p.id === currentProjectId && !scratchMode ? '当前 · ' : pstage ? `${pstage} · ` : ''}{mine ? '我创建 · ' : ''}{count} 会话</kbd>
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
{projects.length === 0 && <div className="tsb-tool-note">还没有项目</div>}
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||
{noProjects && <div className="tsb-tool-note">{emptyProjectsNotice(projectsLoaded, canCreate)}</div>}
|
||||
{canCreate && <>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||
</>}
|
||||
</Menu>
|
||||
|
||||
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||
<MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||
{scratchMode
|
||||
? <MenuItem icon="message" onClick={() => { createConversation(); setMenu(null) }}>新建无项目对话<kbd>⌘N</kbd></MenuItem>
|
||||
: !noProjects && <MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>}
|
||||
{canCreate && <>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||
</>}
|
||||
{noProjects && !canCreate && !scratchMode && <div className="tsb-tool-note">{emptyProjectsNotice(projectsLoaded, canCreate)}</div>}
|
||||
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
|
||||
<MenuItem key={p.id} onClick={() => { createConversation(p.id); setMenu(null) }}>在「{p.name}」新建对话</MenuItem>
|
||||
))}
|
||||
@@ -256,6 +283,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
{rowTask && <>
|
||||
<MenuItem icon="pin" onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
|
||||
<MenuItem icon={rowTask.archived ? 'undo' : 'archive'} onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
|
||||
</>}
|
||||
{rowTask && !rowTask.scratch && <>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="shield" onClick={() => { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'}</MenuItem>
|
||||
<MenuItem icon="users" onClick={() => { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'}</MenuItem>
|
||||
@@ -270,6 +299,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
<span>
|
||||
<div className="tsb-acc-name">{me?.name || '未连接'}</div>
|
||||
<div className="tsb-acc-mail">{me?.owner ? `租户:${me.owner}` : '—'}</div>
|
||||
{me && <div className="tsb-acc-mail">{projectCapabilitySummary(me)}</div>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tsb-menu-div" />
|
||||
@@ -280,7 +310,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
|
||||
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
|
||||
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
|
||||
{(me?.isAdmin || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
|
||||
{(me?.isAdmin || (me?.systemActions?.length ?? 0) > 0 || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
|
||||
{me?.auth && <>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { WorkExecutionRecord } from './WorkExecutionRecord'
|
||||
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
|
||||
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
|
||||
export const docBridge: DocBridge = {
|
||||
onOpenTaskSource: (cardId, source) => store.openTaskSource(cardId, source),
|
||||
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
|
||||
onOpenDoc: id => {
|
||||
const art = store.getState().artifacts.find(a => a.id === id)
|
||||
@@ -21,7 +22,8 @@ export const docBridge: DocBridge = {
|
||||
},
|
||||
}
|
||||
|
||||
const inboxCallbacks = {
|
||||
// 无项目对话(scratch)没有可留档的项目:只用不含 onArchive 的回调。
|
||||
const scratchInboxCallbacks = {
|
||||
onAction: (c: ActivityCard, id: string, payload?: WorkCardAction) => store.cardAction(c, id, payload),
|
||||
onOpenRecord: (c: ActivityCard) => store.openWorkRecord(c),
|
||||
onReference: (c: ActivityCard) => store.referenceWork(c),
|
||||
@@ -29,8 +31,8 @@ const inboxCallbacks = {
|
||||
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
|
||||
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
|
||||
onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId),
|
||||
onArchive: (c: ActivityCard) => store.cardToProject(c),
|
||||
}
|
||||
const inboxCallbacks = { ...scratchInboxCallbacks, onArchive: (c: ActivityCard) => store.cardToProject(c) }
|
||||
|
||||
/** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */
|
||||
function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxFilter }) {
|
||||
@@ -62,7 +64,9 @@ export function Workbench() {
|
||||
const cards = useStore(s => s.cards)
|
||||
const plan = useStore(s => s.plan)
|
||||
const todos = useStore(s => s.todos)
|
||||
const inboxScope = useStore(s => s.inboxScope)
|
||||
const scratch = useStore(s => !!s.tasks.find(t => t.id === s.currentConvId)?.scratch)
|
||||
const storedScope = useStore(s => s.inboxScope)
|
||||
const inboxScope = scratch ? 'conv' : storedScope
|
||||
const projectCards = useStore(s => s.projectCards)
|
||||
const filter = useStore(s => s.ui.inboxFilter)
|
||||
const visible = inboxScope === 'project' ? projectCards : cards
|
||||
@@ -86,7 +90,7 @@ export function Workbench() {
|
||||
</div>
|
||||
{rightTab === 'files'
|
||||
? <FilesTab />
|
||||
: <WorkTab plan={plan} todos={todos} filter={filter} inboxScope={inboxScope} projectCards={projectCards} cards={cards} />}
|
||||
: <WorkTab plan={plan} todos={todos} filter={filter} inboxScope={inboxScope} projectCards={projectCards} cards={cards} scratch={scratch} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -104,13 +108,14 @@ export function hasActiveProgressCard(cards: ActivityCard[]): boolean {
|
||||
return cards.some(c => c.kind === 'progress' && (c.status === 'active' || c.status === 'action_required'))
|
||||
}
|
||||
|
||||
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
||||
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards, scratch }: {
|
||||
plan: Parameters<typeof CurrentTaskCard>[0]['plan']
|
||||
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
|
||||
filter: InboxFilter
|
||||
inboxScope: 'conv' | 'project'
|
||||
projectCards: ActivityCard[]
|
||||
cards: ActivityCard[]
|
||||
scratch: boolean
|
||||
}) {
|
||||
const record = useStore(s => s.workRecord)
|
||||
const showCurrentTask = !hasActiveProgressCard(cards)
|
||||
@@ -121,16 +126,16 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
||||
{showCurrentTask ? <CurrentTaskCard plan={plan} todos={todos} /> : null}
|
||||
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
|
||||
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
|
||||
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
|
||||
{!scratch && <button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
|
||||
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
|
||||
</button>
|
||||
</button>}
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className={filter === 'todo' ? 'active' : ''} title="只看待处理" onClick={() => store.setUI({ inboxFilter: 'todo' })}>待处理</button>
|
||||
<button className={filter === 'all' ? 'active' : ''} title="全部卡片" onClick={() => store.setUI({ inboxFilter: 'all' })}>全部</button>
|
||||
</div>
|
||||
{inboxScope === 'project'
|
||||
? <ProjectInbox cards={projectCards} filter={filter} />
|
||||
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
|
||||
: <InboxList cards={cards} docBridge={docBridge} callbacks={scratch ? scratchInboxCallbacks : inboxCallbacks} filter={filter} />}
|
||||
<WorkspaceSurfaces />
|
||||
</>}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { Project } from '../contracts'
|
||||
import { MemberDutyCell, RdmMembersSection, memberManagementView } from './adminRdmSections'
|
||||
|
||||
const project: Project = {
|
||||
id: 'host-1', owner: 'alice', name: '项目P', createdAt: '', updatedAt: '',
|
||||
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' },
|
||||
}
|
||||
|
||||
describe('MemberManagementEntryHidden', () => {
|
||||
it('stays read-only until the self check confirms member management', () => {
|
||||
expect(memberManagementView(null, null)).toEqual({ canManage: false, notice: '正在核对成员管理权限…' })
|
||||
expect(memberManagementView([], null).canManage).toBe(false)
|
||||
expect(memberManagementView([{ action: 'member.manage', allowed: false, }], null).notice).toContain('成员增删由项目创建者负责')
|
||||
expect(memberManagementView([{ action: 'member.manage', allowed: true }], null)).toEqual({ canManage: true, notice: null })
|
||||
})
|
||||
|
||||
it('does not offer member management on a project that cannot accept work', () => {
|
||||
expect(memberManagementView([{ action: 'member.manage', allowed: true }], '项目已归档').canManage).toBe(false)
|
||||
})
|
||||
|
||||
it('renders no add, role change or removal entry before authorization is known', () => {
|
||||
const html = renderToStaticMarkup(<RdmMembersSection project={project} />)
|
||||
expect(html).toContain('正在核对成员管理权限…')
|
||||
expect(html).not.toContain('添加成员')
|
||||
expect(html).not.toContain('选择人员')
|
||||
expect(html).toContain('实际职责')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CreatorOwnerMemberDuties member table', () => {
|
||||
it('separates the immutable creator from the owner label and plain members', () => {
|
||||
const creator = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'alice', role: 'owner' }} creator="alice" />)
|
||||
expect(creator).toContain('创建者')
|
||||
expect(creator).toContain('成员增删')
|
||||
expect(creator).toContain('随「创建项目」联动')
|
||||
const owner = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'bob', role: 'owner' }} creator="alice" />)
|
||||
expect(owner).toContain('项目负责人')
|
||||
expect(owner).toContain('不含成员管理')
|
||||
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator="alice" />)
|
||||
expect(member).toContain('基础协作')
|
||||
expect(member).not.toContain('成员增删')
|
||||
})
|
||||
|
||||
it('falls back to plain member duty when the project has no creator recorded', () => {
|
||||
const owner = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'bob', role: 'owner' }} creator={undefined} />)
|
||||
expect(owner).toContain('项目负责人')
|
||||
expect(owner).not.toContain('<span class="ad-chip ad-chip-admin">创建者')
|
||||
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator={undefined} />)
|
||||
expect(member).toContain('基础协作')
|
||||
})
|
||||
|
||||
it('marks the creator cell as an admin chip and the plain member as a neutral chip', () => {
|
||||
const creator = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'alice', role: 'owner' }} creator="alice" />)
|
||||
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator="alice" />)
|
||||
expect(creator).toContain('ad-chip-admin')
|
||||
expect(member).toMatch(/ad-chip(?!-admin)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MemberManagementEntryHidden notice clarity', () => {
|
||||
it('names the project-wide stage error as the reason to gate management entries', () => {
|
||||
const result = memberManagementView([{ action: 'member.manage', allowed: true }], '项目已归档,请联系项目负责人恢复后再新建对话')
|
||||
expect(result).toEqual({ canManage: false, notice: null })
|
||||
})
|
||||
|
||||
it('distinguishes pending self-check from a confirmed no-permission state', () => {
|
||||
const pending = memberManagementView(null, null)
|
||||
const denied = memberManagementView([{ action: 'project.members.manage', allowed: false }], null)
|
||||
expect(pending.notice).toMatch(/正在核对/)
|
||||
expect(denied.notice).toMatch(/成员增删由项目创建者负责/)
|
||||
})
|
||||
})
|
||||
@@ -1,167 +1,54 @@
|
||||
// U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 /
|
||||
// 文档职责」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
|
||||
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
|
||||
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
|
||||
// RDM 项目设置组件:成员 / 文档职责。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
|
||||
// 门控集中在 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'
|
||||
import { canManageProjectMembers, memberDuty, projectWorkError } from '../projectmeta'
|
||||
import { useStore } from '../store'
|
||||
import { Btn, Collapsible, EmptyState, Icon, Picker } from '@ui'
|
||||
import { Btn, Collapsible, Icon, Picker } from '@ui'
|
||||
import {
|
||||
ROLE_ACTIONS,
|
||||
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
|
||||
memberAddPayload, memberRemovePayload, memberSetRolePayload,
|
||||
permCheckPayload, describeSource,
|
||||
rolesListPlaceholder, describeRoleListError,
|
||||
} from '../rdmManagement'
|
||||
|
||||
// ---- 系统管理:RDM 角色权限(仅管理员 + rdmRolesAvailable) ----
|
||||
|
||||
interface RoleView {
|
||||
roleId: string
|
||||
name: string
|
||||
disabled: boolean
|
||||
version: number
|
||||
actions: Record<string, [boolean, boolean]> | null
|
||||
accounts: string[] | null
|
||||
}
|
||||
|
||||
export function RdmRolesPanel() {
|
||||
const me = useStore(s => s.me)
|
||||
const [roles, setRoles] = useState<RoleView[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
// 动作编辑器
|
||||
const [editRole, setEditRole] = useState('')
|
||||
const [editAction, setEditAction] = useState<string>(ROLE_ACTIONS[0])
|
||||
const [editDef, setEditDef] = useState(false)
|
||||
const [editCap, setEditCap] = useState(true)
|
||||
// 绑定
|
||||
const [assignOwner, setAssignOwner] = useState('')
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const out = await api.rdmCommand<{ roles: RoleView[] }>('rdm.role.list')
|
||||
setErr(''); setRoles(out.roles ?? [])
|
||||
} catch (e) { setRoles(null); setErr(describeRoleListError(String(e))) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try { await fn(); setNotice(label); await refresh() }
|
||||
catch (e) { setErr(describeRoleListError(String(e))) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (!me?.isAdmin) return <EmptyState icon="shield">仅平台管理员可管理专业角色</EmptyState>
|
||||
|
||||
// 占位行单一出口:加载中仅在无错误时成立(拒绝后永远转圈是误导——见
|
||||
// rolesListPlaceholder 注释)。
|
||||
const placeholder = rolesListPlaceholder(roles, err)
|
||||
|
||||
const sel = roles?.find(r => r.roleId === editRole)
|
||||
const editCurrent = sel?.actions?.[editAction]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" placeholder="新角色名(如:编制工程师)" value={newName}
|
||||
onChange={e => setNewName(e.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !newName.trim()}
|
||||
onClick={() => run(`已创建角色 ${newName.trim()}`, async () => {
|
||||
await api.rdmCommand('rdm.role.create', roleCreatePayload(newName.trim()))
|
||||
setNewName('')
|
||||
})}>创建角色</button>
|
||||
</div>
|
||||
<p className="ad-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
|
||||
</div>
|
||||
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>角色</th><th>状态</th><th>版本</th><th>动作配置</th><th>绑定账号</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(roles ?? []).map(r => (
|
||||
<tr key={r.roleId}>
|
||||
<td><span className="ad-mono">{r.name}</span><br /><span className="ad-dim">{r.roleId}</span></td>
|
||||
<td>{r.disabled ? <span className="ad-chip ad-chip-warn">已停用</span> : <span className="ad-chip">生效</span>}</td>
|
||||
<td className="ad-dim">v{r.version}</td>
|
||||
<td>{Object.keys(r.actions ?? {}).length} 项</td>
|
||||
<td className="ad-mono">{(r.accounts ?? []).join('、') || '—'}</td>
|
||||
<td><button className="ad-mini" disabled={busy}
|
||||
onClick={() => run(r.disabled ? `已启用 ${r.name}` : `已停用 ${r.name}(绑定账号回落内置默认)`,
|
||||
() => api.rdmCommand('rdm.role.disable', roleDisablePayload(r.roleId, !r.disabled)))}>
|
||||
{r.disabled ? '启用' : '停用'}</button></td>
|
||||
</tr>
|
||||
))}
|
||||
{roles !== null && roles.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无专业角色</td></tr>}
|
||||
{placeholder && <tr><td colSpan={6} className="ad-dim">{placeholder}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>动作配置(默认 ∩ 上限)</h4>
|
||||
<div className="ad-row">
|
||||
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
|
||||
<option value="">选择角色…</option>
|
||||
{(roles ?? []).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||||
</select>
|
||||
<select className="lg-input" value={editAction}
|
||||
onChange={e => {
|
||||
setEditAction(e.target.value)
|
||||
const cur = sel?.actions?.[e.target.value]
|
||||
setEditDef(!!cur?.[0]); setEditCap(cur ? cur[1] : true)
|
||||
}}>
|
||||
{ROLE_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
<label className="ad-check"><input type="checkbox" checked={editDef}
|
||||
onChange={e => setEditDef(e.target.checked)} />默认允许</label>
|
||||
<label className="ad-check"><input type="checkbox" checked={editCap}
|
||||
onChange={e => setEditCap(e.target.checked)} />上限允许</label>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !editRole}
|
||||
onClick={() => run(`已配置 ${editRole} · ${editAction}`,
|
||||
() => api.rdmCommand('rdm.role.action', roleActionPayload(editRole, editAction, editDef, editCap)))}>保存动作</button>
|
||||
</div>
|
||||
{sel && editCurrent && <p className="ad-hint">当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}</p>}
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>绑定账号(每账号一个生效角色)</h4>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="账号(owner)" value={assignOwner}
|
||||
onChange={e => setAssignOwner(e.target.value)} />
|
||||
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
|
||||
<option value="">选择角色…</option>
|
||||
{(roles ?? []).filter(r => !r.disabled).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !assignOwner.trim() || !editRole}
|
||||
onClick={() => run(`已把 ${assignOwner.trim()} 绑定到 ${editRole}`,
|
||||
() => api.rdmCommand('rdm.role.assign', roleAssignPayload(assignOwner.trim(), editRole)))}>绑定</button>
|
||||
</div>
|
||||
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 项目设置:成员(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'
|
||||
|
||||
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
|
||||
|
||||
interface MemberRow {
|
||||
owner: string; name?: string; role: string; addedBy: string; addedAt: string
|
||||
profRole?: { roleId: string; name: string } | null
|
||||
}
|
||||
|
||||
/** 成员管理入口门控:本人自省未到达或无成员管理资格时只读(fail-closed)。 */
|
||||
export function memberManagementView(actions: { action: string; allowed: boolean }[] | null, writeError: string | null) {
|
||||
if (actions === null) return { canManage: false, notice: '正在核对成员管理权限…' }
|
||||
if (!canManageProjectMembers(actions)) {
|
||||
return { canManage: false, notice: '你可以查看成员与实际职责;成员增删由项目创建者负责(需具备项目成员管理资格)。' }
|
||||
}
|
||||
return { canManage: !writeError, notice: null }
|
||||
}
|
||||
|
||||
/** 实际职责:创建者(不可变)与界面身份「负责人」分开显示,并标明能力关联来源。 */
|
||||
export function MemberDutyCell({ member, creator }: { member: { owner: string; role: string }; creator: string | undefined }) {
|
||||
const duty = memberDuty(member, creator)
|
||||
return <>
|
||||
<span className={duty.kind === 'member' ? 'ad-chip' : 'ad-chip ad-chip-admin'}>{duty.label}</span>
|
||||
<div className="ad-dim" title={`来源:${duty.source}`}>{duty.duties}</div>
|
||||
</>
|
||||
}
|
||||
|
||||
export function RdmMembersSection({ project }: { project: Project }) {
|
||||
const projectId = project.id
|
||||
const writeError = projectWorkError(project)
|
||||
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
||||
const me = useStore(s => s.me)
|
||||
const [manageActions, setManageActions] = useState<{ action: string; allowed: boolean }[] | null>(null)
|
||||
const manage = memberManagementView(manageActions, writeError)
|
||||
const [rows, setRows] = useState<MemberRow[] | null>(null)
|
||||
const [memberNext, setMemberNext] = useState('')
|
||||
const [memberTotal, setMemberTotal] = useState<number | null>(null)
|
||||
@@ -178,6 +65,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)
|
||||
@@ -188,23 +76,44 @@ 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)
|
||||
}, [project])
|
||||
|
||||
// rdm.role.options 受 member.manage 授权;未确认前先不发请求,避免普通成员被 403。
|
||||
// 跟着 [project, manage.canManage] 重订:项目切换或权限放开后重发,迟到响应丢弃。
|
||||
useEffect(() => {
|
||||
if (!manage.canManage) return
|
||||
let live = true
|
||||
cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId))
|
||||
.then(out => { if (live) setRoleOptions(out.roles ?? []) })
|
||||
.catch(() => { if (live) setRoleOptions(null) })
|
||||
return () => { live = false }
|
||||
}, [project])
|
||||
}, [project, manage.canManage])
|
||||
|
||||
/** 人员选择器取数适配: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])
|
||||
useEffect(() => {
|
||||
setManageActions(null)
|
||||
if (!me?.owner) return
|
||||
let live = true
|
||||
cmd<{ actions?: { action: string; allowed: boolean }[] }>('rdm.perm.check', permCheckPayload(projectId, me.owner))
|
||||
.then(out => { if (live) setManageActions(out.actions ?? []) })
|
||||
.catch(() => { if (live) setManageActions([]) })
|
||||
return () => { live = false }
|
||||
}, [project, me?.owner])
|
||||
|
||||
const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
|
||||
[candMatch, candFields, candRoleId])
|
||||
|
||||
/** 人员选择器取数适配: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) => {
|
||||
@@ -252,7 +161,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 !== ''
|
||||
|
||||
@@ -267,7 +176,8 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
return (
|
||||
<div>
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
<div className="ad-create">
|
||||
{manage.notice && <p className="ad-hint" role="note">{manage.notice}</p>}
|
||||
{manage.canManage && <div className="ad-create">
|
||||
<div className="ad-row">
|
||||
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
|
||||
<select className="lg-input" aria-label="匹配方式" value={candMatch}
|
||||
@@ -275,6 +185,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
|
||||
@@ -283,22 +198,23 @@ 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>
|
||||
<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>
|
||||
</div>
|
||||
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。被加入的成员无需重新登录,项目会自动出现在其项目列表。</p>
|
||||
</div>}
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
|
||||
onChange={e => setMemberQuery(e.target.value)} />
|
||||
@@ -307,16 +223,17 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
</div>
|
||||
{!directoryOk && <p className="ad-hint">账号目录暂不可用:成员姓名可能缺失,仅按账号显示。</p>}
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
|
||||
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>实际职责</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(rows ?? []).map(m => (
|
||||
<tr key={m.owner}>
|
||||
<td className="ad-mono">{personLabel(m)}</td>
|
||||
<td className="ad-dim">{m.profRole?.name || '—'}</td>
|
||||
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td>
|
||||
<td><MemberDutyCell member={m} creator={project.owner} /></td>
|
||||
<td className="ad-dim">{m.addedBy || '—'}</td>
|
||||
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
|
||||
<td>
|
||||
<td>{manage.canManage && <>
|
||||
<button className="ad-mini" disabled={busy || !!writeError}
|
||||
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
|
||||
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
|
||||
@@ -326,11 +243,11 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
<button className="ad-mini" disabled={busy || !!writeError}
|
||||
onClick={() => run(`已移除 ${m.owner}`,
|
||||
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
|
||||
</td>
|
||||
</>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
|
||||
{rows === null && <tr><td colSpan={6} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={7} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
|
||||
{rows === null && <tr><td colSpan={7} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{memberNext && rows !== null && (
|
||||
|
||||
@@ -2,18 +2,20 @@
|
||||
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
|
||||
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, type RecoveryProposal } from '../api'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import type { AgentProfile } from '../contracts'
|
||||
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
|
||||
import { SettingInput, flatten, unflatten } from './SettingsModal'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { docBridge } from './Workbench'
|
||||
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery'
|
||||
import { accountActionPolicy, canAppointAdmins } from '../accountAuthority'
|
||||
import { CreateAdminOption, IdentityChip } from './AccountUsersPanel'
|
||||
import { GeneratedCredentialPanel, oneTimePasswordCredential, type GeneratedCredential } from './oneTimePasswordUi'
|
||||
|
||||
// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码;
|
||||
// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ----
|
||||
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
||||
// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码的唯一处置是
|
||||
// 管理员生成一次性登录密码,用户登录后必须立即改密。 ----
|
||||
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
||||
|
||||
export function UsersPanel() {
|
||||
const me = useStore(s => s.me)
|
||||
@@ -23,72 +25,37 @@ export function UsersPanel() {
|
||||
const [owner, setOwner] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [initialPassword, setInitialPassword] = useState('')
|
||||
const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'temporary' } | null>(null)
|
||||
const [createAsAdmin, setCreateAsAdmin] = useState(false)
|
||||
const [generatedCredential, setGeneratedCredential] = useState<GeneratedCredential | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。
|
||||
const [propFor, setPropFor] = useState<AdminUserRow | null>(null)
|
||||
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
|
||||
|
||||
const 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)) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
const create = async () => {
|
||||
setErr(''); setNotice('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined)
|
||||
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined, canAppointAdmins(me) && createAsAdmin)
|
||||
setGeneratedCredential(out.initialPassword ? { owner: out.owner, password: out.initialPassword, kind: 'initial' } : null)
|
||||
setNotice(out.initialPassword ? `已创建 ${out.owner},请将下方一次性初始密码交付给用户。首次登录后必须改密。` : `已创建 ${out.owner}。用户首次登录后必须改密。`)
|
||||
setOwner(''); setName(''); setInitialPassword('')
|
||||
setOwner(''); setName(''); setInitialPassword(''); setCreateAsAdmin(false)
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const resetPassword = async (u: AdminUserRow) => {
|
||||
const issueOneTimePassword = async (u: AdminUserRow) => {
|
||||
setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true)
|
||||
try {
|
||||
const out = await api.adminResetUserPassword(u.owner)
|
||||
setGeneratedCredential({ owner: u.owner, password: out.temporaryPassword, kind: 'temporary' })
|
||||
setNotice(`已为 ${u.owner} 生成临时密码。该用户登录后必须立即修改密码。`)
|
||||
const out = await api.adminIssueOneTimePassword(u.owner)
|
||||
setGeneratedCredential(oneTimePasswordCredential(out))
|
||||
setNotice(`已为 ${u.owner} 生成一次性登录密码。用户使用后必须立即设置新密码。`)
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const openProposals = async (u: AdminUserRow) => {
|
||||
setErr(''); setNotice(''); setPropFor(u); setProposals(null)
|
||||
await refreshProposals(u.userId)
|
||||
}
|
||||
|
||||
const propose = async () => {
|
||||
if (!propFor) return
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
await api.createAdminRecoveryProposal(propFor.userId, propFor.stateVersion, crypto.randomUUID())
|
||||
setNotice(`已为 ${propFor.owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现`)
|
||||
await refreshProposals(propFor.userId)
|
||||
} catch (e) {
|
||||
if (String(e).includes('409')) {
|
||||
await resetPassword(propFor)
|
||||
return
|
||||
}
|
||||
setErr(recoveryProposalErrorMessage(String(e)))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const revokeProposal = async (p: RecoveryProposal) => {
|
||||
if (!propFor) return
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version)
|
||||
await refreshProposals(propFor.userId)
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{me?.isAdmin && (
|
||||
@@ -99,65 +66,34 @@ export function UsersPanel() {
|
||||
<input className="lg-input" type="password" autoComplete="new-password" placeholder="初始密码(留空随机生成)" value={initialPassword} onChange={e => setInitialPassword(e.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim()} onClick={create}>创建账号</button>
|
||||
</div>
|
||||
<CreateAdminOption me={me} checked={createAsAdmin} disabled={busy} onChange={setCreateAsAdmin} />
|
||||
<p className="ad-hint">可填写管理员指定的初始密码;留空则由服务端生成一次性随机密码。用户首次登录后必须改密。</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{generatedCredential && <div className="ad-reset">
|
||||
<p className="ad-hint">{generatedCredential.kind === 'initial' ? '初始密码' : '临时密码'}仅显示一次,请立即安全交付给 {generatedCredential.owner}。</p>
|
||||
<input className="lg-input ad-mono" readOnly value={generatedCredential.password} aria-label="一次性密码" />
|
||||
<button type="button" className="ad-mini" onClick={() => setGeneratedCredential(null)}>关闭</button>
|
||||
</div>}
|
||||
{generatedCredential && <GeneratedCredentialPanel credential={generatedCredential} onClose={() => setGeneratedCredential(null)} />}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
{propFor ? (
|
||||
<div className="ad-reset">
|
||||
<div className="ad-row">
|
||||
<span><b>{propFor.owner}</b> 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key)</span>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={propose}>发起恢复提案</button>
|
||||
<button type="button" className="ad-mini" onClick={() => { setPropFor(null); setProposals(null) }}>关闭</button>
|
||||
</div>
|
||||
<p className="ad-hint">前提:该用户当前在任一设备保持完整密码登录(会话证明)。已锁死且无会话的账号无法在线恢复,请走服务器本地处置。</p>
|
||||
{proposals !== null && proposals.length > 0 && (
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>提案</th><th>状态</th><th>到期时间</th><th>发起人</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{proposals.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td className="ad-mono">{p.id}</td>
|
||||
<td>{recoveryStatusLabel[p.status]}{recoveryProposalHint(p.status) && <span className="ad-dim"> · {recoveryProposalHint(p.status)}</span>}</td>
|
||||
<td className="ad-dim">{new Date(p.expiresAt).toLocaleString()}</td>
|
||||
<td className="ad-dim">{p.createdBy}</td>
|
||||
<td>{(p.status === 'pending' || p.status === 'confirmed') && (
|
||||
<button className="ad-mini" disabled={busy} onClick={() => revokeProposal(p)}>撤销</button>
|
||||
)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{proposals !== null && proposals.length === 0 && <p className="ad-hint">该用户当前没有恢复提案。</p>}
|
||||
</div>
|
||||
) : (
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(users ?? []).map(u => (
|
||||
<tr key={u.owner}>
|
||||
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||
<td>{u.name || '—'}</td>
|
||||
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||
<td>{me?.isAdmin && <span className="ad-row">
|
||||
<button className="ad-mini" disabled={busy} onClick={() => resetPassword(u)}>重置临时密码</button>
|
||||
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
|
||||
</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{me?.isAdmin && <p className="ad-hint">用户忘记密码时,点击该用户行的「生成一次性登录密码」:用户登录后必须立即设置新密码。</p>}
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(users ?? []).map(u => {
|
||||
const policy = accountActionPolicy(me, u)
|
||||
return <tr key={u.owner}>
|
||||
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||
<td>{u.name || '—'}</td>
|
||||
<td>{u.isSuperAdmin ? <IdentityChip account={u} /> : u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||
<td>{me?.isAdmin && (policy.resetPassword
|
||||
? <button className="ad-mini" disabled={busy} onClick={() => issueOneTimePassword(u)}>生成一次性登录密码</button>
|
||||
: <span className="ad-dim">{policy.protectedReason || '受保护账号不可生成一次性登录密码'}</span>
|
||||
)}</td>
|
||||
</tr>
|
||||
})}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
|
||||
export function humanLoginError(raw: string): string {
|
||||
if (raw.includes('401')) return '用户名或密码不正确'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
|
||||
* 不能沿用登录的「用户名或密码不正确」一刀切。
|
||||
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
|
||||
export function humanChangePasswordError(raw: string): string {
|
||||
if (raw.includes('current password incorrect')) return '当前密码不正确'
|
||||
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
|
||||
if (raw.includes('session expired')) return '会话已过期,请重新登录'
|
||||
if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
|
||||
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return '认证失败,请重新登录'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { AdminIssuedOneTimePassword } from '../api'
|
||||
|
||||
export interface GeneratedCredential {
|
||||
owner: string
|
||||
password: string
|
||||
kind: 'initial' | 'one-time'
|
||||
}
|
||||
|
||||
export function oneTimePasswordCredential(out: AdminIssuedOneTimePassword): GeneratedCredential {
|
||||
return { owner: out.owner, password: out.oneTimePassword, kind: 'one-time' }
|
||||
}
|
||||
|
||||
export function GeneratedCredentialPanel({ credential, onClose }: { credential: GeneratedCredential; onClose: () => void }) {
|
||||
return <div className="ad-reset">
|
||||
<p className="ad-hint">{credential.kind === 'initial'
|
||||
? `初始密码仅显示一次,请立即安全交付给 ${credential.owner}。`
|
||||
: `一次性登录密码仅显示一次,请立即安全交付给 ${credential.owner};使用后必须强制改密。`}</p>
|
||||
<input className="lg-input ad-mono" readOnly value={credential.password} aria-label="一次性密码" />
|
||||
<button type="button" className="ad-mini" onClick={onClose}>关闭</button>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { api, type PasswordChangeMode } from '../api'
|
||||
|
||||
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
|
||||
return mode === 'current_required'
|
||||
}
|
||||
|
||||
export function isForcedPasswordMode(mode: PasswordChangeMode | null | undefined): mode is PasswordChangeMode {
|
||||
return mode === 'grant_setup' || mode === 'current_required' || mode === 'one_time_password'
|
||||
}
|
||||
|
||||
export function ForcedPasswordModeSection({ mode, current, onCurrentChange }: { mode: PasswordChangeMode | null; current: string; onCurrentChange: (value: string) => void }) {
|
||||
if (mode === 'current_required') {
|
||||
return <>
|
||||
<label className="lg-label" htmlFor="forced-current">当前密码</label>
|
||||
<input id="forced-current" className="lg-input" type="password" value={current}
|
||||
onChange={e => onCurrentChange(e.target.value)} autoComplete="current-password" />
|
||||
</>
|
||||
}
|
||||
if (mode === 'grant_setup' || mode === 'one_time_password') {
|
||||
return <p className="lg-hint">当前为受限设密会话,此次无需输入当前密码。</p>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function submitForcedPassword(mode: PasswordChangeMode, current: string, next: string) {
|
||||
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
|
||||
}
|
||||
+27
-1
@@ -150,6 +150,7 @@ export interface ActivityCard {
|
||||
id: ID; projectId: ID; conversationId?: ID;
|
||||
sourceRunId?: ID; anchorMessageId?: ID;
|
||||
kind: CardKind; title: string; status: CardStatus; tone?: string;
|
||||
task?: TaskSnapshot;
|
||||
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
|
||||
pinned?: boolean; createdAt: string; updatedAt: string; work?: WorkCard;
|
||||
}
|
||||
@@ -351,7 +352,7 @@ export interface PluginPageDecl {
|
||||
|
||||
export interface ParsedDocument {
|
||||
meta: {
|
||||
filename: string; mime?: string; parser: string; confidence?: number;
|
||||
filename: string; mime?: string; parser: string; confidence?: number; origin?: DocumentOrigin;
|
||||
crossCheck?: { with: string; diffBlocks: number; verdict: 'agree' | 'differ' };
|
||||
parsedAt: string;
|
||||
};
|
||||
@@ -414,3 +415,28 @@ export interface SurfaceMeta {
|
||||
conversationId?: ID;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Persistent work projection; host-owned identities and revisions.
|
||||
export interface DocumentOrigin { artifactId: ID; contentHash: string; parserRevision: string }
|
||||
export interface TaskPhase { id: string; label: string; observedAt?: string }
|
||||
export interface TaskSourceRef { id: string; artifactId: ID; contentHash: string; parserRevision: string; blockIndex: number; label: string; quote?: string; availability?: string }
|
||||
export interface TaskResultRef { artifactId?: ID; surfaceId?: ID; version?: string; contentHash?: string; availability: string; name?: string }
|
||||
export interface TaskFinding { id: string; text: string; impact?: string; kind: string; status: string; revision: number; sources: TaskSourceRef[]; assumptions?: string; corrects?: string }
|
||||
export interface TaskSnapshot {
|
||||
inputs?: TaskSourceRef[]
|
||||
limitations?: string[]
|
||||
schemaVersion: number; taskId: ID; attemptId: ID; cardId: ID; projectId: ID; conversationId: ID;
|
||||
sourceMessageId?: ID; parentRunId?: ID; runId?: ID; ownerInstanceId: string; executionEpoch?: number;
|
||||
revision: number; sourceSeq: number; streamScope: string; inputRevision?: string; title: string; goal?: string;
|
||||
executionState: 'queued'|'running'|'awaiting_user'|'completed'|'failed'|'interrupted'; outcomeCompleteness: 'none'|'partial'|'complete'; deliveryState: 'pending'|'available'|'reported'; phase: TaskPhase;
|
||||
focus?: string; coverage?: string; remaining?: string; nextStep?: string; conclusion?: string;
|
||||
findings?: TaskFinding[]; actions?: UIAction[]; resultRefs?: TaskResultRef[]; lastActivityAt?: string; lastMeaningfulUpdateAt?: string;
|
||||
checkpointKeys?: Record<string,string>; checkpointCount?: number; checkpointSeq?: number;
|
||||
}
|
||||
export interface TaskSourceQuote { sourceId: string; blockIndex: number; quote: string }
|
||||
interface WorkEvidencePremise { role: "total" | "phase" | "dependent" | "direct_relation"; sourceIndex: number; value?: number }
|
||||
interface WorkFindingEvidence { mode: "direct_relation" | "difference" | "unresolved_relation"; premises: WorkEvidencePremise[]; result?: number }
|
||||
export interface WorkFindingCandidate { findingKey: string; text: string; impact?: string; kind: string; assumptions?: string; corrects?: string; sources: TaskSourceQuote[]; evidence?: WorkFindingEvidence }
|
||||
export interface WorkCheckpointCandidate { checkpointId: string; phaseId: string; focus?: string; remaining?: string; nextStep?: string; findings: WorkFindingCandidate[] }
|
||||
|
||||
export interface UIAction { id: string; label: string; style?: string; command?: string }
|
||||
|
||||
+19
-9
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
+2
-1
@@ -3,13 +3,14 @@
|
||||
export interface PreviewTab {
|
||||
id: string
|
||||
name: string
|
||||
sourceContext?: {notice:string;quote:string;expectedHash?:string}
|
||||
}
|
||||
|
||||
export function openPreviewTab(tabs: PreviewTab[], tab: PreviewTab): { tabs: PreviewTab[]; activeId: string } {
|
||||
const i = tabs.findIndex(t => t.id === tab.id)
|
||||
if (i >= 0) {
|
||||
const next = tabs.slice()
|
||||
next[i] = { ...next[i], name: tab.name || next[i].name }
|
||||
next[i] = { ...next[i], name: tab.name || next[i].name,sourceContext:tab.sourceContext }
|
||||
return { tabs: next, activeId: tab.id }
|
||||
}
|
||||
return { tabs: [...tabs, tab], activeId: tab.id }
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canCreateProject, canManageProjectMembers, emptyProjectsNotice, memberDuty, noProjectError, projectCapabilitySummary,
|
||||
} from './projectmeta'
|
||||
|
||||
const linked = (projectCreate: boolean) => ({ projectCreate, source: 'role', linkedActions: [], authorityEpoch: 3 })
|
||||
|
||||
describe('EmptyListNoCreate', () => {
|
||||
it('asks a member without creation capability to wait for an invite instead of creating a project', () => {
|
||||
const notice = emptyProjectsNotice(true, false)
|
||||
expect(notice).toContain('等待项目负责人邀请')
|
||||
expect(notice).not.toContain('新建')
|
||||
expect(noProjectError(true, false)).not.toContain('新建')
|
||||
})
|
||||
|
||||
it('does not call a list that has not arrived empty', () => {
|
||||
expect(emptyProjectsNotice(false, true)).toBe('正在加载项目列表…')
|
||||
expect(noProjectError(false, false)).toContain('尚未加载')
|
||||
})
|
||||
|
||||
it('points a creator to explicit project creation', () => {
|
||||
expect(emptyProjectsNotice(true, true)).toContain('新建项目')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DefaultProjectDisabled creation entry', () => {
|
||||
it('follows the server-computed creation link first', () => {
|
||||
expect(canCreateProject({ auth: true, isAdmin: true, systemActions: ['project.create'], linkedCapabilities: linked(false) })).toBe(false)
|
||||
expect(canCreateProject({ auth: true, systemActions: [], linkedCapabilities: linked(true) })).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to explicit system actions and fails closed without an identity', () => {
|
||||
expect(canCreateProject({ auth: true, systemActions: [] })).toBe(false)
|
||||
expect(canCreateProject({ auth: true, systemActions: ['project.create'] })).toBe(true)
|
||||
expect(canCreateProject(null)).toBe(false)
|
||||
expect(canCreateProject({ auth: false })).toBe(true)
|
||||
})
|
||||
|
||||
it('summarizes the creation source and the linked own-project management', () => {
|
||||
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: linked(true) })).toContain('来源:职位角色')
|
||||
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: { ...linked(true), conflicts: ['project.members.manage'] } }))
|
||||
.toContain('1 项关联被角色上限阻断')
|
||||
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: linked(false) })).toContain('被邀请加入后项目自动出现')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CreatorOwnerMemberDuties', () => {
|
||||
it('gives member management only to the immutable creator, not to an owner label', () => {
|
||||
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice')).toMatchObject({ kind: 'creator', label: '创建者' })
|
||||
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice').duties).toContain('成员增删')
|
||||
const owner = memberDuty({ owner: 'bob', role: 'owner' }, 'alice')
|
||||
expect(owner).toMatchObject({ kind: 'owner', label: '项目负责人' })
|
||||
expect(owner.duties).toContain('不含成员管理')
|
||||
const member = memberDuty({ owner: 'carol', role: 'member' }, 'alice')
|
||||
expect(member).toMatchObject({ kind: 'member', source: '成员关系' })
|
||||
expect(member.duties).toContain('本人对话')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MemberManagementEntryHidden capability', () => {
|
||||
it('requires an allowed member management action from the self check', () => {
|
||||
expect(canManageProjectMembers(null)).toBe(false)
|
||||
expect(canManageProjectMembers([{ action: 'member.manage', allowed: false }])).toBe(false)
|
||||
expect(canManageProjectMembers([{ action: 'doc.read', allowed: true }])).toBe(false)
|
||||
expect(canManageProjectMembers([{ action: 'member.manage', allowed: true }])).toBe(true)
|
||||
expect(canManageProjectMembers([{ action: 'project.members.manage', allowed: true }])).toBe(true)
|
||||
})
|
||||
|
||||
it('treats the two old and new capability ids as equivalent for backward compatibility', () => {
|
||||
const onlyLegacy = canManageProjectMembers([{ action: 'member.manage', allowed: true }])
|
||||
const onlyModern = canManageProjectMembers([{ action: 'project.members.manage', allowed: true }])
|
||||
const both = canManageProjectMembers([
|
||||
{ action: 'member.manage', allowed: true },
|
||||
{ action: 'project.members.manage', allowed: true },
|
||||
])
|
||||
expect(onlyLegacy).toBe(onlyModern)
|
||||
expect(onlyModern).toBe(true)
|
||||
expect(both).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CreatorOwnerMemberDuties member identity', () => {
|
||||
it('falls back to plain member when the creator record is missing', () => {
|
||||
const member = memberDuty({ owner: 'carol', role: 'member' }, undefined)
|
||||
expect(member).toMatchObject({ kind: 'member', label: '项目成员' })
|
||||
expect(member.duties).toContain('基础协作')
|
||||
})
|
||||
|
||||
it('names the source of each member kind distinctly', () => {
|
||||
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice').source).toMatch(/创建项目/)
|
||||
expect(memberDuty({ owner: 'bob', role: 'owner' }, 'alice').source).toMatch(/成员关系/)
|
||||
expect(memberDuty({ owner: 'carol', role: 'member' }, 'alice').source).toBe('成员关系')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DefaultProjectDisabled empty state messaging', () => {
|
||||
it('keeps both helpers pointing the member to wait for an invite', () => {
|
||||
expect(emptyProjectsNotice(true, false)).toContain('等待项目负责人邀请')
|
||||
expect(noProjectError(true, false)).toContain('等待项目负责人邀请')
|
||||
})
|
||||
|
||||
it('keeps the loading notice distinct from a real empty list', () => {
|
||||
expect(emptyProjectsNotice(false, false)).toMatch(/加载/)
|
||||
expect(noProjectError(false, false)).toMatch(/加载/)
|
||||
expect(emptyProjectsNotice(false, false)).not.toBe(noProjectError(false, false))
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { LinkedCapabilities } from './api'
|
||||
|
||||
/** 阶段徽章:只读项目设置里的 stage 字符串;没有就不显示。 */
|
||||
export function projectStage(settings?: Record<string, unknown> | null): string | null {
|
||||
const stage = settings?.stage
|
||||
@@ -41,3 +43,70 @@ export function projectActionError(error: unknown): string {
|
||||
if (/\b503\b/.test(raw)) return '项目服务暂未就绪,请稍后重试或联系项目负责人'
|
||||
return raw.replace(/^Error:\s*/, '')
|
||||
}
|
||||
|
||||
interface ProjectActor {
|
||||
auth?: boolean; isAdmin?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities
|
||||
}
|
||||
|
||||
/** 只决定是否展示创建入口;服务端仍按当前身份最终判定。 */
|
||||
export function canCreateProject(me: ProjectActor | null | undefined): boolean {
|
||||
if (!me) return false
|
||||
if (me.auth === false) return true
|
||||
if (me.linkedCapabilities) return me.linkedCapabilities.projectCreate
|
||||
// Backends without capability exposure keep the server as the only gate.
|
||||
if (me.systemActions === undefined) return true
|
||||
return !!me.isAdmin || me.systemActions.includes('project.create')
|
||||
}
|
||||
|
||||
const CAPABILITY_SOURCE_LABELS: Record<string, string> = {
|
||||
role: '职位角色', 'system-grant': '明确系统授权', 'bootstrap-admin': '初始管理员',
|
||||
}
|
||||
|
||||
function capabilitySourceLabel(source: string | undefined): string {
|
||||
return (source && CAPABILITY_SOURCE_LABELS[source]) || source || '未知来源'
|
||||
}
|
||||
|
||||
/** 账户区一句话:本人是否能创建项目、来源、以及联动形成的本人项目管理资格。 */
|
||||
export function projectCapabilitySummary(me: ProjectActor | null | undefined): string {
|
||||
const linked = me?.linkedCapabilities
|
||||
if (!linked) return canCreateProject(me) ? '可创建项目' : '未授予创建项目 · 被邀请加入后项目自动出现'
|
||||
if (!linked.projectCreate) return '未授予创建项目 · 被邀请加入后项目自动出现'
|
||||
const conflicts = linked.conflicts?.length ? ` · ${linked.conflicts.length} 项关联被角色上限阻断` : ''
|
||||
return `可创建项目(来源:${capabilitySourceLabel(linked.source)})· 本人创建的项目自动具备设置、成员、项目权限与文档职责管理${conflicts}`
|
||||
}
|
||||
|
||||
/** 空项目列表:未加载不当作空;无创建能力时只提示等待邀请,不诱导新建同名项目。 */
|
||||
export function emptyProjectsNotice(loaded: boolean, canCreate: boolean): string {
|
||||
if (!loaded) return '正在加载项目列表…'
|
||||
if (canCreate) return '还没有项目:从「新建项目」开始。'
|
||||
return '尚无项目:等待项目负责人邀请你加入,加入后会自动出现在这里。'
|
||||
}
|
||||
|
||||
/** 新建对话时没有可用项目:不再自动创建「默认项目」。 */
|
||||
export function noProjectError(loaded: boolean, canCreate: boolean): string {
|
||||
if (!loaded) return '项目列表尚未加载完成,请稍候再试'
|
||||
if (canCreate) return '还没有项目,请先新建项目'
|
||||
return '尚无可用项目,请等待项目负责人邀请你加入'
|
||||
}
|
||||
|
||||
interface MemberDuty { kind: 'creator' | 'owner' | 'member'; label: string; duties: string; source: string }
|
||||
|
||||
/** 项目内实际职责:成员管理只随不可变创建者;负责人只是界面身份。 */
|
||||
export function memberDuty(member: { owner: string; role: string }, creator: string | undefined): MemberDuty {
|
||||
if (creator && member.owner === creator) {
|
||||
return { kind: 'creator', label: '创建者', duties: '项目设置、成员增删、项目内权限、文档职责分配 + 基础协作',
|
||||
source: '随「创建项目」联动(仅本人创建的项目)' }
|
||||
}
|
||||
if (member.role === 'owner') {
|
||||
return { kind: 'owner', label: '项目负责人', duties: '基础协作:项目阅读与本人对话;不含成员管理',
|
||||
source: '成员关系(界面身份不授予创建者管理权)' }
|
||||
}
|
||||
return { kind: 'member', label: '项目成员', duties: '基础协作:查看与切换项目、两树一网阅读、本人对话', source: '成员关系' }
|
||||
}
|
||||
|
||||
const MEMBER_MANAGE_ACTIONS = new Set(['member.manage', 'project.members.manage'])
|
||||
|
||||
/** 本人自省结果里成员管理是否生效;未知(null)按无权处理。 */
|
||||
export function canManageProjectMembers(actions: { action: string; allowed: boolean }[] | null | undefined): boolean {
|
||||
return !!actions?.some(a => MEMBER_MANAGE_ACTIONS.has(a.action) && a.allowed)
|
||||
}
|
||||
|
||||
+104
-22
@@ -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 } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from './recovery'
|
||||
import { humanChangePasswordError, humanRedeemError, requiresCurrentPassword } from './components/LoginPage'
|
||||
|
||||
// 账户链路文案与模式判定(2026-10-14 current_required 加固后前后端契约对齐的
|
||||
// 防漂移锁):改密门必须区分 grant_setup / current_required,恢复提案 409 的
|
||||
// 两种语义必须分开表达。
|
||||
|
||||
describe('requiresCurrentPassword', () => {
|
||||
it('current_required 必须要当前密码,grant_setup 免当前密码', () => {
|
||||
expect(requiresCurrentPassword('current_required')).toBe(true)
|
||||
expect(requiresCurrentPassword('grant_setup')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanChangePasswordError', () => {
|
||||
it('后端 current password incorrect 映射为当前密码不正确', () => {
|
||||
expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确')
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanRedeemError', () => {
|
||||
it('401/409/429 语义分离', () => {
|
||||
expect(humanRedeemError('409: login key already used')).toContain('已被使用')
|
||||
expect(humanRedeemError('401: invalid or expired login key')).toContain('无效或已过期')
|
||||
expect(humanRedeemError('429: too many failed attempts')).toContain('稍后再试')
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryStatusLabel', () => {
|
||||
it('覆盖全部五种提案状态', () => {
|
||||
for (const status of ['pending', 'confirmed', 'completed', 'revoked', 'expired'] as const) {
|
||||
expect(recoveryStatusLabel[status]).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryProposalHint', () => {
|
||||
it('pending/confirmed 给出下一步,终态不给', () => {
|
||||
expect(recoveryProposalHint('pending')).toContain('本人确认')
|
||||
expect(recoveryProposalHint('confirmed')).toContain('新密码')
|
||||
expect(recoveryProposalHint('completed')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryProposalErrorMessage', () => {
|
||||
it('OWNER_PROOF_UNAVAILABLE 与活动提案冲突分开表达', () => {
|
||||
expect(recoveryProposalErrorMessage('409: OWNER_PROOF_UNAVAILABLE')).toContain('会话证明')
|
||||
expect(recoveryProposalErrorMessage('409: store: conflict')).toContain('已有进行中的恢复提案')
|
||||
})
|
||||
it('未识别错误剥状态码原样透出', () => {
|
||||
expect(recoveryProposalErrorMessage('500: boom')).toBe('boom')
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
// 恢复提案展示与错误文案(纯函数):管理端 UsersPanel 与本人 MyAccountSettings
|
||||
// 共用,vitest 锁定语义防漂移。后端 409 两种含义按响应体区分:
|
||||
// OWNER_PROOF_UNAVAILABLE=无合格会话证明;其余 conflict=已有活动提案。
|
||||
import type { RecoveryProposal } from './api'
|
||||
|
||||
export const recoveryStatusLabel: Record<RecoveryProposal['status'], string> = {
|
||||
pending: '待本人确认',
|
||||
confirmed: '已确认,待兑换设密',
|
||||
completed: '已完成',
|
||||
revoked: '已撤销',
|
||||
expired: '已过期',
|
||||
}
|
||||
|
||||
/** 管理端视角的下一步指引:提案成立后动作在属主侧(设置 → 账号)。 */
|
||||
export function recoveryProposalHint(status: RecoveryProposal['status']): string {
|
||||
if (status === 'pending') return '等待该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现'
|
||||
if (status === 'confirmed') return '该用户已确认;请提醒其尽快在受限会话中完成新密码设置,到期未用需重新生成'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function recoveryProposalErrorMessage(raw: string): string {
|
||||
if (raw.includes('OWNER_PROOF_UNAVAILABLE')) {
|
||||
return '无法发起:该账号没有可用的合格会话证明(需该用户当前在任一设备保持完整密码登录)。历史来源或已锁死账号请走服务器本地处置'
|
||||
}
|
||||
if (raw.includes('409')) return '该用户已有进行中的恢复提案;请先撤销旧提案或等它完成/过期'
|
||||
if (raw.includes('403')) return '需要管理员身份'
|
||||
if (raw.includes('404')) return '用户不存在或已被移除'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PROJECT_CREATE_LINKED_ACTIONS, buildCapabilityGroups, capabilityGroupChanges, capabilityGroupState, createRolePermissionDraft,
|
||||
ensureLinkedCapabilities, linkedChainBreaks, projectCreateSources, rebaseGrantDraft, rebaseRoleDraft, setCapabilityGroup,
|
||||
type RolePermissionValue,
|
||||
} from './rolePermissions'
|
||||
|
||||
const catalog = [
|
||||
{ actionId: 'project.create', label: '创建项目', group: '项目入口' },
|
||||
{ actionId: 'project.settings', label: '项目设置', group: '项目管理' },
|
||||
{ actionId: 'project.archive', label: '归档项目', group: '项目管理' },
|
||||
{ actionId: 'project.stage.advance', label: '推进项目阶段', group: '项目管理' },
|
||||
{ actionId: 'project.members.manage', label: '管理项目成员', group: '项目管理' },
|
||||
{ actionId: 'project.permissions.manage', label: '管理项目权限', group: '项目管理' },
|
||||
{ actionId: 'document.duties.assign', label: '分配文档三权', group: '文档职责' },
|
||||
{ actionId: 'document.read', label: '读取文档', group: '文档动作' },
|
||||
{ actionId: 'document.author', label: '编制文档', group: '文档动作' },
|
||||
{ actionId: 'document.review', label: '审核文档', group: '文档动作' },
|
||||
{ actionId: 'future.action', label: '未来动作', group: '扩展' },
|
||||
]
|
||||
const off: RolePermissionValue = { allowed: false, capAllowed: false }
|
||||
const values = (overrides: Record<string, RolePermissionValue> = {}) =>
|
||||
({ ...Object.fromEntries(catalog.map(item => [item.actionId, off])), ...overrides })
|
||||
|
||||
describe('CapabilityGroup model', () => {
|
||||
const groups = buildCapabilityGroups(catalog)
|
||||
const create = groups.find(group => group.id === 'project-create')!
|
||||
|
||||
it('groups actions by business capability and keeps unknown actions visible', () => {
|
||||
expect(groups.map(group => group.id)).toEqual(['project-create', 'project-governance', 'document-duties', 'document-read', 'catalog:扩展'])
|
||||
expect(create.actions).toEqual(['project.create'])
|
||||
expect(create.linked).toEqual([...PROJECT_CREATE_LINKED_ACTIONS])
|
||||
const all = groups.flatMap(group => [...group.actions, ...group.linked]).sort()
|
||||
expect(all).toEqual(catalog.map(item => item.actionId).sort())
|
||||
})
|
||||
|
||||
it('enabling project creation links own-project management caps without granting them by default', () => {
|
||||
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
|
||||
expect(draft.values['project.create']).toEqual({ allowed: true, capAllowed: true })
|
||||
for (const id of PROJECT_CREATE_LINKED_ACTIONS) expect(draft.values[id]).toEqual({ allowed: false, capAllowed: true })
|
||||
expect(capabilityGroupState(draft.values, create)).toEqual({ allowed: 'on', capAllowed: 'on', linkedReady: true })
|
||||
})
|
||||
|
||||
it('turning project creation off keeps already linked management caps', () => {
|
||||
const enabled = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
|
||||
const disabled = setCapabilityGroup(createRolePermissionDraft(enabled.values), create, 'capAllowed', false)
|
||||
expect(disabled.values['project.create']).toEqual(off)
|
||||
for (const id of PROJECT_CREATE_LINKED_ACTIONS) expect(disabled.values[id].capAllowed).toBe(true)
|
||||
})
|
||||
|
||||
it('reports mixed group state and the grouped preview with linked additions', () => {
|
||||
const duties = groups.find(group => group.id === 'document-duties')!
|
||||
const draft = setCapabilityGroup(createRolePermissionDraft(values({ 'document.author': { allowed: true, capAllowed: true } })), create, 'capAllowed', true)
|
||||
expect(capabilityGroupState(draft.values, duties).allowed).toBe('mixed')
|
||||
const changes = capabilityGroupChanges(draft, groups)
|
||||
expect(changes.map(change => change.group.id)).toEqual(['project-create'])
|
||||
expect(changes[0].before.capAllowed).toBe('off')
|
||||
expect(changes[0].after.capAllowed).toBe('on')
|
||||
expect(changes[0].linkedAdded).toEqual([...PROJECT_CREATE_LINKED_ACTIONS])
|
||||
})
|
||||
|
||||
it('detects and repairs a broken link from a server baseline before saving', () => {
|
||||
const broken = createRolePermissionDraft(values({ 'project.create': { allowed: true, capAllowed: true } }))
|
||||
expect(linkedChainBreaks(broken.values)).toEqual([...PROJECT_CREATE_LINKED_ACTIONS])
|
||||
expect(linkedChainBreaks(ensureLinkedCapabilities(broken).values)).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves the grouped draft when an optimistic conflict is replayed on newer server values', () => {
|
||||
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
|
||||
const server = values({ 'document.review': { allowed: true, capAllowed: true } })
|
||||
const rebased = rebaseRoleDraft(draft, server)
|
||||
expect(rebased.values['project.create']).toEqual({ allowed: true, capAllowed: true })
|
||||
expect(rebased.values['project.members.manage'].capAllowed).toBe(true)
|
||||
expect(rebased.values['document.review']).toEqual({ allowed: true, capAllowed: true })
|
||||
})
|
||||
|
||||
it('replays explicit grant edits on the latest grants after a version conflict', () => {
|
||||
expect(rebaseGrantDraft(['system.users.view'], ['project.create'], ['system.users.view', 'system.roles.view']))
|
||||
.toEqual(['project.create', 'system.roles.view'])
|
||||
})
|
||||
|
||||
it('names every project creation source', () => {
|
||||
const role = { name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }
|
||||
expect(projectCreateSources(role, ['project.create'])).toEqual(['职位角色「项目经理」', '明确系统授权'])
|
||||
expect(projectCreateSources(null, [])).toEqual([])
|
||||
})
|
||||
|
||||
it('reports each business-group toggle position alongside the linked chain readiness', () => {
|
||||
const governance = groups.find(group => group.id === 'project-governance')!
|
||||
const duties = groups.find(group => group.id === 'document-duties')!
|
||||
expect(capabilityGroupState(values(), governance)).toEqual({ allowed: 'off', capAllowed: 'off', linkedReady: true })
|
||||
expect(capabilityGroupState(values({ 'document.author': { allowed: true, capAllowed: true },
|
||||
'document.review': { allowed: false, capAllowed: true } }), duties)).toEqual({
|
||||
allowed: 'mixed', capAllowed: 'on', linkedReady: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the linked additions accurate when an unrelated group also changes', () => {
|
||||
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'capAllowed', true)
|
||||
draft.values['project.archive'] = { allowed: true, capAllowed: true }
|
||||
const governanceId = 'project-governance'
|
||||
const changes = capabilityGroupChanges(draft, groups)
|
||||
const governance = changes.find(change => change.group.id === governanceId)
|
||||
expect(governance).toBeDefined()
|
||||
expect(governance!.linkedAdded).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps draft baseline identity during non-conflict rebases', () => {
|
||||
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
|
||||
const same = rebaseRoleDraft(draft, draft.values)
|
||||
expect(same.values).toEqual(draft.values)
|
||||
expect(same.baseline).toEqual(draft.values)
|
||||
expect(same.generation).toBeGreaterThan(draft.generation)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,225 @@
|
||||
export type RolePermissionValue = { allowed: boolean; capAllowed: boolean }
|
||||
type RolePermissionChange = { actionId: string; allowed: boolean; capAllowed: boolean }
|
||||
|
||||
export interface RolePermissionDraft {
|
||||
baseline: Record<string, RolePermissionValue>
|
||||
values: Record<string, RolePermissionValue>
|
||||
generation: number
|
||||
}
|
||||
|
||||
function copy(values: Record<string, RolePermissionValue>): Record<string, RolePermissionValue> {
|
||||
return Object.fromEntries(Object.entries(values).map(([actionId, value]) => [actionId, { ...value }]))
|
||||
}
|
||||
|
||||
export function createRolePermissionDraft(values: Record<string, RolePermissionValue>, generation = 0): RolePermissionDraft {
|
||||
return { baseline: copy(values), values: copy(values), generation }
|
||||
}
|
||||
|
||||
export function updateRolePermission(
|
||||
draft: RolePermissionDraft,
|
||||
actionId: string,
|
||||
value: RolePermissionValue,
|
||||
): RolePermissionDraft {
|
||||
return { ...draft, values: { ...draft.values, [actionId]: { ...value } }, generation: draft.generation + 1 }
|
||||
}
|
||||
|
||||
export function mergeRoleDraft(
|
||||
current: RolePermissionDraft,
|
||||
serverValues: Record<string, RolePermissionValue>,
|
||||
responseGeneration: number,
|
||||
): RolePermissionDraft {
|
||||
if (responseGeneration < current.generation) return current
|
||||
return createRolePermissionDraft(serverValues, responseGeneration)
|
||||
}
|
||||
|
||||
// Rebase local edits on a newer server baseline after an optimistic conflict.
|
||||
// Only fields changed from the old baseline are replayed.
|
||||
export function rebaseRoleDraft(
|
||||
current: RolePermissionDraft,
|
||||
serverValues: Record<string, RolePermissionValue>,
|
||||
): RolePermissionDraft {
|
||||
const values = copy(serverValues)
|
||||
for (const [actionId, local] of Object.entries(current.values)) {
|
||||
const old = current.baseline[actionId]
|
||||
if (!old || old.allowed !== local.allowed || old.capAllowed !== local.capAllowed) values[actionId] = { ...local }
|
||||
}
|
||||
return { baseline: copy(serverValues), values, generation: current.generation + 1 }
|
||||
}
|
||||
|
||||
export function visiblePatch(draft: RolePermissionDraft, visibleActionIds: string[]): RolePermissionChange[] {
|
||||
const seen = new Set<string>()
|
||||
const changes: RolePermissionChange[] = []
|
||||
for (const actionId of visibleActionIds) {
|
||||
if (seen.has(actionId)) continue
|
||||
seen.add(actionId)
|
||||
const value = draft.values[actionId]
|
||||
if (!value) continue
|
||||
const baseline = draft.baseline[actionId]
|
||||
if (baseline && baseline.allowed === value.allowed && baseline.capAllowed === value.capAllowed) continue
|
||||
changes.push({ actionId, ...value })
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
// ---- 业务能力组(KTD1):普通界面按业务组配置,低层动作留给服务端鉴权与审计 ----
|
||||
|
||||
export const PROJECT_CREATE_ACTION = 'project.create'
|
||||
/** 与 Backend catalog.ProjectCreateLinkedActions 同序:创建者在本人项目里的管理资格。 */
|
||||
export const PROJECT_CREATE_LINKED_ACTIONS = [
|
||||
'project.settings', 'project.members.manage', 'project.permissions.manage', 'document.duties.assign',
|
||||
] as const
|
||||
|
||||
interface CapabilityCatalogItem { actionId: string; label: string; group: string; protected?: boolean }
|
||||
|
||||
export interface CapabilityGroup {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
/** 组开关直接控制的动作。 */
|
||||
actions: string[]
|
||||
/** 组内受控项:随主能力联动,不单独勾选。 */
|
||||
linked: string[]
|
||||
}
|
||||
|
||||
const BUSINESS_GROUPS: (Omit<CapabilityGroup, 'actions' | 'linked'> & { actions: readonly string[]; linked?: readonly string[] })[] = [
|
||||
{ id: 'project-create', label: '创建与管理本人项目',
|
||||
description: '创建新项目;本人创建的项目随之具备设置、成员增删、项目内权限与文档职责分配资格,不涉及他人项目',
|
||||
actions: [PROJECT_CREATE_ACTION], linked: PROJECT_CREATE_LINKED_ACTIONS },
|
||||
{ id: 'project-governance', label: '项目归档与阶段', description: '归档/恢复与推进本人创建项目的阶段',
|
||||
actions: ['project.archive', 'project.stage.advance'] },
|
||||
{ id: 'document-duties', label: '文档编制与审签', description: '履行已分配的文档编制、审核、批准与发布职责',
|
||||
actions: ['document.author', 'document.review', 'document.approve', 'document.publish'] },
|
||||
{ id: 'document-read', label: '文档阅读', description: '读取获准的项目文档;项目成员的基础协作另由成员关系提供',
|
||||
actions: ['document.read'] },
|
||||
{ id: 'project-content', label: '追溯与知识', description: '编辑追溯网、发布项目知识与读取业务审计',
|
||||
actions: ['trace.edit', 'knowledge.publish', 'project.audit.read'] },
|
||||
]
|
||||
|
||||
/** 目录里未归入业务组的动作按目录分组兜底,保证新增动作不会从界面消失。 */
|
||||
export function buildCapabilityGroups(catalog: CapabilityCatalogItem[]): CapabilityGroup[] {
|
||||
const known = new Set(catalog.map(item => item.actionId))
|
||||
const claimed = new Set<string>()
|
||||
const groups: CapabilityGroup[] = []
|
||||
for (const group of BUSINESS_GROUPS) {
|
||||
const actions = group.actions.filter(id => known.has(id))
|
||||
if (actions.length === 0) continue
|
||||
const linked = (group.linked ?? []).filter(id => known.has(id))
|
||||
for (const id of [...actions, ...linked]) claimed.add(id)
|
||||
groups.push({ id: group.id, label: group.label, description: group.description, actions, linked })
|
||||
}
|
||||
const rest = new Map<string, CapabilityGroup>()
|
||||
for (const item of catalog) {
|
||||
if (claimed.has(item.actionId)) continue
|
||||
const group = rest.get(item.group) ?? { id: `catalog:${item.group}`, label: item.group, description: '', actions: [], linked: [] }
|
||||
group.actions.push(item.actionId)
|
||||
rest.set(item.group, group)
|
||||
}
|
||||
return [...groups, ...rest.values()]
|
||||
}
|
||||
|
||||
export type GroupLevel = 'on' | 'off' | 'mixed'
|
||||
|
||||
function level(flags: boolean[]): GroupLevel {
|
||||
if (flags.length > 0 && flags.every(Boolean)) return 'on'
|
||||
return flags.some(Boolean) ? 'mixed' : 'off'
|
||||
}
|
||||
|
||||
const OFF: RolePermissionValue = { allowed: false, capAllowed: false }
|
||||
|
||||
export function capabilityGroupState(values: Record<string, RolePermissionValue>, group: CapabilityGroup) {
|
||||
const items = group.actions.map(id => values[id] ?? OFF)
|
||||
return {
|
||||
allowed: level(items.map(item => item.allowed)),
|
||||
capAllowed: level(items.map(item => item.capAllowed)),
|
||||
linkedReady: group.linked.every(id => (values[id] ?? OFF).capAllowed),
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建能力开启(默认或上限)即补齐联动项上限;只开上限,不把管理动作默认授予所有成员项目。 */
|
||||
export function ensureLinkedCapabilities(draft: RolePermissionDraft): RolePermissionDraft {
|
||||
const create = draft.values[PROJECT_CREATE_ACTION]
|
||||
if (!create?.allowed && !create?.capAllowed) return draft
|
||||
let next = draft
|
||||
for (const id of PROJECT_CREATE_LINKED_ACTIONS) {
|
||||
const current = next.values[id]
|
||||
if (!current || current.capAllowed) continue
|
||||
next = updateRolePermission(next, id, { ...current, capAllowed: true })
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** 组开关:沿用「默认不超过上限」耦合;关闭创建不反向删除已有联动资格。 */
|
||||
export function setCapabilityGroup(
|
||||
draft: RolePermissionDraft, group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean,
|
||||
): RolePermissionDraft {
|
||||
let next = draft
|
||||
for (const id of group.actions) {
|
||||
const current = next.values[id] ?? OFF
|
||||
const value = field === 'allowed'
|
||||
? { allowed: checked, capAllowed: checked ? true : current.capAllowed }
|
||||
: { allowed: checked ? current.allowed : false, capAllowed: checked }
|
||||
next = updateRolePermission(next, id, value)
|
||||
}
|
||||
return ensureLinkedCapabilities(next)
|
||||
}
|
||||
|
||||
/** 创建能力已开但联动项上限仍关:断链,需在保存时补齐或由服务端整笔拒绝。 */
|
||||
export function linkedChainBreaks(values: Record<string, RolePermissionValue>): string[] {
|
||||
const create = values[PROJECT_CREATE_ACTION]
|
||||
if (!create?.allowed && !create?.capAllowed) return []
|
||||
return PROJECT_CREATE_LINKED_ACTIONS.filter(id => id in values && !values[id].capAllowed)
|
||||
}
|
||||
|
||||
export interface CapabilityGroupChange {
|
||||
group: CapabilityGroup
|
||||
before: ReturnType<typeof capabilityGroupState>
|
||||
after: ReturnType<typeof capabilityGroupState>
|
||||
/** 本次联动补齐上限的受控项。 */
|
||||
linkedAdded: string[]
|
||||
}
|
||||
|
||||
export function capabilityGroupChanges(draft: RolePermissionDraft, groups: CapabilityGroup[]): CapabilityGroupChange[] {
|
||||
const changed = (id: string) => {
|
||||
const before = draft.baseline[id] ?? OFF
|
||||
const after = draft.values[id] ?? OFF
|
||||
return before.allowed !== after.allowed || before.capAllowed !== after.capAllowed
|
||||
}
|
||||
return groups.flatMap(group => {
|
||||
if (![...group.actions, ...group.linked].some(changed)) return []
|
||||
return [{
|
||||
group,
|
||||
before: capabilityGroupState(draft.baseline, group),
|
||||
after: capabilityGroupState(draft.values, group),
|
||||
linkedAdded: group.linked.filter(id => !(draft.baseline[id] ?? OFF).capAllowed && (draft.values[id] ?? OFF).capAllowed),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
/** 系统授权草稿冲突重放:只把本地相对旧基线的增删应用到最新服务端授权上。 */
|
||||
export function rebaseGrantDraft(baseline: string[], local: string[], server: string[]): string[] {
|
||||
const before = new Set(baseline)
|
||||
const now = new Set(local)
|
||||
const next = new Set(server)
|
||||
for (const id of now) if (!before.has(id)) next.add(id)
|
||||
for (const id of before) if (!now.has(id)) next.delete(id)
|
||||
return [...next].sort()
|
||||
}
|
||||
|
||||
/** 某账号 project.create 的实际来源(角色默认与明确系统授权可并存)。
|
||||
* getUserRole 的角色摘要不带 permissions,缺失按「无角色默认权限」处理。 */
|
||||
export function projectCreateSources(
|
||||
role: { name: string; permissions?: { actionId: string; allowed: boolean }[] } | null | undefined, grants: string[],
|
||||
): string[] {
|
||||
const sources: string[] = []
|
||||
if (role?.permissions?.some(item => item.actionId === PROJECT_CREATE_ACTION && item.allowed)) sources.push(`职位角色「${role.name}」`)
|
||||
if (grants.includes(PROJECT_CREATE_ACTION)) sources.push('明确系统授权')
|
||||
return sources
|
||||
}
|
||||
|
||||
export function validateRolePermissionDraft(draft: RolePermissionDraft): { actionId: string; reason: string } | null {
|
||||
for (const actionId of Object.keys(draft.values).sort()) {
|
||||
const value = draft.values[actionId]
|
||||
if (value.allowed && !value.capAllowed) return { actionId, reason: 'default-exceeds-cap' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { auth } from './auth'
|
||||
import {
|
||||
PROJECT_SYNC_INTERVAL_MS, PROJECT_SYNC_RENDER_BUDGET_MS, PROJECT_SYNC_REQUEST_BUDGET_MS,
|
||||
createProjectAutoDiscover, projectSyncDelay, store, type ProjectSyncEnv, type ProjectSyncStatus,
|
||||
} from './store'
|
||||
import type { ConversationView } from './api'
|
||||
import type { Project } from './contracts'
|
||||
|
||||
const project = (id: string, owner = 'alice'): Project => ({ id, owner, name: id, createdAt: '', updatedAt: '' })
|
||||
const task = (id: string, projectId: string): ConversationView => ({ id, projectId, title: id, createdAt: '', updatedAt: '' })
|
||||
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((ok, fail) => { resolve = ok; reject = fail })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
/** 可控的页面环境:可见性、联网与 wake/sleep 事件由测试驱动。 */
|
||||
function fakeEnv() {
|
||||
const env = {
|
||||
visible: true,
|
||||
online: true,
|
||||
handlers: null as null | { wake: () => void; sleep: (status: 'paused' | 'offline') => void },
|
||||
}
|
||||
const port: ProjectSyncEnv = {
|
||||
visible: () => env.visible,
|
||||
online: () => env.online,
|
||||
subscribe: handlers => { env.handlers = handlers; return () => { env.handlers = null } },
|
||||
}
|
||||
return {
|
||||
port,
|
||||
hide() { env.visible = false; env.handlers?.sleep('paused') },
|
||||
show() { env.visible = true; env.handlers?.wake() },
|
||||
offline() { env.online = false; env.handlers?.sleep('offline') },
|
||||
online() { env.online = true; env.handlers?.wake() },
|
||||
focus() { env.handlers?.wake() },
|
||||
}
|
||||
}
|
||||
|
||||
let projects: Project[]
|
||||
let tasks: ConversationView[]
|
||||
let requests: { path: string; init?: RequestInit }[]
|
||||
let route: (path: string, init?: RequestInit) => Promise<Response>
|
||||
|
||||
beforeEach(async () => {
|
||||
projects = [project('host-a')]
|
||||
tasks = [task('conv-a', 'host-a')]
|
||||
requests = []
|
||||
const saved = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { saved.set(key, value) },
|
||||
removeItem: (key: string) => { saved.delete(key) } })
|
||||
route = async (path, init) => {
|
||||
if (path === '/api/projects' && !init?.method) return response(projects)
|
||||
if (path === '/api/conversations' && !init?.method) return response(tasks.filter(t => projects.some(p => p.id === t.projectId)))
|
||||
if (path === '/api/conversations/conv-a') return response({ conversation: tasks[0], messages: [], runs: [], activeHitl: [], lastSeq: 0 })
|
||||
if (path === '/api/me') return response({ owner: 'bob', name: 'Bob', auth: true, systemActions: [] })
|
||||
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
|
||||
if (path.endsWith('/artifacts') || path.endsWith('/surfaces')) return response([])
|
||||
if (path.endsWith('/cards')) return response({ cards: [] })
|
||||
return response({})
|
||||
}
|
||||
vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => {
|
||||
requests.push({ path, init })
|
||||
return route(path, init)
|
||||
})
|
||||
vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
|
||||
auth.beginSession()
|
||||
await store.refreshMe()
|
||||
await store.refreshProjects()
|
||||
await store.openTask('conv-a')
|
||||
requests = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
store.stopProjectSync()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function advanceUntil(check: () => boolean, limitMs: number, stepMs = 250): Promise<number> {
|
||||
let elapsed = 0
|
||||
while (!check() && elapsed < limitMs) {
|
||||
await vi.advanceTimersByTimeAsync(stepMs)
|
||||
elapsed += stepMs
|
||||
}
|
||||
return elapsed
|
||||
}
|
||||
|
||||
describe('AutoDiscover project list', () => {
|
||||
it('keeps the visible-page budget inside 5 seconds', () => {
|
||||
expect(PROJECT_SYNC_INTERVAL_MS + PROJECT_SYNC_REQUEST_BUDGET_MS + PROJECT_SYNC_RENDER_BUDGET_MS).toBeLessThanOrEqual(5000)
|
||||
expect(projectSyncDelay(0)).toBe(PROJECT_SYNC_INTERVAL_MS)
|
||||
})
|
||||
|
||||
it('surfaces a project the member was added to within 5 seconds without switching the current work', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = fakeEnv()
|
||||
store.startProjectSync(env.port)
|
||||
projects = [project('host-a'), project('host-b', 'carol')]
|
||||
const elapsed = await advanceUntil(() => store.getState().projects.some(p => p.id === 'host-b'), 5000)
|
||||
expect(store.getState().projects.map(p => p.id)).toEqual(['host-a', 'host-b'])
|
||||
expect(elapsed).toBeLessThanOrEqual(5000)
|
||||
expect(store.getState()).toMatchObject({ currentProjectId: 'host-a', currentConvId: 'conv-a', projectsLoaded: true })
|
||||
expect(requests.map(r => r.path)).not.toContain('/api/conversations/conv-a')
|
||||
expect(store.getState().projectSync.status).toBe('ok')
|
||||
})
|
||||
|
||||
it('clears a removed current project within 5 seconds and keeps the remaining projects', async () => {
|
||||
projects = [project('host-a'), project('host-b', 'carol')]
|
||||
await store.refreshProjects()
|
||||
vi.useFakeTimers()
|
||||
store.startProjectSync(fakeEnv().port)
|
||||
projects = [project('host-b', 'carol')]
|
||||
const elapsed = await advanceUntil(() => store.getState().currentProjectId !== 'host-a', 5000)
|
||||
expect(elapsed).toBeLessThanOrEqual(5000)
|
||||
expect(store.getState()).toMatchObject({ currentProjectId: 'host-b', currentConvId: null })
|
||||
expect(store.getState().projects.map(p => p.id)).toEqual(['host-b'])
|
||||
expect(store.getState().tasks.map(t => t.id)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not re-render or re-open anything when the list is unchanged', async () => {
|
||||
vi.useFakeTimers()
|
||||
store.startProjectSync(fakeEnv().port)
|
||||
const before = store.getState()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 3)
|
||||
expect(requests.filter(r => r.path === '/api/projects')).toHaveLength(3)
|
||||
expect(store.getState().projects).toBe(before.projects)
|
||||
expect(requests.map(r => r.path)).not.toContain('/api/conversations')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RefreshSingleFlight', () => {
|
||||
it('never overlaps requests and folds wake-ups during a request into one immediate follow-up', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = fakeEnv()
|
||||
let concurrent = 0
|
||||
let peak = 0
|
||||
const calls: ReturnType<typeof deferred<void>>[] = []
|
||||
const fetchOnce = vi.fn(() => {
|
||||
concurrent++; peak = Math.max(peak, concurrent)
|
||||
const call = deferred<void>()
|
||||
calls.push(call)
|
||||
return call.promise.finally(() => { concurrent-- })
|
||||
})
|
||||
const sync = createProjectAutoDiscover(fetchOnce, env.port, () => {})
|
||||
sync.start()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||
env.focus(); env.online(); env.show()
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||
calls[0].resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(2)
|
||||
calls[1].resolve()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS - 1)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(3)
|
||||
expect(peak).toBe(1)
|
||||
sync.stop()
|
||||
})
|
||||
|
||||
it('schedules the next round only after the current one finishes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const slow = deferred<void>()
|
||||
const fetchOnce = vi.fn().mockReturnValueOnce(slow.promise).mockResolvedValue(undefined)
|
||||
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, () => {})
|
||||
sync.start()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS + 1500)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||
slow.resolve()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(2)
|
||||
sync.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RefreshBackoff', () => {
|
||||
it('reports a sync failure, backs off exponentially and returns to the 2-second rhythm on success', async () => {
|
||||
vi.useFakeTimers()
|
||||
const statuses: [ProjectSyncStatus, number][] = []
|
||||
let fail = true
|
||||
const fetchOnce = vi.fn(async () => { if (fail) throw new Error('503: unavailable') })
|
||||
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, (status, failures) => statuses.push([status, failures]))
|
||||
sync.start()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(statuses.at(-1)).toEqual(['degraded', 1])
|
||||
await vi.advanceTimersByTimeAsync(3999)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(2)
|
||||
expect(statuses.at(-1)).toEqual(['degraded', 2])
|
||||
fail = false
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(3)
|
||||
expect(statuses.at(-1)).toEqual(['ok', 0])
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(4)
|
||||
expect(projectSyncDelay(10)).toBe(30000)
|
||||
sync.stop()
|
||||
})
|
||||
|
||||
it('aborts a request that exceeds its 2-second budget and counts it as a failure', async () => {
|
||||
vi.useFakeTimers()
|
||||
let signal: AbortSignal | undefined
|
||||
const fetchOnce = vi.fn((s: AbortSignal) => { signal = s; return new Promise<void>(() => {}) })
|
||||
const statuses: ProjectSyncStatus[] = []
|
||||
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, status => statuses.push(status))
|
||||
sync.start()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS + PROJECT_SYNC_REQUEST_BUDGET_MS)
|
||||
expect(signal?.aborted).toBe(true)
|
||||
expect(statuses.at(-1)).toBe('degraded')
|
||||
sync.stop()
|
||||
})
|
||||
|
||||
it('shows the store sync state as degraded while the list endpoint fails', async () => {
|
||||
vi.useFakeTimers()
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/projects' ? Promise.resolve(response({ error: 'down' }, 503)) : previous(path, init)
|
||||
store.startProjectSync(fakeEnv().port)
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
||||
expect(store.getState().projectSync).toEqual({ status: 'degraded', failures: 1 })
|
||||
expect(store.getState().projects.map(p => p.id)).toEqual(['host-a'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('RefreshHiddenPause', () => {
|
||||
it('pauses on a hidden page and resumes immediately when it becomes visible again', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = fakeEnv()
|
||||
const statuses: ProjectSyncStatus[] = []
|
||||
const fetchOnce = vi.fn(async () => {})
|
||||
const sync = createProjectAutoDiscover(fetchOnce, env.port, status => statuses.push(status))
|
||||
sync.start()
|
||||
env.hide()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 10)
|
||||
expect(fetchOnce).not.toHaveBeenCalled()
|
||||
expect(statuses.at(-1)).toBe('paused')
|
||||
env.show()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||
expect(statuses.at(-1)).toBe('ok')
|
||||
sync.stop()
|
||||
})
|
||||
|
||||
it('does not start when the page is already hidden', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = fakeEnv()
|
||||
env.hide()
|
||||
const fetchOnce = vi.fn(async () => {})
|
||||
const sync = createProjectAutoDiscover(fetchOnce, env.port, () => {})
|
||||
sync.start()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 5)
|
||||
expect(fetchOnce).not.toHaveBeenCalled()
|
||||
sync.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RefreshReconnectCompensate', () => {
|
||||
it('stops while offline and re-checks immediately when the network returns', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = fakeEnv()
|
||||
const statuses: ProjectSyncStatus[] = []
|
||||
const fetchOnce = vi.fn(async () => {})
|
||||
const sync = createProjectAutoDiscover(fetchOnce, env.port, status => statuses.push(status))
|
||||
sync.start()
|
||||
env.offline()
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 5)
|
||||
expect(fetchOnce).not.toHaveBeenCalled()
|
||||
expect(statuses.at(-1)).toBe('offline')
|
||||
env.online()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
||||
sync.stop()
|
||||
})
|
||||
|
||||
it('re-checks immediately on focus instead of waiting for the next interval', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = fakeEnv()
|
||||
store.startProjectSync(env.port)
|
||||
projects = [project('host-a'), project('host-b', 'carol')]
|
||||
env.focus()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(store.getState().projects.map(p => p.id)).toContain('host-b')
|
||||
})
|
||||
|
||||
it('drops a late list from the previous identity after a reconnect under another account', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = fakeEnv()
|
||||
const late = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/projects' ? late.promise : previous(path, init)
|
||||
store.startProjectSync(env.port)
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
||||
auth.beginSession()
|
||||
route = previous
|
||||
projects = [project('host-c', 'dave')]
|
||||
await store.refreshProjects()
|
||||
late.resolve(response([project('host-a'), project('alice-secret')]))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(store.getState().projects.map(p => p.id)).toEqual(['host-c'])
|
||||
})
|
||||
})
|
||||
|
||||
function routeScratchSession() {
|
||||
const previous = route
|
||||
route = (path, init) => {
|
||||
if (path === '/api/scratch-sessions' && init?.method === 'POST') {
|
||||
return Promise.resolve(response({ project: project('scratch-1'), conversation: { ...task('scratch-conv', 'scratch-1'), scratch: true } }))
|
||||
}
|
||||
if (path === '/api/conversations/scratch-conv') return Promise.resolve(response({ conversation: { ...task('scratch-conv', 'scratch-1'), scratch: true }, messages: [], runs: [], activeHitl: [], lastSeq: 0 }))
|
||||
return previous(path, init)
|
||||
}
|
||||
}
|
||||
|
||||
describe('DefaultProjectDisabled', () => {
|
||||
it('does not create a default project when the member has no projects yet', async () => {
|
||||
projects = []
|
||||
await store.refreshProjects()
|
||||
routeScratchSession()
|
||||
requests = []
|
||||
await store.createTask()
|
||||
const posts = requests.filter(r => r.init?.method === 'POST').map(r => r.path)
|
||||
expect(posts).toEqual(['/api/scratch-sessions'])
|
||||
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-conv', currentProjectId: 'scratch-1' })
|
||||
})
|
||||
|
||||
it('does not treat a list that has not arrived as empty', async () => {
|
||||
auth.beginSession()
|
||||
const pending = deferred<Response>()
|
||||
route = path => path === '/api/projects' ? pending.promise : Promise.resolve(response({}))
|
||||
void store.refreshProjects()
|
||||
await expect(store.createTask()).rejects.toThrow(/尚未加载/)
|
||||
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
|
||||
pending.resolve(response([]))
|
||||
})
|
||||
|
||||
it('points a creator to explicit project creation instead of creating one silently', async () => {
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/me'
|
||||
? Promise.resolve(response({ owner: 'bob', name: 'Bob', auth: true, systemActions: ['project.create'],
|
||||
linkedCapabilities: { projectCreate: true, source: 'role', linkedActions: [], authorityEpoch: 1 } }))
|
||||
: previous(path, init)
|
||||
await store.refreshMe()
|
||||
projects = []
|
||||
await store.refreshProjects()
|
||||
routeScratchSession()
|
||||
requests = []
|
||||
await store.createTask()
|
||||
expect(requests.filter(r => r.path === '/api/projects' && r.init?.method === 'POST')).toEqual([])
|
||||
expect(requests.filter(r => r.init?.method === 'POST').map(r => r.path)).toEqual(['/api/scratch-sessions'])
|
||||
expect(store.getState().projects).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces the stage-specific project error before any POST is sent', async () => {
|
||||
projects = [{ ...project('host-archived'), settings: { 'rdm.state': 'archived' } } as Project]
|
||||
await store.refreshProjects()
|
||||
requests = []
|
||||
await expect(store.createTask()).rejects.toThrow(/请先新建项目|归档/)
|
||||
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AutoDiscover sync ownership on lifecycle events', () => {
|
||||
it('surfaces sync start and stop without leaking listeners across re-starts', () => {
|
||||
const env = fakeEnv()
|
||||
const release: Array<() => void> = []
|
||||
const port: ProjectSyncEnv = { ...env.port, subscribe: handlers => {
|
||||
const releaseFn = () => {}
|
||||
release.push(releaseFn)
|
||||
env.port.subscribe(handlers)
|
||||
return () => { releaseFn() }
|
||||
} }
|
||||
store.startProjectSync(port)
|
||||
store.stopProjectSync()
|
||||
store.startProjectSync(port)
|
||||
expect(release.length).toBe(2)
|
||||
store.stopProjectSync()
|
||||
})
|
||||
|
||||
it('keeps the store sync state idle after a clean stop and ready to re-start', () => {
|
||||
store.startProjectSync(fakeEnv().port)
|
||||
store.stopProjectSync()
|
||||
expect(store.getState().projectSync.status).toBe('idle')
|
||||
expect(store.getState().projectSync.failures).toBe(0)
|
||||
})
|
||||
|
||||
it('drops the late response when an identity reset interleaves a sync', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = fakeEnv()
|
||||
const pending = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/projects' ? pending.promise : previous(path, init)
|
||||
store.startProjectSync(env.port)
|
||||
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
||||
store.resetIdentity()
|
||||
pending.resolve(response([project('host-late')]))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(store.getState().projects.find(p => p.id === 'host-late')).toBeUndefined()
|
||||
store.stopProjectSync()
|
||||
})
|
||||
})
|
||||
+358
-54
@@ -1,9 +1,9 @@
|
||||
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
|
||||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { type AdminZoneSection, api, connectEvents } from './api'
|
||||
import { type AdminZoneSection, type ConversationView, type LinkedCapabilities, api, connectEvents } from './api'
|
||||
import { auth } from './auth'
|
||||
import { projectActionError, projectWorkError } from './projectmeta'
|
||||
import { noProjectError, projectActionError, projectWorkError } from './projectmeta'
|
||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
||||
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
|
||||
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage, type WorkReference } from './outbox'
|
||||
@@ -23,9 +23,9 @@ export function isFontSizeChoice(value: number): value is FontSizeChoice {
|
||||
}
|
||||
import type {
|
||||
ActivityCard,
|
||||
TaskSourceRef,
|
||||
AgentProfile,
|
||||
Artifact,
|
||||
Conversation,
|
||||
HITLCard,
|
||||
Message,
|
||||
PlanSnapshot,
|
||||
@@ -84,19 +84,38 @@ export interface UIState {
|
||||
taskOrg: 'project' | 'timeline'
|
||||
showArchived: boolean
|
||||
lastProjectId: string | null
|
||||
/** 用户上次停留在无项目对话视图;刷新后回到同一视图。 */
|
||||
lastScratch: boolean
|
||||
/** 系统管理全屏视图(阶段② IA:仅管理员;入口账户菜单,纵深防御在 AdminZone 再拦一次)。 */
|
||||
adminZoneOpen: boolean
|
||||
changePwOpen: boolean
|
||||
projectSettingsId: string | null
|
||||
}
|
||||
|
||||
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }
|
||||
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities }
|
||||
|
||||
/** 能否新建真实项目(无项目对话不需要此能力);服务端仍按当前身份最终判定。 */
|
||||
export function canCreateProject(me: Pick<Me, 'auth' | 'systemActions'> | null | undefined): boolean {
|
||||
return !!me && (!me.auth || !!me.systemActions?.includes('project.create'))
|
||||
}
|
||||
|
||||
/** KTD9 项目自动发现:可见页每 2 秒串行重查;单次请求预算 2 秒、渲染预留 1 秒,正常链路 5 秒内可见。 */
|
||||
export const PROJECT_SYNC_INTERVAL_MS = 2000
|
||||
export const PROJECT_SYNC_REQUEST_BUDGET_MS = 2000
|
||||
export const PROJECT_SYNC_RENDER_BUDGET_MS = 1000
|
||||
const PROJECT_SYNC_MAX_BACKOFF_MS = 30000
|
||||
export type ProjectSyncStatus = 'idle' | 'ok' | 'degraded' | 'paused' | 'offline'
|
||||
|
||||
interface AppState {
|
||||
contextObs: Record<string, ContextObs>
|
||||
projects: Project[]
|
||||
/** 本身份的项目列表是否已到达过;未到达不等于空列表。 */
|
||||
projectsLoaded: boolean
|
||||
projectSync: { status: ProjectSyncStatus; failures: number }
|
||||
currentProjectId: string | null
|
||||
tasks: Conversation[]
|
||||
/** 无项目对话视图:currentProjectId 此时是无项目对话的内部项目,不在 projects 里。 */
|
||||
scratchMode: boolean
|
||||
tasks: ConversationView[]
|
||||
me: Me | null
|
||||
currentConvId: string | null
|
||||
messages: Message[]
|
||||
@@ -170,6 +189,7 @@ filesView: 'list', workspaceSurfacePin: null,
|
||||
taskOrg: 'project',
|
||||
showArchived: false,
|
||||
lastProjectId: null,
|
||||
lastScratch: false,
|
||||
adminZoneOpen: false,
|
||||
changePwOpen: false,
|
||||
projectSettingsId: null,
|
||||
@@ -198,7 +218,8 @@ const surfaceActionsInflight: Record<string, boolean> = {}
|
||||
|
||||
let state: AppState = {
|
||||
contextObs: {},
|
||||
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||
projects: [], projectsLoaded: false, projectSync: { status: 'idle', failures: 0 },
|
||||
currentProjectId: null, scratchMode: false, tasks: [], me: null, currentConvId: null,
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||
serverWorkspaces: [],
|
||||
@@ -232,7 +253,8 @@ function syncFilesEntry(hasTabs: boolean) {
|
||||
|
||||
function setUI(patch: Partial<UIState>) {
|
||||
const ui = { ...state.ui, ...patch }
|
||||
state = { ...state, ui }
|
||||
const navigation=(['rightTab','filesView','rightCollapsed','activeWorkspace'] as const).some(key=>key in patch&&patch[key]!==state.ui[key])
|
||||
state = { ...state, ui,previewOpenIntent:state.previewOpenIntent+(navigation?1:0) }
|
||||
try { localStorage.setItem('as.ui', JSON.stringify(ui)) } catch { /* ignore */ }
|
||||
applyTheme(ui.theme)
|
||||
applyFontSize(ui.fontSize)
|
||||
@@ -298,6 +320,9 @@ let projectsRequest = 0
|
||||
let tasksRequest = 0
|
||||
let meRequest = 0
|
||||
let streamSeq = 0
|
||||
let streamHasWatermark = false
|
||||
let gapReconnectPending = false
|
||||
let streamPending = new Map<number, StreamEvent>()
|
||||
let detailRequest = 0
|
||||
let detailSeq = 0
|
||||
let detailVersion = 0
|
||||
@@ -317,6 +342,44 @@ const actionInflight = new Set<string>()
|
||||
const actionFeedbackOwners: Record<string, symbol> = {}
|
||||
const workActionExpectations = new Map<string, { actionId: string; payload: WorkCardAction }>()
|
||||
|
||||
/** 离开当前项目:关闭项目/会话流并清空项目内投影(不触碰服务端历史)。 */
|
||||
function resetProjectScope() {
|
||||
projectSse?.close(); projectSse = null
|
||||
sse?.close(); sse = null
|
||||
projectSeq = 0
|
||||
projectCardsRequest++
|
||||
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
|
||||
conversationEpoch++; streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||||
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
|
||||
artifacts: [], outbox: [], outboxDraining: false, posting: false })
|
||||
setUI({ previewExpanded: false, filesView: 'list' })
|
||||
}
|
||||
|
||||
/** remember=false 用于没有真实项目时的自动进入:不改写用户上次选择的视图。 */
|
||||
function setScratchMode(on: boolean, remember = true) {
|
||||
if (state.scratchMode !== on) set({ scratchMode: on })
|
||||
if (remember && state.ui.lastScratch !== on) setUI({ lastScratch: on })
|
||||
}
|
||||
|
||||
/** 绑定会话所属项目(真实项目或无项目对话的内部项目),供上传与工件归属。 */
|
||||
async function bindProject(projectId: string, remember: boolean) {
|
||||
if (state.currentProjectId !== projectId) resetProjectScope()
|
||||
set({ currentProjectId: projectId })
|
||||
if (state.inboxScope === 'project') {
|
||||
store.loadProjectCards()
|
||||
store.connectProject()
|
||||
}
|
||||
if (remember) setUI({ lastProjectId: projectId })
|
||||
await store.refreshArtifacts(projectId)
|
||||
}
|
||||
|
||||
function latestScratchTask(): ConversationView | undefined {
|
||||
return state.tasks.filter(t => t.scratch && !t.archived)
|
||||
.reduce<ConversationView | undefined>((latest, t) => !latest || t.updatedAt > latest.updatedAt ? t : latest, undefined)
|
||||
}
|
||||
|
||||
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
|
||||
const version = ++detailVersion
|
||||
observations[id] = { ...observations[id], version, seq,
|
||||
@@ -353,12 +416,24 @@ function scheduleTerminalReconcile() {
|
||||
|
||||
// Snapshot queries may overlap events. Only rows changed after the request began
|
||||
// override that snapshot; a newer request/context invalidates the old query entirely.
|
||||
export function mergeTaskSnapshot<T extends {taskId:string;attemptId:string;revision:number}>(current:T,incoming:T):T {
|
||||
if(current.taskId!==incoming.taskId||current.attemptId!==incoming.attemptId||!Number.isSafeInteger(incoming.revision)||incoming.revision<=current.revision)return current
|
||||
return incoming
|
||||
}
|
||||
function mergeTaskCard(current:ActivityCard|undefined,incoming:ActivityCard):ActivityCard {
|
||||
if(!current?.task||!incoming.task||current.task.schemaVersion!==1||incoming.task.schemaVersion!==1)return incoming
|
||||
const a=current.task,b=incoming.task
|
||||
if(a.taskId!==b.taskId||a.attemptId!==b.attemptId||a.projectId!==b.projectId||a.conversationId!==b.conversationId||a.ownerInstanceId!==b.ownerInstanceId||(a.executionEpoch??0)>(b.executionEpoch??0)||a.revision>b.revision)return current
|
||||
if(['completed','failed','interrupted'].includes(a.executionState)&&!['completed','failed','interrupted'].includes(b.executionState))return current
|
||||
if(a.revision!==b.revision&&mergeTaskSnapshot(a,b)===a)return current
|
||||
return incoming
|
||||
}
|
||||
function mergeCardSnapshot(snapshot: ActivityCard[], current: ActivityCard[], versions: Record<string, number>, started: number,
|
||||
patches: Record<string, Pick<ActivityCard, 'status' | 'tone'>>) {
|
||||
const fresh = current.filter(c => (versions[c.id] ?? 0) > started)
|
||||
const freshIds = new Set(fresh.map(c => c.id))
|
||||
return [...fresh, ...snapshot.filter(c => !freshIds.has(c.id)).map(c =>
|
||||
(versions[c.id] ?? 0) > started && patches[c.id] ? { ...c, ...patches[c.id], tone: patches[c.id].tone ?? c.tone } : c)]
|
||||
const combined=fresh.map(c=>{const row=snapshot.find(r=>r.id===c.id);return row?.task&&c.task&&row.task.revision>c.task.revision?mergeTaskCard(c,row):c})
|
||||
return [...combined, ...snapshot.filter(c => !freshIds.has(c.id)).map(row => {const c=mergeTaskCard(current.find(old=>old.id===row.id),row);return (versions[c.id] ?? 0) > started && patches[c.id] ? { ...c, ...patches[c.id], tone: patches[c.id].tone ?? c.tone } : c})]
|
||||
}
|
||||
|
||||
function hitlSource(card: ActivityCard): string | undefined {
|
||||
@@ -421,9 +496,144 @@ export function useStore<T>(selector: (s: AppState) => T): T {
|
||||
return useSyncExternalStore(
|
||||
(cb) => { listeners.add(cb); return () => listeners.delete(cb) },
|
||||
() => selector(state),
|
||||
() => selector(state),
|
||||
)
|
||||
}
|
||||
|
||||
export interface ProjectSyncEnv {
|
||||
visible: () => boolean
|
||||
online: () => boolean
|
||||
/** wake:重新聚焦/页面恢复可见/网络恢复;sleep:页面隐藏或断网。 */
|
||||
subscribe: (handlers: { wake: () => void; sleep: (status: 'paused' | 'offline') => void }) => () => void
|
||||
}
|
||||
|
||||
export function projectSyncDelay(failures: number): number {
|
||||
return failures <= 0 ? PROJECT_SYNC_INTERVAL_MS : Math.min(PROJECT_SYNC_MAX_BACKOFF_MS, PROJECT_SYNC_INTERVAL_MS * 2 ** failures)
|
||||
}
|
||||
|
||||
/**
|
||||
* 单一所有者的串行重查:同一时刻至多一个请求;进行中被唤醒只记一次补偿,完成后立即再查。
|
||||
* 下一轮在本轮结束后才排期(不重叠);失败/超时按指数退避,成功或唤醒补偿后恢复 2 秒节奏。
|
||||
*/
|
||||
export function createProjectAutoDiscover(
|
||||
fetchOnce: (signal: AbortSignal) => Promise<void>,
|
||||
env: ProjectSyncEnv,
|
||||
report: (status: ProjectSyncStatus, failures: number) => void,
|
||||
) {
|
||||
let active = false
|
||||
let inflight = false
|
||||
let pending = false
|
||||
let failures = 0
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let controller: AbortController | null = null
|
||||
let unsubscribe: (() => void) | null = null
|
||||
const clear = () => { if (timer !== null) { clearTimeout(timer); timer = null } }
|
||||
const blocked = (): 'paused' | 'offline' | null => !env.online() ? 'offline' : !env.visible() ? 'paused' : null
|
||||
const schedule = () => {
|
||||
clear()
|
||||
const reason = blocked()
|
||||
if (reason) { report(reason, failures); return }
|
||||
timer = setTimeout(() => { timer = null; void run() }, projectSyncDelay(failures))
|
||||
}
|
||||
const run = async (): Promise<void> => {
|
||||
if (!active) return
|
||||
const reason = blocked()
|
||||
if (reason) { clear(); report(reason, failures); return }
|
||||
if (inflight) { pending = true; return }
|
||||
clear()
|
||||
inflight = true
|
||||
const own = new AbortController()
|
||||
controller = own
|
||||
const budget = setTimeout(() => own.abort(), PROJECT_SYNC_REQUEST_BUDGET_MS)
|
||||
const aborted = new Promise<never>((_, reject) =>
|
||||
own.signal.addEventListener('abort', () => reject(new Error('project sync timeout')), { once: true }))
|
||||
try {
|
||||
await Promise.race([fetchOnce(own.signal), aborted])
|
||||
if (active && controller === own) { failures = 0; report('ok', 0) }
|
||||
} catch {
|
||||
if (active && controller === own) { failures++; report('degraded', failures) }
|
||||
} finally {
|
||||
clearTimeout(budget)
|
||||
inflight = false
|
||||
if (controller === own) controller = null
|
||||
}
|
||||
if (!active) return
|
||||
if (pending) { pending = false; return run() }
|
||||
schedule()
|
||||
}
|
||||
return {
|
||||
start() {
|
||||
if (active) return
|
||||
active = true
|
||||
unsubscribe = env.subscribe({
|
||||
wake: () => { void run() },
|
||||
sleep: status => { clear(); report(status, failures) },
|
||||
})
|
||||
schedule()
|
||||
},
|
||||
stop() {
|
||||
active = false; pending = false; failures = 0
|
||||
clear()
|
||||
controller?.abort(); controller = null
|
||||
unsubscribe?.(); unsubscribe = null
|
||||
report('idle', 0)
|
||||
},
|
||||
trigger: () => run(),
|
||||
}
|
||||
}
|
||||
|
||||
function browserProjectSyncEnv(): ProjectSyncEnv {
|
||||
return {
|
||||
visible: () => typeof document === 'undefined' || document.visibilityState !== 'hidden',
|
||||
online: () => typeof navigator === 'undefined' || navigator.onLine !== false,
|
||||
subscribe: ({ wake, sleep }) => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return () => {}
|
||||
const onVisibility = () => { if (document.visibilityState === 'hidden') sleep('paused'); else wake() }
|
||||
const onOffline = () => sleep('offline')
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
window.addEventListener('focus', wake)
|
||||
window.addEventListener('online', wake)
|
||||
window.addEventListener('offline', onOffline)
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
window.removeEventListener('focus', wake)
|
||||
window.removeEventListener('online', wake)
|
||||
window.removeEventListener('offline', onOffline)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let projectSync: ReturnType<typeof createProjectAutoDiscover> | null = null
|
||||
|
||||
const projectIdsKey = (projects: Project[]) => projects.map(p => p.id).sort().join('\n')
|
||||
|
||||
/** 列表合并只更新投影:不重复 openProject、不切对话、不重建流;仅当前项目失效时清除其状态。 */
|
||||
async function mergeProjectList(projects: Project[]) {
|
||||
if (state.projectsLoaded && JSON.stringify(state.projects) === JSON.stringify(projects)) return
|
||||
const membershipChanged = !state.projectsLoaded || projectIdsKey(state.projects) !== projectIdsKey(projects)
|
||||
set({ projects, projectsLoaded: true, error: null })
|
||||
const current = state.currentProjectId
|
||||
// 无项目对话视图绑定的是不在列表里的内部项目,列表变化不切换视图。
|
||||
if (state.scratchMode) { /* keep the scratch view */ }
|
||||
else if (current && !projects.some(p => p.id === current)) {
|
||||
const fallback = projects[0]
|
||||
if (fallback) await store.openProject(fallback.id)
|
||||
else {
|
||||
resetProjectScope()
|
||||
set({ currentProjectId: null })
|
||||
setUI({ lastProjectId: null })
|
||||
setScratchMode(true, false)
|
||||
}
|
||||
store.toast('你已不在原项目中,已清除该项目的入口')
|
||||
} else if (!current) {
|
||||
const next = projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
|
||||
if (next) await store.openProject(next.id)
|
||||
else setScratchMode(true, false)
|
||||
}
|
||||
if (membershipChanged) await store.refreshTasks()
|
||||
}
|
||||
|
||||
export const store = {
|
||||
getState: () => state,
|
||||
getIdentityEpoch: () => identityEpoch,
|
||||
@@ -443,7 +653,7 @@ export const store = {
|
||||
for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key]
|
||||
convRestoreDone = true
|
||||
persistConvId(null)
|
||||
set({ projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||
set({ projects: [], projectsLoaded: false, currentProjectId: null, scratchMode: false, tasks: [], me: null, currentConvId: null,
|
||||
contextObs: {}, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
|
||||
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
|
||||
@@ -452,7 +662,7 @@ export const store = {
|
||||
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES,
|
||||
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
|
||||
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
|
||||
setUI({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null,
|
||||
setUI({ lastProjectId: null, lastScratch: false, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null,
|
||||
settingsOpen: false, changePwOpen: false, trackerRunId: null, workspaceSurfacePin: null,
|
||||
previewExpanded: false, filesView: 'list', showArchived: false })
|
||||
},
|
||||
@@ -460,16 +670,45 @@ export const store = {
|
||||
async refreshProjects() {
|
||||
const epoch = identityEpoch
|
||||
const request = ++projectsRequest
|
||||
let scratch = false
|
||||
try {
|
||||
const projects = await api.listProjects()
|
||||
if (epoch !== identityEpoch || request !== projectsRequest) return
|
||||
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
|
||||
set({ projects, currentProjectId: current?.id ?? null, error: null })
|
||||
if (current) await store.openProject(current.id)
|
||||
// 没有真实项目(或上次停留在无项目对话)时进入无项目对话视图,而不是停在空项目上。
|
||||
scratch = projects.length === 0 || state.ui.lastScratch
|
||||
if (scratch) set({ projects, projectsLoaded: true, error: null })
|
||||
else {
|
||||
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
|
||||
set({ projects, projectsLoaded: true, currentProjectId: current?.id ?? null, error: null })
|
||||
if (current) await store.openProject(current.id)
|
||||
}
|
||||
} catch (e) {
|
||||
if (epoch === identityEpoch && request === projectsRequest) set({ error: projectActionError(e) })
|
||||
}
|
||||
if (epoch === identityEpoch && request === projectsRequest) await store.refreshTasks()
|
||||
if (scratch && epoch === identityEpoch && request === projectsRequest) await store.enterScratchMode(state.ui.lastScratch)
|
||||
},
|
||||
|
||||
/** 自动发现的一次重查:与 refreshProjects 共用请求代次,迟到/旧身份响应丢弃;失败抛出供退避。 */
|
||||
async syncProjects(signal?: AbortSignal) {
|
||||
const epoch = identityEpoch
|
||||
const request = ++projectsRequest
|
||||
const projects = await api.listProjects(signal)
|
||||
if (epoch !== identityEpoch || request !== projectsRequest) return
|
||||
await mergeProjectList(projects)
|
||||
},
|
||||
|
||||
startProjectSync(env: ProjectSyncEnv = browserProjectSyncEnv()) {
|
||||
projectSync?.stop()
|
||||
projectSync = createProjectAutoDiscover(signal => store.syncProjects(signal), env, (status, failures) => {
|
||||
if (state.projectSync.status !== status || state.projectSync.failures !== failures) set({ projectSync: { status, failures } })
|
||||
})
|
||||
projectSync.start()
|
||||
},
|
||||
|
||||
stopProjectSync() {
|
||||
projectSync?.stop()
|
||||
projectSync = null
|
||||
},
|
||||
|
||||
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
|
||||
@@ -478,14 +717,16 @@ export const store = {
|
||||
const request = ++tasksRequest
|
||||
try {
|
||||
const tasks = await api.listTasks(includeArchived)
|
||||
if (epoch !== identityEpoch || request !== tasksRequest) return
|
||||
if (epoch !== identityEpoch || request !== tasksRequest || !Array.isArray(tasks)) return
|
||||
set({ tasks })
|
||||
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
|
||||
if (!convRestoreDone) {
|
||||
convRestoreDone = true
|
||||
const saved = savedConvId()
|
||||
if (saved && !state.currentConvId && tasks.some(t => t.id === saved && !t.archived)) {
|
||||
store.openConversation(saved)
|
||||
const restored = saved ? tasks.find(t => t.id === saved && !t.archived) : undefined
|
||||
if (restored && !state.currentConvId) {
|
||||
if (restored.scratch) await store.openTask(restored.id)
|
||||
else store.openConversation(restored.id)
|
||||
}
|
||||
}
|
||||
} catch { /* 列表竞态,静默 */ }
|
||||
@@ -523,40 +764,44 @@ export const store = {
|
||||
}
|
||||
},
|
||||
|
||||
/** 打开真实项目:同时离开无项目对话视图。 */
|
||||
async openProject(projectId: string) {
|
||||
if (state.currentProjectId !== projectId) {
|
||||
projectSse?.close(); projectSse = null
|
||||
sse?.close(); sse = null
|
||||
projectSeq = 0
|
||||
projectCardsRequest++
|
||||
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
|
||||
conversationEpoch++; streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||||
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
|
||||
artifacts: [], outbox: [], outboxDraining: false, posting: false })
|
||||
setUI({ previewExpanded: false, filesView: 'list' })
|
||||
}
|
||||
set({ currentProjectId: projectId })
|
||||
if (store.getState().inboxScope === 'project') {
|
||||
store.loadProjectCards()
|
||||
store.connectProject()
|
||||
}
|
||||
store.setUI({ lastProjectId: projectId })
|
||||
await store.refreshArtifacts(projectId)
|
||||
setScratchMode(false)
|
||||
await bindProject(projectId, true)
|
||||
},
|
||||
|
||||
/** 进入无项目对话视图:当前会话不是无项目对话时清空会话视图,并打开最近一条未归档的无项目对话。 */
|
||||
async enterScratchMode(remember = true) {
|
||||
const epoch = identityEpoch
|
||||
const current = state.tasks.find(t => t.id === state.currentConvId)
|
||||
setScratchMode(true, remember)
|
||||
if (current?.scratch) return
|
||||
if (state.currentProjectId || state.currentConvId) resetProjectScope()
|
||||
set({ currentProjectId: null })
|
||||
const latest = latestScratchTask()
|
||||
if (latest && epoch === identityEpoch) await store.openTask(latest.id)
|
||||
},
|
||||
|
||||
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。
|
||||
* 空库时主按钮与空状态都写着「点击新建任务开始」,这里先落一个默认项目再开会话。 */
|
||||
* 无项目对话视图、或项目列表已到达且没有真实项目时,新建无项目对话;从不代建真实项目。 */
|
||||
async createTask(projectId?: string) {
|
||||
const epoch = identityEpoch
|
||||
const changed = () => { if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') }
|
||||
try {
|
||||
if (state.scratchMode || (!projectId && !state.currentProjectId && state.projectsLoaded && state.projects.length === 0)) {
|
||||
const { conversation } = await api.createScratchSession()
|
||||
changed()
|
||||
setScratchMode(true)
|
||||
await store.refreshTasks()
|
||||
changed()
|
||||
if (!state.tasks.some(t => t.id === conversation.id)) set({ tasks: [{ ...conversation, scratch: true }, ...state.tasks] })
|
||||
await store.openTask(conversation.id)
|
||||
changed()
|
||||
return
|
||||
}
|
||||
let pid = projectId ?? state.currentProjectId
|
||||
if (!pid && state.projects.length > 0) pid = state.projects[0].id
|
||||
if (!pid) {
|
||||
const p = await store.createProject('默认项目')
|
||||
pid = p.id
|
||||
}
|
||||
if (!pid) throw new Error(noProjectError(state.projectsLoaded, canCreateProject(state.me)))
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
const error = projectWorkError(state.projects.find(p => p.id === pid))
|
||||
if (error) throw new Error(error)
|
||||
@@ -580,12 +825,15 @@ export const store = {
|
||||
if (title) store.toast('已在当前项目创建任务')
|
||||
},
|
||||
|
||||
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */
|
||||
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确);视图随任务进入或离开无项目对话。 */
|
||||
async openTask(convId: string) {
|
||||
const epoch = identityEpoch
|
||||
const t = state.tasks.find(x => x.id === convId)
|
||||
if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
|
||||
if (t?.scratch) {
|
||||
if (t.projectId !== state.currentProjectId) await bindProject(t.projectId, false)
|
||||
} else if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
|
||||
if (epoch !== identityEpoch) return
|
||||
if (t) setScratchMode(!!t.scratch)
|
||||
await store.openConversation(convId)
|
||||
},
|
||||
|
||||
@@ -621,7 +869,7 @@ export const store = {
|
||||
async openConversation(convId: string) {
|
||||
if (sse) { sse.close(); sse = null }
|
||||
const epoch = ++conversationEpoch
|
||||
streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||
streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], workRecord: null, workReference: null, surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||||
setUI({ previewExpanded: false })
|
||||
@@ -636,7 +884,7 @@ export const store = {
|
||||
}
|
||||
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
|
||||
// surfaceList 返回扁平 SurfaceMeta,不可写入 surfaces;必须经 surfaceGet 水合为 Snapshot。
|
||||
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
|
||||
if (state.currentProjectId) api.projectSurfaces(state.currentProjectId).then(list => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
|
||||
api.surfaceList(convId).then(async list => {
|
||||
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
|
||||
const metas = list ?? []
|
||||
@@ -719,15 +967,21 @@ export const store = {
|
||||
streamSeq = Math.max(streamSeq, since ?? state.lastSeq)
|
||||
sse?.close()
|
||||
sse = connectEvents(convId, since ?? state.lastSeq, {
|
||||
onOpen: () => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ connected: true }) },
|
||||
onOpen: () => { if (state.currentConvId === convId && conversationEpoch === epoch) {set({ connected: true });void store.refreshCards(convId)} },
|
||||
onError: () => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ connected: false }) },
|
||||
onEvent: e => { if (state.currentConvId === convId && conversationEpoch === epoch) store.applyEvent(e) },
|
||||
})
|
||||
},
|
||||
|
||||
applyEvent(e: StreamEvent) {
|
||||
if (!state.currentConvId || e.conversationId !== state.currentConvId || !Number.isFinite(e.seq) || e.seq <= streamSeq) return
|
||||
if (!state.currentConvId || e.conversationId !== state.currentConvId || !Number.isSafeInteger(e.seq) || e.seq <= streamSeq) return
|
||||
if(streamHasWatermark&&e.seq>streamSeq+1){
|
||||
if(streamPending.size<1000)streamPending.set(e.seq,e)
|
||||
if(!gapReconnectPending){gapReconnectPending=true;const conv=state.currentConvId,epoch=conversationEpoch;queueMicrotask(()=>{gapReconnectPending=false;if(conv===state.currentConvId&&epoch===conversationEpoch)store.connectLive(streamSeq)})}
|
||||
return
|
||||
}
|
||||
streamSeq = e.seq
|
||||
if(streamPending.has(e.seq+1)){const pending=streamPending.get(e.seq+1)!;streamPending.delete(e.seq+1);queueMicrotask(()=>store.applyEvent(pending))}
|
||||
const previousSeq = state.lastSeq
|
||||
// Running-turn replay reconstructs transient output, but cannot regress the
|
||||
// persistent card/plan/status snapshot already returned by the detail query.
|
||||
@@ -878,6 +1132,7 @@ export const store = {
|
||||
}
|
||||
case 'artifact.created':
|
||||
case 'artifact.updated': {
|
||||
if(e.type==='artifact.updated'&&state.currentConvId)void store.refreshCards(state.currentConvId)
|
||||
const p = e.payload as { artifact?: { id: string; name: string; kind?: string } }
|
||||
if (p?.artifact) {
|
||||
const ref = { artifactId: p.artifact.id, name: p.artifact.name, kind: p.artifact.kind }
|
||||
@@ -957,10 +1212,12 @@ export const store = {
|
||||
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [], ...(taskRef ? { taskRef } : {}) }) })
|
||||
return 'busy'
|
||||
}
|
||||
store.toast(`发送失败:${e}`)
|
||||
const removed = isNotProjectMember(e)
|
||||
store.toast(removed ? '无法继续发送:你已不在该项目中或项目已关闭;历史对话仍可查看' : `发送失败:${e}`)
|
||||
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds,
|
||||
attachmentNames: attachmentIds.map(attachmentId => state.artifacts.find(artifact => artifact.id === attachmentId)?.name ?? '已添加材料'),
|
||||
...(taskRef ? { taskRef } : {}), deliveryError: '发送未成功,内容已保留;可修改后重试或移除' }) })
|
||||
...(taskRef ? { taskRef } : {}),
|
||||
deliveryError: removed ? '无法发送:你已不在该项目中或项目已关闭,内容已保留' : '发送未成功,内容已保留;可修改后重试或移除' }) })
|
||||
await store.reconcile()
|
||||
return 'rejected'
|
||||
} finally {
|
||||
@@ -1074,6 +1331,7 @@ export const store = {
|
||||
if (card.conversationId && card.conversationId !== state.currentConvId) return
|
||||
cardVersions[card.id] = ++cardVersion
|
||||
delete cardPatches[card.id]
|
||||
card=mergeTaskCard(state.cards.find(c=>c.id===card.id),card)
|
||||
const i = state.cards.findIndex(c => c.id === card.id)
|
||||
const cards = i >= 0 ? state.cards.map(c => c.id === card.id ? card : c) : [card, ...state.cards]
|
||||
set({ cards })
|
||||
@@ -1085,8 +1343,10 @@ export const store = {
|
||||
const request = ++cardsRequest
|
||||
const started = cardVersion
|
||||
try {
|
||||
const snapshot = await api.listCards(convId)
|
||||
const out = await api.listCardsSnapshot(convId)
|
||||
const snapshot=out.cards??[]
|
||||
if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== cardsRequest) return
|
||||
if(Number.isSafeInteger(out.replaySeq)&&out.replaySeq!>=0)streamHasWatermark=true
|
||||
const cards = mergeCardSnapshot(snapshot, state.cards, cardVersions, started, cardPatches)
|
||||
set({ cards })
|
||||
settleCardFeedback(cards)
|
||||
@@ -1288,7 +1548,8 @@ export const store = {
|
||||
applyProjectEvent(e: StreamEvent) {
|
||||
if (!Number.isFinite(e.seq) || e.seq <= projectSeq) return
|
||||
projectSeq = e.seq
|
||||
const upsert = (card: ActivityCard) => {
|
||||
const upsert = (incoming: ActivityCard) => {
|
||||
const card=mergeTaskCard(state.projectCards.find(c=>c.id===incoming.id),incoming)
|
||||
if (card.projectId !== state.currentProjectId) return
|
||||
projectCardVersions[card.id] = ++projectCardVersion
|
||||
const list = store.getState().projectCards
|
||||
@@ -1401,6 +1662,41 @@ export const store = {
|
||||
* 工件预览唯一入口:在第三栏「文件」入口打开或激活标签(多文件多标签)。
|
||||
* 打开第一张自动切到预览子视图;关掉最后一张回到文件列表,并收回展开态。
|
||||
*/
|
||||
async openTaskSource(cardId: string, source: TaskSourceRef) {
|
||||
const conversationId = state.currentConvId
|
||||
const projectId = state.currentProjectId
|
||||
const epoch = conversationEpoch
|
||||
const intent = state.previewOpenIntent + 1
|
||||
set({ previewOpenIntent: intent })
|
||||
const isCurrent = () => conversationId === state.currentConvId && projectId === state.currentProjectId
|
||||
&& epoch === conversationEpoch && intent === state.previewOpenIntent
|
||||
try {
|
||||
const out = await api.taskSource(cardId, source.id)
|
||||
if (!isCurrent()) return
|
||||
if (!out.artifact) {
|
||||
store.toast('来源当前不可用,正在核实工作记录')
|
||||
if (conversationId) void store.refreshCards(conversationId)
|
||||
return
|
||||
}
|
||||
store.requestTaskSourcePreview(out.artifact, {
|
||||
notice: out.versionState === 'changed'
|
||||
? '当前文件已更新;下方引用来自任务处理时的版本,不能作为当前文件的结论'
|
||||
: '任务处理时引用的来源版本;下方是本项发现引用的片段',
|
||||
quote: out.source.quote ?? '',
|
||||
expectedHash: out.versionState === 'current' ? out.source.contentHash : undefined,
|
||||
})
|
||||
if (out.versionState === 'changed' && conversationId) void store.refreshCards(conversationId)
|
||||
} catch {
|
||||
if (!isCurrent()) return
|
||||
store.toast('来源当前无法读取,正在核实工作记录')
|
||||
if (conversationId) void store.refreshCards(conversationId)
|
||||
}
|
||||
},
|
||||
requestTaskSourcePreview(a:Artifact,sourceContext:{notice:string;quote:string;expectedHash?:string}){
|
||||
store.requestPreview(a)
|
||||
const conv=previewTabsKey(state.currentConvId)
|
||||
set({previewTabs:{...state.previewTabs,[conv]:(state.previewTabs[conv]??[]).map(tab=>tab.id===a.id?{...tab,sourceContext}:tab)}})
|
||||
},
|
||||
requestPreview(a: Artifact) {
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name })
|
||||
@@ -1452,7 +1748,7 @@ export const store = {
|
||||
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
|
||||
activatePreview(id: string) {
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
set({ previewActive: { ...state.previewActive, [conv]: id } })
|
||||
set({ previewActive: { ...state.previewActive, [conv]: id },previewOpenIntent:state.previewOpenIntent+1 })
|
||||
},
|
||||
closePreview(id: string) {
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
@@ -1460,6 +1756,7 @@ export const store = {
|
||||
set({
|
||||
previewTabs: { ...state.previewTabs, [conv]: next.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: next.activeId },
|
||||
previewOpenIntent:state.previewOpenIntent+1,
|
||||
})
|
||||
if (next.tabs.length === 0) setUI({ previewExpanded: false })
|
||||
syncFilesEntry(next.tabs.length > 0)
|
||||
@@ -1492,6 +1789,11 @@ function isConversationBusy(err: unknown): boolean {
|
||||
return String(err).includes('409:')
|
||||
}
|
||||
|
||||
/** 已被移出项目或项目已关闭:后端以 403 `conversation: not-project-member` 拒绝继续发送。 */
|
||||
function isNotProjectMember(err: unknown): boolean {
|
||||
return String(err).includes('not-project-member')
|
||||
}
|
||||
|
||||
function shellPhase() {
|
||||
const liveRunId = state.live?.runId ?? null
|
||||
return deriveShellPhase({
|
||||
@@ -1516,5 +1818,7 @@ export function mergeStartedRun(runs: Run[], run: Run): Run[] {
|
||||
export function eventTime(e: { ts?: string }, now = Date.now()): number {
|
||||
if (!e.ts) return now
|
||||
const t = Date.parse(e.ts)
|
||||
return Number.isNaN(t) ? now : t
|
||||
// 后端 0 值占位(0001-01-01T00:00:00Z 等)会让状态栏读到巨大耗时,回落 now 让「已工作」保持真实时钟。
|
||||
if (Number.isNaN(t) || t <= 0) return now
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -517,10 +517,33 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.ad-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
|
||||
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
|
||||
.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-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-diff { margin: 0; padding-left: 20px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||
.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-single { grid-template-columns: 1fr; }
|
||||
.rp-role-list { max-height: 220px; overflow-y: auto; }
|
||||
}
|
||||
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
|
||||
.ad-dim { color: var(--text-dim); }
|
||||
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
|
||||
.ad-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
|
||||
.ad-chip-super { background: var(--accent); color: var(--text-on-accent); }
|
||||
.ad-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
|
||||
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
|
||||
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { credentialLabel } from '../src/components/AccountUsersPanel'
|
||||
|
||||
describe('account directory credential state', () => {
|
||||
it('shows whether password is ready without legacy initialization labels', () => {
|
||||
expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待设置密码')
|
||||
expect(credentialLabel({ initializationState: 'initialized', passwordSet: true })).toBe('已设置密码')
|
||||
expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('待设置密码')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
// 管理端用户面板两处缺陷的回归锁定:
|
||||
// (1) getUserRole 的 role 摘要不带 permissions(线上实测),projectCreateSources 不得因此抛异常;
|
||||
// 展示来源时用已加载角色列表里同一 roleId 的条目补齐权限,不新增请求。
|
||||
// (2) 一次性登录密码按服务端严格解码要求发送 JSON 对象体。
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { api, type RoleRecord } from '../src/api'
|
||||
import { auth } from '../src/auth'
|
||||
import { ProjectCreateSourceNote, roleWithPermissions } from '../src/components/AccountUsersPanel'
|
||||
import { projectCreateSources } from '../src/rolePermissions'
|
||||
|
||||
const listed: RoleRecord = {
|
||||
roleId: 'role-pm', name: '项目经理', description: '研发', enabled: true, roleVersion: 3, bindingCount: 1,
|
||||
permissions: [
|
||||
{ actionId: 'project.create', allowed: true, capAllowed: true, permissionVersion: 2 },
|
||||
{ actionId: 'document.read', allowed: true, capAllowed: true, permissionVersion: 1 },
|
||||
],
|
||||
}
|
||||
/** getUserRole 的真实响应形状:有角色字段、没有 permissions。 */
|
||||
const summary = { roleId: 'role-pm', name: '项目经理', description: '研发', enabled: true, roleVersion: 3, bindingCount: 1 }
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('角色摘要缺少 permissions 时的创建能力来源', () => {
|
||||
it('把缺失的 permissions 当作空集,不抛异常也不虚构角色来源', () => {
|
||||
expect(projectCreateSources({ name: '项目经理' }, [])).toEqual([])
|
||||
expect(projectCreateSources({ name: '项目经理' }, ['project.create'])).toEqual(['明确系统授权'])
|
||||
expect(projectCreateSources({ ...summary, permissions: undefined }, [])).toEqual([])
|
||||
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={{ name: '项目经理' }} grants={[]} />))
|
||||
.toContain('被加入项目后由成员关系提供基础协作')
|
||||
})
|
||||
|
||||
it('用角色列表里同一 roleId 的权限补齐来源', () => {
|
||||
const resolved = roleWithPermissions(summary, [listed])
|
||||
expect(resolved?.permissions?.some(item => item.actionId === 'project.create')).toBe(true)
|
||||
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={resolved} grants={[]} />))
|
||||
.toContain('创建项目来源:职位角色「项目经理」')
|
||||
})
|
||||
|
||||
it('列表里没有对应条目时按无角色默认权限处理,角色自带权限时不被列表覆盖', () => {
|
||||
expect(projectCreateSources(roleWithPermissions(summary, []), [])).toEqual([])
|
||||
const denied = { ...listed, roleId: 'role-other', permissions: [{ actionId: 'project.create', allowed: false, capAllowed: false, permissionVersion: 1 }] }
|
||||
expect(projectCreateSources(roleWithPermissions({ ...summary, permissions: denied.permissions }, [denied]), [])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('管理员一次性登录密码请求体', () => {
|
||||
it('发送 POST + JSON 对象体,避免服务端 400 EOF', async () => {
|
||||
const issued = { owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true }
|
||||
const request = vi.spyOn(auth, 'fetch').mockResolvedValue(new Response(JSON.stringify(issued)))
|
||||
|
||||
const out = await api.adminIssueOneTimePassword('alice')
|
||||
|
||||
const [url, options] = request.mock.calls[0]
|
||||
expect(url).toBe('/api/admin/users/alice/one-time-password')
|
||||
expect(options?.method).toBe('POST')
|
||||
expect(options?.headers).toEqual({ 'Content-Type': 'application/json' })
|
||||
expect(JSON.parse(String(options?.body))).toEqual({})
|
||||
expect(out).toEqual(issued)
|
||||
})
|
||||
})
|
||||
@@ -329,3 +329,56 @@ describe('conversation work reading', () => {
|
||||
expect(render()).toContain('会覆盖旧草稿,原件保留')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProjectLifecycleConversation work record', () => {
|
||||
it('opens a project lifecycle conversation with a single focused work card and a closed history', () => {
|
||||
snapshot.state!.cards = [card({ title: '整理材料', status: 'active' }), card({
|
||||
id: 'history_1', status: 'done', tone: 'ok', title: '旧一轮记录',
|
||||
body: [{ type: 'markdown', markdown: '项目准备阶段的总结报告' }],
|
||||
updatedAt: '2026-10-12T08:00:00Z',
|
||||
})]
|
||||
const html = renderWork()
|
||||
expect(html).toContain('整理材料')
|
||||
expect(html).not.toContain('项目准备阶段的总结报告')
|
||||
expect(html).toContain('更多工作与成果 · 1 项')
|
||||
})
|
||||
|
||||
it('names a project lifecycle conversation by its own work record when the right work pane is closed', () => {
|
||||
snapshot.state!.workRecord = { cardId: 'card_1', runId: 'run_1', conversationId: 'cnv_1', title: '材料整理' }
|
||||
snapshot.state!.cards = [card()]
|
||||
const html = render()
|
||||
expect(html).toContain('data-conversation-work')
|
||||
expect(html).not.toContain('核对会议结论')
|
||||
})
|
||||
|
||||
it('falls back to a project lifecycle conversation level when the auxiliary work pane already owns the detail', () => {
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
snapshot.state!.ui.rightTab = 'work'
|
||||
snapshot.state!.cards = [card()]
|
||||
const html = render()
|
||||
expect(html).not.toContain('data-conversation-work')
|
||||
expect(html).not.toContain('核对会议结论')
|
||||
})
|
||||
|
||||
it('keeps the project lifecycle conversation summary headline visible regardless of the right pane state', () => {
|
||||
snapshot.state!.cards = [card({ status: 'done', work: { result: 'project-lifecycle-summary-result', resultVersion: 1, readVersion: 0 } })]
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
const html = render()
|
||||
expect(html).toContain('1 项工作有新结果')
|
||||
expect(html).not.toContain('project-lifecycle-summary-result')
|
||||
})
|
||||
|
||||
it('lists only the project lifecycle conversation\'s active work alongside its completed history', () => {
|
||||
snapshot.state!.cards = [
|
||||
card({ status: 'active' }),
|
||||
card({ id: 'other_project', projectId: 'prj_2', conversationId: 'cnv_2', title: '其它项目的工作',
|
||||
body: [{ type: 'kv', kv: [['正在做', '其它项目的工作细节']] }] }),
|
||||
card({ id: 'other_conv', projectId: 'prj_1', conversationId: 'cnv_9', title: '其它会话的工作',
|
||||
body: [{ type: 'kv', kv: [['正在做', '其它会话的工作细节']] }] }),
|
||||
]
|
||||
const html = renderWork()
|
||||
expect(html).toContain('核对会议结论')
|
||||
expect(html).not.toContain('其它项目的工作细节')
|
||||
expect(html).not.toContain('其它会话的工作细节')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// eventTime 取事件上的服务端时间;后端 0 值占位(0001-01-01T00:00:00Z)
|
||||
// 不能让状态栏把「已工作」读成几百年的耗时——非正 epoch 一律回落到墙上时钟。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { eventTime } from '../src/store'
|
||||
|
||||
describe('eventTime', () => {
|
||||
it('returns now when the timestamp is missing', () => {
|
||||
const now = 1_700_000_000_000
|
||||
expect(eventTime({}, now)).toBe(now)
|
||||
expect(eventTime({ ts: '' }, now)).toBe(now)
|
||||
expect(eventTime({ ts: undefined }, now)).toBe(now)
|
||||
})
|
||||
|
||||
it('returns now for unparseable timestamps', () => {
|
||||
const now = 1_700_000_000_000
|
||||
expect(eventTime({ ts: 'not-a-date' }, now)).toBe(now)
|
||||
expect(eventTime({ ts: '2026-13-40T99:99:99Z' }, now)).toBe(now)
|
||||
})
|
||||
|
||||
it('returns now when the parsed time is not a positive epoch (zero-value placeholder)', () => {
|
||||
// 0001-01-01T00:00:00Z is the Go/Unix zero time;过去不能解释为有效事件时间。
|
||||
const now = 1_700_000_000_000
|
||||
expect(eventTime({ ts: '0001-01-01T00:00:00Z' }, now)).toBe(now)
|
||||
// Date.parse('1970-01-01T00:00:00Z') === 0,应被一并回落,否则状态栏会显示从 epoch 至今。
|
||||
expect(eventTime({ ts: '1970-01-01T00:00:00Z' }, now)).toBe(now)
|
||||
})
|
||||
|
||||
it('returns the parsed epoch for a real, parseable timestamp', () => {
|
||||
const now = 1_700_000_000_000
|
||||
expect(eventTime({ ts: '2024-01-02T03:04:05Z' }, now)).toBe(Date.parse('2024-01-02T03:04:05Z'))
|
||||
expect(eventTime({ ts: '2026-10-12T10:00:00Z' }, now)).toBe(Date.parse('2026-10-12T10:00:00Z'))
|
||||
})
|
||||
})
|
||||
+53
-13
@@ -1,7 +1,17 @@
|
||||
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
|
||||
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { auth } from '../src/auth'
|
||||
import {
|
||||
ForcedPasswordModeSection,
|
||||
isForcedPasswordMode,
|
||||
requiresCurrentPassword,
|
||||
submitForcedPassword,
|
||||
} from '../src/components/passwordChangeMode'
|
||||
import { humanChangePasswordError, humanLoginError } from '../src/components/loginErrors'
|
||||
|
||||
describe('humanLoginError(登录)', () => {
|
||||
it('401 统一为用户名或密码不正确', () => {
|
||||
@@ -31,19 +41,49 @@ describe('humanChangePasswordError(改密门)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanRedeemError(一次性登录 Key 兑换)', () => {
|
||||
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('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
|
||||
})
|
||||
it('未知错误剥状态码前缀透传', () => {
|
||||
expect(humanRedeemError('400: bad')).toBe('bad')
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanChangePasswordError(登录恢复新增文案)', () => {
|
||||
it('并发凭据变化给出重新登录指引', () => {
|
||||
expect(humanChangePasswordError('401: credentials changed concurrently; retry login')).toBe('登录状态已变化(密码在其他地方被更新),请重新登录')
|
||||
})
|
||||
})
|
||||
|
||||
describe('one_time_password 强制改密流程', () => {
|
||||
afterEach(() => {
|
||||
auth.clear()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('识别 one_time_password 模式且无需当前密码字段', () => {
|
||||
expect(isForcedPasswordMode('one_time_password')).toBe(true)
|
||||
expect(requiresCurrentPassword('one_time_password')).toBe(false)
|
||||
const html = renderToStaticMarkup(createElement(ForcedPasswordModeSection, {
|
||||
mode: 'one_time_password',
|
||||
current: '',
|
||||
onCurrentChange: () => undefined,
|
||||
}))
|
||||
expect(html).not.toContain('forced-current')
|
||||
expect(html).toContain('无需输入当前密码')
|
||||
})
|
||||
|
||||
it('提交 one_time_password 改密时走 /api/auth/password', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
owner: 'alice',
|
||||
name: 'Alice',
|
||||
isAdmin: false,
|
||||
mustChangePassword: false,
|
||||
reloginRequired: true,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
const changeSpy = vi.spyOn(api, 'changePassword')
|
||||
|
||||
await submitForcedPassword('one_time_password', 'should-be-ignored', 'new-password-123')
|
||||
|
||||
expect(changeSpy).toHaveBeenCalledWith('', 'new-password-123')
|
||||
expect(fetchSpy).toHaveBeenCalledWith('/api/auth/password', expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current: '', next: 'new-password-123' }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { auth } from '../src/auth'
|
||||
import { GeneratedCredentialPanel, oneTimePasswordCredential } from '../src/components/oneTimePasswordUi'
|
||||
|
||||
describe('管理员一次性密码发放', () => {
|
||||
afterEach(() => {
|
||||
auth.clear()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('调用精确 one-time-password 端点并使用 POST', async () => {
|
||||
const response = { owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true }
|
||||
const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
const out = await api.adminIssueOneTimePassword('alice')
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
// 服务端严格解码 JSON 对象体:无体无 Content-Type 会被拒为 400 EOF。
|
||||
expect(fetchSpy).toHaveBeenCalledWith('/api/admin/users/alice/one-time-password', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
|
||||
})
|
||||
expect(out).toEqual(response)
|
||||
})
|
||||
|
||||
it('展示后端返回的 oneTimePassword', () => {
|
||||
const credential = oneTimePasswordCredential({ owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true })
|
||||
const html = renderToStaticMarkup(createElement(GeneratedCredentialPanel, {
|
||||
credential,
|
||||
onClose: () => undefined,
|
||||
}))
|
||||
expect(html).toContain('otp-0001')
|
||||
expect(html).toContain('一次性登录密码仅显示一次')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
// ps-frontend-no-project-surfaces-r2:openConversation 在 state.currentProjectId 为空时
|
||||
// 不再触发 /api/projects/<id>/surfaces 请求(避免被移除项目成员打开历史会话时的 404 噪音)。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { store } from '../src/store'
|
||||
import type { Conversation, Message, SurfaceMeta } from '../src/contracts'
|
||||
|
||||
function detail(id: string) {
|
||||
return {
|
||||
conversation: { id, projectId: '', title: id, createdAt: '', updatedAt: '' } as Conversation,
|
||||
messages: [] as Message[], runs: [], activeHitl: [], lastSeq: 0, resumeSeq: 0,
|
||||
}
|
||||
}
|
||||
class FakeEventSource {
|
||||
onopen = null
|
||||
onerror = null
|
||||
onmessage = null
|
||||
addEventListener() {}
|
||||
close() {}
|
||||
}
|
||||
|
||||
let projectSurfacesSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubGlobal('EventSource', FakeEventSource)
|
||||
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => detail(id))
|
||||
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'listCards').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listTasks').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listArtifacts').mockResolvedValue([])
|
||||
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
|
||||
projectSurfacesSpy = vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listProjectCards').mockResolvedValue([])
|
||||
// 还原至 currentProjectId = null 的干净投影,避免别的套件残留影响本次断言。
|
||||
store.resetIdentity()
|
||||
})
|
||||
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
describe('openConversation projectSurfaces gating', () => {
|
||||
it('skips the /api/projects/.../surfaces request and keeps projectSurfaces empty when no project is current', async () => {
|
||||
expect(store.getState().currentProjectId).toBeNull()
|
||||
await store.openConversation('cnv_no_project')
|
||||
// 让微任务(包含 surfaceList/Stream 等链)落定,但不改变当前结论。
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(projectSurfacesSpy).not.toHaveBeenCalled()
|
||||
expect(store.getState().projectSurfaces).toEqual([])
|
||||
expect(store.getState().currentProjectId).toBeNull()
|
||||
})
|
||||
|
||||
it('still requests /api/projects/<id>/surfaces when a project is current', async () => {
|
||||
const metas: SurfaceMeta[] = [{ surfaceId: 'surf_1', title: '页面一', pluginId: 'plg_1', revision: 1, updatedAt: '2026-10-12T00:00:00Z' }]
|
||||
projectSurfacesSpy.mockResolvedValueOnce(metas)
|
||||
await store.openProject('prj_with_surfaces')
|
||||
expect(store.getState().currentProjectId).toBe('prj_with_surfaces')
|
||||
projectSurfacesSpy.mockClear()
|
||||
await store.openConversation('cnv_with_project')
|
||||
expect(projectSurfacesSpy).toHaveBeenCalledTimes(1)
|
||||
expect(projectSurfacesSpy.mock.calls[0][0]).toBe('prj_with_surfaces')
|
||||
// 微任务处理完 .then 后,projectSurfaces 投影被填进服务端的元数据列表。
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(store.getState().projectSurfaces).toEqual(metas)
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// RdmMembersSection rdm.role.options 授权门控:rdm.perm.check 给出 member.manage
|
||||
// 未允许时,不得提前发 rdm.role.options(普通成员被 403);允许后必须发。
|
||||
//
|
||||
// 测试在 node 环境下用最小化的 DOM mock 驱动 React 19 createRoot,让 useEffect 正常落槽;
|
||||
// 不去依赖 happy-dom/jsdom(受网络/包锁约束,allowed_paths 也限制仅 src + test)。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { RdmMembersSection } from '../src/components/adminRdmSections'
|
||||
import { api } from '../src/api'
|
||||
import type { Project } from '../src/contracts'
|
||||
|
||||
// 用 vi.mock 隔离 useStore:组件只用到 select(state).me,其它 store 字段触发不到,
|
||||
// 不会影响此处关注点(避免 host Store 启动监听器、送 SSE 等副作用污染测试)。
|
||||
vi.mock('../src/store', () => {
|
||||
const state = {
|
||||
me: { owner: 'bob', name: 'bob', auth: true },
|
||||
}
|
||||
return {
|
||||
useStore: (select: (value: typeof state) => unknown) => select(state),
|
||||
eventTime: (_e: { ts?: string }, now: number) => now,
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 自定义 React DOM 桩:让 useEffect 在 node 环境落到 commit 阶段 ----
|
||||
// 服务端 React 不运行 useEffect;createRoot 需要一个 DOM-shaped container。
|
||||
// 这里只覆盖 react-dom-client 用到的几个关键入口(addEventListener、tagName、namespaceURI、
|
||||
// ownerDocument.defaultView.HTMLIFrameElement、activeElement、documentElement)。任何
|
||||
// 新字段缺失都会在第一处 React 调用时显式爆出,便于修复而不是静默吞下。
|
||||
class HTMLIFrameElement {}
|
||||
class MockDocument {
|
||||
nodeType = 9
|
||||
defaultView: any
|
||||
HTMLIFrameElement = HTMLIFrameElement
|
||||
activeElement: any = null
|
||||
documentElement: any
|
||||
constructor() {
|
||||
this.defaultView = makeWindow()
|
||||
this.defaultView.document = this
|
||||
this.activeElement = null
|
||||
this.documentElement = this
|
||||
}
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
dispatchEvent() {}
|
||||
contains() { return false }
|
||||
createElement(tag: string) {
|
||||
const el = new MockElement()
|
||||
el.tagName = tag
|
||||
el.nodeName = tag
|
||||
el.ownerDocument = this
|
||||
if (renderObserver) renderObserver.append(tag)
|
||||
return el
|
||||
}
|
||||
createElementNS(_ns: string, tag: string) { return this.createElement(tag) }
|
||||
createTextNode() { return new MockElement() }
|
||||
}
|
||||
|
||||
/** 创建 DOM 节点时把它们的 tag 累积下来,替代 innerHTML 读取(Mock 上 innerHTML 为空)。 */
|
||||
let renderObserver: { tags: string[]; reset(): void; append(t: string): void } | null = null
|
||||
class MockElement {
|
||||
nodeType = 1
|
||||
style: Record<string, string> = {}
|
||||
attributes: Record<string, string> = {}
|
||||
tagName = 'DIV'
|
||||
nodeName = 'DIV'
|
||||
namespaceURI = 'http://www.w3.org/1999/xhtml'
|
||||
ownerDocument: any
|
||||
childNodes: any[] = []
|
||||
parentNode: any = null
|
||||
appendChild(c: any) { this.childNodes.push(c); c.parentNode = this; return c }
|
||||
removeChild(c: any) { const i = this.childNodes.indexOf(c); if (i >= 0) this.childNodes.splice(i, 1); c.parentNode = null; return c }
|
||||
insertBefore(c: any, ref: any) {
|
||||
const i = ref ? this.childNodes.indexOf(ref) : this.childNodes.length
|
||||
this.childNodes.splice(i < 0 ? this.childNodes.length : i, 0, c)
|
||||
c.parentNode = this
|
||||
return c
|
||||
}
|
||||
contains(c: any) {
|
||||
for (let n: any = c; n; n = n.parentNode) if (n === this) return true
|
||||
return false
|
||||
}
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
dispatchEvent() {}
|
||||
focus() {}
|
||||
setAttribute(k: string, v: string) { this.attributes[k] = v }
|
||||
removeAttribute(k: string) { delete this.attributes[k] }
|
||||
cloneNode() { return new MockElement() }
|
||||
/** React 写 `<select>` 时会读 `select.options`,给一个可索引的 length 让 updateOptions 不崩。 */
|
||||
get options() {
|
||||
return this.childNodes
|
||||
}
|
||||
/** React 写入 innerHTML;这里读为空即可(断言用别的字段)。 */
|
||||
get innerHTML() { return '' }
|
||||
get outerHTML() { return '' }
|
||||
get textContent() { return '' }
|
||||
set textContent(_v: string) { /* 允许 commit 阶段写入 */ }
|
||||
get value() { return this.attributes.value ?? '' }
|
||||
set value(v: string) { this.attributes.value = v }
|
||||
get firstChild() { return this.childNodes[0] }
|
||||
get lastChild() { return this.childNodes[this.childNodes.length - 1] }
|
||||
get nextSibling() {
|
||||
if (!this.parentNode) return null
|
||||
const i = this.parentNode.childNodes.indexOf(this)
|
||||
return this.parentNode.childNodes[i + 1] ?? null
|
||||
}
|
||||
get previousSibling() {
|
||||
if (!this.parentNode) return null
|
||||
const i = this.parentNode.childNodes.indexOf(this)
|
||||
return this.parentNode.childNodes[i - 1] ?? null
|
||||
}
|
||||
}
|
||||
function makeWindow() {
|
||||
const w: any = {
|
||||
event: undefined,
|
||||
HTMLIFrameElement,
|
||||
document: undefined as any,
|
||||
navigator: { userAgent: 'node' },
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
function makeContainer(): MockElement {
|
||||
const doc = new MockDocument()
|
||||
const el = doc.createElement('div') as MockElement
|
||||
return el
|
||||
}
|
||||
|
||||
function installDomGlobals() {
|
||||
const win = makeWindow()
|
||||
const doc = new MockDocument()
|
||||
doc.defaultView = win
|
||||
win.document = doc
|
||||
;(globalThis as any).window = win
|
||||
;(globalThis as any).document = doc
|
||||
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
||||
;(globalThis as any).MessageChannel = class { port1 = { postMessage() {} }; port2 = { onmessage: null as any, postMessage() {} } }
|
||||
;(globalThis as any).setImmediate = (cb: (...args: any[]) => void) => Promise.resolve().then(() => cb())
|
||||
}
|
||||
|
||||
let _installed = false
|
||||
function ensureDomGlobals() {
|
||||
if (_installed) return
|
||||
installDomGlobals()
|
||||
_installed = true
|
||||
}
|
||||
|
||||
// ---- 用例辅助 ----
|
||||
|
||||
const project: Project = {
|
||||
id: 'host-1', owner: 'alice', name: '项目P', createdAt: '', updatedAt: '',
|
||||
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' },
|
||||
}
|
||||
|
||||
type CmdHandler = (id: string, params: Record<string, unknown>) => Promise<unknown> | unknown
|
||||
|
||||
/** 直接劫持 api.rdmProjectCommand,用 handlers[id] 分别登记每个命令的响应。 */
|
||||
function spyCommandBus(handlers: Record<string, CmdHandler>) {
|
||||
const calls: Array<[string, Record<string, unknown>]> = []
|
||||
const original = api.rdmProjectCommand.bind(api)
|
||||
const spy = vi.spyOn(api, 'rdmProjectCommand').mockImplementation(async (_proj: Project, id: string, params: Record<string, unknown> = {}) => {
|
||||
const args: [string, Record<string, unknown>] = [id, params]
|
||||
calls.push(args)
|
||||
if (!handlers[id]) throw new Error(`unexpected command: ${id} (no handler registered)`)
|
||||
return handlers[id](id, params)
|
||||
})
|
||||
return {
|
||||
calls,
|
||||
roleOptionsCalls: () => calls.filter(([id]) => id === 'rdm.role.options'),
|
||||
permCheckCalls: () => calls.filter(([id]) => id === 'rdm.perm.check'),
|
||||
memberListCalls: () => calls.filter(([id]) => id === 'rdm.project.member.list'),
|
||||
restore: () => {
|
||||
spy.mockRestore()
|
||||
;(api as any).rdmProjectCommand = original
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let root: Root | null = null
|
||||
let container: MockElement | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
ensureDomGlobals()
|
||||
renderObserver = {
|
||||
tags: [],
|
||||
reset() { this.tags = [] },
|
||||
append(t: string) { this.tags.push(t) },
|
||||
}
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => root!.unmount())
|
||||
root = null
|
||||
}
|
||||
container = null
|
||||
renderObserver = null
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function renderAndFlush(element: ReactNode, flushes: number = 3) {
|
||||
container = makeContainer()
|
||||
await act(async () => {
|
||||
root = createRoot(container as any)
|
||||
root!.render(element)
|
||||
})
|
||||
// 反复冲一轮 microtask flush,让 perm.check 的 .then 落进 commit。
|
||||
for (let i = 0; i < flushes; i++) {
|
||||
await act(async () => { await Promise.resolve() })
|
||||
}
|
||||
}
|
||||
|
||||
/** 助记:累计的 render 标签里「候选专业角色筛选」下拉 = 看到标 'select' + 紧邻的 option 角色字符串。 */
|
||||
function sawCandidateRoleFilter(): boolean {
|
||||
if (!renderObserver) return false
|
||||
// React 渲染时按 tag 序列创建 select/option;候选筛选断言只需确保 select 节点创建了。
|
||||
return renderObserver.tags.includes('select')
|
||||
}
|
||||
|
||||
describe('RdmMembersSection rdm.role.options 授权门控', () => {
|
||||
it('member.manage 未被允许时不得请求 rdm.role.options(普通成员避免 403)', async () => {
|
||||
const bus = spyCommandBus({
|
||||
'rdm.perm.check': () => ({ actions: [{ action: 'member.manage', allowed: false }] }),
|
||||
'rdm.project.member.list': () => ({ members: [], accountDirectoryAvailable: true }),
|
||||
})
|
||||
|
||||
await renderAndFlush(createElement(RdmMembersSection, { project }))
|
||||
|
||||
expect(bus.permCheckCalls().length).toBe(1)
|
||||
expect(bus.roleOptionsCalls().length).toBe(0)
|
||||
})
|
||||
|
||||
it('member.manage 被允许后再请求 rdm.role.options,并把结果写回角色筛选', async () => {
|
||||
const bus = spyCommandBus({
|
||||
'rdm.perm.check': () => ({ actions: [{ action: 'member.manage', allowed: true }] }),
|
||||
'rdm.role.options': () => ({ roles: [
|
||||
{ roleId: 'r-eng', name: '工程师' },
|
||||
{ roleId: 'r-qa', name: '测试' },
|
||||
] }),
|
||||
'rdm.project.member.list': () => ({ members: [], accountDirectoryAvailable: true }),
|
||||
})
|
||||
|
||||
await renderAndFlush(createElement(RdmMembersSection, { project }))
|
||||
|
||||
expect(bus.permCheckCalls().length).toBe(1)
|
||||
expect(bus.roleOptionsCalls().length).toBe(1)
|
||||
})
|
||||
|
||||
it('门控生效前的初始挂载阶段不出现专业角色筛选下拉(不提供误导选项)', async () => {
|
||||
// 用一个永挂起的 perm.check 模拟「自省中」——此时页面上确实只见核对提示,
|
||||
// 候选区不出现;这是顺带的渲染回归保单,防止门控未来误把 options 提前注入。
|
||||
// memberManagementView 在 actions===null 时返回 canManage:false,rdm.role.options 因此不发请求;
|
||||
// 同时 candRoleId 筛选(占位 select)也不应渲染——manage.canManage=false 时整个 ad-create 块被跳掉。
|
||||
spyCommandBus({
|
||||
'rdm.perm.check': () => new Promise<{ actions: { action: string; allowed: boolean }[] }>(() => {}),
|
||||
'rdm.project.member.list': () => new Promise<any>(() => {}),
|
||||
})
|
||||
|
||||
await renderAndFlush(createElement(RdmMembersSection, { project }))
|
||||
|
||||
expect(sawCandidateRoleFilter()).toBe(false)
|
||||
})
|
||||
})
|
||||
+103
-22
@@ -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')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PermissionDescriptor, RoleRecord } from '../src/api'
|
||||
import {
|
||||
filterRoles, groupPermissionPage, hasSystemAction, shouldAcceptRoleResponse,
|
||||
} from '../src/components/RolePermissionsPanel'
|
||||
|
||||
const roles: RoleRecord[] = [
|
||||
{ roleId: 'role_engineer', name: '软件工程师', description: '研发', enabled: true, roleVersion: 1, bindingCount: 2, permissions: [] },
|
||||
{ roleId: 'role_reviewer', name: '审核工程师', description: '质量', enabled: false, roleVersion: 1, bindingCount: 1, permissions: [] },
|
||||
]
|
||||
|
||||
const catalog: PermissionDescriptor[] = [
|
||||
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', scope: 'system', protected: false },
|
||||
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', scope: 'project', protected: false },
|
||||
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', scope: 'project', protected: false },
|
||||
]
|
||||
|
||||
describe('role permission UI helpers', () => {
|
||||
it('combines exact or fuzzy search with the role status filter', () => {
|
||||
expect(filterRoles(roles, '软件工程师', 'exact', 'enabled').map(role => role.roleId)).toEqual(['role_engineer'])
|
||||
expect(filterRoles(roles, '工程师', 'fuzzy', 'all').map(role => role.roleId)).toEqual(['role_engineer', 'role_reviewer'])
|
||||
expect(filterRoles(roles, '', 'fuzzy', 'disabled').map(role => role.roleId)).toEqual(['role_reviewer'])
|
||||
})
|
||||
|
||||
it('groups and pages matrix columns without changing their stable action IDs', () => {
|
||||
expect(groupPermissionPage(catalog, '文档动作', 0, 1).map(item => item.actionId)).toEqual(['document.read'])
|
||||
expect(groupPermissionPage(catalog, '文档动作', 1, 1).map(item => item.actionId)).toEqual(['document.author'])
|
||||
})
|
||||
|
||||
it('drops late results and checks explicit system actions', () => {
|
||||
expect(shouldAcceptRoleResponse(4, 3)).toBe(false)
|
||||
expect(shouldAcceptRoleResponse(4, 4)).toBe(true)
|
||||
expect(hasSystemAction(['system.roles.view'], 'system.roles.view')).toBe(true)
|
||||
expect(hasSystemAction(['system.roles.view'], 'system.permissions.manage')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createRolePermissionDraft, mergeRoleDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
|
||||
type RolePermissionValue,
|
||||
} from '../src/rolePermissions'
|
||||
|
||||
const initial: Record<string, RolePermissionValue> = {
|
||||
'project.create': { allowed: false, capAllowed: true },
|
||||
'document.read': { allowed: true, capAllowed: true },
|
||||
'document.author': { allowed: false, capAllowed: true },
|
||||
}
|
||||
|
||||
describe('role permission draft', () => {
|
||||
it('retains hidden edits and patches only explicit visible actions', () => {
|
||||
let draft = createRolePermissionDraft(initial)
|
||||
draft = updateRolePermission(draft, 'project.create', { allowed: true, capAllowed: true })
|
||||
draft = updateRolePermission(draft, 'document.author', { allowed: true, capAllowed: true })
|
||||
|
||||
expect(visiblePatch(draft, ['project.create'])).toEqual([
|
||||
{ actionId: 'project.create', allowed: true, capAllowed: true },
|
||||
])
|
||||
expect(draft.values['document.author']).toEqual({ allowed: true, capAllowed: true })
|
||||
})
|
||||
|
||||
it('produces the same patch for single-role and matrix edits', () => {
|
||||
const single = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: false, capAllowed: true })
|
||||
const matrix = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: false, capAllowed: true })
|
||||
expect(visiblePatch(single, Object.keys(initial))).toEqual(visiblePatch(matrix, Object.keys(initial)))
|
||||
})
|
||||
|
||||
it('discards a stale server refresh without replacing newer edits', () => {
|
||||
const edited = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true })
|
||||
const merged = mergeRoleDraft(edited, initial, edited.generation - 1)
|
||||
expect(merged).toBe(edited)
|
||||
})
|
||||
|
||||
it('rejects defaults that exceed the role cap', () => {
|
||||
const invalid = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: true, capAllowed: false })
|
||||
expect(validateRolePermissionDraft(invalid)).toEqual({ actionId: 'document.read', reason: 'default-exceeds-cap' })
|
||||
})
|
||||
|
||||
it('rebases local changes on a newer role version without losing the draft', () => {
|
||||
const edited = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true })
|
||||
const server = { ...initial, 'document.read': { allowed: false, capAllowed: true } }
|
||||
const rebased = rebaseRoleDraft(edited, server)
|
||||
expect(rebased.baseline['document.read']).toEqual({ allowed: false, capAllowed: true })
|
||||
expect(rebased.values['project.create']).toEqual({ allowed: true, capAllowed: true })
|
||||
expect(rebased.values['document.read']).toEqual({ allowed: false, capAllowed: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { auth } from '../src/auth'
|
||||
import { canCreateProject, store } from '../src/store'
|
||||
import type { ConversationView } from '../src/api'
|
||||
import type { Project } from '../src/contracts'
|
||||
|
||||
const project = (id: string): Project => ({ id, name: id, createdAt: '', updatedAt: '' })
|
||||
const task = (id: string, projectId: string, scratch = false, updatedAt = ''): ConversationView =>
|
||||
({ id, projectId, title: id, createdAt: '', updatedAt, ...(scratch ? { scratch: true } : {}) })
|
||||
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
|
||||
const detail = (conversation: ConversationView) => response({ conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 })
|
||||
|
||||
let projects: Project[]
|
||||
let tasks: ConversationView[]
|
||||
let me: Record<string, unknown>
|
||||
let requests: { path: string; init?: RequestInit }[]
|
||||
let scratchCount: number
|
||||
|
||||
const posts = () => requests.filter(r => r.init?.method === 'POST').map(r => r.path)
|
||||
|
||||
beforeEach(async () => {
|
||||
projects = []; tasks = []; requests = []; scratchCount = 0
|
||||
me = { owner: 'bob', name: 'Bob', auth: true, systemActions: [] }
|
||||
const saved = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { saved.set(key, value) },
|
||||
removeItem: (key: string) => { saved.delete(key) } })
|
||||
vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => {
|
||||
requests.push({ path, init })
|
||||
if (path === '/api/projects' && !init?.method) return response(projects)
|
||||
if (path === '/api/conversations' && !init?.method) return response(tasks)
|
||||
if (path === '/api/me') return response(me)
|
||||
if (path === '/api/scratch-sessions' && init?.method === 'POST') {
|
||||
const id = `scratch-${++scratchCount}`
|
||||
const conversation = task(`${id}-conv`, id, true)
|
||||
tasks = [conversation, ...tasks]
|
||||
return response({ project: project(id), conversation })
|
||||
}
|
||||
const created = path.match(/^\/api\/projects\/([^/]+)\/conversations$/)
|
||||
if (created && init?.method === 'POST') {
|
||||
const conversation = task(`${created[1]}-new`, created[1])
|
||||
tasks = [conversation, ...tasks]
|
||||
return response(conversation)
|
||||
}
|
||||
const conv = path.match(/^\/api\/conversations\/([^/]+)$/)
|
||||
if (conv && !init?.method) {
|
||||
const found = tasks.find(t => t.id === conv[1])
|
||||
return found ? detail(found) : response({ error: 'not found' }, 404)
|
||||
}
|
||||
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
|
||||
if (path.endsWith('/artifacts') || path.endsWith('/surfaces')) return response([])
|
||||
if (path.endsWith('/cards')) return response({ cards: [] })
|
||||
return response({})
|
||||
})
|
||||
vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
|
||||
auth.beginSession()
|
||||
await store.refreshMe()
|
||||
})
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
describe('scratch session entry', () => {
|
||||
it('enters scratch mode for an account without project creation and no projects, and never creates a project', async () => {
|
||||
await store.refreshProjects()
|
||||
expect(store.getState()).toMatchObject({ scratchMode: true, projectsLoaded: true, currentProjectId: null })
|
||||
requests = []
|
||||
await store.createTask()
|
||||
expect(posts()).toEqual(['/api/scratch-sessions'])
|
||||
expect(requests.some(r => r.path === '/api/projects' && r.init?.method === 'POST')).toBe(false)
|
||||
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-1-conv', currentProjectId: 'scratch-1', projects: [] })
|
||||
})
|
||||
|
||||
it('always creates a scratch session in scratch mode even when real projects exist', async () => {
|
||||
projects = [project('host-a')]
|
||||
await store.refreshProjects()
|
||||
expect(store.getState()).toMatchObject({ scratchMode: false, currentProjectId: 'host-a' })
|
||||
await store.enterScratchMode()
|
||||
expect(store.getState()).toMatchObject({ scratchMode: true, currentProjectId: null, currentConvId: null })
|
||||
requests = []
|
||||
await store.createTask()
|
||||
await store.createTask()
|
||||
expect(posts()).toEqual(['/api/scratch-sessions', '/api/scratch-sessions'])
|
||||
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-2-conv', currentProjectId: 'scratch-2' })
|
||||
})
|
||||
|
||||
it('keeps creating project conversations in project mode', async () => {
|
||||
projects = [project('host-a')]
|
||||
await store.refreshProjects()
|
||||
requests = []
|
||||
await store.createTask()
|
||||
expect(posts()).toEqual(['/api/projects/host-a/conversations'])
|
||||
expect(store.getState()).toMatchObject({ scratchMode: false, currentConvId: 'host-a-new', currentProjectId: 'host-a' })
|
||||
})
|
||||
|
||||
it('returns to the remembered scratch view on reload and opens the latest scratch task', async () => {
|
||||
projects = [project('host-a')]
|
||||
tasks = [task('old', 'scratch-o', true, '2026-01-01'), task('new', 'scratch-n', true, '2026-02-01'), task('work', 'host-a')]
|
||||
await store.refreshProjects()
|
||||
await store.enterScratchMode()
|
||||
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'new', currentProjectId: 'scratch-n' })
|
||||
expect(store.getState().ui.lastScratch).toBe(true)
|
||||
await store.refreshProjects()
|
||||
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'new', currentProjectId: 'scratch-n' })
|
||||
await store.openProject('host-a')
|
||||
expect(store.getState()).toMatchObject({ scratchMode: false, currentProjectId: 'host-a' })
|
||||
expect(store.getState().ui.lastScratch).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scratch mode follows the opened task', () => {
|
||||
it('sets scratch mode for a scratch task and clears it for a normal task', async () => {
|
||||
projects = [project('host-a')]
|
||||
tasks = [task('scratch-conv', 'scratch-1', true), task('work', 'host-a')]
|
||||
await store.refreshProjects()
|
||||
await store.openTask('scratch-conv')
|
||||
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-conv', currentProjectId: 'scratch-1' })
|
||||
expect(store.getState().ui.lastProjectId).toBe('host-a')
|
||||
await store.openTask('work')
|
||||
expect(store.getState()).toMatchObject({ scratchMode: false, currentConvId: 'work', currentProjectId: 'host-a' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('canCreateProject', () => {
|
||||
it('allows project creation without auth or with project.create only', () => {
|
||||
expect(canCreateProject({ auth: false })).toBe(true)
|
||||
expect(canCreateProject({ auth: true, systemActions: ['project.create'] })).toBe(true)
|
||||
expect(canCreateProject({ auth: true, systemActions: [] })).toBe(false)
|
||||
expect(canCreateProject({ auth: true })).toBe(false)
|
||||
expect(canCreateProject(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { setPersistedOpen } from '@ui'
|
||||
import type { ActivityCard } from '../src/contracts'
|
||||
import { Conversation } from '../src/components/Conversation'
|
||||
import { ConversationWork } from '../src/components/ConversationWork'
|
||||
import { Workbench } from '../src/components/Workbench'
|
||||
import { store } from '../src/store'
|
||||
|
||||
// SSR has no browser subscription snapshot: substitute only the hook boundary.
|
||||
const snapshot = vi.hoisted(() => ({ state: undefined as ReturnType<typeof store.getState> | undefined }))
|
||||
vi.mock('../src/store', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../src/store')>()
|
||||
return { ...actual, useStore: (select: (s: ReturnType<typeof store.getState>) => unknown) => select(snapshot.state!) }
|
||||
})
|
||||
|
||||
// Record the callbacks each card surface receives; the rendered archive button belongs to the UI repo.
|
||||
const passed = vi.hoisted(() => ({ inbox: [] as Record<string, unknown>[], card: [] as Record<string, unknown>[] }))
|
||||
vi.mock('@ui', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('@ui')>()
|
||||
return {
|
||||
...actual,
|
||||
InboxList: (props: { callbacks: Record<string, unknown> }) => { passed.inbox.push(props.callbacks); return null },
|
||||
ActivityCardView: (props: { callbacks: Record<string, unknown> }) => { passed.card.push(props.callbacks); return null },
|
||||
}
|
||||
})
|
||||
|
||||
const timestamp = '2026-10-12T10:00:00Z'
|
||||
const card: ActivityCard = {
|
||||
id: 'card_1', projectId: 'prj_1', conversationId: 'cnv_1', kind: 'progress', title: '整理材料',
|
||||
status: 'active', tone: 'info', body: [], createdAt: timestamp, updatedAt: timestamp,
|
||||
}
|
||||
|
||||
function setup(scratch: boolean) {
|
||||
const base = store.getState()
|
||||
snapshot.state = {
|
||||
...base, currentProjectId: 'prj_1', currentConvId: 'cnv_1', connected: true, inboxScope: 'project',
|
||||
tasks: [{ id: 'cnv_1', projectId: 'prj_1', title: '讨论', createdAt: timestamp, updatedAt: timestamp, ...(scratch ? { scratch: true } : {}) }],
|
||||
messages: [], cards: [card], projectCards: [], runs: [], activeHitl: [], live: null, artifacts: [], outbox: [], posting: false,
|
||||
ui: { ...base.ui, rightCollapsed: true, rightTab: 'work', inboxFilter: 'all' },
|
||||
cardActionStates: {}, workRecord: null, surfaces: [],
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
passed.inbox = []; passed.card = []
|
||||
setPersistedOpen('conversation-settings:prj_1:cnv_1', true)
|
||||
})
|
||||
|
||||
describe.each([
|
||||
{ name: 'scratch conversation', scratch: true },
|
||||
{ name: 'project conversation', scratch: false },
|
||||
])('project-only surfaces in a $name', ({ scratch }) => {
|
||||
beforeEach(() => setup(scratch))
|
||||
|
||||
it(scratch ? 'hides the cross-conversation read setting' : 'keeps the cross-conversation read setting', () => {
|
||||
const html = renderToStaticMarkup(createElement(Conversation))
|
||||
expect(html).toContain('会话设置')
|
||||
expect(html).toContain('这次工作的要求')
|
||||
expect(html.includes('读取同项目的其他对话')).toBe(!scratch)
|
||||
expect(html.includes('实际读取范围还需项目设置允许')).toBe(!scratch)
|
||||
})
|
||||
|
||||
it(scratch ? 'keeps the conversation inbox without the project scope' : 'keeps the project scope and inbox', () => {
|
||||
const html = renderToStaticMarkup(createElement(Workbench))
|
||||
expect(html).toContain('本会话')
|
||||
expect(html.includes('>项目')).toBe(!scratch)
|
||||
expect(html.includes('项目收件箱为空')).toBe(!scratch)
|
||||
if (scratch) {
|
||||
expect(passed.inbox).toHaveLength(1)
|
||||
expect(passed.inbox[0]).not.toHaveProperty('onArchive')
|
||||
expect(passed.inbox[0]).toHaveProperty('onDismiss')
|
||||
}
|
||||
})
|
||||
|
||||
it(scratch ? 'omits archive-to-project from conversation inbox cards' : 'offers archive-to-project on conversation inbox cards', () => {
|
||||
snapshot.state!.inboxScope = 'conv'
|
||||
renderToStaticMarkup(createElement(Workbench))
|
||||
expect(passed.inbox).toHaveLength(1)
|
||||
expect(typeof passed.inbox[0].onArchive).toBe(scratch ? 'undefined' : 'function')
|
||||
})
|
||||
|
||||
it(scratch ? 'omits archive-to-project from conversation work cards' : 'offers archive-to-project on conversation work cards', () => {
|
||||
renderToStaticMarkup(createElement(ConversationWork))
|
||||
expect(passed.card.length).toBeGreaterThan(0)
|
||||
for (const callbacks of passed.card) {
|
||||
expect(typeof callbacks.onArchive).toBe(scratch ? 'undefined' : 'function')
|
||||
expect(callbacks).toHaveProperty('onDismiss')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
// 被移出项目/项目关闭后的发送拒绝:403 `conversation: not-project-member` 走专用文案,
|
||||
// 其他失败保持通用文案(见 src/store.ts sendMessage 的 catch 分支)。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { store } from '../src/store'
|
||||
import type { Conversation, Message } from '../src/contracts'
|
||||
|
||||
const REMOVED_TOAST = '无法继续发送:你已不在该项目中或项目已关闭;历史对话仍可查看'
|
||||
const REMOVED_DELIVERY_ERROR = '无法发送:你已不在该项目中或项目已关闭,内容已保留'
|
||||
const GENERIC_DELIVERY_ERROR = '发送未成功,内容已保留;可修改后重试或移除'
|
||||
|
||||
function detail(id: string) {
|
||||
return {
|
||||
conversation: { id, projectId: 'p', title: id, createdAt: '', updatedAt: '' } as Conversation,
|
||||
messages: [] as Message[], runs: [], activeHitl: [], lastSeq: 0, resumeSeq: 0,
|
||||
}
|
||||
}
|
||||
class FakeEventSource {
|
||||
onopen = null
|
||||
onerror = null
|
||||
onmessage = null
|
||||
addEventListener() {}
|
||||
close() {}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubGlobal('EventSource', FakeEventSource)
|
||||
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => detail(id))
|
||||
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'listCards').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listTasks').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listArtifacts').mockResolvedValue([])
|
||||
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
|
||||
vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listProjectCards').mockResolvedValue([])
|
||||
await store.openProject('p')
|
||||
await store.openConversation('a')
|
||||
})
|
||||
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
describe('sendMessage rejection copy', () => {
|
||||
it('keeps the message in the outbox with the removed-member copy on 403 not-project-member', async () => {
|
||||
vi.spyOn(api, 'sendMessage').mockRejectedValue(new Error('403: conversation: not-project-member'))
|
||||
expect(await store.sendMessage('这条还要继续吗', [], 'kept-id')).toBe('rejected')
|
||||
expect(store.getState().outbox[0]).toMatchObject({ id: 'kept-id', text: '这条还要继续吗', attachmentIds: [] })
|
||||
expect(store.getState().outbox[0].deliveryError).toBe(REMOVED_DELIVERY_ERROR)
|
||||
expect(store.getState().toast).toBe(REMOVED_TOAST)
|
||||
})
|
||||
|
||||
it('keeps the generic copy for any other send failure', async () => {
|
||||
vi.spyOn(api, 'sendMessage').mockRejectedValue(new Error('500: upstream down'))
|
||||
expect(await store.sendMessage('这条还要继续吗', [], 'kept-id')).toBe('rejected')
|
||||
expect(store.getState().outbox[0].deliveryError).toBe(GENERIC_DELIVERY_ERROR)
|
||||
expect(store.getState().toast).toContain('发送失败:')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
// 超级管理员 UI 门控(计划 2026-10-19-0113 R3/R4/R8):身份标签可见;建号为管理员与
|
||||
// 任命/撤销入口只对超级管理员出现;超级管理员不可降级、停用或被普通管理员维护。
|
||||
// 服务端授权仍为权威,这里只锁定前端呈现与请求体。
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { api } from '../src/api'
|
||||
import { auth } from '../src/auth'
|
||||
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../src/accountAuthority'
|
||||
import { AdminAppointmentButton, CreateAdminOption, IdentityChip } from '../src/components/AccountUsersPanel'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const superAdmin = { isAdmin: true, isSuperAdmin: true }
|
||||
const admin = { isAdmin: true, isSuperAdmin: false }
|
||||
const user = { isAdmin: false, isSuperAdmin: false }
|
||||
const noop = () => undefined
|
||||
|
||||
describe('身份标签', () => {
|
||||
it('超级管理员、管理员与普通用户各有可辨识标签', () => {
|
||||
expect(accountIdentityKind(superAdmin)).toBe('super')
|
||||
expect(accountIdentityKind(admin)).toBe('admin')
|
||||
expect(accountIdentityKind(user)).toBe('user')
|
||||
expect(renderToStaticMarkup(createElement(IdentityChip, { account: superAdmin }))).toContain('超级管理员')
|
||||
expect(renderToStaticMarkup(createElement(IdentityChip, { account: admin }))).toContain('>管理员<')
|
||||
expect(renderToStaticMarkup(createElement(IdentityChip, { account: user }))).toContain('普通用户')
|
||||
})
|
||||
it('isAdmin 不推导超级管理员身份', () => {
|
||||
expect(accountIdentityKind({ isAdmin: true })).toBe('admin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('管理员任命入口只对超级管理员开放', () => {
|
||||
it('建号:超级管理员看到「创建为管理员账号」,普通管理员只见普通建号说明', () => {
|
||||
expect(canAppointAdmins(superAdmin)).toBe(true)
|
||||
expect(canAppointAdmins(admin)).toBe(false)
|
||||
expect(canAppointAdmins(null)).toBe(false)
|
||||
const superHtml = renderToStaticMarkup(createElement(CreateAdminOption, { me: superAdmin, checked: false, disabled: false, onChange: noop }))
|
||||
expect(superHtml).toContain('type="checkbox"')
|
||||
expect(superHtml).toContain('创建为管理员账号')
|
||||
const adminHtml = renderToStaticMarkup(createElement(CreateAdminOption, { me: admin, checked: false, disabled: false, onChange: noop }))
|
||||
expect(adminHtml).not.toContain('checkbox')
|
||||
expect(adminHtml).toContain('仅超级管理员可创建或任命管理员')
|
||||
})
|
||||
it('任命/撤销:超级管理员可任命普通用户、撤销管理员;普通管理员看不到任何任命按钮', () => {
|
||||
expect(accountActionPolicy(superAdmin, user).adminChange).toBe('promote')
|
||||
expect(accountActionPolicy(superAdmin, admin).adminChange).toBe('demote')
|
||||
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: user, busy: false, onChange: noop }))).toContain('设为管理员')
|
||||
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: admin, busy: false, onChange: noop }))).toContain('撤销管理员')
|
||||
for (const target of [user, admin, superAdmin]) {
|
||||
expect(accountActionPolicy(admin, target).adminChange).toBeNull()
|
||||
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: admin, account: target, busy: false, onChange: noop }))).toBe('')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('超级管理员账号受保护', () => {
|
||||
it('超级管理员本人不可被降级或停用,也不走管理员重置密码', () => {
|
||||
const self = accountActionPolicy(superAdmin, superAdmin)
|
||||
expect(self).toMatchObject({ adminChange: null, toggleEnabled: false, resetPassword: false, maintain: true })
|
||||
expect(self.protectedReason).toContain('不能停用、降级')
|
||||
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: superAdmin, busy: false, onChange: noop }))).toBe('')
|
||||
})
|
||||
it('普通管理员不能编辑、停用、降级或重置超级管理员,并获得明确说明', () => {
|
||||
const policy = accountActionPolicy(admin, superAdmin)
|
||||
expect(policy).toMatchObject({ maintain: false, toggleEnabled: false, resetPassword: false, adminChange: null })
|
||||
expect(policy.protectedReason).toContain('超级管理员账号受保护')
|
||||
})
|
||||
it('普通账号的既有维护能力不受影响', () => {
|
||||
expect(accountActionPolicy(admin, user)).toMatchObject({ maintain: true, toggleEnabled: true, resetPassword: true, protectedReason: '' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('账号管理请求体', () => {
|
||||
const stub = (body: unknown) => vi.spyOn(auth, 'fetch').mockImplementation(async () => new Response(JSON.stringify(body)))
|
||||
|
||||
it('adminSetUserAdmin 以乐观版本 PUT /api/admin/users/:userId/admin', async () => {
|
||||
const request = stub({ userId: 'u 1' })
|
||||
await api.adminSetUserAdmin('u 1', 7, true)
|
||||
const [url, options] = request.mock.calls[0]
|
||||
expect(url).toBe('/api/admin/users/u%201/admin')
|
||||
expect(options?.method).toBe('PUT')
|
||||
expect(JSON.parse(String(options?.body))).toEqual({ expectedStateVersion: 7, isAdmin: true })
|
||||
})
|
||||
it('建号只在请求管理员身份时携带 isAdmin', async () => {
|
||||
const request = stub({ userId: 'u1', owner: 'alice' })
|
||||
await api.createManagedUser('alice', { name: 'A' })
|
||||
await api.createManagedUser('bob', {}, true)
|
||||
await api.createAdminUser('carol', 'C')
|
||||
await api.createAdminUser('dave', 'D', undefined, true)
|
||||
const bodies = request.mock.calls.map(call => JSON.parse(String(call[1]?.body)))
|
||||
expect(bodies[0]).toEqual({ owner: 'alice', profile: { name: 'A' } })
|
||||
expect(bodies[1]).toEqual({ owner: 'bob', profile: {}, isAdmin: true })
|
||||
expect(bodies[2]).toEqual({ owner: 'carol', profile: { name: 'C' } })
|
||||
expect(bodies[3]).toEqual({ owner: 'dave', profile: { name: 'D' }, isAdmin: true })
|
||||
})
|
||||
})
|
||||
+60
-1
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { store } from '../src/store'
|
||||
import { store, mergeTaskSnapshot } from '../src/store'
|
||||
import type { ActivityCard, Conversation, Message, Run, StreamEvent, SurfaceSnapshot } from '../src/contracts'
|
||||
|
||||
function deferred<T>() {
|
||||
@@ -32,6 +32,7 @@ beforeEach(async () => {
|
||||
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'listCards').mockResolvedValue([])
|
||||
vi.spyOn(api,'listCardsSnapshot').mockImplementation(async id=>({cards:await api.listCards(id)}))
|
||||
vi.spyOn(api, 'listTasks').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listArtifacts').mockResolvedValue([])
|
||||
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
|
||||
@@ -518,3 +519,61 @@ describe('shared action feedback', () => {
|
||||
expect(store.getState().cards).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a newer task result when an older projection arrives in a later stream event',()=>{
|
||||
const task=(revision:number,executionState:string)=>({...card(),task:{schemaVersion:1,taskId:'t',attemptId:'a1',cardId:'task',projectId:'p',conversationId:'a',ownerInstanceId:'i',executionEpoch:1,revision,sourceSeq:revision,streamScope:'conversation:a',title:'整理',executionState,outcomeCompleteness:'partial',deliveryState:'available',phase:{id:'reading',label:'读取材料'}}}) as ActivityCard
|
||||
store.applyEvent(event(1,'card.created',{card:task(9,'completed')}))
|
||||
store.applyEvent(event(2,'card.updated',{card:task(8,'running')}))
|
||||
expect(store.getState().cards[0].task?.revision).toBe(9)
|
||||
expect(store.getState().cards[0].task?.executionState).toBe('completed')
|
||||
})
|
||||
it('does not lose an event behind a detected sequence gap',async()=>{
|
||||
vi.mocked(api.listCardsSnapshot).mockResolvedValue({cards:[],replaySeq:0})
|
||||
await store.refreshCards('a')
|
||||
store.applyEvent(event(2,'card.updated',{card:card('later')}))
|
||||
expect(store.getState().lastSeq).toBe(0)
|
||||
store.applyEvent(event(1,'card.created',{card:card('earlier')}))
|
||||
await Promise.resolve()
|
||||
expect(store.getState().cards.map(c=>c.id)).toEqual(['later','earlier'])
|
||||
expect(store.getState().lastSeq).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects old revisions and foreign attempts in the task merge contract',()=>{
|
||||
expect(mergeTaskSnapshot({taskId:'t',attemptId:'a',revision:9},{taskId:'t',attemptId:'a',revision:8}).revision).toBe(9)
|
||||
expect(mergeTaskSnapshot({taskId:'t',attemptId:'a',revision:9},{taskId:'t',attemptId:'b',revision:10}).attemptId).toBe('a')
|
||||
})
|
||||
|
||||
it('keeps the newer explicit preview when a source response arrives late',async()=>{
|
||||
const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'}
|
||||
const pending=deferred<Awaited<ReturnType<typeof api.taskSource>>>()
|
||||
vi.spyOn(api,'taskSource').mockReturnValue(pending.promise)
|
||||
const {docBridge}=await import('../src/components/Workbench')
|
||||
const opening=docBridge.onOpenTaskSource!('card',source)
|
||||
store.requestPreview({id:'b',name:'B',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0})
|
||||
pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'})
|
||||
await opening
|
||||
expect(store.getState().previewActive.a).toBe('b')
|
||||
})
|
||||
|
||||
it('rejects a source response after leaving and returning to the same conversation',async()=>{
|
||||
const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'}
|
||||
const pending=deferred<Awaited<ReturnType<typeof api.taskSource>>>()
|
||||
vi.spyOn(api,'taskSource').mockReturnValue(pending.promise)
|
||||
const opening=store.openTaskSource('card',source)
|
||||
await store.openConversation('b');await store.openConversation('a')
|
||||
store.requestPreview({id:'new',name:'New',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0})
|
||||
pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'})
|
||||
await opening
|
||||
expect(store.getState().previewActive.a).toBe('new')
|
||||
})
|
||||
it('keeps the activated tab when a source request is still pending',async()=>{
|
||||
store.requestPreview({id:'b',name:'B',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0})
|
||||
store.requestPreview({id:'c',name:'C',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0})
|
||||
const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'}
|
||||
const pending=deferred<Awaited<ReturnType<typeof api.taskSource>>>()
|
||||
vi.spyOn(api,'taskSource').mockReturnValue(pending.promise)
|
||||
const opening=store.openTaskSource('card',source);store.activatePreview('b')
|
||||
pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'})
|
||||
await opening
|
||||
expect(store.getState().previewActive.a).toBe('b')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user