@changfenhuang/dsh-genui 0.9.1
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.
- package/CHANGELOG.md +367 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/README.zh-CN.md +261 -0
- package/SKILL.md +115 -0
- package/cordis.patch.yml +9 -0
- package/demo-prompts.md +118 -0
- package/lib/assets/echarts.js +64 -0
- package/lib/assets/mermaid.js +3591 -0
- package/lib/assets/three.js +3844 -0
- package/lib/client.js +5 -0
- package/lib/index.js +1895 -0
- package/lib/invariant.js +19 -0
- package/lib/types/client/EChartNode.d.ts +4 -0
- package/lib/types/client/ErrorBoundary.d.ts +30 -0
- package/lib/types/client/GenuiBlock.d.ts +7 -0
- package/lib/types/client/PlotBlock.d.ts +44 -0
- package/lib/types/client/action-context.d.ts +7 -0
- package/lib/types/client/asset-echarts.d.ts +15 -0
- package/lib/types/client/asset-loader.d.ts +31 -0
- package/lib/types/client/asset-mermaid.d.ts +1 -0
- package/lib/types/client/asset-three.d.ts +1 -0
- package/lib/types/client/blocks/advanced.d.ts +104 -0
- package/lib/types/client/blocks/basic.d.ts +27 -0
- package/lib/types/client/blocks/charts.d.ts +38 -0
- package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
- package/lib/types/client/blocks/diagram/index.d.ts +5 -0
- package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
- package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
- package/lib/types/client/blocks/forms.d.ts +83 -0
- package/lib/types/client/blocks/render-node.d.ts +10 -0
- package/lib/types/client/blocks/state.d.ts +58 -0
- package/lib/types/client/dom-fence.d.ts +14 -0
- package/lib/types/client/echarts-lazy.d.ts +17 -0
- package/lib/types/client/fence-render.d.ts +58 -0
- package/lib/types/client/gallery.d.ts +9 -0
- package/lib/types/client/guard.d.ts +126 -0
- package/lib/types/client/index.d.ts +36 -0
- package/lib/types/client/interaction-store.d.ts +43 -0
- package/lib/types/client/mermaid-core.d.ts +8 -0
- package/lib/types/client/mermaid-lazy.d.ts +9 -0
- package/lib/types/client/mermaid-safe.d.ts +52 -0
- package/lib/types/client/panel-command.d.ts +29 -0
- package/lib/types/client/panel-store.d.ts +94 -0
- package/lib/types/client/panel.d.ts +15 -0
- package/lib/types/client/parse-partial.d.ts +37 -0
- package/lib/types/client/safe-math.d.ts +43 -0
- package/lib/types/client/scene3d-core.d.ts +21 -0
- package/lib/types/client/scene3d-lazy.d.ts +8 -0
- package/lib/types/client/spec.d.ts +610 -0
- package/lib/types/client/toolview.d.ts +7 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/plugin/index.d.ts +25 -0
- package/lib/types/plugin/invariant.d.ts +15 -0
- package/lib/types/plugin/tool.d.ts +27 -0
- package/lib/types/shared/fence-repair.d.ts +67 -0
- package/package.json +121 -0
- package/src/client/EChartNode.tsx +242 -0
- package/src/client/ErrorBoundary.tsx +66 -0
- package/src/client/GenuiBlock.module.css +1263 -0
- package/src/client/GenuiBlock.tsx +166 -0
- package/src/client/PlotBlock.module.css +155 -0
- package/src/client/PlotBlock.tsx +431 -0
- package/src/client/action-context.ts +32 -0
- package/src/client/asset-echarts.ts +25 -0
- package/src/client/asset-loader.ts +79 -0
- package/src/client/asset-mermaid.ts +13 -0
- package/src/client/asset-three.ts +12 -0
- package/src/client/blocks/advanced.tsx +435 -0
- package/src/client/blocks/basic.tsx +116 -0
- package/src/client/blocks/charts.tsx +319 -0
- package/src/client/blocks/diagram/geometry.ts +151 -0
- package/src/client/blocks/diagram/index.tsx +284 -0
- package/src/client/blocks/diagram/layout.ts +199 -0
- package/src/client/blocks/diagram/theme.ts +108 -0
- package/src/client/blocks/forms.tsx +523 -0
- package/src/client/blocks/render-node.tsx +260 -0
- package/src/client/blocks/state.ts +61 -0
- package/src/client/dom-fence.tsx +641 -0
- package/src/client/echarts-lazy.ts +35 -0
- package/src/client/fence-render.tsx +230 -0
- package/src/client/gallery.ts +128 -0
- package/src/client/guard.ts +1450 -0
- package/src/client/index.tsx +195 -0
- package/src/client/interaction-store.ts +122 -0
- package/src/client/mermaid-core.ts +121 -0
- package/src/client/mermaid-lazy.ts +31 -0
- package/src/client/mermaid-safe.ts +122 -0
- package/src/client/panel-command.ts +119 -0
- package/src/client/panel-store.ts +471 -0
- package/src/client/panel.tsx +185 -0
- package/src/client/parse-partial.ts +162 -0
- package/src/client/safe-math.ts +270 -0
- package/src/client/scene3d-core.ts +192 -0
- package/src/client/scene3d-lazy.ts +26 -0
- package/src/client/spec.ts +767 -0
- package/src/client/toolview.tsx +66 -0
- package/src/css-modules.d.ts +5 -0
- package/src/index.ts +12 -0
- package/src/plugin/index.ts +173 -0
- package/src/plugin/invariant.ts +30 -0
- package/src/plugin/node-shims.d.ts +31 -0
- package/src/plugin/tool.ts +330 -0
- package/src/shared/fence-repair.ts +244 -0
- package/tsconfig.json +35 -0
- package/tsdown.config.ts +193 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recursive render dispatcher: maps the white-listed GenuiNode union to
|
|
3
|
+
* concrete components. Leaf cases render inline; compound families live in
|
|
4
|
+
* the sibling block modules. Depth-guarded against pathological specs.
|
|
5
|
+
* @module @changfenhuang/dsh-genui/client/blocks/render-node
|
|
6
|
+
*/
|
|
7
|
+
import { type ReactNode, type ComponentType } from 'react'
|
|
8
|
+
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
|
|
9
|
+
import css from '../GenuiBlock.module.css'
|
|
10
|
+
import { GENUI_LIMITS } from '../guard.ts'
|
|
11
|
+
import type { GenuiList, GenuiNode } from '../spec.ts'
|
|
12
|
+
import type { AnswersState, GenuiBlockProps } from './state.ts'
|
|
13
|
+
import { AudioNode, avatarColor, ClickFeedbackButton, VideoNode } from './basic.tsx'
|
|
14
|
+
import { ChartNode, TableNode } from './charts.tsx'
|
|
15
|
+
import {
|
|
16
|
+
InputNode, RadioNode, SelectNode, SliderNode, SubmitNode, SwitchNode, TextareaNode,
|
|
17
|
+
} from './forms.tsx'
|
|
18
|
+
import {
|
|
19
|
+
AccordionNode, BreadcrumbNode, CalloutNode, CodeNode, CopyNode, DiffNode, FileTreeNode, JsonNode, KeyValueNode,
|
|
20
|
+
MermaidNode, PlotNode, QuizNode, Scene3DNode, StepsNode, TabsNode, TimelineNode,
|
|
21
|
+
} from './advanced.tsx'
|
|
22
|
+
import { DiagramNode } from './diagram/index.tsx'
|
|
23
|
+
|
|
24
|
+
import { EChartNode } from '../EChartNode.tsx'
|
|
25
|
+
|
|
26
|
+
/** Custom node data shape (declared locally: pristine hosts export no type). */
|
|
27
|
+
interface GenuiCustomNode {
|
|
28
|
+
type: string
|
|
29
|
+
[key: string]: unknown
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Props a registered custom renderer receives. */
|
|
33
|
+
interface GenuiCustomProps {
|
|
34
|
+
node: GenuiCustomNode
|
|
35
|
+
onAction?: GenuiBlockProps['onAction']
|
|
36
|
+
renderChildren: (nodes: unknown[], keyBase: string) => unknown
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Custom-component registry lookup, feature-detected (contract hosts only). */
|
|
40
|
+
type HostGenuiExt = {
|
|
41
|
+
getGenuiComponent?: (type: string) => ComponentType<GenuiCustomProps> | undefined
|
|
42
|
+
}
|
|
43
|
+
const getGenuiComponent = (primitives as unknown as HostGenuiExt).getGenuiComponent
|
|
44
|
+
|
|
45
|
+
function isListItemNode(item: GenuiList['items'][number]): item is GenuiNode {
|
|
46
|
+
return typeof item === 'object' && item !== null && 'type' in item
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function renderNode(
|
|
50
|
+
node: GenuiNode,
|
|
51
|
+
key: number,
|
|
52
|
+
onAction: GenuiBlockProps['onAction'] | undefined,
|
|
53
|
+
depth = 0,
|
|
54
|
+
answers?: AnswersState,
|
|
55
|
+
): ReactNode {
|
|
56
|
+
// Depth guard: a pathological spec must never recurse past the limit
|
|
57
|
+
// (stack overflow / DOM explosion). The fence path already repairs specs
|
|
58
|
+
// against the same limit; this is the belt-and-suspenders for direct
|
|
59
|
+
// GenuiBlock use and plugin-registered custom renderers.
|
|
60
|
+
if (depth > GENUI_LIMITS.maxDepth) return null
|
|
61
|
+
switch (node.type) {
|
|
62
|
+
case 'text': {
|
|
63
|
+
const size = node.size ?? 'body'
|
|
64
|
+
return (
|
|
65
|
+
<div key={key} className={`${css.text} ${css[size]}` + (node.center ? ` ${css.center}` : '')}>
|
|
66
|
+
{node.content}
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
case 'row': {
|
|
71
|
+
return (
|
|
72
|
+
<div key={key} className={css.row + (node.wrap ? ` ${css.wrap}` : '')}>
|
|
73
|
+
{node.items.map((c, i) => renderNode(c, i, onAction, depth + 1, answers))}
|
|
74
|
+
{node.spacer && <div className={css.spacer} />}
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
case 'col': {
|
|
79
|
+
return (
|
|
80
|
+
<div key={key} className={css.col} style={node.gap !== undefined ? { gap: `${node.gap}px` } : undefined}>
|
|
81
|
+
{node.items.map((c, i) => renderNode(c, i, onAction, depth + 1, answers))}
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
case 'grid': {
|
|
86
|
+
return (
|
|
87
|
+
<div key={key} className={css.grid} style={{ gridTemplateColumns: `repeat(${Math.max(1, node.cols)}, minmax(0, 1fr))` }}>
|
|
88
|
+
{node.items.map((c, i) => renderNode(c, i, onAction, depth + 1, answers))}
|
|
89
|
+
</div>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
case 'card': {
|
|
93
|
+
return (
|
|
94
|
+
<div key={key} className={css.card}>
|
|
95
|
+
{node.title !== undefined && <div className={css.cardTitle}>{node.title}</div>}
|
|
96
|
+
{node.items.map((c, i) => renderNode(c, i, onAction, depth + 1, answers))}
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
case 'button': {
|
|
101
|
+
const tone = node.tone ?? ''
|
|
102
|
+
const cls = `${css.button} ${css[tone] || ''}` + (node.full ? ` ${css.full}` : '') + (node.small ? ` ${css.small}` : '')
|
|
103
|
+
const action = node.action
|
|
104
|
+
// A button without an action (or without an action provider) is a
|
|
105
|
+
// display-only control: render it DISABLED so the affordance is honest
|
|
106
|
+
// — clickable-looking dead buttons were the top complaint in the field.
|
|
107
|
+
const interactive = action !== undefined && onAction !== undefined
|
|
108
|
+
return (
|
|
109
|
+
<ClickFeedbackButton
|
|
110
|
+
key={key}
|
|
111
|
+
className={cls}
|
|
112
|
+
disabled={!interactive}
|
|
113
|
+
onClick={interactive ? () => onAction(action, { type: 'button', label: node.label }) : undefined}
|
|
114
|
+
>
|
|
115
|
+
{node.icon !== undefined && <span aria-hidden>{node.icon} </span>}
|
|
116
|
+
{node.label}
|
|
117
|
+
</ClickFeedbackButton>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
case 'input': return <InputNode key={key} node={node} onAction={onAction} answers={answers} />
|
|
121
|
+
case 'select': return <SelectNode key={key} node={node} onAction={onAction} answers={answers} />
|
|
122
|
+
case 'checkbox': {
|
|
123
|
+
const action = node.action
|
|
124
|
+
return (
|
|
125
|
+
<label key={key} className={css.checkbox}>
|
|
126
|
+
<input
|
|
127
|
+
type="checkbox"
|
|
128
|
+
defaultChecked={node.checked === true}
|
|
129
|
+
onChange={action !== undefined && onAction !== undefined
|
|
130
|
+
? e => onAction(action, { type: 'checkbox', checked: e.currentTarget.checked })
|
|
131
|
+
: undefined}
|
|
132
|
+
/>
|
|
133
|
+
<span>{node.label}</span>
|
|
134
|
+
</label>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
case 'link': {
|
|
138
|
+
// Honest affordance: with a whitelisted href this is a REAL anchor;
|
|
139
|
+
// without one it is plain styled text (a dead clickable-looking button
|
|
140
|
+
// was the same complaint class as the disabled-button fix).
|
|
141
|
+
const href = node.href
|
|
142
|
+
return href !== undefined
|
|
143
|
+
? <a key={key} className={css.link} href={href} target="_blank" rel="noopener noreferrer">{node.label}</a>
|
|
144
|
+
: <span key={key} className={css.linkText}>{node.label}</span>
|
|
145
|
+
}
|
|
146
|
+
case 'audio': return <AudioNode key={`${key}:${node.src}`} node={node} />
|
|
147
|
+
case 'video': return <VideoNode key={`${key}:${node.src}`} node={node} />
|
|
148
|
+
case 'badge': {
|
|
149
|
+
const tone = node.tone ?? ''
|
|
150
|
+
return (
|
|
151
|
+
<span key={key} className={`${css.badge} ${css[tone] || ''}`}>
|
|
152
|
+
{node.icon !== undefined && <span aria-hidden>{node.icon} </span>}
|
|
153
|
+
{node.label}
|
|
154
|
+
</span>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
case 'stat': {
|
|
158
|
+
const down = node.delta !== undefined && node.delta.startsWith('-')
|
|
159
|
+
return (
|
|
160
|
+
<div key={key} className={css.stat}>
|
|
161
|
+
<span className={css.statLabel}>{node.label}</span>
|
|
162
|
+
<span className={css.statValue}>{node.value}</span>
|
|
163
|
+
{node.delta !== undefined && <span className={`${css.statDelta} ${down ? css.down : css.up}`}>{node.delta}</span>}
|
|
164
|
+
</div>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
case 'progress': {
|
|
168
|
+
const v = Math.max(0, Math.min(100, Number(node.value) || 0))
|
|
169
|
+
return (
|
|
170
|
+
<div
|
|
171
|
+
key={key}
|
|
172
|
+
className={css.progress}
|
|
173
|
+
role="progressbar"
|
|
174
|
+
aria-valuemin={0}
|
|
175
|
+
aria-valuemax={100}
|
|
176
|
+
aria-valuenow={v}
|
|
177
|
+
aria-label={node.label ?? node.valueLabel ?? undefined}
|
|
178
|
+
>
|
|
179
|
+
{(node.label !== undefined || node.valueLabel !== undefined) && (
|
|
180
|
+
<div className={css.progressRow}>
|
|
181
|
+
<span>{node.label}</span>
|
|
182
|
+
{node.valueLabel !== undefined && <span>{node.valueLabel}</span>}
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
<div className={css.track}><div className={css.fill} style={{ width: `${v}%` }} /></div>
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
case 'divider': return <hr key={key} className={css.divider} />
|
|
190
|
+
case 'list': {
|
|
191
|
+
const items = node.items.slice(0, GENUI_LIMITS.maxListItems)
|
|
192
|
+
return (
|
|
193
|
+
<div key={key} className={css.list}>
|
|
194
|
+
{items.map((item, i) => (
|
|
195
|
+
<div key={i} className={css.li}>
|
|
196
|
+
{isListItemNode(item)
|
|
197
|
+
? renderNode(item, i, onAction, depth + 1, answers)
|
|
198
|
+
: <><span className={css.liTitle}>{typeof item === 'string' ? item : item.title}</span>{typeof item !== 'string' && item.desc !== undefined && <span className={css.liDesc}>{item.desc}</span>}</>}
|
|
199
|
+
</div>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
case 'table': return <TableNode key={key} node={node} />
|
|
205
|
+
case 'chart': return <ChartNode key={key} chart={node} />
|
|
206
|
+
case 'tabs': return <TabsNode key={key} tabs={node} onAction={onAction} depth={depth + 1} answers={answers} />
|
|
207
|
+
case 'avatar': {
|
|
208
|
+
return (
|
|
209
|
+
<div key={key} className={css.avatar} style={{ background: node.color ?? avatarColor(node.name) }}>
|
|
210
|
+
{node.name.slice(0, 1).toUpperCase()}
|
|
211
|
+
</div>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
case 'spacer': return <div key={key} className={css.spacer} />
|
|
215
|
+
case 'plot': return <PlotNode key={key} plot={node} />
|
|
216
|
+
case 'callout': return <CalloutNode key={key} node={node} />
|
|
217
|
+
case 'steps': return <StepsNode key={key} steps={node} />
|
|
218
|
+
case 'keyvalue': return <KeyValueNode key={key} node={node} />
|
|
219
|
+
case 'diff': return <DiffNode key={key} node={node} />
|
|
220
|
+
case 'json': return <JsonNode key={key} node={node} />
|
|
221
|
+
case 'code': return <CodeNode key={key} node={node} />
|
|
222
|
+
case 'radio': return <RadioNode key={`${key}:r${answers?.round ?? 0}`} node={node} onAction={onAction} answers={answers} />
|
|
223
|
+
case 'submit': return <SubmitNode key={key} node={node} onAction={onAction} answers={answers} />
|
|
224
|
+
case 'switch': return <SwitchNode key={key} node={node} onAction={onAction} />
|
|
225
|
+
case 'slider': return <SliderNode key={key} node={node} onAction={onAction} answers={answers} />
|
|
226
|
+
case 'textarea': return <TextareaNode key={key} node={node} onAction={onAction} answers={answers} />
|
|
227
|
+
case 'accordion': return <AccordionNode key={key} node={node} onAction={onAction} depth={depth + 1} answers={answers} />
|
|
228
|
+
case 'copy': return <CopyNode key={key} node={node} />
|
|
229
|
+
case 'mermaid': return <MermaidNode key={key} node={node} />
|
|
230
|
+
case 'scene3d': return <Scene3DNode key={key} node={node} />
|
|
231
|
+
case 'timeline': return <TimelineNode key={key} node={node} />
|
|
232
|
+
case 'file-tree': return <FileTreeNode key={key} node={node} />
|
|
233
|
+
case 'breadcrumb': return <BreadcrumbNode key={key} node={node} />
|
|
234
|
+
case 'quiz': return <QuizNode key={key} node={node} onAction={onAction} />
|
|
235
|
+
case 'diagram': return <DiagramNode key={key} node={node} />
|
|
236
|
+
|
|
237
|
+
case 'echart': return <EChartNode key={key} node={node} />
|
|
238
|
+
default: {
|
|
239
|
+
// Plugin-registered custom types: a plugin ships a renderer through
|
|
240
|
+
// registerGenuiComponent; unregistered unknowns render nothing. The
|
|
241
|
+
// spec union is exhaustive, so an unknown node arrives as a plugin
|
|
242
|
+
// extension — treat it as a generic data node.
|
|
243
|
+
const custom = node as unknown as GenuiCustomNode
|
|
244
|
+
const Custom = getGenuiComponent?.(custom.type)
|
|
245
|
+
if (Custom !== undefined) {
|
|
246
|
+
return (
|
|
247
|
+
<Custom
|
|
248
|
+
key={key}
|
|
249
|
+
node={custom}
|
|
250
|
+
onAction={onAction}
|
|
251
|
+
renderChildren={(nodes, base) => nodes.map((c, i) => renderNode(c as GenuiNode, Number(base) + i, onAction, depth + 1, answers))}
|
|
252
|
+
/>
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
return null
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* ---------------- v1.1 nodes ---------------- */
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared block-level state types: the block props contract and the answers
|
|
3
|
+
* registry (grouped radios → submit grading). Lives OUTSIDE GenuiBlock.tsx so
|
|
4
|
+
* the per-family block modules can import the types without a cycle back
|
|
5
|
+
* into the block shell.
|
|
6
|
+
* @module @changfenhuang/dsh-genui/client/blocks/state
|
|
7
|
+
*/
|
|
8
|
+
import type { GenuiSpec } from '../spec.ts'
|
|
9
|
+
|
|
10
|
+
export interface GenuiBlockProps {
|
|
11
|
+
/** Parsed spec to render. */
|
|
12
|
+
spec: GenuiSpec
|
|
13
|
+
/**
|
|
14
|
+
* v2: optional action callback. Interactive components carrying an
|
|
15
|
+
* `action` field fire it (button click, switch toggle, form submit);
|
|
16
|
+
* absent = components are display-only (v1 behavior).
|
|
17
|
+
*/
|
|
18
|
+
onAction?: ((action: string, payload: Record<string, unknown>) => void) | undefined
|
|
19
|
+
/**
|
|
20
|
+
* v2.7: durable-state key (session + slot + content fingerprint). When set,
|
|
21
|
+
* interaction state (radio answers, submit lock, field values) persists to
|
|
22
|
+
* localStorage and restores on refresh / re-render of the same content.
|
|
23
|
+
*/
|
|
24
|
+
stateKey?: string | undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Per-question metadata registered by grouped radios for local grading. */
|
|
28
|
+
export interface QuestionMeta {
|
|
29
|
+
label: string
|
|
30
|
+
options: string[]
|
|
31
|
+
/** Correct option: index (number) or label (string); absent = no local grading. */
|
|
32
|
+
answer?: number | string | undefined
|
|
33
|
+
/** Shown after local grading. */
|
|
34
|
+
explanation?: string | undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Block-wide answers registry (v2.5/v2.6): grouped radios record selections
|
|
38
|
+
* and register their question metadata here; a sibling `submit` node either
|
|
39
|
+
* grades IN PLACE (questions carry `answer` data) or collects everything
|
|
40
|
+
* into ONE action. Lives in GenuiBlock state so re-renders keep the
|
|
41
|
+
* selections, threaded down through the recursive render walk. Answers are
|
|
42
|
+
* plain group → chosen-option-label strings (the display label lives in
|
|
43
|
+
* QuestionMeta; localStorage stores the same string table — no migration). */
|
|
44
|
+
export interface AnswersState {
|
|
45
|
+
answers: Record<string, string>
|
|
46
|
+
/** Field values by id (input/textarea with an `id`), collected by submit. */
|
|
47
|
+
fields: Record<string, string>
|
|
48
|
+
/** Field ids whose value must never be persisted or collected (secrets). */
|
|
49
|
+
secretFields: ReadonlySet<string>
|
|
50
|
+
meta: Record<string, QuestionMeta>
|
|
51
|
+
/** True after a local grading: questions are locked until 重新作答. */
|
|
52
|
+
locked: boolean
|
|
53
|
+
/** Bumped by every reset; radios use it as their remount key. */
|
|
54
|
+
round: number
|
|
55
|
+
setAnswer: (group: string, choice: string) => void
|
|
56
|
+
setField: (id: string, value: string) => void
|
|
57
|
+
registerSecretField: (id: string) => void
|
|
58
|
+
registerMeta: (group: string, meta: QuestionMeta) => void
|
|
59
|
+
clear: () => void
|
|
60
|
+
setLocked: (locked: boolean) => void
|
|
61
|
+
}
|