Merge Frontend main into the unified document renderer workspace
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:
weichen
2026-10-18 12:32:10 +00:00
co-authored by Cursor
15 changed files with 1358 additions and 132 deletions
+125
View File
@@ -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)
})
})
+199
View File
@@ -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('&lt;b&gt;粗&lt;/b&gt;甲')
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('无效引用')
})
})