Merge Frontend main into the unified document renderer workspace
governance / governance (pull_request) Successful in 15s
governance / governance (pull_request) Successful in 15s
Why: PR #106 is behind main by 15 commits (password login, regulation
citations, RDM member search) and conflicted in RdmAuditWorkspace.tsx and
rdmManagement.ts; the user authorized merging the renderer line on 2026-10-18.
Evidence: content is the agent-exec transport ec5012fe (cursor worker, unit
frontend-task11-sync-main-50ec836-r2, receipt
/tmp/cleanup-20261018/render/frontend-sync-run2.json, SELF): tsc -b, vitest
541/541 and vite build passed; host reviewed both conflict resolutions as unions.
Impact: no behaviour removed from either side; regulation tab and document
open flow coexist in the materials tab.
Refs: PR #106, Frontend main 50ec836
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# 法规结构化引用前端呈现(U7) 任务卡
|
||||
|
||||
任务主记录:本文件随 PR 描述维护(PR 链接见下方“结果与验证”)。
|
||||
状态:已完成(合入前闭合);owner:weichen。PR:[Frontend PR #109](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/109)
|
||||
|
||||
## 基线与授权
|
||||
|
||||
- 权威仓:weichen/Agent-System-Frontend;root_target:main;immediate_target:main
|
||||
- branch_kind:standalone;initiative_id:无(跨仓大任务 Agent-System-Dev `feature/regulations-kb` 的 U7 前端子交付,Dev PR #614 登记)
|
||||
- R0(initial_main_sha):0459567(origin/main);工作区:/home/weichen/projects/Agent-System-Frontend-wt-feature-regulations-kb-citations(worktree);分支:feature/regulations-kb-citations
|
||||
- 父任务信息(仅子任务填写):无
|
||||
- accepting_requirements:false
|
||||
- 需求分流:verdict=JOIN(由 Dev 大任务承接);selected_initiative=Agent-System-Dev feature/regulations-kb;理由=计划 U7 前端范围
|
||||
- 授权与交付终点:owner 以 /goal 授权按法规知识库计划全部实施;终点为本 PR 合入 main,再由 RDM/Dev 组合钉住。
|
||||
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
目标:按 Dev 规格 `docs/superpowers/specs/2026-10-18-regulations-citation-contract.md` 第 5、6 节,在审计工作区新增“法规核验”树(`rdm.inspect.tree kind=regs`)与批/引用详情;驾驶舱阅读器把正文中的 `[cit-…]` 渲染为编号引用标记并可展开元数据与原文片段;未核验、无效与待复核状态清晰区分。
|
||||
|
||||
非目标:不改 RDM 写路径与命令;不改设计令牌;不做部署。
|
||||
|
||||
## Agent 实施收据
|
||||
|
||||
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| frontend-u7-regulation-citations | src/、test/ | implementation | cursor→antigravity→pi | 1(35d799203497) | cursor / not-observable(新进程,fresh_session=true) | success | 6a3f244(apply 快进,run 4cf12d61) | 否 | /tmp/regkb-u7fe-receipt.json |
|
||||
|
||||
## 同步记录
|
||||
|
||||
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|
||||
|---|---|---|---|---|
|
||||
| 启动 | 0459567 | 0459567 | 2026-10-18 | 一致 |
|
||||
| 验收前 | 0459567(origin/main) | 6a3f244 | 2026-10-18 | 一致,无需同步 |
|
||||
|
||||
## 结果与验证(完成后填写)
|
||||
|
||||
- 结果:新增 `src/regCitations.ts`(契约类型、`[cit-…]` 标记解析与编号、状态/过期/省略原因文案、样式档、审计树请求参数)与 `src/components/RegCitation.tsx`(编号标记、引用卡片、引用清单,折叠用 @ui Collapsible,正文与片段仅作文本渲染);审计工作区新增“法规核验”树(证据批→引用)与批/引用详情(状态、原因、腿状态、封存、引用元数据、受披露约束的片段);驾驶舱阅读器正文渲染引用标记与引用清单。只有 verified 且未过期显示为有效依据,其余状态与无效引用使用明确的未核验/无效样式。样式只用令牌(`src/reg-citations.css`)。
|
||||
- 验证:host 在临时 Dev 组合布局(modules/ui c918747)重跑 `bash /tmp/regkb-fe-verify.sh all`(SELF):vitest 37 文件 345 用例通过(含 design-guard 与新增 reg-citations 测试),knip 未用导出 50 ≤ 51,`tsc -b` 与 `vite build` 通过,日志 /tmp/regkb-u7fe-verify.log;`git diff --check` 通过。权威结论以 PR HEAD CI 与 Dev 组合 `make test` 为准。
|
||||
- Review:host 独立审阅实施 diff(未复用实施会话):未使用 dangerouslySetInnerHTML 与 `<details>`,外链仅 http(s),字段名与 RDM U6 JSON 一致;无阻塞项。
|
||||
- 影响与剩余项:数据由 RDM U7(`rdm.inspect.tree kind=regs`、`rdm.inspect.object`、`rdm.cockpit.node` citations)提供;RDM 未提供时界面为空态,不影响既有标签页。Dev 组合 pin 在 U8 推进。
|
||||
@@ -66,10 +66,8 @@ test('管理员建号:UI 请求新契约并给出待初始化提示', async ({
|
||||
const body = await createBody
|
||||
expect(body).toEqual({ owner: newOwner, profile: { name: '组合验收' } })
|
||||
|
||||
// 契约断言二:成功提示逐字含「待初始化」与 CLI 发放指引。
|
||||
await expect(page.locator('.ad-notice')).toHaveText(
|
||||
`已创建 ${newOwner}(待初始化):请在服务器执行 user initialize-account 发放激活 Key`,
|
||||
)
|
||||
// 契约断言二:页面创建后账号可直接使用,不再提示 CLI 初始化。
|
||||
await expect(page.locator('.ad-notice')).toContainText(`已创建 ${newOwner}`)
|
||||
|
||||
// 契约断言三:用户表出现该账号,状态为未设密码(HTTP 建号不能设密码/授权限)。
|
||||
const row = page.locator('.ad-table tbody tr', { hasText: newOwner })
|
||||
@@ -78,6 +76,6 @@ test('管理员建号:UI 请求新契约并给出待初始化提示', async ({
|
||||
await expect(row).not.toContainText('ADMIN')
|
||||
|
||||
// 界面说明与后端流程一致(激活 Key 由服务器本地 CLI 发放)。
|
||||
await expect(page.locator('.ad-create .ad-hint')).toContainText('待初始化')
|
||||
await expect(page.locator('.ad-create .ad-hint')).toContainText('user initialize-account')
|
||||
await expect(page.locator('.ad-create .ad-hint')).toContainText('初始密码')
|
||||
await expect(page.locator('.ad-create .ad-hint')).not.toContainText('user initialize-account')
|
||||
})
|
||||
|
||||
+21
-30
@@ -1,7 +1,5 @@
|
||||
// 登录恢复流程真实浏览器验收(2026-10-13 合同,docs/svc-deploy/
|
||||
// 2026-10-13-login-recovery-contract.md):管理员签发一次性登录 Key →
|
||||
// 用户在登录页只输 Key 兑换 → 受限设密门(无当前密码框)→ 设密成功回登录页 →
|
||||
// 新密码登录进工作台;重复兑换同一 Key 被拒。
|
||||
// 管理员随机临时密码与登录入口回归:管理员重置 → 用户密码登录 → 强制改密;
|
||||
// 登录页不再提供一次性 Key 登录 tab。Key 的分发/管理 API 与非登录页流程保留。
|
||||
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
|
||||
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
|
||||
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
|
||||
@@ -53,23 +51,21 @@ async function adminLogin(page: Page) {
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新密码登录;重复兑换被拒', async ({ page }) => {
|
||||
test('管理员随机重置:临时密码登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
|
||||
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
|
||||
test.setTimeout(120_000)
|
||||
// 1) 管理员进入用户管理,为目标用户生成一次性登录 Key(明文仅显示一次)。
|
||||
// 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()
|
||||
// 每个用户行都有同款按钮:限定目标用户所在行。
|
||||
await zone.locator('tr', { hasText: USER }).getByRole('button', { name: '生成登录 Key' }).click()
|
||||
await zone.getByRole('button', { name: '生成 Key', exact: true }).click()
|
||||
const keyInput = zone.locator('input[aria-label="一次性登录 Key(仅显示一次)"]')
|
||||
await expect(keyInput).toBeVisible({ timeout: 10_000 })
|
||||
const key = await keyInput.inputValue()
|
||||
expect(key).toMatch(/^alk_/)
|
||||
await expect(zone.getByText(/单次有效/)).toBeVisible()
|
||||
const row = zone.locator('tr', { hasText: USER })
|
||||
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()
|
||||
expect(password.length).toBeGreaterThan(0)
|
||||
|
||||
// 2) 管理员退出系统管理与登录态,回到登录页。
|
||||
//(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
|
||||
@@ -78,35 +74,30 @@ test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新
|
||||
await page.getByRole('button', { name: '退出登录' }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// 3) 用户只输 Key 兑换;身份由服务端绑定确定(页面不出现用户名输入框)。
|
||||
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
|
||||
await page.getByPlaceholder('alk_…').fill(key)
|
||||
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
|
||||
|
||||
// 4) 受限设密门:服务端确认用户名;无当前密码输入框。
|
||||
// 3) 用户使用临时密码登录后进入强制改密门。
|
||||
await passwordLogin(page, USER, password)
|
||||
const gate = page.locator('.lg-card')
|
||||
await expect(gate.getByText(`账号 ${USER}(服务端确认)`)).toBeVisible({ timeout: 10_000 })
|
||||
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(0)
|
||||
await expect(gate.getByText(`账号 ${USER}`)).toBeVisible({ timeout: 10_000 })
|
||||
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(1)
|
||||
const pw = gate.locator('input[type="password"]')
|
||||
await pw.nth(0).fill(USER_PW)
|
||||
await pw.nth(0).fill(password)
|
||||
await pw.nth(1).fill(USER_PW)
|
||||
await pw.nth(2).fill(USER_PW)
|
||||
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||
|
||||
// 5) 成功页:提示用新密码登录,唯一出口回登录页。
|
||||
// 4) 成功页:提示用新密码登录,唯一出口回登录页。
|
||||
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible()
|
||||
|
||||
// 6) 新密码登录进工作台(旧初始密码已不可用一并验证)。
|
||||
// 5) 新密码登录进工作台。
|
||||
await passwordLogin(page, USER, USER_PW)
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// 7) 同一 Key 重复兑换被拒,错误文案指向管理员重签。
|
||||
// 6) 登录页不再提供一次性 Key 登录入口。
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '退出登录' }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
|
||||
await page.getByPlaceholder('alk_…').fill(key)
|
||||
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
|
||||
await expect(page.getByText('该 Key 已被使用过,请联系管理员重新签发')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByRole('tab', { name: '一次性登录 Key' })).toHaveCount(0)
|
||||
await expect(page.getByPlaceholder('alk_…')).toHaveCount(0)
|
||||
})
|
||||
|
||||
+3
-2
@@ -105,8 +105,9 @@ export const api = {
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: 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)),
|
||||
// 建号只创建待初始化账号(无密码、无权限位);初始凭证由服务器本地 CLI 发放激活 Key。
|
||||
createAdminUser: (owner: string, name: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name } }) }).then(r => json<{ owner: string; 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 ?? []),
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
type CockpitMeta, type CockpitNodeRef, type CockpitTreeNode,
|
||||
type CockpitVersionSummary, type Density, type PanelTabId,
|
||||
} from '../documentCockpit'
|
||||
import type { CitationView } from '../regCitations'
|
||||
import { CitedBody } from './RegCitation'
|
||||
|
||||
export interface ReaderSelection { ref: CockpitNodeRef; parentDocId?: string }
|
||||
|
||||
@@ -19,6 +21,8 @@ interface NodeDetailShape {
|
||||
meta: CockpitMeta
|
||||
node: CockpitTreeNode
|
||||
body?: CockpitContentBlock[]
|
||||
/** U7:章节正文 [cit-…] 标记的引用视图(契约 §3)。 */
|
||||
citations?: CitationView[] | null
|
||||
children: CockpitTreeNode[]
|
||||
sources: { items: any[] }
|
||||
history: { items: CockpitHistorySummary[]; hasMore: boolean; nextCursor?: string }
|
||||
@@ -118,7 +122,7 @@ export function CockpitReader({ pid, selection, sections, density, onBack }: {
|
||||
</div>
|
||||
{isSection && detail && (
|
||||
<>
|
||||
{renderBody(detail.body, detail.meta)}
|
||||
{renderBody(detail.body, detail.meta, detail.citations, `sec-${selection.ref.id}`)}
|
||||
<VersionBlock versions={detail.versions} density={density} />
|
||||
</>
|
||||
)}
|
||||
@@ -133,9 +137,13 @@ export function CockpitReader({ pid, selection, sections, density, onBack }: {
|
||||
? <span className="cockpit-badge">内容受限({sec.blocks[0].reason || '权限'})</span>
|
||||
: sec.contentState === 'empty' && <span className="cockpit-badge">空</span>}
|
||||
</div>
|
||||
{sec.blocks.map((b, i) => b.kind === 'text'
|
||||
? <p key={i} className="cockpit-reader-para">{b.text}</p>
|
||||
: <p key={i} className="empty-note">内容受限——标题与状态可见,正文不展示</p>)}
|
||||
{sec.blocks[0]?.kind === 'text' && (
|
||||
<CitedBody texts={sec.blocks.filter(b => b.kind === 'text').map(b => b.text || '')}
|
||||
citations={sectionDetails[sec.ref.id]?.citations} scope={`doc-${sec.ref.id}`} />
|
||||
)}
|
||||
{sec.blocks.map((b, i) => b.kind !== 'text' && (
|
||||
<p key={i} className="empty-note">内容受限——标题与状态可见,正文不展示</p>
|
||||
))}
|
||||
<VersionBlock versions={sec.versions} density={density} />
|
||||
</section>
|
||||
))}
|
||||
@@ -145,14 +153,14 @@ export function CockpitReader({ pid, selection, sections, density, onBack }: {
|
||||
)
|
||||
}
|
||||
|
||||
function renderBody(body: CockpitContentBlock[] | undefined, meta: CockpitMeta) {
|
||||
function renderBody(body: CockpitContentBlock[] | undefined, meta: CockpitMeta, citations: CitationView[] | null | undefined, scope: string) {
|
||||
if (!body || body.length === 0) return <div className="empty-note">暂无正文</div>
|
||||
if (body[0].kind === 'restricted') {
|
||||
return <div className="empty-note">内容受限({body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{body.map((b, i) => <p key={i} className="cockpit-reader-para">{b.text}</p>)}
|
||||
<CitedBody texts={body.map(b => b.text || '')} citations={citations} scope={scope} />
|
||||
{meta.truncated && <div className="cockpit-truncated">详情已截断:{meta.truncationReasons?.join(';')}</div>}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
|
||||
// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(恢复/激活,只输 Key);
|
||||
// LoginPage —— 用户名+密码唯一入口;
|
||||
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
|
||||
// grant_setup 免当前密码(激活/恢复 Key 兑换),current_required 必须校验当前密码
|
||||
//(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
|
||||
@@ -33,10 +33,8 @@ function BrandPanel() {
|
||||
* 长期 API 密钥(ask_)不再作为浏览器日常登录入口——程序化客户端直接带
|
||||
* Authorization 头;浏览器保存长期密钥属于持久凭据暴露面。 */
|
||||
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
|
||||
const [tab, setTab] = useState<'password' | 'loginkey'>('password')
|
||||
const [owner, setOwner] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [key, setKey] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
@@ -56,51 +54,20 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const submitKey = async () => {
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
// 只提交 Key:目标账户由服务端绑定记录确定,客户端不能指定身份。
|
||||
const me = await api.redeemLoginKey(key.trim())
|
||||
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
|
||||
onMustChange(me.owner)
|
||||
return
|
||||
}
|
||||
onAuthed()
|
||||
} catch (e) {
|
||||
setErr(humanRedeemError(String(e)))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lg-wrap">
|
||||
<BrandPanel />
|
||||
<div className="lg-formside">
|
||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
|
||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); submitPassword() }}>
|
||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||
<h2 className="lg-title">登录</h2>
|
||||
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员签发一次性登录 Key</p>
|
||||
<div className="lg-tabs" role="tablist">
|
||||
<button type="button" role="tab" aria-selected={tab === 'password'} className={`lg-tab ${tab === 'password' ? 'on' : ''}`} onClick={() => setTab('password')}>账号密码</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'loginkey'} className={`lg-tab ${tab === 'loginkey' ? 'on' : ''}`} onClick={() => setTab('loginkey')}>一次性登录 Key</button>
|
||||
</div>
|
||||
{tab === 'password' ? (
|
||||
<>
|
||||
<label className="lg-label">用户名</label>
|
||||
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
|
||||
<label className="lg-label">密码</label>
|
||||
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="lg-label">一次性登录 Key</label>
|
||||
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="alk_…" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '兑换中…' : '兑换并设置新密码'}</button>
|
||||
<p className="lg-hint">忘记密码时由管理员发起恢复提案,并由你在已登录设备的「设置 → 账号」中本人确认后获得;新账号使用管理员交付的激活 Key。单次有效、限时兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。</p>
|
||||
</>
|
||||
)}
|
||||
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员重置临时密码</p>
|
||||
<label className="lg-label">用户名</label>
|
||||
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
|
||||
<label className="lg-label">密码</label>
|
||||
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,13 @@ import {
|
||||
} from '../rdmAuditTree'
|
||||
import { store, useStore } from '../store'
|
||||
import { exportNeedsDraft } from '../workspaces'
|
||||
import {
|
||||
CITATION_CHIP_TONE, citationStateLabel, citationStyle, legStateLabel, REG_TREE_KIND_LABELS,
|
||||
regsTreeParams, sealStateLabel,
|
||||
type RegBatchDetail, type RegCitationDetail,
|
||||
} from '../regCitations'
|
||||
import { DocumentRenderWorkspace, saveBase64File } from './DocumentRenderWorkspace'
|
||||
import { CitationCard, CitationListItem, CitationStateChips } from './RegCitation'
|
||||
|
||||
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
|
||||
|
||||
@@ -289,7 +295,7 @@ function ExportSection({ pid }: { pid: string }) {
|
||||
// ---- 资料与文档 ----
|
||||
|
||||
function MaterialsTab({ pid }: { pid: string }) {
|
||||
const [kind, setKind] = useState<'notes' | 'docs'>('docs')
|
||||
const [kind, setKind] = useState<'notes' | 'docs' | 'regs'>('docs')
|
||||
// 打开文档时树列表保持挂载(只隐藏),返回后展开状态不丢。
|
||||
const [openDoc, setOpenDoc] = useState<TreeItem | null>(null)
|
||||
return (
|
||||
@@ -301,13 +307,195 @@ function MaterialsTab({ pid }: { pid: string }) {
|
||||
<div className="tabs">
|
||||
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
|
||||
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
|
||||
<button className={`tab${kind === 'regs' ? ' active' : ''}`} onClick={() => setKind('regs')}>法规核验</button>
|
||||
</div>
|
||||
<TreeList pid={pid} kind={kind} onOpenDoc={setOpenDoc} />
|
||||
{kind === 'regs' ? <RegsTreeList pid={pid} /> : <TreeList pid={pid} kind={kind} onOpenDoc={setOpenDoc} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 法规核验树(U7:rdm.inspect.tree kind=regs;根层证据批,parent=<batchId> 列引用) ----
|
||||
|
||||
function RegTreeNode({ item, selected, onSelect }: { item: TreeItem; selected: boolean; onSelect: () => void }) {
|
||||
return (
|
||||
<button className={`rdm-audit-tree-node${selected ? ' sel' : ''}`} onClick={onSelect}>
|
||||
<span className="rdm-audit-tree-kind">{REG_TREE_KIND_LABELS[item.kind] || item.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{item.title || item.id}</span>
|
||||
{item.state && <Chip tone={CITATION_CHIP_TONE[citationStyle({ state: item.state })]}>{citationStateLabel(item.state)}</Chip>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function RegsTreeList({ pid }: { pid: string }) {
|
||||
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [children, setChildren] = useState<Record<string, ListShell<TreeItem>>>({})
|
||||
const [selected, setSelected] = useState<TreeItem | null>(null)
|
||||
const [detail, setDetail] = useState<RegBatchDetail | RegCitationDetail | null>(null)
|
||||
const [detailErr, setDetailErr] = useState('')
|
||||
// genRef:项目切换代际(树与子页);selRef:选中代际(快速切换时丢弃旧详情响应)。
|
||||
const genRef = useRef(0)
|
||||
const selRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const gen = ++genRef.current
|
||||
selRef.current++
|
||||
setShell(null); setErr(''); setSelected(null); setDetail(null); setDetailErr(''); setChildren({})
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid))
|
||||
.then(s => { if (gen === genRef.current) setShell({ ...s, items: s.items || [] }) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid])
|
||||
|
||||
const loadMore = () => {
|
||||
if (!shell?.nextCursor) return
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid, { 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 loadChildren = (batchId: string, cursor?: string) => {
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid, { parent: batchId, cursor }))
|
||||
.then(s => {
|
||||
if (gen !== genRef.current) return
|
||||
setChildren(prev => {
|
||||
const items = cursor ? [...(prev[batchId]?.items || []), ...(s.items || [])] : (s.items || [])
|
||||
return { ...prev, [batchId]: { ...s, items } }
|
||||
})
|
||||
})
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
const toggleBatch = (item: TreeItem) => {
|
||||
if (children[item.id]) {
|
||||
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
|
||||
return
|
||||
}
|
||||
loadChildren(item.id)
|
||||
}
|
||||
|
||||
const selectNode = (item: TreeItem) => {
|
||||
setSelected(item); setDetail(null); setDetailErr('')
|
||||
const gen = genRef.current
|
||||
const sel = ++selRef.current
|
||||
const current = () => gen === genRef.current && sel === selRef.current
|
||||
api.rdmCommand<RegBatchDetail | RegCitationDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
|
||||
.then(d => { if (current()) setDetail(d) })
|
||||
.catch(e => { if (current()) setDetailErr(e.message) })
|
||||
}
|
||||
|
||||
if (err) return <RdmErrorState raw={err} />
|
||||
if (!shell) return <div className="empty-note">加载中…</div>
|
||||
if (shell.items.length === 0) return <EmptyState icon="folder">暂无法规核验证据批</EmptyState>
|
||||
|
||||
return (
|
||||
<div className="rdm-audit-tree">
|
||||
<div className="rdm-audit-tree-list">
|
||||
{shell.items.map(item => {
|
||||
const sub = children[item.id]
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<div className="regcite-tree-row">
|
||||
<button type="button" className="regcite-tree-caret" aria-label={sub ? '收起引用' : '展开引用'}
|
||||
aria-expanded={!!sub} onClick={() => toggleBatch(item)}>{sub ? '▾' : '▸'}</button>
|
||||
<RegTreeNode item={item} selected={selected?.id === item.id} onSelect={() => selectNode(item)} />
|
||||
</div>
|
||||
{sub && (
|
||||
<div className="rdm-audit-tree-children">
|
||||
{sub.items.length === 0 && <div className="empty-note">无引用</div>}
|
||||
{sub.items.map(c => (
|
||||
<RegTreeNode key={c.id} item={c} selected={selected?.id === c.id} onSelect={() => selectNode(c)} />
|
||||
))}
|
||||
{sub.hasMore && (
|
||||
<button className="btn v-ghost rdm-audit-more" onClick={() => loadChildren(item.id, sub.nextCursor)}>加载更多引用</button>
|
||||
)}
|
||||
{sub.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
|
||||
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="rdm-audit-detail">
|
||||
<SectionLabel>核验详情</SectionLabel>
|
||||
{detailErr ? <RdmErrorState raw={detailErr} />
|
||||
: !detail ? <div className="empty-note">加载中…</div>
|
||||
: selected.kind === 'reg-batch'
|
||||
? <RegBatchView item={selected} detail={detail as RegBatchDetail} />
|
||||
: <RegCitationView item={selected} detail={detail as RegCitationDetail} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RegBatchView({ item, detail }: { item: TreeItem; detail: RegBatchDetail }) {
|
||||
const reasons = detail.stateReasons || []
|
||||
const citations = detail.citations || []
|
||||
const resolutions = detail.resolutions || []
|
||||
return (
|
||||
<div className="rdm-audit-detail-body">
|
||||
<div><strong>{detail.title || item.title || item.id}</strong></div>
|
||||
<div className="regcite-mono">reg-batch:{item.id}</div>
|
||||
<div>状态:<CitationStateChips c={{ state: detail.state }} /></div>
|
||||
{reasons.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel>状态原因</SectionLabel>
|
||||
<ul className="regcite-reasons">{reasons.map((r, i) => <li key={i}>{r}</li>)}</ul>
|
||||
</div>
|
||||
)}
|
||||
{detail.legStates && (
|
||||
<div>
|
||||
<SectionLabel>来源腿</SectionLabel>
|
||||
<dl className="regcite-fields">
|
||||
<dt>知识库</dt><dd>{legStateLabel(detail.legStates.kb, detail.legStates.kbReason)}</dd>
|
||||
<dt>网络</dt><dd>{legStateLabel(detail.legStates.web, detail.legStates.webReason)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
{detail.seal && (
|
||||
<div>
|
||||
<SectionLabel>封存</SectionLabel>
|
||||
<dl className="regcite-fields">
|
||||
<dt>状态</dt><dd>{sealStateLabel(detail.seal.state)}</dd>
|
||||
<dt>密钥</dt><dd className="regcite-mono">{detail.seal.keyId || '—'}</dd>
|
||||
<dt>清单哈希</dt><dd className="regcite-mono">{detail.seal.manifestSha256 || '—'}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<SectionLabel>引用({citations.length})</SectionLabel>
|
||||
{citations.length === 0 && <div className="empty-note">该批没有引用</div>}
|
||||
{citations.map(c => <CitationListItem key={c.id} id={c.id} citation={c} scope={`audit-${item.id}`} />)}
|
||||
</div>
|
||||
{resolutions.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel>人工裁决({resolutions.length})</SectionLabel>
|
||||
{resolutions.map((r, i) => (
|
||||
<div key={i} className="rdm-audit-version">
|
||||
{r.decision} · {r.actor} · {new Date(r.at).toLocaleString('zh-CN')}{r.note ? ` · ${r.note}` : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RegCitationView({ item, detail }: { item: TreeItem; detail: RegCitationDetail }) {
|
||||
return (
|
||||
<div className="rdm-audit-detail-body">
|
||||
<div><strong>{detail.title || item.title || item.id}</strong></div>
|
||||
<CitationCard id={item.id} citation={detail.citation} scope="audit" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeList({ pid, kind, onOpenDoc }: { pid: string; kind: 'notes' | 'docs'; onOpenDoc: (doc: TreeItem) => void }) {
|
||||
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// U7 法规结构化引用的共享呈现:编号标记、引用卡片、引用清单(审计工作区与驾驶舱阅读器共用)。
|
||||
// 正文与原文片段一律作为文本节点渲染,不经 HTML。非 verified 状态用未核验样式,invalid 用无效样式;
|
||||
// 折叠只用 @ui Collapsible;颜色/字号/圆角只消费 @ui/tokens.css(见 reg-citations.css)。
|
||||
import { Fragment, useState, type ReactNode } from 'react'
|
||||
import { Chip, Collapsible } from '@ui'
|
||||
import {
|
||||
CITATION_CHIP_TONE, citationListOrder, citationMarkText, citationStateLabel, citationStyle,
|
||||
disclosureLabel, excerptView, parseCitedBlocks, safeWebHref, staleLabel,
|
||||
type CitationView,
|
||||
} from '../regCitations'
|
||||
import '../reg-citations.css'
|
||||
|
||||
/** 状态徽标:核验状态 + 过期「待复核」。 */
|
||||
export function CitationStateChips({ c }: { c: { state?: string; stale?: boolean; staleReason?: string } }) {
|
||||
const stale = staleLabel(c)
|
||||
return (
|
||||
<span className="regcite-chips">
|
||||
<Chip tone={CITATION_CHIP_TONE[citationStyle({ state: c.state })]}>{citationStateLabel(c.state)}</Chip>
|
||||
{stale && <Chip tone="warn">{stale}</Chip>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function citationHeading(c: CitationView): string {
|
||||
const parts = [c.docNo, c.clauseNo].filter(Boolean)
|
||||
return parts.length > 0 ? parts.join(' ') : (c.title || c.id)
|
||||
}
|
||||
|
||||
function formatTime(at: string | undefined): string {
|
||||
if (!at) return '—'
|
||||
const d = new Date(at)
|
||||
return Number.isNaN(d.getTime()) ? at : d.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function Excerpt({ id, label, text, redaction }: { id: string; label: string; text?: string; redaction?: string }) {
|
||||
if (!text) return <div className="regcite-redaction">{label}未展示:{redaction}</div>
|
||||
return (
|
||||
<Collapsible id={id} trigger={<span className="regcite-toggle">{label}</span>}>
|
||||
<blockquote className="regcite-excerpt">{text}</blockquote>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
/** 引用卡片:元数据 + 可展开原文片段(受披露约束);缺失或 invalid 的 ID 只给无效说明。 */
|
||||
export function CitationCard({ id, citation, scope }: { id: string; citation?: CitationView; scope: string }) {
|
||||
const style = citationStyle(citation)
|
||||
if (!citation || style === 'invalid') {
|
||||
return (
|
||||
<div className="regcite-card regcite-card-invalid">
|
||||
<div className="regcite-card-head">
|
||||
<Chip tone="danger">无效引用</Chip>
|
||||
<span className="regcite-mono">{id}</span>
|
||||
</div>
|
||||
<div className="regcite-note">
|
||||
{citation ? '引用 ID 无法解析为证据批' : '本节引用清单中没有该引用'}——不得作为法律依据
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const web = citation.web
|
||||
const href = safeWebHref(web?.finalUrl || web?.url)
|
||||
const fields: [string, ReactNode][] = []
|
||||
const add = (label: string, v: ReactNode) => { if (v !== undefined && v !== null && v !== '') fields.push([label, v]) }
|
||||
add('文号', citation.docNo)
|
||||
add('标题', citation.title)
|
||||
add('版本', citation.editionLabel)
|
||||
add('效力状态', citation.legalStatus)
|
||||
add('条款', citation.clauseNo)
|
||||
add('章节路径', citation.headingPath?.length ? citation.headingPath.join(' › ') : undefined)
|
||||
add('语料修订', citation.corpusRevision && <span className="regcite-mono">{citation.corpusRevision}</span>)
|
||||
add('原文披露', citation.disclosure && disclosureLabel(citation.disclosure))
|
||||
if (web) {
|
||||
add('网络来源', href
|
||||
? <a className="regcite-link" href={href} target="_blank" rel="noopener noreferrer">{web.finalUrl || web.url}</a>
|
||||
: web.finalUrl || web.url)
|
||||
add('官方来源', web.official ? '是' : '否')
|
||||
add('抓取时间', formatTime(web.fetchedAt))
|
||||
}
|
||||
add('证据批', citation.batchId && <span className="regcite-mono">{citation.batchId}</span>)
|
||||
add('引用 ID', <span className="regcite-mono">{citation.id}</span>)
|
||||
const ex = excerptView(citation)
|
||||
return (
|
||||
<div className={`regcite-card regcite-card-${style}`}>
|
||||
<div className="regcite-card-head">
|
||||
<span className="regcite-card-title">{citationHeading(citation)}</span>
|
||||
<CitationStateChips c={citation} />
|
||||
</div>
|
||||
{style !== 'valid' && <div className="regcite-note">未核验引用——不得作为有效法律依据</div>}
|
||||
{citation.claim && <div className="regcite-claim">主张:{citation.claim}</div>}
|
||||
<dl className="regcite-fields">
|
||||
{fields.map(([label, v]) => (
|
||||
<Fragment key={label}><dt>{label}</dt><dd>{v}</dd></Fragment>
|
||||
))}
|
||||
</dl>
|
||||
<Excerpt id={`regcite-excerpt-${scope}-${citation.id}`} label="原文片段"
|
||||
text={'excerpt' in ex ? ex.excerpt : undefined}
|
||||
redaction={'redaction' in ex ? ex.redaction : undefined} />
|
||||
{web?.excerpt && (
|
||||
<Excerpt id={`regcite-web-${scope}-${citation.id}`} label="网页摘录" text={web.excerpt} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 引用清单一行:摘要触发 + 折叠卡片。 */
|
||||
export function CitationListItem({ id, n, citation, scope }: { id: string; n?: number; citation?: CitationView; scope: string }) {
|
||||
const style = citationStyle(citation)
|
||||
return (
|
||||
<div className={`regcite-item regcite-item-${style}`}>
|
||||
<Collapsible id={`regcite-item-${scope}-${id}`} trigger={
|
||||
<span className="regcite-item-head">
|
||||
{n !== undefined && <span className="regcite-item-num">[{n}]</span>}
|
||||
<span className="regcite-item-title">{citation && style !== 'invalid' ? citationHeading(citation) : id}</span>
|
||||
{citation && style !== 'invalid' && citation.title && citation.title !== citationHeading(citation) && (
|
||||
<span className="regcite-item-sub">{citation.title}</span>
|
||||
)}
|
||||
{citation && style !== 'invalid'
|
||||
? <CitationStateChips c={citation} />
|
||||
: <Chip tone="danger">无效引用</Chip>}
|
||||
</span>
|
||||
}>
|
||||
<CitationCard id={id} citation={citation} scope={scope} />
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 带引用的正文:[cit-…] 渲染为编号标记,点击在段落下展开引用卡片;正文下附引用清单。 */
|
||||
export function CitedBody({ texts, citations, scope, paraClassName = 'cockpit-reader-para' }: {
|
||||
texts: string[]
|
||||
citations?: CitationView[] | null
|
||||
scope: string
|
||||
paraClassName?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState<string | null>(null)
|
||||
const { blocks, order } = parseCitedBlocks(texts)
|
||||
const index = new Map((citations || []).map(c => [c.id, c]))
|
||||
const list = citationListOrder(order, citations)
|
||||
return (
|
||||
<>
|
||||
{blocks.map((segs, bi) => {
|
||||
const ids = [...new Set(segs.flatMap(s => (s.kind === 'cite' ? [s.id] : [])))]
|
||||
if (ids.length === 0) {
|
||||
return <p key={bi} className={paraClassName}>{segs.map(s => (s.kind === 'text' ? s.text : '')).join('')}</p>
|
||||
}
|
||||
return (
|
||||
<div key={bi} className="regcite-block">
|
||||
<p className={paraClassName}>
|
||||
{segs.map((s, si) => {
|
||||
if (s.kind === 'text') return <Fragment key={si}>{s.text}</Fragment>
|
||||
const c = index.get(s.id)
|
||||
const key = `${bi}:${s.id}`
|
||||
const isOpen = open === key
|
||||
return (
|
||||
<button key={si} type="button"
|
||||
className={`regcite-mark regcite-${citationStyle(c)}${isOpen ? ' regcite-mark-open' : ''}`}
|
||||
aria-expanded={isOpen}
|
||||
title={`引用 ${s.n}:${c ? citationStateLabel(c.state) : '无效引用'}`}
|
||||
onClick={() => setOpen(isOpen ? null : key)}>
|
||||
[{citationMarkText(s.n, c)}]
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
{ids.map(id => (
|
||||
<Collapsible key={id} open={open === `${bi}:${id}`}>
|
||||
<CitationCard id={id} citation={index.get(id)} scope={`${scope}-inline`} />
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{list.length > 0 && (
|
||||
<div className="regcite-list">
|
||||
<div className="regcite-list-head">引用({list.length})</div>
|
||||
{list.map((id, i) => (
|
||||
<CitationListItem key={id} id={id} n={i + 1} citation={index.get(id)} scope={scope} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -147,8 +147,14 @@ export function RdmRolesPanel() {
|
||||
|
||||
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
|
||||
|
||||
import {
|
||||
type MemberMatchMode, type PersonPickItem, type RdmAccount, type RdmMemberPage, type RdmProfRole,
|
||||
MATCH_MODE_LABELS, accountListParams, accountsToPickItems, appendMemberPage, memberListParams,
|
||||
personLabel, roleOptionsPayload,
|
||||
} from '../rdmManagement'
|
||||
|
||||
interface MemberRow {
|
||||
owner: string; role: string; addedBy: string; addedAt: string
|
||||
owner: string; name?: string; role: string; addedBy: string; addedAt: string
|
||||
profRole?: { roleId: string; name: string } | null
|
||||
}
|
||||
|
||||
@@ -157,42 +163,78 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
const writeError = projectWorkError(project)
|
||||
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
||||
const [rows, setRows] = useState<MemberRow[] | null>(null)
|
||||
const [memberNext, setMemberNext] = useState('')
|
||||
const [memberTotal, setMemberTotal] = useState<number | null>(null)
|
||||
const [directoryOk, setDirectoryOk] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [memberQuery, setMemberQuery] = useState('')
|
||||
const [memberRoleId, setMemberRoleId] = useState('')
|
||||
// null=未加载或 rdm.role.options 失败:两处专业角色筛选均不渲染(不给误导选项)。
|
||||
const [roleOptions, setRoleOptions] = useState<RdmProfRole[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [picked, setPicked] = useState<string[]>([])
|
||||
const [pickedItems, setPickedItems] = useState<PersonPickItem[]>([])
|
||||
const [candRoleId, setCandRoleId] = useState('')
|
||||
const [candMatch, setCandMatch] = useState<MemberMatchMode>('fuzzy')
|
||||
const [role, setRole] = useState<'member' | 'owner'>('member')
|
||||
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
|
||||
const rowsRef = useRef<MemberRow[] | null>(null)
|
||||
rowsRef.current = rows
|
||||
const knownPicks = useRef(new Map<string, PersonPickItem>())
|
||||
// 成员表请求代次:切项目/切筛选推进代次,迟到响应丢弃。
|
||||
const memberGen = useRef(0)
|
||||
|
||||
/** 人员选择器取数适配(P7-A):rdm.account.list 目录(member.manage 生效者)。
|
||||
* query 透传后端(基座升级 AccountSearcher 后全量生效);旧基座忽略 query 时
|
||||
* 对首页做本地包含匹配降级(翻页为浏览模式,不过滤)。 */
|
||||
const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => {
|
||||
const out = await cmd<{ accounts?: { owner: string; name?: string; valid: boolean }[]; next?: string }>(
|
||||
'rdm.account.list', { projectId, cursor, limit, query })
|
||||
const joined = new Set((rowsRef.current ?? []).map(m => m.owner))
|
||||
let items = (out.accounts ?? []).filter(a => a.valid).map(a => ({
|
||||
id: a.owner,
|
||||
title: a.name || a.owner,
|
||||
subtitle: a.owner,
|
||||
badge: joined.has(a.owner) ? '已加入' : undefined,
|
||||
}))
|
||||
if (query !== '' && cursor === '') {
|
||||
const q = query.toLowerCase()
|
||||
items = items.filter(it => it.id.toLowerCase().includes(q) || it.title.toLowerCase().includes(q))
|
||||
}
|
||||
return { items, nextCursor: out.next || undefined }
|
||||
useEffect(() => {
|
||||
setPicked([]); setPickedItems([]); knownPicks.current.clear()
|
||||
setCandRoleId(''); setCandMatch('fuzzy'); setMemberQuery(''); setMemberRoleId('')
|
||||
setRoleOptions(null)
|
||||
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])
|
||||
|
||||
const refresh = async () => {
|
||||
/** 人员选择器取数适配: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])
|
||||
|
||||
/** cursor 空=重载首页(clear 时先清空表格);非空=追加下一页。 */
|
||||
const loadMembers = useCallback(async (cursor: string, clear: boolean) => {
|
||||
const gen = cursor ? memberGen.current : ++memberGen.current
|
||||
if (cursor) setLoadingMore(true)
|
||||
else {
|
||||
setLoadingMore(false)
|
||||
if (clear) { setErr(''); setRows(null); setMemberNext(''); setMemberTotal(null) }
|
||||
}
|
||||
try {
|
||||
const out = await cmd<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
|
||||
setRows(out.members ?? [])
|
||||
} catch (e) { setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { setPicked([]); refresh() }, [project])
|
||||
const out = await cmd<RdmMemberPage>('rdm.project.member.list',
|
||||
memberListParams(projectId, { query: memberQuery, roleId: memberRoleId, cursor }))
|
||||
if (gen !== memberGen.current) return
|
||||
setRows(prev => appendMemberPage(cursor ? prev : null, out.members))
|
||||
setMemberNext(out.next || '')
|
||||
setMemberTotal(typeof out.total === 'number' ? out.total : null)
|
||||
setDirectoryOk(out.accountDirectoryAvailable !== false)
|
||||
} catch (e) {
|
||||
if (gen === memberGen.current) setErr(String(e))
|
||||
} finally {
|
||||
if (cursor && gen === memberGen.current) setLoadingMore(false)
|
||||
}
|
||||
}, [project, memberQuery, memberRoleId])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => { void loadMembers('', true) }, memberQuery.trim() ? 250 : 0)
|
||||
return () => { clearTimeout(timer); ++memberGen.current }
|
||||
}, [loadMembers])
|
||||
|
||||
const refresh = () => loadMembers('', false)
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
@@ -200,38 +242,76 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const onPick = (ids: string[]) => {
|
||||
setPicked(ids)
|
||||
setPickedItems(ids.flatMap(id => {
|
||||
const it = knownPicks.current.get(id)
|
||||
return it ? [it] : []
|
||||
}))
|
||||
}
|
||||
|
||||
const pickedId = picked[0] ?? ''
|
||||
const pickedItem = pickedItems.find(it => it.id === pickedId)
|
||||
const pickedValid = !!pickedId && !pickedItem?.disabled && !(rows ?? []).some(m => m.owner === pickedId)
|
||||
const pickerDisabled = busy || !!writeError
|
||||
const filtering = memberQuery.trim() !== '' || memberRoleId !== ''
|
||||
|
||||
const roleFilter = (value: string, onChange: (v: string) => void, label: string) =>
|
||||
roleOptions && roleOptions.length > 0 ? (
|
||||
<select className="lg-input" aria-label={label} value={value} onChange={e => onChange(e.target.value)}>
|
||||
<option value="">全部专业角色</option>
|
||||
{roleOptions.map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||||
</select>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div>
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
|
||||
<select className="lg-input" aria-label="匹配方式" value={candMatch}
|
||||
onChange={e => setCandMatch(e.target.value as MemberMatchMode)}>
|
||||
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m =>
|
||||
<option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<Picker
|
||||
aria-label="选择人员"
|
||||
selected={picked}
|
||||
onSelect={setPicked}
|
||||
onSelect={onPick}
|
||||
selectedItems={pickedItems}
|
||||
fetchPage={fetchPage}
|
||||
reloadKey={`${projectId}|${candRoleId}|${candMatch}`}
|
||||
disabled={pickerDisabled}
|
||||
placeholder="输入账号或姓名,或浏览候选…"
|
||||
/>
|
||||
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
||||
<option value="member">项目成员</option>
|
||||
<option value="owner">项目负责人</option>
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || !pickedId}
|
||||
onClick={() => run(`已添加 ${pickedId}`, async () => {
|
||||
<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))
|
||||
setPicked([])
|
||||
setPicked([]); setPickedItems([])
|
||||
})}>添加成员</button>
|
||||
</div>
|
||||
<p className="ad-hint">候选来自系统账号目录(姓名为空时显示账号);「已加入」标记避免重复添加造成隐式变更——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
|
||||
<p className="ad-hint">候选来自系统账号目录(姓名为空时显示账号),无效账号不提供;已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
|
||||
onChange={e => setMemberQuery(e.target.value)} />
|
||||
{roleFilter(memberRoleId, setMemberRoleId, '成员专业角色筛选')}
|
||||
{rows !== null && <span className="ad-dim">共 {memberTotal ?? rows.length} 名成员匹配</span>}
|
||||
</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>
|
||||
<tbody>
|
||||
{(rows ?? []).map(m => (
|
||||
<tr key={m.owner}>
|
||||
<td><span className="ad-mono">{m.owner}</span>{m.profRole?.name ? <span className="ad-dim"> · {m.profRole.name}</span> : null}</td>
|
||||
<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 className="ad-dim">{m.addedBy || '—'}</td>
|
||||
@@ -249,10 +329,16 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无成员</td></tr>}
|
||||
{rows === null && <tr><td colSpan={6} className="ad-dim">加载中…</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>}
|
||||
</tbody>
|
||||
</table>
|
||||
{memberNext && rows !== null && (
|
||||
<div className="ad-row">
|
||||
<button className="ad-mini" disabled={loadingMore}
|
||||
onClick={() => loadMembers(memberNext, false)}>{loadingMore ? '加载中…' : '加载更多成员'}</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>
|
||||
|
||||
@@ -11,10 +11,8 @@ import type { SettingDesc } from '../contracts'
|
||||
import { docBridge } from './Workbench'
|
||||
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery'
|
||||
|
||||
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制):建号仅创建待初始化
|
||||
// 账号(HTTP 契约不含密码与管理员位),初始凭证由服务器本地 CLI 发放激活 Key;
|
||||
// 恢复走属主确认制提案:管理员提案 → 用户在「设置 → 账号」本人确认 → 一次性 Key
|
||||
// 只在属主会话内出现(直接签发 Key 的 HTTP 入口已被 410 替换)。----
|
||||
// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码;
|
||||
// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ----
|
||||
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
||||
|
||||
export function UsersPanel() {
|
||||
@@ -24,6 +22,8 @@ export function UsersPanel() {
|
||||
const [notice, setNotice] = useState('')
|
||||
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 [busy, setBusy] = useState(false)
|
||||
// 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。
|
||||
const [propFor, setPropFor] = useState<AdminUserRow | null>(null)
|
||||
@@ -41,13 +41,24 @@ export function UsersPanel() {
|
||||
setErr(''); setNotice('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const out = await api.createAdminUser(owner.trim(), name.trim())
|
||||
setNotice(`已创建 ${out.owner}(待初始化):请在服务器执行 user initialize-account 发放激活 Key`)
|
||||
setOwner(''); setName('')
|
||||
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined)
|
||||
setGeneratedCredential(out.initialPassword ? { owner: out.owner, password: out.initialPassword, kind: 'initial' } : null)
|
||||
setNotice(out.initialPassword ? `已创建 ${out.owner},请将下方一次性初始密码交付给用户。首次登录后必须改密。` : `已创建 ${out.owner}。用户首次登录后必须改密。`)
|
||||
setOwner(''); setName(''); setInitialPassword('')
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const resetPassword = 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} 生成临时密码。该用户登录后必须立即修改密码。`)
|
||||
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)
|
||||
@@ -60,7 +71,13 @@ export function UsersPanel() {
|
||||
await api.createAdminRecoveryProposal(propFor.userId, propFor.stateVersion, crypto.randomUUID())
|
||||
setNotice(`已为 ${propFor.owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现`)
|
||||
await refreshProposals(propFor.userId)
|
||||
} catch (e) { setErr(recoveryProposalErrorMessage(String(e))) } finally { setBusy(false) }
|
||||
} catch (e) {
|
||||
if (String(e).includes('409')) {
|
||||
await resetPassword(propFor)
|
||||
return
|
||||
}
|
||||
setErr(recoveryProposalErrorMessage(String(e)))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const revokeProposal = async (p: RecoveryProposal) => {
|
||||
@@ -79,12 +96,18 @@ export function UsersPanel() {
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
|
||||
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
|
||||
<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>
|
||||
<p className="ad-hint">账号创建后处于待初始化状态:在服务器执行 user initialize-account 发放一次性激活 Key,用户经兑换入口设置密码后登录</p>
|
||||
<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>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
{propFor ? (
|
||||
<div className="ad-reset">
|
||||
@@ -125,9 +148,10 @@ export function UsersPanel() {
|
||||
<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 && (
|
||||
<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>
|
||||
)}</td>
|
||||
</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
|
||||
@@ -27,6 +27,7 @@ const PROJECT_READ_COMMANDS = new Set([
|
||||
'rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix',
|
||||
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get',
|
||||
'rdm.duty.matrix', 'rdm.duty.preview', 'rdm.duty.legacy.scan',
|
||||
'rdm.role.options',
|
||||
])
|
||||
|
||||
export function rdmProjectCommandError(project: Project, command: string): string | null {
|
||||
@@ -168,6 +169,100 @@ export const commandRequestBody = (params: Record<string, unknown>, source?: Com
|
||||
? { params, conversationId: source.conversationId, anchorMessageId: source.anchorMessageId, entrance: source.entrance }
|
||||
: { params }
|
||||
|
||||
// ---- 项目成员页:候选检索 + 成员表检索(R2 契约:服务端先过滤后分页) ----
|
||||
|
||||
export type MemberMatchMode = 'fuzzy' | 'exact'
|
||||
export interface RdmProfRole { roleId: string; name: string }
|
||||
/** rdm.account.list 单条候选。 */
|
||||
export interface RdmAccount {
|
||||
owner: string; name?: string; valid: boolean; joined?: boolean
|
||||
projectRole?: string; profRole?: RdmProfRole | null
|
||||
}
|
||||
/** rdm.project.member.list 单条成员。 */
|
||||
export interface RdmMemberRow {
|
||||
owner: string; name?: string; role: string; addedBy: string; addedAt: string
|
||||
profRole?: RdmProfRole | null
|
||||
}
|
||||
export interface RdmMemberPage {
|
||||
members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean
|
||||
}
|
||||
/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库)。 */
|
||||
export interface PersonPickItem {
|
||||
id: string; title: string; subtitle?: string; disabled?: boolean; disabledReason?: string
|
||||
}
|
||||
|
||||
export const MEMBER_PAGE_LIMIT = 50
|
||||
export const JOINED_REASON = '已加入项目'
|
||||
export const MATCH_MODE_LABELS: Record<MemberMatchMode, string> = {
|
||||
fuzzy: '模糊匹配',
|
||||
exact: '精确账号或姓名',
|
||||
}
|
||||
|
||||
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
|
||||
}) {
|
||||
const query = opts.query?.trim() ?? ''
|
||||
return {
|
||||
projectId,
|
||||
limit: opts.limit,
|
||||
match: opts.match ?? 'fuzzy',
|
||||
...(query ? { query } : {}),
|
||||
...(opts.cursor ? { cursor: opts.cursor } : {}),
|
||||
...(opts.roleId ? { roleId: opts.roleId } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function memberListParams(projectId: string, opts: {
|
||||
query?: string; roleId?: string; cursor?: string; limit?: number
|
||||
match?: MemberMatchMode; projectRole?: 'owner' | 'member'
|
||||
} = {}) {
|
||||
const query = opts.query?.trim() ?? ''
|
||||
return {
|
||||
projectId,
|
||||
limit: opts.limit ?? MEMBER_PAGE_LIMIT,
|
||||
...(query ? { query } : {}),
|
||||
...(opts.roleId ? { roleId: opts.roleId } : {}),
|
||||
...(opts.cursor ? { cursor: opts.cursor } : {}),
|
||||
...(opts.match ? { match: opts.match } : {}),
|
||||
...(opts.projectRole ? { projectRole: opts.projectRole } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** 人员标签:「姓名 · 账号」,姓名为空时只显示账号。 */
|
||||
export function personLabel(p: { owner: string; name?: string }): string {
|
||||
const name = p.name?.trim()
|
||||
return name && name !== p.owner ? `${name} · ${p.owner}` : p.owner
|
||||
}
|
||||
|
||||
/** 候选映射:无效账号不提供;已加入(服务端 joined 或已在成员表)可见但不可选。 */
|
||||
export function accountsToPickItems(accounts: RdmAccount[] | undefined, joinedOwners: Iterable<string> = []): PersonPickItem[] {
|
||||
const joined = new Set(joinedOwners)
|
||||
return (accounts ?? []).filter(a => a.valid).map(a => {
|
||||
const isJoined = a.joined === true || joined.has(a.owner)
|
||||
return {
|
||||
id: a.owner,
|
||||
title: a.name?.trim() || a.owner,
|
||||
subtitle: a.owner,
|
||||
...(isJoined ? { disabled: true, disabledReason: JOINED_REASON } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 成员表追加下一页:保序,已存在的账号不重复。 */
|
||||
export function appendMemberPage(prev: RdmMemberRow[] | null, page: RdmMemberRow[] | undefined): RdmMemberRow[] {
|
||||
const out = [...(prev ?? [])]
|
||||
const seen = new Set(out.map(m => m.owner))
|
||||
for (const m of page ?? []) {
|
||||
if (seen.has(m.owner)) continue
|
||||
seen.add(m.owner)
|
||||
out.push(m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 矩阵来源标签(人话映射;未知来源回退原文——服务端新增来源时不至于空白)。 */
|
||||
export function describeSource(source: string): string {
|
||||
switch (source) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/* U7 法规结构化引用:编号标记、引用卡片、引用清单与审计法规树。
|
||||
颜色、圆角、字号只消费 @ui/tokens.css;折叠动效由 @ui Collapsible(mk-col)承担。
|
||||
有效依据(verified)用实线成功色;未核验用虚线警告色;无效用虚线错误色加删除线。 */
|
||||
|
||||
.regcite-chips { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px; }
|
||||
.regcite-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); color: var(--text-dim); word-break: break-all; }
|
||||
|
||||
.regcite-mark {
|
||||
display: inline;
|
||||
margin: 0 2px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: var(--fs-ui-sm);
|
||||
line-height: 1.4;
|
||||
vertical-align: baseline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.regcite-mark:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.regcite-mark.regcite-valid { color: var(--ok-text); border-color: var(--ok-border); background: var(--ok-soft); }
|
||||
.regcite-mark.regcite-unverified { color: var(--warn-text); border-style: dashed; border-color: var(--warn); background: var(--warn-soft); }
|
||||
.regcite-mark.regcite-invalid { color: var(--err-text); border-style: dashed; border-color: var(--err); background: var(--err-soft); text-decoration: line-through; }
|
||||
.regcite-mark-open { outline: 1px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
.regcite-block { margin: 0; }
|
||||
|
||||
.regcite-card {
|
||||
margin: 6px 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-left-width: 3px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-raised);
|
||||
font-size: var(--fs-ui-sm);
|
||||
color: var(--text);
|
||||
}
|
||||
.regcite-card-valid { border-left-color: var(--ok); }
|
||||
.regcite-card-unverified { border-left-color: var(--warn); border-left-style: dashed; }
|
||||
.regcite-card-invalid { border-left-color: var(--err); border-left-style: dashed; }
|
||||
.regcite-card-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.regcite-card-title { font-weight: 600; color: var(--text); }
|
||||
.regcite-note { margin-top: 4px; color: var(--text-dim); }
|
||||
.regcite-claim { margin-top: 4px; color: var(--text); }
|
||||
.regcite-fields { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 2px 10px; margin: 6px 0 0; }
|
||||
.regcite-fields dt { color: var(--text-dim); }
|
||||
.regcite-fields dd { margin: 0; color: var(--text); word-break: break-word; }
|
||||
.regcite-link { color: var(--accent-text); word-break: break-all; }
|
||||
.regcite-toggle { font-size: var(--fs-ui-sm); color: var(--accent-text); }
|
||||
.regcite-excerpt {
|
||||
margin: 4px 0 0;
|
||||
padding: 6px 10px;
|
||||
border-left: 2px solid var(--border-strong);
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--bg-inset);
|
||||
white-space: pre-wrap;
|
||||
color: var(--text);
|
||||
}
|
||||
.regcite-redaction { margin-top: 6px; color: var(--text-dim); }
|
||||
|
||||
.regcite-list { margin-top: 12px; padding-top: 8px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 2px; }
|
||||
.regcite-list-head { font-size: var(--fs-ui-sm); color: var(--text-dim); padding-bottom: 4px; }
|
||||
.regcite-item-head { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: var(--fs-ui-sm); color: var(--text); text-align: left; }
|
||||
.regcite-item-num { font-family: var(--mono); color: var(--text-dim); }
|
||||
.regcite-item-title { font-weight: 550; }
|
||||
.regcite-item-sub { color: var(--text-dim); }
|
||||
.regcite-item-invalid .regcite-item-title { color: var(--err-text); text-decoration: line-through; }
|
||||
|
||||
.regcite-reasons { margin: 0; padding-left: 18px; color: var(--text-dim); }
|
||||
.regcite-tree-row { display: flex; align-items: center; gap: 2px; }
|
||||
.regcite-tree-row .rdm-audit-tree-node { flex: 1; min-width: 0; }
|
||||
.regcite-tree-caret {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-dim);
|
||||
font-size: var(--fs-ui-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.regcite-tree-caret:hover { background: var(--bg-hover); color: var(--text); }
|
||||
@@ -0,0 +1,235 @@
|
||||
// U7 法规结构化引用纯逻辑层(契约:Dev 仓 docs/superpowers/specs
|
||||
// 2026-10-18-regulations-citation-contract.md §1/§3/§5/§6)。只放可测判定:
|
||||
// 正文 [cit-…] 标记解析与编号、核验状态文案与样式、原文省略原因、审计树请求参数。
|
||||
// 引用是 RDM 证据批的派生视图;除 verified 外一律不得呈现为有效法律依据。
|
||||
|
||||
// ---- 契约类型 ----
|
||||
|
||||
interface CitationWeb {
|
||||
url: string
|
||||
finalUrl?: string
|
||||
captureId?: string
|
||||
contentSha256?: string
|
||||
official?: boolean
|
||||
fetchedAt?: string
|
||||
excerpt?: string
|
||||
excerptStart?: number
|
||||
excerptEnd?: number
|
||||
}
|
||||
|
||||
export interface CitationView {
|
||||
id: string
|
||||
batchId?: string
|
||||
claim?: string
|
||||
state: string // verified|single_source|degraded|edition_unresolved|conflict|unverified|not_applicable|invalid
|
||||
stale?: boolean
|
||||
staleReason?: string
|
||||
regulationId?: string
|
||||
editionId?: string
|
||||
nodeId?: string
|
||||
docNo?: string
|
||||
title?: string
|
||||
editionLabel?: string
|
||||
legalStatus?: string
|
||||
clauseNo?: string
|
||||
headingPath?: string[]
|
||||
corpusRevision?: string
|
||||
excerpt?: string
|
||||
excerptStart?: number
|
||||
excerptEnd?: number
|
||||
nodeSha256?: string
|
||||
disclosure?: string // full|excerpt|metadata_only|withheld
|
||||
redactionReason?: string
|
||||
web?: CitationWeb
|
||||
manifestSha256?: string
|
||||
}
|
||||
|
||||
interface RegLegStates { kb: string; web: string; kbReason?: string; webReason?: string }
|
||||
interface RegSealView { state: string; keyId?: string; manifestSha256?: string }
|
||||
interface RegResolution { actor: string; decision: string; note?: string; at: string; batchManifestSha256?: string }
|
||||
|
||||
/** rdm.inspect.object kind=reg-batch(契约 §5)。 */
|
||||
export interface RegBatchDetail {
|
||||
title: string
|
||||
state: string
|
||||
stateReasons?: string[] | null
|
||||
legStates?: RegLegStates
|
||||
seal?: RegSealView
|
||||
citations?: CitationView[] | null
|
||||
resolutions?: RegResolution[] | null
|
||||
}
|
||||
|
||||
/** rdm.inspect.object kind=reg-citation(契约 §5)。 */
|
||||
export interface RegCitationDetail { title: string; citation: CitationView }
|
||||
|
||||
// ---- 正文标记:[cit-<12 位小写十六进制>] ----
|
||||
|
||||
const MARKER = /\[(cit-[0-9a-f]{12})\]/g
|
||||
|
||||
type CitedSegment =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'cite'; id: string; n: number }
|
||||
|
||||
/** 解析多段正文:按首次出现编号(跨段连续),重复标记复用编号;不合规的近似标记原样保留为文本。 */
|
||||
export function parseCitedBlocks(texts: string[]): { blocks: CitedSegment[][]; order: string[] } {
|
||||
const order: string[] = []
|
||||
const numbers = new Map<string, number>()
|
||||
const blocks = texts.map(text => {
|
||||
const out: CitedSegment[] = []
|
||||
let last = 0
|
||||
for (const m of text.matchAll(MARKER)) {
|
||||
const at = m.index ?? 0
|
||||
if (at > last) out.push({ kind: 'text', text: text.slice(last, at) })
|
||||
const id = m[1]
|
||||
let n = numbers.get(id)
|
||||
if (n === undefined) {
|
||||
order.push(id)
|
||||
n = order.length
|
||||
numbers.set(id, n)
|
||||
}
|
||||
out.push({ kind: 'cite', id, n })
|
||||
last = at + m[0].length
|
||||
}
|
||||
if (last < text.length) out.push({ kind: 'text', text: text.slice(last) })
|
||||
return out
|
||||
})
|
||||
return { blocks, order }
|
||||
}
|
||||
|
||||
/** 引用清单顺序:正文编号在前,正文未出现的引用按返回顺序续编。 */
|
||||
export function citationListOrder(order: string[], citations: CitationView[] | null | undefined): string[] {
|
||||
const out = [...order]
|
||||
const seen = new Set(order)
|
||||
for (const c of citations || []) {
|
||||
if (!seen.has(c.id)) { seen.add(c.id); out.push(c.id) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- 核验状态 ----
|
||||
|
||||
const STATE_LABELS: Record<string, string> = {
|
||||
verified: '已核验',
|
||||
single_source: '单一来源',
|
||||
degraded: '降级',
|
||||
edition_unresolved: '版本未定',
|
||||
conflict: '冲突',
|
||||
unverified: '未核验',
|
||||
not_applicable: '不适用',
|
||||
invalid: '无效引用',
|
||||
}
|
||||
|
||||
/** 状态文案;未知状态原样显示,不冒充已知状态。 */
|
||||
export function citationStateLabel(state: string | undefined): string {
|
||||
if (!state) return STATE_LABELS.unverified
|
||||
return STATE_LABELS[state] ?? state
|
||||
}
|
||||
|
||||
type CitationStyle = 'valid' | 'unverified' | 'invalid'
|
||||
|
||||
/** 样式档:仅 verified 且未过期才是有效依据;ID 缺失或 invalid 为无效;其余一律未核验档。 */
|
||||
export function citationStyle(c: { state?: string; stale?: boolean } | undefined): CitationStyle {
|
||||
if (!c || c.state === 'invalid') return 'invalid'
|
||||
if (c.state === 'verified' && !c.stale) return 'valid'
|
||||
return 'unverified'
|
||||
}
|
||||
|
||||
export const CITATION_CHIP_TONE: Record<CitationStyle, 'ok' | 'warn' | 'danger'> = {
|
||||
valid: 'ok',
|
||||
unverified: 'warn',
|
||||
invalid: 'danger',
|
||||
}
|
||||
|
||||
const STALE_REASONS: Record<string, string> = {
|
||||
node_text_changed: '条款文本已变更',
|
||||
legal_status_changed: '效力状态已变更',
|
||||
edition_superseded: '版本已被替代',
|
||||
}
|
||||
|
||||
/** 过期提示:stale 显示「待复核」并附原因;未过期返回 null。 */
|
||||
export function staleLabel(c: { stale?: boolean; staleReason?: string }): string | null {
|
||||
if (!c.stale) return null
|
||||
if (!c.staleReason) return '待复核'
|
||||
return `待复核(${STALE_REASONS[c.staleReason] ?? c.staleReason})`
|
||||
}
|
||||
|
||||
/** 标记文字:有效依据只显示编号;其余附状态文案,不靠颜色单独区分。 */
|
||||
export function citationMarkText(n: number, c: CitationView | undefined): string {
|
||||
const style = citationStyle(c)
|
||||
if (style === 'valid') return `${n}`
|
||||
if (style === 'invalid') return `${n}·${STATE_LABELS.invalid}`
|
||||
return `${n}·${c?.stale && c.state === 'verified' ? '待复核' : citationStateLabel(c?.state)}`
|
||||
}
|
||||
|
||||
// ---- 原文披露 ----
|
||||
|
||||
const REDACTION_LABELS: Record<string, string> = {
|
||||
withheld_by_rights: '受版权限制',
|
||||
metadata_only: '仅元数据',
|
||||
no_source_text_permission: '无原文查看权限',
|
||||
source_withdrawn: '来源已撤回',
|
||||
}
|
||||
|
||||
/** 原文省略原因;未知原因原样显示。 */
|
||||
export function redactionLabel(reason: string | undefined): string {
|
||||
if (!reason) return '未提供原文'
|
||||
return REDACTION_LABELS[reason] ?? reason
|
||||
}
|
||||
|
||||
/** 原文片段呈现:有片段给片段;省略时给中文原因,不留空白。 */
|
||||
export function excerptView(c: Pick<CitationView, 'excerpt' | 'redactionReason'>): { excerpt: string } | { redaction: string } {
|
||||
if (c.excerpt) return { excerpt: c.excerpt }
|
||||
return { redaction: redactionLabel(c.redactionReason) }
|
||||
}
|
||||
|
||||
const DISCLOSURE_LABELS: Record<string, string> = {
|
||||
full: '全文',
|
||||
excerpt: '片段',
|
||||
metadata_only: '仅元数据',
|
||||
withheld: '已扣留',
|
||||
}
|
||||
|
||||
export function disclosureLabel(d: string | undefined): string {
|
||||
if (!d) return '—'
|
||||
return DISCLOSURE_LABELS[d] ?? d
|
||||
}
|
||||
|
||||
const LEG_LABELS: Record<string, string> = {
|
||||
ok: '正常',
|
||||
empty: '无结果',
|
||||
unavailable: '不可用',
|
||||
degraded: '降级',
|
||||
error: '出错',
|
||||
rejected: '已拒绝',
|
||||
}
|
||||
|
||||
export function legStateLabel(state: string | undefined, reason?: string): string {
|
||||
const label = state ? (LEG_LABELS[state] ?? state) : '—'
|
||||
return reason ? `${label}(${reason})` : label
|
||||
}
|
||||
|
||||
export function sealStateLabel(state: string | undefined): string {
|
||||
if (state === 'sealed') return '已封存'
|
||||
if (state === 'unsealed') return '未封存'
|
||||
return state || '—'
|
||||
}
|
||||
|
||||
/** 只有 http(s) 地址才可作为外链;其余地址按文本显示。 */
|
||||
export function safeWebHref(url: string | undefined): string | null {
|
||||
if (!url) return null
|
||||
return /^https?:\/\//i.test(url) ? url : null
|
||||
}
|
||||
|
||||
// ---- 审计读模型请求(rdm.inspect.tree kind=regs;契约 §5) ----
|
||||
|
||||
/** 根层列证据批;parent=<batchId> 列该批的引用。cursor 沿用既有键集分页。 */
|
||||
export function regsTreeParams(projectId: string, opts: { parent?: string; cursor?: string } = {}): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = { projectId, kind: 'regs', cursor: opts.cursor ?? '' }
|
||||
if (opts.parent) params.parent = opts.parent
|
||||
return params
|
||||
}
|
||||
|
||||
export const REG_TREE_KIND_LABELS: Record<string, string> = {
|
||||
'reg-batch': '证据批',
|
||||
'reg-citation': '引用',
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// 项目成员页检索(R2 契约):服务端先过滤后分页,前端只拼参数、映射候选与追加分页。
|
||||
// node 环境无 DOM——决策逻辑全部落在 src/rdmManagement.ts 的纯函数上逐项断言。
|
||||
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,
|
||||
} 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('omits empty filters and defaults to fuzzy matching', () => {
|
||||
expect(accountListParams('p1', { query: ' ', cursor: '', limit: 20, roleId: '' }))
|
||||
.toEqual({ projectId: 'p1', limit: 20, match: 'fuzzy' })
|
||||
})
|
||||
|
||||
it('labels both match modes in Chinese', () => {
|
||||
expect(MATCH_MODE_LABELS).toEqual({ fuzzy: '模糊匹配', exact: '精确账号或姓名' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('memberListParams', () => {
|
||||
it('sends query, roleId and cursor with a page size of 50', () => {
|
||||
expect(memberListParams('p1', { query: 'li', roleId: 'r-qa', cursor: 'n1' }))
|
||||
.toEqual({ projectId: 'p1', query: 'li', roleId: 'r-qa', cursor: 'n1', limit: MEMBER_PAGE_LIMIT })
|
||||
expect(MEMBER_PAGE_LIMIT).toBe(50)
|
||||
})
|
||||
|
||||
it('loads the unfiltered first page when no filter is set', () => {
|
||||
expect(memberListParams('p1', { query: '', roleId: '', cursor: '' })).toEqual({ projectId: 'p1', limit: 50 })
|
||||
})
|
||||
|
||||
it('forwards the optional match and projectRole filters', () => {
|
||||
expect(memberListParams('p1', { match: 'exact', projectRole: 'owner', limit: 10 }))
|
||||
.toEqual({ projectId: 'p1', limit: 10, match: 'exact', projectRole: 'owner' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('roleOptionsPayload', () => {
|
||||
it('asks for member-purpose role options', () => {
|
||||
expect(roleOptionsPayload('p1')).toEqual({ projectId: 'p1', purpose: 'members' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('accountsToPickItems', () => {
|
||||
it('drops invalid accounts', () => {
|
||||
const items = accountsToPickItems([
|
||||
{ owner: 'a', name: '甲', valid: true, joined: false },
|
||||
{ owner: 'gone', name: '离职', valid: false, joined: false },
|
||||
])
|
||||
expect(items.map(it => it.id)).toEqual(['a'])
|
||||
})
|
||||
|
||||
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 },
|
||||
], ['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' },
|
||||
])
|
||||
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 }])
|
||||
expect(it.title).toBe('张三')
|
||||
expect(it.subtitle).toBe('zhangsan')
|
||||
})
|
||||
|
||||
it('tolerates a missing account list', () => {
|
||||
expect(accountsToPickItems(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('personLabel', () => {
|
||||
it('renders name · account', () => {
|
||||
expect(personLabel({ owner: 'zhangsan', name: '张三' })).toBe('张三 · zhangsan')
|
||||
})
|
||||
|
||||
it('falls back to the account when there is no name', () => {
|
||||
expect(personLabel({ owner: 'zhangsan' })).toBe('zhangsan')
|
||||
expect(personLabel({ owner: 'zhangsan', name: ' ' })).toBe('zhangsan')
|
||||
expect(personLabel({ owner: 'zhangsan', name: 'zhangsan' })).toBe('zhangsan')
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendMemberPage', () => {
|
||||
it('appends the next page in order without duplicating accounts', () => {
|
||||
const first = [row('a', '甲'), row('b')]
|
||||
const got = appendMemberPage(first, [row('b', '乙-重复'), row('c', '丙')])
|
||||
expect(got.map(m => m.owner)).toEqual(['a', 'b', 'c'])
|
||||
expect(got[1].name).toBeUndefined()
|
||||
expect(first.map(m => m.owner)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('starts a fresh list from null and dedupes within a page', () => {
|
||||
expect(appendMemberPage(null, [row('a'), row('a')]).map(m => m.owner)).toEqual(['a'])
|
||||
expect(appendMemberPage(null, undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rdm.role.options command gating', () => {
|
||||
it('is a project read command, so archived projects can still load role filters', () => {
|
||||
const archived = { id: 'p1', settings: { 'rdm.state': 'archived', 'rdm.projectId': 'rdm-1' } } as unknown as Project
|
||||
expect(rdmProjectCommandError(archived, 'rdm.role.options')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not gate member page readiness', () => {
|
||||
expect(RDM_MEMBER_COMMANDS).not.toContain('rdm.role.options')
|
||||
expect(rdmMembersReady(RDM_MEMBER_COMMANDS.map(id => ({ id, enabled: true })))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
// U7 法规结构化引用:标记解析与编号、核验状态文案与样式、原文省略原因、审计树请求参数,
|
||||
// 以及正文渲染不经 HTML(契约:Dev 仓 specs/2026-10-18-regulations-citation-contract.md)。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import {
|
||||
CITATION_CHIP_TONE, citationListOrder, citationMarkText, citationStateLabel, citationStyle,
|
||||
excerptView, parseCitedBlocks, redactionLabel, regsTreeParams, safeWebHref, staleLabel,
|
||||
type CitationView,
|
||||
} from '../src/regCitations'
|
||||
import { CitationCard, CitedBody } from '../src/components/RegCitation'
|
||||
|
||||
const A = 'cit-0123456789ab'
|
||||
const B = 'cit-abcdef012345'
|
||||
const C = 'cit-ffffffffffff'
|
||||
|
||||
const cite = (id: string, over: Partial<CitationView> = {}): CitationView => ({
|
||||
id, batchId: 'rb-1', state: 'verified', docNo: 'GB 9706.1-2020', clauseNo: '4.2',
|
||||
title: '医用电气设备 第1部分', editionLabel: '2020 版', legalStatus: '现行', corpusRevision: 'rev-7',
|
||||
headingPath: ['4 通用要求', '4.2 风险管理'], excerpt: '应实施风险管理过程。', disclosure: 'excerpt', ...over,
|
||||
})
|
||||
|
||||
describe('parseCitedBlocks:标记解析与编号', () => {
|
||||
it('多个标记按首次出现编号,文本原样切分', () => {
|
||||
const { blocks, order } = parseCitedBlocks([`甲[${A}]乙[${B}]丙`])
|
||||
expect(order).toEqual([A, B])
|
||||
expect(blocks[0]).toEqual([
|
||||
{ kind: 'text', text: '甲' },
|
||||
{ kind: 'cite', id: A, n: 1 },
|
||||
{ kind: 'text', text: '乙' },
|
||||
{ kind: 'cite', id: B, n: 2 },
|
||||
{ kind: 'text', text: '丙' },
|
||||
])
|
||||
})
|
||||
|
||||
it('重复标记复用编号,编号跨段连续', () => {
|
||||
const { blocks, order } = parseCitedBlocks([`[${B}]开头[${A}]`, `再引[${B}]与[${C}][${A}]`])
|
||||
expect(order).toEqual([B, A, C])
|
||||
const nums = blocks.map(b => b.flatMap(s => (s.kind === 'cite' ? [`${s.id}:${s.n}`] : [])))
|
||||
expect(nums).toEqual([[`${B}:1`, `${A}:2`], [`${B}:1`, `${C}:3`, `${A}:2`]])
|
||||
})
|
||||
|
||||
it('未知 ID 也按出现顺序编号(渲染时判为无效引用)', () => {
|
||||
const { order } = parseCitedBlocks([`[${C}]`])
|
||||
expect(order).toEqual([C])
|
||||
})
|
||||
|
||||
it('没有标记的文本原样返回单个文本片段;空文本没有片段', () => {
|
||||
const { blocks, order } = parseCitedBlocks(['普通正文 [slc-1] 不变', ''])
|
||||
expect(order).toEqual([])
|
||||
expect(blocks).toEqual([[{ kind: 'text', text: '普通正文 [slc-1] 不变' }], []])
|
||||
})
|
||||
|
||||
it('形似标记但 ID 不合规的文本不解析', () => {
|
||||
const texts = [
|
||||
'[cit-0123456789a]', // 11 位
|
||||
'[cit-0123456789abc]', // 13 位
|
||||
'[cit-0123456789AB]', // 大写
|
||||
'[cit-0123456789ag]', // 非十六进制
|
||||
'cit-0123456789ab', // 无方括号
|
||||
'[cit 0123456789ab]',
|
||||
'[CIT-0123456789ab]',
|
||||
]
|
||||
const { blocks, order } = parseCitedBlocks(texts)
|
||||
expect(order).toEqual([])
|
||||
blocks.forEach((b, i) => expect(b).toEqual([{ kind: 'text', text: texts[i] }]))
|
||||
})
|
||||
|
||||
it('嵌套方括号只取合规的内层标记', () => {
|
||||
const { blocks } = parseCitedBlocks([`[[${A}]]`])
|
||||
expect(blocks[0]).toEqual([{ kind: 'text', text: '[' }, { kind: 'cite', id: A, n: 1 }, { kind: 'text', text: ']' }])
|
||||
})
|
||||
|
||||
it('引用清单:正文编号在前,未出现在正文的引用续编', () => {
|
||||
expect(citationListOrder([B, C], [cite(A), cite(B)])).toEqual([B, C, A])
|
||||
expect(citationListOrder([], null)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('核验状态文案与样式', () => {
|
||||
it('八种状态的中文文案;未知状态原样显示', () => {
|
||||
expect(['verified', 'single_source', 'degraded', 'edition_unresolved', 'conflict', 'unverified', 'not_applicable', 'invalid']
|
||||
.map(citationStateLabel))
|
||||
.toEqual(['已核验', '单一来源', '降级', '版本未定', '冲突', '未核验', '不适用', '无效引用'])
|
||||
expect(citationStateLabel('mystery')).toBe('mystery')
|
||||
expect(citationStateLabel(undefined)).toBe('未核验')
|
||||
})
|
||||
|
||||
it('仅 verified 为有效样式;invalid 与缺失 ID 为无效样式;其余为未核验样式', () => {
|
||||
expect(citationStyle({ state: 'verified' })).toBe('valid')
|
||||
for (const s of ['single_source', 'degraded', 'edition_unresolved', 'conflict', 'unverified', 'not_applicable', 'mystery']) {
|
||||
expect(citationStyle({ state: s })).toBe('unverified')
|
||||
}
|
||||
expect(citationStyle({ state: 'invalid' })).toBe('invalid')
|
||||
expect(citationStyle(undefined)).toBe('invalid')
|
||||
expect(CITATION_CHIP_TONE).toEqual({ valid: 'ok', unverified: 'warn', invalid: 'danger' })
|
||||
})
|
||||
|
||||
it('stale 的 verified 不再呈现为有效依据,显示「待复核」及原因', () => {
|
||||
expect(citationStyle({ state: 'verified', stale: true })).toBe('unverified')
|
||||
expect(staleLabel({ stale: false })).toBeNull()
|
||||
expect(staleLabel({ stale: true })).toBe('待复核')
|
||||
expect(staleLabel({ stale: true, staleReason: 'node_text_changed' })).toBe('待复核(条款文本已变更)')
|
||||
expect(staleLabel({ stale: true, staleReason: 'legal_status_changed' })).toBe('待复核(效力状态已变更)')
|
||||
expect(staleLabel({ stale: true, staleReason: 'edition_superseded' })).toBe('待复核(版本已被替代)')
|
||||
expect(staleLabel({ stale: true, staleReason: 'other_reason' })).toBe('待复核(other_reason)')
|
||||
})
|
||||
|
||||
it('标记文字:有效只给编号,其余附状态文案', () => {
|
||||
expect(citationMarkText(1, cite(A))).toBe('1')
|
||||
expect(citationMarkText(2, cite(A, { state: 'single_source' }))).toBe('2·单一来源')
|
||||
expect(citationMarkText(3, cite(A, { stale: true }))).toBe('3·待复核')
|
||||
expect(citationMarkText(4, cite(A, { state: 'invalid' }))).toBe('4·无效引用')
|
||||
expect(citationMarkText(5, undefined)).toBe('5·无效引用')
|
||||
})
|
||||
})
|
||||
|
||||
describe('原文省略原因', () => {
|
||||
it('已知原因中文化;未知原因原样;缺省给通用说明', () => {
|
||||
expect(redactionLabel('withheld_by_rights')).toBe('受版权限制')
|
||||
expect(redactionLabel('metadata_only')).toBe('仅元数据')
|
||||
expect(redactionLabel('no_source_text_permission')).toBe('无原文查看权限')
|
||||
expect(redactionLabel('source_withdrawn')).toBe('来源已撤回')
|
||||
expect(redactionLabel('court_order')).toBe('court_order')
|
||||
expect(redactionLabel(undefined)).toBe('未提供原文')
|
||||
})
|
||||
|
||||
it('有片段给片段;省略时给原因', () => {
|
||||
expect(excerptView({ excerpt: '原文' })).toEqual({ excerpt: '原文' })
|
||||
expect(excerptView({ redactionReason: 'withheld_by_rights' })).toEqual({ redaction: '受版权限制' })
|
||||
})
|
||||
|
||||
it('只有 http(s) 地址可作外链', () => {
|
||||
expect(safeWebHref('https://www.nmpa.gov.cn/x')).toBe('https://www.nmpa.gov.cn/x')
|
||||
expect(safeWebHref('javascript:alert(1)')).toBeNull()
|
||||
expect(safeWebHref(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('regsTreeParams:rdm.inspect.tree kind=regs 请求参数', () => {
|
||||
it('根层:kind=regs、空游标、无 parent', () => {
|
||||
expect(regsTreeParams('p1')).toEqual({ projectId: 'p1', kind: 'regs', cursor: '' })
|
||||
})
|
||||
it('分页沿用游标', () => {
|
||||
expect(regsTreeParams('p1', { cursor: 'c2' })).toEqual({ projectId: 'p1', kind: 'regs', cursor: 'c2' })
|
||||
})
|
||||
it('展开证据批:parent=<batchId>', () => {
|
||||
expect(regsTreeParams('p1', { parent: 'rb-1' })).toEqual({ projectId: 'p1', kind: 'regs', cursor: '', parent: 'rb-1' })
|
||||
expect(regsTreeParams('p1', { parent: 'rb-1', cursor: 'c3' })).toEqual({ projectId: 'p1', kind: 'regs', cursor: 'c3', parent: 'rb-1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('引用呈现', () => {
|
||||
it('正文标记编号渲染,未知与 invalid 显示无效引用,正文不作 HTML', () => {
|
||||
const html = renderToStaticMarkup(createElement(CitedBody, {
|
||||
texts: [`<b>粗</b>甲[${A}]乙[${B}]丙[${C}]`, `再引[${A}]`],
|
||||
citations: [cite(A), cite(B, { state: 'invalid' })],
|
||||
scope: 't',
|
||||
}))
|
||||
expect(html).toContain('<b>粗</b>甲')
|
||||
expect(html).not.toContain('<b>')
|
||||
expect(html).toContain('regcite-mark regcite-valid')
|
||||
expect(html).toContain('[1]')
|
||||
expect(html).toContain('[2·无效引用]')
|
||||
expect(html).toContain('[3·无效引用]')
|
||||
expect(html.match(/regcite-mark regcite-valid/g)).toHaveLength(2)
|
||||
expect(html).toContain('引用(3)')
|
||||
expect(html).not.toContain('<details')
|
||||
})
|
||||
|
||||
it('无标记且无引用时正文原样,不出现引用清单', () => {
|
||||
const html = renderToStaticMarkup(createElement(CitedBody, { texts: ['普通 [cit-xyz] 正文'], citations: undefined, scope: 't' }))
|
||||
expect(html).toBe('<p class="cockpit-reader-para">普通 [cit-xyz] 正文</p>')
|
||||
})
|
||||
|
||||
it('引用卡片:省略原文时显示中文原因;非 verified 标注未核验', () => {
|
||||
const html = renderToStaticMarkup(createElement(CitationCard, {
|
||||
id: A, scope: 't',
|
||||
citation: cite(A, { state: 'conflict', excerpt: undefined, redactionReason: 'no_source_text_permission', stale: true, staleReason: 'edition_superseded' }),
|
||||
}))
|
||||
expect(html).toContain('regcite-card-unverified')
|
||||
expect(html).toContain('冲突')
|
||||
expect(html).toContain('待复核(版本已被替代)')
|
||||
expect(html).toContain('未核验引用')
|
||||
expect(html).toContain('原文片段未展示:无原文查看权限')
|
||||
expect(html).toContain('GB 9706.1-2020')
|
||||
expect(html).toContain('4 通用要求 › 4.2 风险管理')
|
||||
})
|
||||
|
||||
it('引用卡片:verified 为有效样式,原文片段在折叠区内;缺失 ID 为无效卡片', () => {
|
||||
const ok = renderToStaticMarkup(createElement(CitationCard, { id: A, scope: 't', citation: cite(A) }))
|
||||
expect(ok).toContain('regcite-card-valid')
|
||||
expect(ok).toContain('mk-col')
|
||||
expect(ok).not.toContain('未核验引用')
|
||||
const missing = renderToStaticMarkup(createElement(CitationCard, { id: C, scope: 't' }))
|
||||
expect(missing).toContain('regcite-card-invalid')
|
||||
expect(missing).toContain('无效引用')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user