From 9ea8249be859b6f01bf090f225b147d45ba5f0a7 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 02:45:55 +0000 Subject: [PATCH] Restore management controls and complete note-tree browsing Why: EasyList hid ad-prefixed admin controls, note spines could not expand, and late details and stale permission checkboxes misrepresented selections. Evidence: Dev task feature/admin-notes-repair records the red/green browser checks, 215 Frontend tests, and build results as SELF. Impact: Rename management selectors, use recursive Collapsible tree levels with local retry and request guards, and load role checkbox values correctly. No permission or storage contracts change. Refs: Dev feature/admin-notes-repair; RDM feature/inspect-notes-repair --- e2e/admin-notes-repair.playwright.ts | 178 +++++++++++++++++++++++++++ src/components/AdminUsersModal.tsx | 10 +- src/components/RdmAuditWorkspace.tsx | 154 ++++++++++++----------- src/components/adminRdmSections.tsx | 141 +++++++++++---------- src/components/adminSections.tsx | 38 +++--- src/styles.css | 48 ++++---- 6 files changed, 378 insertions(+), 191 deletions(-) create mode 100644 e2e/admin-notes-repair.playwright.ts diff --git a/e2e/admin-notes-repair.playwright.ts b/e2e/admin-notes-repair.playwright.ts new file mode 100644 index 0000000..f354908 --- /dev/null +++ b/e2e/admin-notes-repair.playwright.ts @@ -0,0 +1,178 @@ +import { test, expect, type Page } from '@playwright/test' + +test.skip(!process.env.ADMIN_NOTES_REPAIR_BROWSER, 'Run against the task-owned UI preview') + +// Fixed EasyList cosmetic rules that hid the real management controls on 8788. +// https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_general_hide.txt +const cosmeticRules = '.ad-body,.ad-hint,.ad-notice,.ad-row,.ad-table { display:none!important }' +const stamp = '2026-10-13T02:00:00Z' +const commands = ['rdm.role.create', 'rdm.role.action', 'rdm.role.assign', 'rdm.role.disable', 'rdm.role.list', + 'rdm.inspect.capabilities', 'rdm.inspect.summary', 'rdm.inspect.tree', 'rdm.inspect.object', + 'rdm.inspect.relations', 'rdm.inspect.events', 'rdm.inspect.history'] +const roles = [ + { roleId: 'engineer', name: '工程师', disabled: false, version: 1, actions: { 'project.create': [true, true], 'doc.write': [false, true] }, accounts: [] }, + { roleId: 'reviewer', name: '审核员', disabled: false, version: 1, actions: { 'project.create': [false, false] }, accounts: [] }, +] +const shell = (items: unknown[], nextCursor = '') => ({ items, hasMore: !!nextCursor, nextCursor, truncated: false, coverage: [] }) + +async function fixture(page: Page) { + let detailFailed = false + let childFailed = false + let releaseSlow!: () => void + let reportSlow!: () => void + const slow = new Promise(resolve => { releaseSlow = resolve }) + const slowStarted = new Promise(resolve => { reportSlow = resolve }) + page.on('close', releaseSlow) + await page.route('**/api/**', async route => { + const request = route.request() + const path = new URL(request.url()).pathname + let body: unknown = [] + if (path === '/api/health') body = { ok: true } + else if (path === '/api/me') body = { owner: 'repair-admin', name: '修复验证', auth: true, authVia: 'session', isAdmin: true, mustChangePassword: false } + else if (path === '/api/settings') body = {} + else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true })) + else if (path === '/api/admin-zone/sections') body = [{ id: 'rdm.audit', title: '项目追溯与审计', icon: 'search' }] + else if (path === '/api/admin/users') body = [{ owner: 'repair-admin', name: '修复验证', isAdmin: true, mustChangePassword: false, hasPassword: true, createdAt: stamp }] + else if (path.startsWith('/api/commands/')) { + const command = decodeURIComponent(path.slice('/api/commands/'.length)) + const params = request.postDataJSON()?.params ?? {} + let payload: unknown + if (command === 'rdm.role.list') payload = { roles, count: 2 } + else if (command === 'rdm.inspect.capabilities') payload = { actor: 'repair-admin', crossProject: true, projects: [ + { projectId: 'p1', name: '主项目', canAudit: true }, { projectId: 'p2', name: '隔离项目', canAudit: true }, + ] } + else if (command === 'rdm.inspect.summary') payload = { projectId: params.projectId, counts: {}, watermark: 10, coverage: [] } + else if (command === 'rdm.inspect.tree') { + if (params.projectId === 'p2') payload = shell([{ kind: 'spine', id: 'other', title: '隔离骨架' }]) + else if (params.kind === 'docs') payload = shell(params.parent ? [{ kind: 'section', id: 'sec1', title: '章节一' }] : [{ kind: 'doc', id: 'doc1', title: '项目文档' }]) + else if (!params.parent) payload = shell([ + { kind: 'spine', id: 'root', title: '阶段骨架' }, + { kind: 'slice', id: 'slow', title: '慢速资料' }, { kind: 'slice', id: 'fast', title: '快速资料' }, + ]) + else if (params.parent === 'root') payload = params.cursor ? shell([{ kind: 'slice', id: 'second', title: '第二页资料' }]) : shell([{ kind: 'spine', id: 'child', title: '需求骨架' }], 'root-page-2') + else if (params.parent === 'child') { + if (!childFailed) { + childFailed = true + return route.fulfill({ status: 400, json: { receipt: { state: 'rejected', reason: 'temporary child query failure' } } }) + } + payload = shell([{ kind: 'slice', id: 'meeting', title: '会议资料' }]) + } else payload = shell([]) + } else if (command === 'rdm.inspect.object') { + if (params.id === 'meeting' && !detailFailed) { + detailFailed = true + return route.fulfill({ status: 400, json: { receipt: { state: 'rejected', reason: 'temporary detail query failure' } } }) + } + if (params.id === 'slow') { reportSlow(); await slow } + const titles: Record = { root: '阶段骨架详情', child: '需求骨架详情', meeting: '会议资料详情', slow: '慢速资料详情', fast: '快速资料详情', second: '第二页资料详情', sec1: '章节一详情', other: '隔离骨架详情' } + payload = { kind: params.kind, id: params.id, projectId: params.projectId, title: titles[params.id], parentId: params.id === 'child' ? 'root' : '', childCount: 1, sliceCount: 1, relations: [], recentEvents: [], coverage: [] } + } else return route.fulfill({ status: 400, json: { receipt: { state: 'rejected', reason: `unexpected command ${command}` } } }) + body = { receipt: { state: 'committed', commandId: command }, payload } + } + await route.fulfill({ status: 200, json: body }) + }) + await page.goto('/') + await expect(page.locator('.tsb-user')).toContainText('修复验证') + await page.locator('.tsb-user').click() + await page.getByText('系统管理…', { exact: true }).click() + return { slowStarted, releaseSlow } +} + +async function notes(page: Page) { + await page.getByRole('button', { name: '项目追溯与审计', exact: true }).click() + await page.getByRole('tab', { name: '资料与文档' }).click() + await page.getByRole('button', { name: '笔记树', exact: true }).click() + await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' })).toBeVisible() +} + +test('advertising filters leave management forms and role tables visible', async ({ page }, testInfo) => { + await fixture(page) + await page.addStyleTag({ content: cosmeticRules }) + await expect(page.getByPlaceholder('用户名(登录名)')).toBeVisible() + await page.getByPlaceholder('用户名(登录名)').fill('new-user') + await page.getByPlaceholder('初始密码(≥8 位)').fill('fixture-password') + await expect(page.getByRole('button', { name: '创建账号', exact: true })).toBeEnabled() + await expect(page.getByRole('cell', { name: 'repair-admin', exact: true })).toBeVisible() + await page.screenshot({ path: testInfo.outputPath('users-with-adblock.png') }) + await page.getByRole('button', { name: 'RDM 角色权限', exact: true }).click() + await expect(page.getByRole('button', { name: '创建角色', exact: true })).toBeVisible() + await expect(page.getByRole('cell', { name: /工程师/ })).toBeVisible() + await expect(page.getByLabel('默认允许')).toBeVisible() + await page.screenshot({ path: testInfo.outputPath('roles-with-adblock.png') }) +}) + +test('selecting a role and action loads their actual permission checkboxes', async ({ page }) => { + await fixture(page) + await page.getByRole('button', { name: 'RDM 角色权限', exact: true }).click() + const selects = page.locator('.az-main select') + await selects.nth(0).selectOption('engineer') + await expect(page.getByLabel('默认允许')).toBeChecked() + await expect(page.getByLabel('上限允许')).toBeChecked() + await selects.nth(1).selectOption('doc.write') + await expect(page.getByLabel('默认允许')).not.toBeChecked() + await expect(page.getByLabel('上限允许')).toBeChecked() + await selects.nth(1).selectOption('project.create') + await selects.nth(0).selectOption('reviewer') + await expect(page.getByLabel('默认允许')).not.toBeChecked() + await expect(page.getByLabel('上限允许')).not.toBeChecked() +}) + +test('notes expand nested spines, retry a child query, and inspect mounted slices', async ({ page }, testInfo) => { + await fixture(page) + await notes(page) + await page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' }).click() + await expect(page.locator('.rdm-audit-detail')).toContainText('阶段骨架详情') + await page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' }).click() + await expect(page.getByRole('button', { name: '重试列表', exact: true })).toBeVisible() + await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' })).toBeVisible() + await page.getByRole('button', { name: '重试列表', exact: true }).click() + await page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' }).click() + await expect(page.getByRole('button', { name: '重试详情', exact: true })).toBeVisible() + await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' })).toBeVisible() + await page.getByRole('button', { name: '重试详情', exact: true }).click() + await expect(page.locator('.rdm-audit-detail')).toContainText('会议资料详情') + await page.screenshot({ path: testInfo.outputPath('notes-detail.png') }) +}) + +test('each expanded spine can load its next page', async ({ page }) => { + await fixture(page) + await notes(page) + await page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' }).click() + await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' })).toBeVisible() + await page.getByRole('button', { name: '加载更多', exact: true }).click() + await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '第二页资料' })).toBeVisible() +}) + +test('late detail responses cannot replace the newly selected object', async ({ page }) => { + const control = await fixture(page) + await notes(page) + await page.locator('.rdm-audit-tree-node').filter({ hasText: '慢速资料' }).click() + await control.slowStarted + await page.locator('.rdm-audit-tree-node').filter({ hasText: '快速资料' }).click() + await expect(page.locator('.rdm-audit-detail')).toContainText('快速资料详情') + const response = page.waitForResponse(r => r.url().endsWith('/rdm.inspect.object') && r.request().postDataJSON()?.params?.id === 'slow') + control.releaseSlow() + await response + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) + await expect(page.locator('.rdm-audit-detail')).toContainText('快速资料详情') +}) + +test('project switching clears note descendants and in-flight object details', async ({ page }) => { + const control = await fixture(page) + await notes(page) + await page.locator('.rdm-audit-tree-node').filter({ hasText: '慢速资料' }).click() + await control.slowStarted + await page.locator('.rdm-audit-project').selectOption('p2') + await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '隔离骨架' })).toBeVisible() + control.releaseSlow() + await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' })).toHaveCount(0) + await expect(page.locator('.rdm-audit-detail')).toHaveCount(0) +}) + +test('document expansion and section inspection remain available', async ({ page }) => { + await fixture(page) + await page.getByRole('button', { name: '项目追溯与审计', exact: true }).click() + await page.getByRole('tab', { name: '资料与文档' }).click() + await page.locator('.rdm-audit-tree-node').filter({ hasText: '项目文档' }).click() + await page.locator('.rdm-audit-tree-node').filter({ hasText: '章节一' }).click() + await expect(page.locator('.rdm-audit-detail')).toContainText('章节一详情') +}) diff --git a/src/components/AdminUsersModal.tsx b/src/components/AdminUsersModal.tsx index 511ec55..9d951b2 100644 --- a/src/components/AdminUsersModal.tsx +++ b/src/components/AdminUsersModal.tsx @@ -9,13 +9,13 @@ import { humanChangePasswordError } from './LoginPage' function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) { return (
{ if (e.target === e.currentTarget) onClose() }}> -
+
{title} - +
-
{children}
+
{children}
) @@ -51,8 +51,8 @@ export function ChangePasswordModal({ onClose }: { onClose: () => void }) { return ( - {ok ?
密码已更新,其他设备的会话已注销。
: ( -
{ e.preventDefault(); submit() }}> + {ok ?
密码已更新,其他设备的会话已注销。
: ( + { e.preventDefault(); submit() }}> diff --git a/src/components/RdmAuditWorkspace.tsx b/src/components/RdmAuditWorkspace.tsx index 7653201..90b7e12 100644 --- a/src/components/RdmAuditWorkspace.tsx +++ b/src/components/RdmAuditWorkspace.tsx @@ -17,7 +17,7 @@ interface TreeItem { kind: string; id: string; title: string; state?: string } interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number } interface EventDTO { seq: number; at: string; op: string; actor: string; source: string; target: { kind: string; id: string }; detail: string; operationId: string; disclosure: string } interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] } -interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] } +interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; parentId?: string; childCount?: number; sliceCount?: number; mounts?: string[]; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] } interface HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean } // ---- 工作区主组件 ---- @@ -148,102 +148,107 @@ function MaterialsTab({ pid }: { pid: string }) {
- + ) } function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) { - const [shell, setShell] = useState | null>(null) - const [err, setErr] = useState('') const [selected, setSelected] = useState(null) const [detail, setDetail] = useState(null) - // docs 树是层级结构(rdm.inspect.tree parent= 才返回章节):文档节点 - // 点击展开子章节,不直接查对象详情(doc 不在 inspect.object 支持面)。 - const [children, setChildren] = useState>({}) + const [detailError, setDetailError] = useState('') const genRef = useRef(0) - useEffect(() => { + useEffect(() => () => { genRef.current++ }, []) + + const selectNode = useCallback((item: TreeItem) => { const gen = ++genRef.current - setShell(null); setErr(''); setSelected(null); setDetail(null); setChildren({}) - api.rdmCommand>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' }) - .then(s => { if (gen === genRef.current) setShell(s) }) - .catch(e => { if (gen === genRef.current) setErr(e.message) }) - }, [pid, kind]) - - const loadMore = () => { - if (!shell?.nextCursor) return - const gen = genRef.current - api.rdmCommand>('rdm.inspect.tree', { projectId: pid, kind, cursor: shell.nextCursor }) - .then(s => { if (gen === genRef.current) setShell(prev => prev ? { ...s, items: [...prev.items, ...s.items] } : s) }) - .catch(e => { if (gen === genRef.current) setErr(e.message) }) - } - - const toggleDoc = (item: TreeItem) => { - if (children[item.id]) { - setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next }) - return - } - const gen = genRef.current - api.rdmCommand>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: item.id, cursor: '' }) - .then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s.items })) }) - .catch(e => { if (gen === genRef.current) setErr(e.message) }) - } - - const selectNode = (item: TreeItem) => { - if (item.kind === 'doc') { toggleDoc(item); return } - setSelected(item); setDetail(null) - const gen = genRef.current + setSelected(item); setDetail(null); setDetailError('') api.rdmCommand('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id }) .then(d => { if (gen === genRef.current) setDetail(d) }) - .catch(e => { if (gen === genRef.current) setErr(e.message) }) - } - - if (err) return - if (!shell) return
加载中…
- if (shell.items.length === 0) return 暂无{kind === 'notes' ? '骨架节点' : '文档'} + .catch(e => { if (gen === genRef.current) setDetailError(e.message) }) + }, [pid]) return (
-
- {shell.items.map(item => ( -
- - {item.kind === 'doc' && children[item.id] && ( -
- {children[item.id].length === 0 &&
无章节
} - {children[item.id].map(sub => ( - - ))} -
- )} -
- ))} - {shell.hasMore && } - {shell.truncated &&
已截断(深度或数量上限)
} -
+ {selected && (
对象详情 - {!detail ?
加载中…
: } + {detailError ? <> + + + : detail ? :
加载中…
}
)}
) } +interface TreeLevelProps { pid: string; kind: 'notes' | 'docs'; parent: string; selected: TreeItem | null; onSelect: (item: TreeItem) => void } + +function TreeLevel({ pid, kind, parent, selected, onSelect }: TreeLevelProps) { + const [shell, setShell] = useState | null>(null) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const genRef = useRef(0) + const cursorRef = useRef('') + const load = useCallback((cursor = '') => { + const gen = ++genRef.current + cursorRef.current = cursor + setLoading(true); setError('') + api.rdmCommand>('rdm.inspect.tree', { projectId: pid, kind, parent, cursor }) + .then(next => { + if (gen !== genRef.current) return + setShell(prev => cursor && prev ? { ...next, items: [...prev.items, ...next.items] } : next) + }) + .catch(e => { if (gen === genRef.current) setError(e.message) }) + .finally(() => { if (gen === genRef.current) setLoading(false) }) + }, [pid, kind, parent]) + + useEffect(() => { + load() + return () => { genRef.current++ } + }, [load]) + + return ( +
+ {shell?.items.map(item => )} + {!shell && loading &&
加载中…
} + {shell?.items.length === 0 &&
{parent ? kind === 'notes' ? '无子骨架或挂载切片' : '无章节' : kind === 'notes' ? '暂无骨架节点或切片' : '暂无文档'}
} + {error && <> + + + } + {shell?.hasMore && !error && } + {shell?.truncated &&
已截断(深度或数量上限)
} +
+ ) +} + +function TreeNode({ item, pid, kind, selected, onSelect }: Omit & { item: TreeItem }) { + const [open, setOpen] = useState(false) + const branch = item.kind === 'doc' || item.kind === 'spine' + const className = `rdm-audit-tree-node${selected?.kind === item.kind && selected.id === item.id ? ' sel' : ''}` + const label = <> + {branch && {open ? '▾' : '▸'}} + {item.kind} + {item.title} + {item.state && {item.state}} + + if (!branch) return + return { + setOpen(next) + if (item.kind === 'spine') onSelect(item) + }}> + + +} + // ---- 关联追溯 ---- function TraceTab({ pid }: { pid: string }) { @@ -470,6 +475,11 @@ function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
{detail.title || detail.id}
{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}
+ {detail.kind === 'spine' && <> +
父骨架:{detail.parentId || '根节点'}
+
子骨架 {detail.childCount ?? 0} · 挂载切片 {detail.sliceCount ?? 0}
+ } + {!!detail.mounts?.length &&
挂载骨架:{detail.mounts.join('、')}
} {detail.state &&
状态:{detail.state}
} {detail.labels &&
标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}
} {detail.versions && detail.versions.length > 0 && ( diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index a0cab8d..8a661a6 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -2,7 +2,7 @@ // 文档职责 · 整文审签」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力 // 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal // 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用 -// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。 +// 既有 admin-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。 import { useEffect, useState } from 'react' import { api } from '../api' import { useStore } from '../store' @@ -38,9 +38,11 @@ export function RdmRolesPanel() { const [editRole, setEditRole] = useState('') const [editAction, setEditAction] = useState(ROLE_ACTIONS[0]) const [editDef, setEditDef] = useState(false) - const [editCap, setEditCap] = useState(true) + const [editCap, setEditCap] = useState(false) // 绑定 const [assignOwner, setAssignOwner] = useState('') + const sel = roles?.find(role => role.roleId === editRole) + const editCurrent = sel?.actions?.[editAction] const refresh = async () => { try { @@ -50,6 +52,11 @@ export function RdmRolesPanel() { } useEffect(() => { refresh() }, []) + useEffect(() => { + setEditDef(editCurrent?.[0] ?? false) + setEditCap(editCurrent?.[1] ?? false) + }, [roles, editRole, editAction]) + const run = async (label: string, fn: () => Promise) => { setErr(''); setNotice(''); setBusy(true) try { await fn(); setNotice(label); await refresh() } @@ -62,83 +69,75 @@ export function RdmRolesPanel() { // rolesListPlaceholder 注释)。 const placeholder = rolesListPlaceholder(roles, err) - const sel = roles?.find(r => r.roleId === editRole) - const editCurrent = sel?.actions?.[editAction] - return (
-
-
+
+
setNewName(e.target.value)} /> -
-

新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。

+

新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。

- +
{(roles ?? []).map(r => ( - - - + + + - - + ))} - {roles !== null && roles.length === 0 && } - {placeholder && } + {roles !== null && roles.length === 0 && } + {placeholder && }
角色状态版本动作配置绑定账号
{r.name}
{r.roleId}
{r.disabled ? 已停用 : 生效}v{r.version}{r.name}
{r.roleId}
{r.disabled ? 已停用 : 生效}v{r.version} {Object.keys(r.actions ?? {}).length} 项{(r.accounts ?? []).join('、') || '—'}
暂无专业角色
{placeholder}
暂无专业角色
{placeholder}

动作配置(默认 ∩ 上限)

-
+
- setEditAction(e.target.value)}> {ROLE_ACTIONS.map(a => )} - - -
- {sel && editCurrent &&

当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}

} + {sel && editCurrent &&

当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}

}

绑定账号(每账号一个生效角色)

-
+
setAssignOwner(e.target.value)} /> -
- {notice &&
{notice}
} + {notice &&
{notice}
} {err &&
{err}
}
) @@ -172,41 +171,41 @@ export function RdmMembersSection({ projectId }: { projectId: string }) { return (
-
-
+
+
setMember(e.target.value)} /> -
-

加入后成员即可见本项目;移除即时生效。最后一位负责人不可移除/降级。

+

加入后成员即可见本项目;移除即时生效。最后一位负责人不可移除/降级。

- +
{(rows ?? []).map(m => ( - - - - - + + + + ))} - {rows !== null && rows.length === 0 && } - {rows === null && } + {rows !== null && rows.length === 0 && } + {rows === null && }
账号项目角色添加人添加时间
{m.owner}{m.role === 'owner' ? OWNER : MEMBER}{m.addedBy || '—'}{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}{m.role === 'owner' ? OWNER : MEMBER}{m.addedBy || '—'}{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}
暂无成员
加载中…
暂无成员
加载中…
- {notice &&
{notice}
} + {notice &&
{notice}
} {err &&
{err}
}
) @@ -253,8 +252,8 @@ export function RdmDutiesSection({ projectId }: { projectId: string }) { return (
-
-
+
+
setDocId(e.target.value)} /> 该文档尚无整文提交记录} {sub && (
-

第 {sub.round} 轮 · {sub.state === 'submitted' ? 审签在途(全文锁定) : sub.state === 'final' ? 已终批 : 已驳回}

-
编制 {sub.duties.author} · 审核 {(sub.duties.reviewers ?? []).join('、')} · 批准 {sub.duties.approver}
+

第 {sub.round} 轮 · {sub.state === 'submitted' ? 审签在途(全文锁定) : sub.state === 'final' ? 已终批 : 已驳回}

+
编制 {sub.duties.author} · 审核 {(sub.duties.reviewers ?? []).join('、')} · 批准 {sub.duties.approver}
{Object.entries(sub.reviews ?? {}).map(([r, ok]) => `${r}:${ok ? '✓' : '…'}`).join(' ') || '—'}
-
{(sub.digest ?? '').slice(0, 16)}…
-
-
+
+ - -
)} - {sub === undefined && docId &&

输入文档 ID 后查看整文状态。

} + {sub === undefined && docId &&

输入文档 ID 后查看整文状态。

}

分配文档三权(整文)

-
+
setDocId(e.target.value)} /> setApprover(e.target.value)} />
-
- -
-

三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。

+

三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。

- {notice &&
{notice}
} + {notice &&
{notice}
} {err &&
{err}
}
) @@ -351,39 +350,39 @@ export function RdmPermMatrixSection({ projectId }: { projectId: string }) { return (
-
-
+
+
query(member)}>查看矩阵
-

普通成员可查看自己的有效权限;查看他人矩阵须项目管理权。矩阵与实际判定同源(服务端解释,非前端推算)。

+

普通成员可查看自己的有效权限;查看他人矩阵须项目管理权。矩阵与实际判定同源(服务端解释,非前端推算)。

{matrix && ( <> -
- 成员 {matrix.member} · 项目关系 {matrix.role} +
+ 成员 {matrix.member} · 项目关系 {matrix.role} {matrix.profRole && <> · 专业角色 {matrix.profRole.name}({matrix.profRole.roleId})}
- +
{(matrix.actions ?? []).map(a => ( - - - - + + + + ))}
动作生效来源原因(拒绝时)
{a.action}{a.allowed ? 允许 : 拒绝}{describeSource(a.source)}{a.reason || '—'}{a.action}{a.allowed ? 允许 : 拒绝}{describeSource(a.source)}{a.reason || '—'}
)} - {!matrix && !err && members !== null &&

选择成员后展示 12 项动作的有效权限与第一拒绝原因。

} + {!matrix && !err && members !== null &&

选择成员后展示 12 项动作的有效权限与第一拒绝原因。

} {err &&
{err}
}
) diff --git a/src/components/adminSections.tsx b/src/components/adminSections.tsx index 15faddf..50ca4b1 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -58,47 +58,47 @@ export function UsersPanel() { return (
{me?.isAdmin && ( -
-
+
+
setOwner(e.target.value)} /> setName(e.target.value)} />
-
+
setPw(e.target.value)} /> - - - + + +
-

创建后该用户首次登录必须修改此初始密码。

+

创建后该用户首次登录必须修改此初始密码。

)} - {notice &&
{notice}
} + {notice &&
{notice}
} {err &&
{err}
} {resetFor ? ( -
+
重置 {resetFor} 的密码: setResetPw(e.target.value)} /> - - - + + +
) : ( - +
{(users ?? []).map(u => ( - + - - - + + + ))} - {users !== null && users.length === 0 && } + {users !== null && users.length === 0 && }
用户名显示名角色状态密码设置于
{u.owner}{u.owner} {u.name || '—'}{u.isAdmin ? ADMIN : USER}{u.mustChangePassword ? 待改密 : u.hasPassword ? '正常' : '未设密码'}{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}{u.isAdmin ? ADMIN : USER}{u.mustChangePassword ? 待改密 : u.hasPassword ? '正常' : '未设密码'}{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'} {me?.isAdmin && ( - + )}
暂无账号
暂无账号
)} diff --git a/src/styles.css b/src/styles.css index 15e659d..4c9b901 100644 --- a/src/styles.css +++ b/src/styles.css @@ -497,29 +497,29 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .az-section-head p { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-faint); } /* ---- 用户管理 / 改密弹层 ---- */ -.ad-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 12px; max-height: 70vh; overflow-y: auto; } -.ad-create { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-panel); } -.ad-row { display: flex; gap: 8px; align-items: center; } -.ad-row .lg-input { flex: 1; min-width: 0; height: 34px; } -.ad-check { display: flex; align-items: center; gap: 5px; font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: nowrap; } -.ad-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; } -.ad-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: var(--fs-ui-sm); cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; } -.ad-mini:hover { color: var(--text); border-color: var(--accent); } -.ad-hint { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-dim); } -.ad-notice { font-size: var(--fs-ui-sm); color: var(--ok-text); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; } -.ad-notice svg { flex: none; margin-top: 1px; } -.ad-table { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); } -.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; } -.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-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; } -.ad-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; } +.admin-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 12px; max-height: 70vh; overflow-y: auto; } +.admin-create { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-panel); } +.admin-row { display: flex; gap: 8px; align-items: center; } +.admin-row .lg-input { flex: 1; min-width: 0; height: 34px; } +.admin-check { display: flex; align-items: center; gap: 5px; font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: nowrap; } +.admin-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; } +.admin-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: var(--fs-ui-sm); cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; } +.admin-mini:hover { color: var(--text); border-color: var(--accent); } +.admin-hint { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-dim); } +.admin-notice { font-size: var(--fs-ui-sm); color: var(--ok-text); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; } +.admin-notice svg { flex: none; margin-top: 1px; } +.admin-table { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); } +.admin-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); } +.admin-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); } +.admin-table tr:last-child td { border-bottom: none; } +.admin-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); } +.admin-dim { color: var(--text-dim); } +.admin-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); } +.admin-chip-admin { background: var(--accent-soft); color: var(--accent-text); } +.admin-chip-warn { background: var(--warn-soft); color: var(--warn-text); } +.admin-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); } +.admin-reset .lg-input { flex: 1; min-width: 160px; height: 32px; } +.admin-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; } /* ---- 收起轨道(左栏收起态) ---- */ .icon-only { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 12px; } @@ -531,7 +531,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .role-mark { width: 20px; height: 20px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); } .role-mark.role-assistant { background: none; border: none; padding: 0; } .msg.user .msg-role { align-items: center; } -.ad-user-cell { display: flex; align-items: center; gap: 8px; } +.admin-user-cell { display: flex; align-items: center; gap: 8px; } .artifact-chip .ac-act { border: none; background: none; }