@changfenhuang/dsh-genui 0.11.2 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ import { GENUI_LIMITS } from './genui-runtime/index.ts'
2
+ import type { GenuiSpec } from './spec.ts'
3
+ import { walkGenuiNodes } from './walk-spec.ts'
4
+
5
+ export type SubmissionMember = RadioSubmissionMember | CheckboxSubmissionMember | FieldSubmissionMember
6
+
7
+ export interface RadioSubmissionMember {
8
+ kind: 'radio'
9
+ key: string
10
+ label: string
11
+ options: string[]
12
+ answer?: number | string
13
+ explanation?: string
14
+ }
15
+
16
+ export interface CheckboxSubmissionMember {
17
+ kind: 'checkbox'
18
+ key: string
19
+ }
20
+
21
+ export interface FieldSubmissionMember {
22
+ kind: 'field'
23
+ key: string
24
+ fieldType: 'input' | 'textarea' | 'select' | 'slider'
25
+ secret: boolean
26
+ }
27
+
28
+ export interface SubmissionRegistry {
29
+ members: ReadonlyMap<string, SubmissionMember>
30
+ }
31
+
32
+ export interface SubmitInteractionState {
33
+ answers: Record<string, string>
34
+ multiAnswers: Record<string, string[]>
35
+ fields: Record<string, string>
36
+ secretFields: ReadonlySet<string>
37
+ }
38
+
39
+ export interface ResolvedSubmitState {
40
+ scope: SubmissionMember[]
41
+ answered: number
42
+ total: number
43
+ localGradeEligible: boolean
44
+ hasOutOfScopePayload: boolean
45
+ }
46
+
47
+ /** 从 spec 建立成员表,并诊断 submission key 和 submit.groups。 */
48
+ export function analyzeSubmissionRegistry(spec: GenuiSpec): { registry: SubmissionRegistry; diagnostics: string[] } {
49
+ const members = new Map<string, SubmissionMember>()
50
+ const paths = new Map<string, string>()
51
+ const diagnostics: string[] = []
52
+ const submits: Array<{ groups: string[]; path: string }> = []
53
+
54
+ walkGenuiNodes(spec, (node, path) => {
55
+ if (node.type === 'submit') {
56
+ if (node.groups !== undefined) submits.push({ groups: node.groups, path })
57
+ return
58
+ }
59
+ let member: SubmissionMember | undefined
60
+ let keyPath: string
61
+ switch (node.type) {
62
+ case 'radio':
63
+ if (node.group === undefined) return
64
+ member = { kind: 'radio', key: node.group, label: node.label ?? node.group, options: node.options.slice(0, GENUI_LIMITS.maxOptions), ...(node.answer === undefined ? {} : { answer: node.answer }), ...(node.explanation === undefined ? {} : { explanation: node.explanation }) }
65
+ keyPath = `${path}.group`
66
+ break
67
+ case 'checkbox':
68
+ if (node.group === undefined) return
69
+ member = { kind: 'checkbox', key: node.group }
70
+ keyPath = `${path}.group`
71
+ break
72
+ case 'input':
73
+ case 'textarea':
74
+ case 'select':
75
+ case 'slider':
76
+ if (node.id === undefined) return
77
+ member = { kind: 'field', key: node.id, fieldType: node.type, secret: node.type === 'input' && node.inputType === 'password' }
78
+ keyPath = `${path}.id`
79
+ break
80
+ default:
81
+ return
82
+ }
83
+ if (member === undefined) return
84
+ const existing = members.get(member.key)
85
+ if (existing !== undefined) {
86
+ if (existing.kind !== 'checkbox' || member.kind !== 'checkbox') {
87
+ diagnostics.push(`${keyPath} conflicts with ${paths.get(member.key)}: submission key '${member.key}' is already used`)
88
+ }
89
+ return
90
+ }
91
+ members.set(member.key, member)
92
+ paths.set(member.key, keyPath)
93
+ })
94
+
95
+ for (const { groups, path } of submits) {
96
+ const seen = new Set<string>()
97
+ groups.forEach((key, index) => {
98
+ const at = `${path}.groups[${index}]`
99
+ if (seen.has(key)) diagnostics.push(`${at}: duplicate submission member '${key}'`)
100
+ seen.add(key)
101
+ const member = members.get(key)
102
+ if (member === undefined) diagnostics.push(`${at}: submit.groups references unknown submission member '${key}'`)
103
+ else if (member.kind === 'field' && member.secret) diagnostics.push(`${at}: submit.groups cannot require secret field '${key}'`)
104
+ })
105
+ }
106
+ return { registry: { members }, diagnostics }
107
+ }
108
+
109
+ /** 编译供运行时读取的静态 submission 成员表。 */
110
+ export function compileSubmissionRegistry(spec: GenuiSpec): SubmissionRegistry {
111
+ return analyzeSubmissionRegistry(spec).registry
112
+ }
113
+
114
+ /** 根据当前交互状态判断一个 submission member 是否完成。 */
115
+ export function isSubmissionMemberAnswered(member: SubmissionMember, state: SubmitInteractionState): boolean {
116
+ switch (member.kind) {
117
+ case 'radio': return state.answers[member.key] !== undefined
118
+ case 'checkbox': return (state.multiAnswers[member.key]?.length ?? 0) > 0
119
+ case 'field': {
120
+ if (member.secret) return false
121
+ const value = state.fields[member.key]
122
+ return value !== undefined && value.trim() !== ''
123
+ }
124
+ }
125
+ }
126
+
127
+ /** 计算 submit 进度和纯本地判卷条件。 */
128
+ export function resolveSubmitState({ registry, groups, state }: {
129
+ registry: SubmissionRegistry
130
+ groups?: string[]
131
+ state: SubmitInteractionState
132
+ }): ResolvedSubmitState {
133
+ const hasUnknownMember = groups?.some(key => !registry.members.has(key)) ?? false
134
+ const scope = groups === undefined
135
+ ? [...registry.members.values()].filter(member => isSubmissionMemberAnswered(member, state))
136
+ : groups.flatMap(key => {
137
+ const member = registry.members.get(key)
138
+ return member === undefined ? [] : [member]
139
+ })
140
+ const answered = scope.filter(member => isSubmissionMemberAnswered(member, state)).length
141
+ const total = groups?.length ?? scope.length
142
+ const scopeKeys = new Set(scope.map(member => member.key))
143
+ const hasOutOfScopePayload = Object.keys(state.answers).some(key => !scopeKeys.has(key))
144
+ || Object.keys(state.multiAnswers).some(key => !scopeKeys.has(key))
145
+ || Object.entries(state.fields).some(([key, value]) => value.trim() !== '' && !state.secretFields.has(key) && !scopeKeys.has(key))
146
+ const localGradeEligible = !hasUnknownMember && scope.length > 0
147
+ && scope.every(member => member.kind === 'radio')
148
+ && scope.some(member => member.kind === 'radio' && member.answer !== undefined)
149
+ return { scope, answered, total, localGradeEligible, hasOutOfScopePayload }
150
+ }
@@ -0,0 +1,151 @@
1
+ import { GENUI_LIMITS } from './genui-runtime/index.ts'
2
+
3
+ /** 保存 repair 所需的原始 table 字段。 */
4
+ export interface TableDetailSource {
5
+ columns?: unknown
6
+ rows?: unknown
7
+ data?: unknown
8
+ types?: unknown
9
+ }
10
+
11
+ /** 保存 repair 后的 table 列和行。 */
12
+ export interface PreparedTableRows {
13
+ columns: string[]
14
+ rows: Array<Array<string | number>>
15
+ }
16
+
17
+ /** 使用 table repair 相同的转换规则处理列和行。 */
18
+ export function prepareTableRows(table: TableDetailSource): PreparedTableRows | null {
19
+ const declaredColumns = table.columns
20
+ const columnsSpecified = Array.isArray(declaredColumns) && declaredColumns.length > 0
21
+ const rawColumns = columnsSpecified && Array.isArray(declaredColumns)
22
+ && typeof declaredColumns[0] === 'object' && declaredColumns[0] !== null
23
+ ? declaredColumns.map(columnHeaderText)
24
+ : declaredColumns
25
+ let columns = repairColumnValues(rawColumns)
26
+ let rawRows = table.rows !== undefined ? table.rows : table.data
27
+
28
+ if (Array.isArray(rawRows) && rawRows.length > 0
29
+ && typeof rawRows[0] === 'object' && rawRows[0] !== null && !Array.isArray(rawRows[0])) {
30
+ const keys = Array.isArray(table.columns) && table.columns.length > 0
31
+ && typeof table.columns[0] === 'object' && table.columns[0] !== null
32
+ ? table.columns.map(columnKeyOf).filter((key): key is string => key !== undefined)
33
+ : Object.keys(rawRows[0] as Record<string, unknown>)
34
+ rawRows = rawRows.map(row => keys.map(key => cellText((row as Record<string, unknown>)[key])))
35
+ }
36
+
37
+ let derived: PreparedTableRows | null = null
38
+ if (!columnsSpecified && Array.isArray(rawRows) && rawRows.length > 0 && Array.isArray(rawRows[0])) {
39
+ const grid = repairRows(rawRows)
40
+ const candidate = grid.length === 0 ? null : deriveTableColumns(grid)
41
+ if (candidate !== null && repairRows(candidate.rows).length === candidate.rows.length) {
42
+ derived = candidate
43
+ columns = candidate.columns.map(column => column.slice(0, 128))
44
+ }
45
+ }
46
+
47
+ if (columns.length === 0 && !Array.isArray(table.columns)) return null
48
+ const rows = repairRows(derived === null ? rawRows : derived.rows)
49
+ if (!Array.isArray(rawRows)) return null
50
+ return { columns: columns.slice(0, GENUI_LIMITS.maxTableCols), rows }
51
+ }
52
+
53
+ /** 返回 repair 后可显示详情的 table 行。 */
54
+ export function tableRowsForDetails<Row = unknown>(table: TableDetailSource): Row[] {
55
+ return (prepareTableRows(table)?.rows ?? []) as Row[]
56
+ }
57
+
58
+ /** 按照 table renderer 的规则识别分组标题行。 */
59
+ export function isTableGroupHeaderRow(row: unknown, types: unknown): boolean {
60
+ if (!Array.isArray(row) || !Array.isArray(types) || types[0] !== 'group') return false
61
+ return String(row[0] ?? '').trim() !== ''
62
+ && row.slice(1).every(cell => String(cell ?? '').trim() === '')
63
+ }
64
+
65
+ /** 判断详情索引对应的行是否可展开并显示。 */
66
+ export function isTableDetailReachable(table: TableDetailSource, rowIndex: number): boolean {
67
+ const rows = tableRowsForDetails(table)
68
+ return rowIndex >= 0
69
+ && rowIndex < rows.length
70
+ && !isTableGroupHeaderRow(rows[rowIndex], table.types)
71
+ }
72
+
73
+ /** 按照 guard 接受的 object alias 规则修复 table 列。 */
74
+ function repairColumnValues(value: unknown): string[] {
75
+ if (!Array.isArray(value)) return []
76
+ const columns: string[] = []
77
+ for (const item of value) {
78
+ if (columns.length >= GENUI_LIMITS.maxTableCols) break
79
+ if (typeof item === 'string') columns.push(item.slice(0, 128))
80
+ else if (item !== null && typeof item === 'object') {
81
+ const column = item as Record<string, unknown>
82
+ const label = typeof column.label === 'string' ? column.label
83
+ : typeof column.value === 'string' ? column.value
84
+ : typeof column.title === 'string' ? column.title
85
+ : JSON.stringify(item)
86
+ columns.push(label.slice(0, 128))
87
+ }
88
+ }
89
+ return columns
90
+ }
91
+
92
+ /** 按照 table repair 的单元格规则裁剪并清理行。 */
93
+ function repairRows(value: unknown): Array<Array<string | number>> {
94
+ if (!Array.isArray(value)) return []
95
+ const rows: Array<Array<string | number>> = []
96
+ for (const row of value) {
97
+ if (rows.length >= GENUI_LIMITS.maxTableRows) break
98
+ if (!Array.isArray(row)) continue
99
+ const cells: Array<string | number> = []
100
+ for (const cell of row) {
101
+ if (cells.length >= GENUI_LIMITS.maxTableCols) break
102
+ if (typeof cell === 'string') cells.push(cell.slice(0, 256))
103
+ else if (typeof cell === 'number' && Number.isFinite(cell)) cells.push(cell)
104
+ }
105
+ if (cells.length > 0) rows.push(cells)
106
+ }
107
+ return rows
108
+ }
109
+
110
+ /** 从有效的首行表头生成 table 列。 */
111
+ function deriveTableColumns(rows: Array<Array<string | number>>): PreparedTableRows | null {
112
+ const header = rows[0]
113
+ if (header === undefined || header.length === 0) return null
114
+ const body = rows.slice(1)
115
+ if (body.length === 0) {
116
+ const columns = header.map(cell => String(cell).trim())
117
+ return columns.every(column => column !== '') ? { columns, rows: [] } : null
118
+ }
119
+ if (body.every(row => row.length === header.length)) {
120
+ return { columns: header.map(cell => String(cell).trim()), rows: body }
121
+ }
122
+ return { columns: Array.from({ length: header.length }, (_unused, index) => `列${index + 1}`), rows }
123
+ }
124
+
125
+ /** 读取 object row 映射到列时使用的 key。 */
126
+ function columnKeyOf(value: unknown): string | undefined {
127
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
128
+ const column = value as Record<string, unknown>
129
+ for (const key of ['key', 'dataIndex', 'title', 'label'] as const) {
130
+ if (typeof column[key] === 'string' && column[key] !== '') return column[key]
131
+ }
132
+ return undefined
133
+ }
134
+
135
+ /** 将 object row 中的值转换为 table 单元格,并保留列位置。 */
136
+ function cellText(value: unknown): string | number {
137
+ if (typeof value === 'string') return value
138
+ if (typeof value === 'number' && Number.isFinite(value)) return value
139
+ if (value === null || value === undefined) return ''
140
+ return JSON.stringify(value)
141
+ }
142
+
143
+ /** 读取 object column 显示的标题。 */
144
+ function columnHeaderText(value: unknown): string {
145
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return String(value)
146
+ const column = value as Record<string, unknown>
147
+ for (const key of ['title', 'label', 'key', 'dataIndex'] as const) {
148
+ if (typeof column[key] === 'string' && column[key] !== '') return column[key]
149
+ }
150
+ return JSON.stringify(value)
151
+ }
@@ -0,0 +1,43 @@
1
+ import type { GenuiNode, GenuiSpec } from './spec.ts'
2
+ import { GENUI_LIMITS } from './genui-runtime/index.ts'
3
+ import { isTableDetailReachable, tableRowsForDetails } from './table-details.ts'
4
+
5
+ /** 遍历完整 GenUI 组件树,并提供每个组件在 spec 中的路径。 */
6
+ export function walkGenuiNodes(spec: GenuiSpec, visitor: (node: GenuiNode, path: string) => void): void {
7
+ const walk = (nodes: GenuiNode[], path: string, depth: number): void => {
8
+ if (depth > GENUI_LIMITS.maxDepth) return
9
+ nodes.forEach((node, index) => visit(node, `${path}[${index}]`, depth))
10
+ }
11
+ const visit = (node: GenuiNode, at: string, depth: number): void => {
12
+ if (depth > GENUI_LIMITS.maxDepth) return
13
+ visitor(node, at)
14
+ if (depth >= GENUI_LIMITS.maxDepth) return
15
+ switch (node.type) {
16
+ case 'row':
17
+ case 'col':
18
+ case 'grid':
19
+ case 'card':
20
+ walk(node.items, `${at}.items`, depth + 1)
21
+ break
22
+ case 'tabs':
23
+ node.tabs.forEach((tab, tabIndex) => walk(tab.items, `${at}.tabs[${tabIndex}].items`, depth + 1))
24
+ break
25
+ case 'accordion':
26
+ node.items.forEach((item, itemIndex) => walk(item.items, `${at}.items[${itemIndex}].items`, depth + 1))
27
+ break
28
+ case 'list':
29
+ node.items.forEach((item, itemIndex) => {
30
+ if (item !== null && typeof item === 'object' && 'type' in item) visit(item, `${at}.items[${itemIndex}]`, depth + 1)
31
+ })
32
+ break
33
+ case 'table':
34
+ node.details?.slice(0, tableRowsForDetails(node).length).forEach((detail, rowIndex) => {
35
+ if (detail !== null && isTableDetailReachable(node, rowIndex)) {
36
+ walk(detail, `${at}.details[${rowIndex}]`, depth + 1)
37
+ }
38
+ })
39
+ break
40
+ }
41
+ }
42
+ walk(spec.items, 'items', 0)
43
+ }