@c2-markdown-ui/svelte 0.0.0-stage → 0.4.2

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,65 @@
1
+ <script lang="ts">
2
+ import { parseChoices } from './remoteChoices';
3
+ interface Props {
4
+ question: string;
5
+ choices: string[];
6
+ correctAnswer?: string;
7
+ showFeedback?: boolean;
8
+ /** 是否禁用:禁用后选项不可点击 */
9
+ disabled?: boolean;
10
+ onchange: (value: { answer: string; isCorrect?: boolean }) => void;
11
+ }
12
+ let { question, choices, correctAnswer = undefined, showFeedback = false, disabled = false, onchange }: Props = $props();
13
+ let selectedAnswer = $state('');
14
+ let hasAnswered = $state(false);
15
+
16
+ // choices 支持 key:label 格式:value(key) 是取值,label 是展示文案
17
+ const items = $derived(parseChoices(choices));
18
+
19
+ function onSelect(answer: string) {
20
+ if (disabled) return;
21
+ selectedAnswer = answer;
22
+ hasAnswered = true;
23
+ const isCorrect = correctAnswer ? answer === correctAnswer : undefined;
24
+ onchange({ answer, isCorrect });
25
+ }
26
+
27
+ function choiceClass(value: string) {
28
+ let cls = 'mcq-choice';
29
+ if (hasAnswered && showFeedback && correctAnswer) {
30
+ if (value === correctAnswer) cls += ' mcq-choice-correct';
31
+ else if (value === selectedAnswer && value !== correctAnswer) cls += ' mcq-choice-incorrect';
32
+ } else if (value === selectedAnswer) {
33
+ cls += ' mcq-choice-selected';
34
+ }
35
+ return cls;
36
+ }
37
+
38
+ // 反馈文案:correctAnswer 是 key,展示时映射回 label
39
+ const correctLabel = $derived(
40
+ correctAnswer ? (items.find(i => i.value === correctAnswer)?.label ?? correctAnswer) : ''
41
+ );
42
+ </script>
43
+
44
+ <div class="widget-mcq">
45
+ <div class="mcq-question">{question}</div>
46
+ <div class="mcq-choices">
47
+ {#each items as item, idx}
48
+ <button
49
+ class={choiceClass(item.value)}
50
+ onclick={() => onSelect(item.value)}
51
+ disabled={disabled || (hasAnswered && showFeedback)}
52
+ >{item.label}</button>
53
+ {/each}
54
+ </div>
55
+ {#if hasAnswered && showFeedback && selectedAnswer}
56
+ <div class={`mcq-feedback ${selectedAnswer === correctAnswer ? 'correct' : 'incorrect'}`}>
57
+ {#if correctAnswer}
58
+ {selectedAnswer === correctAnswer ? '✓ Correct!' : `✗ Incorrect. The correct answer is: ${correctLabel}`}
59
+ {:else}
60
+ You selected: {selectedAnswer}
61
+ {/if}
62
+ </div>
63
+ {/if}
64
+
65
+ </div>
@@ -0,0 +1,149 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ label?: string;
4
+ /** 最小值 */
5
+ min?: number;
6
+ /** 最大值 */
7
+ max?: number;
8
+ /** 步长(点击上下按钮/方向键的增减量),默认 1 */
9
+ step?: number;
10
+ /** 数值精度(小数位数) */
11
+ precision?: number;
12
+ default?: number;
13
+ placeholder?: string;
14
+ hint?: string;
15
+ /** 是否必填:空值提交校验不通过 */
16
+ required?: boolean;
17
+ /** 校验失败提示文案 */
18
+ message?: string;
19
+ /** 外部(form 提交时)传入的错误信息,优先于组件内部即时校验 */
20
+ error?: string;
21
+ /** 是否禁用 */
22
+ disabled?: boolean;
23
+ onchange: (value: number | null) => void;
24
+ }
25
+ let {
26
+ label = '',
27
+ min,
28
+ max,
29
+ step = 1,
30
+ precision,
31
+ default: initial,
32
+ placeholder = '',
33
+ hint = '',
34
+ required = false,
35
+ message = '',
36
+ error = '',
37
+ disabled = false,
38
+ onchange
39
+ }: Props = $props();
40
+
41
+ // 输入框显示的原始文本:允许输入中途的临时态(如 "-" / "1."),失焦/回车时规范化
42
+ let text = $state(initial !== undefined && initial !== null ? format(initial) : '');
43
+ let localError = $state('');
44
+
45
+ /** 显示格式化:设置精度时固定位数小数(如 18 → "18.00",antd 行为) */
46
+ function format(v: number): string {
47
+ if (precision !== undefined && precision >= 0) return v.toFixed(precision);
48
+ return String(v);
49
+ }
50
+
51
+ /** 文本 → 数字:空串或输入中途态返回 null,非法文本返回 null */
52
+ function parse(v: string): number | null {
53
+ const s = v.trim();
54
+ if (!s || s === '-' || s === '.' || s === '-.' || s === '+' || s === '+.') return null;
55
+ const n = Number(s);
56
+ return isNaN(n) ? null : n;
57
+ }
58
+
59
+ /** 应用精度四舍五入 + min/max 钳制 */
60
+ function normalize(n: number): number {
61
+ if (precision !== undefined && precision >= 0) n = Number(n.toFixed(precision));
62
+ if (min !== undefined && n < min) n = min;
63
+ if (max !== undefined && n > max) n = max;
64
+ return n;
65
+ }
66
+
67
+ /** 校验当前值:返回错误文案或空串 */
68
+ function validate(v: number | null): string {
69
+ if (required && v === null) return message || (label ? `请填写${label}` : '该项为必填项');
70
+ return '';
71
+ }
72
+
73
+ /** 提交:规范化显示文本并对外发值(空/非法 → null) */
74
+ function commit() {
75
+ const n = parse(text);
76
+ if (n === null) {
77
+ text = '';
78
+ localError = validate(null);
79
+ onchange(null);
80
+ return;
81
+ }
82
+ const v = normalize(n);
83
+ text = format(v);
84
+ localError = '';
85
+ onchange(v);
86
+ }
87
+
88
+ /** 步进:direction 为 1(加)/ -1(减);空值从 0(或 min/max)起步 */
89
+ function stepBy(direction: 1 | -1) {
90
+ const base = parse(text);
91
+ const start = base ?? 0;
92
+ const v = normalize(start + step * direction);
93
+ text = format(v);
94
+ localError = '';
95
+ onchange(v);
96
+ }
97
+
98
+ function onKeydown(e: KeyboardEvent) {
99
+ if (e.key === 'ArrowUp') {
100
+ e.preventDefault();
101
+ stepBy(1);
102
+ } else if (e.key === 'ArrowDown') {
103
+ e.preventDefault();
104
+ stepBy(-1);
105
+ } else if (e.key === 'Enter') {
106
+ commit();
107
+ }
108
+ }
109
+
110
+ const current = $derived(parse(text));
111
+ const atMax = $derived(max !== undefined && current !== null && current >= max);
112
+ const atMin = $derived(min !== undefined && current !== null && current <= min);
113
+ const shownError = $derived(error || localError);
114
+ </script>
115
+
116
+ <div class="widget-number-input" class:widget-field-error={!!shownError}>
117
+ {#if label || hint || required}
118
+ <div class="widget-label-row">
119
+ {#if required}<span class="widget-required-mark" aria-hidden="true">*</span>{/if}
120
+ {#if label}<label>{label}</label>{/if}
121
+ {#if hint}<span class="widget-hint" data-hint={hint}>ⓘ</span>{/if}
122
+ </div>
123
+ {/if}
124
+ <div class="widget-field-content">
125
+ <div class="ni-wrap">
126
+ <input
127
+ type="text"
128
+ inputmode="decimal"
129
+ autocomplete="off"
130
+ class:widget-input-error={!!shownError}
131
+ bind:value={text}
132
+ {placeholder}
133
+ {disabled}
134
+ onkeydown={onKeydown}
135
+ onfocusout={commit}
136
+ oninput={() => { if (localError) localError = validate(parse(text)); }}
137
+ />
138
+ <span class="ni-steps" aria-hidden="true">
139
+ <button type="button" class="ni-step-up" onclick={() => stepBy(1)} disabled={disabled || atMax} aria-label="增加" tabindex={-1}>
140
+ <svg viewBox="0 0 10 6" width="10" height="6"><path d="M5 0 L10 6 H0 Z" fill="currentColor" /></svg>
141
+ </button>
142
+ <button type="button" class="ni-step-down" onclick={() => stepBy(-1)} disabled={disabled || atMin} aria-label="减少" tabindex={-1}>
143
+ <svg viewBox="0 0 10 6" width="10" height="6"><path d="M5 6 L0 0 H10 Z" fill="currentColor" /></svg>
144
+ </button>
145
+ </span>
146
+ </div>
147
+ {#if shownError}<div class="widget-error-text" role="alert">{shownError}</div>{/if}
148
+ </div>
149
+ </div>
@@ -0,0 +1,301 @@
1
+ <script lang="ts">
2
+ import { parseChoices, type RemoteChoice } from './remoteChoices';
3
+ interface QuizQuestion {
4
+ id: string;
5
+ type: 'mcq' | 'short-answer';
6
+ question: string;
7
+ points: number;
8
+ choices?: string[];
9
+ correctAnswer?: string;
10
+ placeholder?: string;
11
+ correctAnswers?: string[];
12
+ _streaming?: boolean; // Indicates question is still being streamed
13
+ }
14
+
15
+ interface QuizAnswer {
16
+ answer: string;
17
+ isCorrect: boolean;
18
+ points: number;
19
+ submitted: boolean;
20
+ }
21
+
22
+ interface QuizState {
23
+ answers: Record<string, QuizAnswer>;
24
+ totalScore: number;
25
+ maxScore: number;
26
+ questionsAnswered: number;
27
+ isCompleted: boolean;
28
+ }
29
+
30
+ interface Props {
31
+ title: string;
32
+ questions: QuizQuestion[];
33
+ showScore?: boolean;
34
+ showProgress?: boolean;
35
+ passingScore?: number;
36
+ /** 是否禁用:级联禁用所有题目 */
37
+ disabled?: boolean;
38
+ onchange: (value: QuizState) => void;
39
+ _streaming?: boolean; // Indicates quiz is still being streamed
40
+ _pending?: { awaitingQuestions?: boolean };
41
+ }
42
+
43
+ let { title, questions = [], showScore = true, showProgress = true, passingScore = undefined, disabled = false, onchange, _streaming = false, _pending }: Props = $props();
44
+
45
+ // Filter out incomplete questions that don't have enough data to render
46
+ const validQuestions = $derived(questions.filter(q => q.id && q.question));
47
+
48
+ let answers = $state<Record<string, QuizAnswer>>({});
49
+ let draftInputs = $state<Record<string, string>>({});
50
+
51
+ // Use validQuestions for calculations to handle streaming gracefully
52
+ const maxScore = $derived(validQuestions.reduce((s, q) => s + q.points, 0));
53
+ const totalScore = $derived(Object.values(answers).reduce((s, a) => s + a.points, 0));
54
+ const questionsAnswered = $derived(Object.keys(answers).length);
55
+ // Only mark as completed when not streaming and all questions answered
56
+ const isCompleted = $derived(!_streaming && validQuestions.length > 0 && questionsAnswered === validQuestions.length);
57
+ const progressPercentage = $derived(validQuestions.length > 0 ? Math.round((questionsAnswered / validQuestions.length) * 100) : 0);
58
+ const scorePercentage = $derived(maxScore > 0 ? Math.round((totalScore / maxScore) * 100) : 0);
59
+ const isPassing = $derived(passingScore !== undefined ? scorePercentage >= (passingScore as number) : false);
60
+
61
+ // Check if we're awaiting questions
62
+ const awaitingQuestions = $derived(_streaming && validQuestions.length === 0);
63
+
64
+ function emitChange() {
65
+ onchange({
66
+ answers: { ...answers },
67
+ totalScore,
68
+ maxScore,
69
+ questionsAnswered,
70
+ isCompleted
71
+ });
72
+ }
73
+
74
+ // mcq choices 支持 key:label 格式:value(key) 是取值,label 是展示文案
75
+ const questionItems = $derived<Record<string, RemoteChoice[]>>(
76
+ Object.fromEntries(validQuestions
77
+ .filter(q => q.type === 'mcq' && q.choices)
78
+ .map(q => [q.id, parseChoices(q.choices as string[])]))
79
+ );
80
+
81
+ function onAnswer(questionId: string, userAnswer: string) {
82
+ if (disabled) return;
83
+ const q = validQuestions.find(q => q.id === questionId);
84
+ if (!q || answers[questionId]?.submitted) return;
85
+
86
+ let correct = false;
87
+ if (q.type === 'mcq') {
88
+ correct = q.correctAnswer === userAnswer;
89
+ } else if (q.type === 'short-answer') {
90
+ if (q.correctAnswers) {
91
+ correct = q.correctAnswers.some(a => a.toLowerCase() === userAnswer.toLowerCase());
92
+ }
93
+ }
94
+
95
+ answers = { ...answers, [questionId]: { answer: userAnswer, isCorrect: correct, points: correct ? q.points : 0, submitted: true } };
96
+ if (draftInputs[questionId]) {
97
+ const { [questionId]: _, ...rest } = draftInputs; // remove draft
98
+ draftInputs = rest;
99
+ }
100
+ emitChange();
101
+ }
102
+
103
+ function submitShort(q: QuizQuestion) {
104
+ const val = (draftInputs[q.id] || '').trim();
105
+ if (!val) return;
106
+ onAnswer(q.id, val);
107
+ }
108
+
109
+ function mcqChoiceClass(q: QuizQuestion, choice: string) {
110
+ const ans = answers[q.id];
111
+ let cls = 'quiz-choice';
112
+ if (ans?.submitted && typeof q.correctAnswer !== 'undefined') {
113
+ if (choice === q.correctAnswer) cls += ' quiz-choice-correct';
114
+ else if (choice === ans.answer && choice !== q.correctAnswer) cls += ' quiz-choice-incorrect';
115
+ }
116
+ return cls;
117
+ }
118
+ </script>
119
+
120
+ <div class="widget-quiz" class:streaming={_streaming}>
121
+ <div class="quiz-header">
122
+ <h2 class="quiz-title">{title}{#if _streaming}<span class="streaming-indicator">...</span>{/if}</h2>
123
+ <div class="quiz-stats">
124
+ {#if showProgress}
125
+ <div class="quiz-progress">
126
+ <span class="quiz-progress-text">
127
+ Progress: {questionsAnswered}/{_streaming ? `${validQuestions.length}+` : validQuestions.length} questions
128
+ </span>
129
+ <div class="quiz-progress-bar">
130
+ <div class="quiz-progress-fill" style={`width: ${progressPercentage}%`}></div>
131
+ </div>
132
+ </div>
133
+ {/if}
134
+ {#if showScore}
135
+ <div class="quiz-score">
136
+ <span class="quiz-score-text">Score: {totalScore}/{_streaming ? `${maxScore}+` : maxScore} ({scorePercentage}%)</span>
137
+ </div>
138
+ {/if}
139
+ </div>
140
+ </div>
141
+
142
+ <div class="quiz-questions">
143
+ {#if awaitingQuestions}
144
+ <!-- Skeleton loading state while waiting for questions -->
145
+ <div class="quiz-question quiz-question-skeleton">
146
+ <div class="quiz-question-header">
147
+ <h3 class="quiz-question-text skeleton-text">Loading question...</h3>
148
+ <span class="quiz-question-points skeleton-text">-- pts</span>
149
+ </div>
150
+ <div class="quiz-choices">
151
+ <div class="quiz-choice skeleton-choice"></div>
152
+ <div class="quiz-choice skeleton-choice"></div>
153
+ <div class="quiz-choice skeleton-choice"></div>
154
+ </div>
155
+ </div>
156
+ {/if}
157
+ {#each validQuestions as q (q.id)}
158
+ {#if q.type === 'mcq'}
159
+ <div class="quiz-question" class:question-streaming={q._streaming}>
160
+ <div class="quiz-question-header">
161
+ <h3 class="quiz-question-text">{q.question}{#if q._streaming}<span class="streaming-indicator">...</span>{/if}</h3>
162
+ <span class="quiz-question-points">{q.points} pts</span>
163
+ </div>
164
+ <div class="quiz-choices">
165
+ {#if q.choices && q.choices.length > 0}
166
+ {@const qItems = questionItems[q.id] ?? []}
167
+ {#each qItems as it}
168
+ <button class={mcqChoiceClass(q, it.value)} onclick={() => onAnswer(q.id, it.value)} disabled={disabled || answers[q.id]?.submitted || q._streaming}>{it.label}</button>
169
+ {/each}
170
+ {#if q._streaming}
171
+ <div class="quiz-choice skeleton-choice">...</div>
172
+ {/if}
173
+ {:else if q._streaming}
174
+ <div class="quiz-choice skeleton-choice"></div>
175
+ <div class="quiz-choice skeleton-choice"></div>
176
+ {/if}
177
+ </div>
178
+ {#if answers[q.id]?.submitted}
179
+ {#if typeof q.correctAnswer !== 'undefined'}
180
+ {@const correctLabel = (questionItems[q.id] ?? []).find(i => i.value === q.correctAnswer)?.label ?? q.correctAnswer}
181
+ <div class={`quiz-feedback ${answers[q.id].isCorrect ? 'correct' : 'incorrect'}`}>
182
+ {answers[q.id].isCorrect ? `✓ Correct! (+${answers[q.id].points} pts)` : `✗ Incorrect. The correct answer is: ${correctLabel}`}
183
+ </div>
184
+ {:else}
185
+ <div class="quiz-feedback">You selected: {answers[q.id].answer}</div>
186
+ {/if}
187
+ {/if}
188
+ </div>
189
+ {:else}
190
+ <div class="quiz-question">
191
+ <div class="quiz-question-header">
192
+ <h3 class="quiz-question-text">{q.question}</h3>
193
+ <span class="quiz-question-points">{q.points} pts</span>
194
+ </div>
195
+ <div class="quiz-input-container">
196
+ <input
197
+ type="text"
198
+ value={answers[q.id]?.submitted ? answers[q.id].answer : (draftInputs[q.id] || '')}
199
+ oninput={(e) => { if (!disabled && !answers[q.id]?.submitted) draftInputs[q.id] = (e.target as HTMLInputElement).value; }}
200
+ onkeydown={(e) => { if (e.key === 'Enter' && !disabled && !answers[q.id]?.submitted) submitShort(q); }}
201
+ placeholder={q.placeholder || 'Type your answer here...'}
202
+ disabled={disabled || answers[q.id]?.submitted}
203
+ class={`quiz-input ${answers[q.id]?.submitted ? ((q.correctAnswers && q.correctAnswers.length > 0) ? (answers[q.id].isCorrect ? 'quiz-input-correct' : 'quiz-input-incorrect') : '') : ''}`}
204
+ />
205
+ {#if !answers[q.id]?.submitted}
206
+ <button class="quiz-submit" onclick={() => submitShort(q)} disabled={disabled || !((draftInputs[q.id] || '').trim())}>Submit</button>
207
+ {/if}
208
+ </div>
209
+ {#if answers[q.id]?.submitted}
210
+ {#if (q.correctAnswers && q.correctAnswers.length > 0)}
211
+ <div class={`quiz-feedback ${answers[q.id].isCorrect ? 'correct' : 'incorrect'}`}>
212
+ {answers[q.id].isCorrect ? `✓ Correct! (+${answers[q.id].points} pts)` : `✗ Incorrect. Accepted answers: ${(q.correctAnswers || []).join(', ') || 'N/A'}`}
213
+ </div>
214
+ {:else}
215
+ <div class="quiz-feedback">Your answer: {answers[q.id].answer}</div>
216
+ {/if}
217
+ {/if}
218
+ </div>
219
+ {/if}
220
+ {/each}
221
+ </div>
222
+
223
+ {#if isCompleted}
224
+ <div class="quiz-summary">
225
+ <h3>Quiz Complete!</h3>
226
+ <div class="quiz-final-score">Final Score: {totalScore}/{maxScore} points ({scorePercentage}%)</div>
227
+ {#if passingScore !== undefined}
228
+ <div class={`quiz-result ${isPassing ? 'passed' : 'failed'}`}>
229
+ {isPassing ? '🎉 Passed!' : '❌ Failed'} (Passing: {passingScore}%)
230
+ </div>
231
+ {/if}
232
+ </div>
233
+ {/if}
234
+
235
+ {#if _streaming && validQuestions.length > 0}
236
+ <!-- Show indicator that more questions may be loading -->
237
+ <div class="quiz-streaming-footer">
238
+ <span class="streaming-dots">Loading more content</span>
239
+ </div>
240
+ {/if}
241
+ </div>
242
+
243
+ <style>
244
+ .streaming-indicator {
245
+ animation: pulse 1.5s ease-in-out infinite;
246
+ opacity: 0.6;
247
+ }
248
+
249
+ .streaming-dots::after {
250
+ content: '...';
251
+ animation: dots 1.5s steps(4, end) infinite;
252
+ }
253
+
254
+ @keyframes pulse {
255
+ 0%, 100% { opacity: 0.6; }
256
+ 50% { opacity: 1; }
257
+ }
258
+
259
+ @keyframes dots {
260
+ 0%, 20% { content: ''; }
261
+ 40% { content: '.'; }
262
+ 60% { content: '..'; }
263
+ 80%, 100% { content: '...'; }
264
+ }
265
+
266
+ .quiz-question-skeleton {
267
+ opacity: 0.6;
268
+ }
269
+
270
+ .skeleton-text {
271
+ background: linear-gradient(90deg, rgba(0, 0, 0, 0.06) 25%, rgba(0, 0, 0, 0.04) 50%, rgba(0, 0, 0, 0.06) 75%);
272
+ background-size: 200% 100%;
273
+ animation: shimmer 1.5s infinite;
274
+ border-radius: 4px;
275
+ }
276
+
277
+ .skeleton-choice {
278
+ background: linear-gradient(90deg, rgba(0, 0, 0, 0.06) 25%, rgba(0, 0, 0, 0.04) 50%, rgba(0, 0, 0, 0.06) 75%);
279
+ background-size: 200% 100%;
280
+ animation: shimmer 1.5s infinite;
281
+ height: 2.5rem;
282
+ border-radius: 6px;
283
+ pointer-events: none;
284
+ }
285
+
286
+ @keyframes shimmer {
287
+ 0% { background-position: 200% 0; }
288
+ 100% { background-position: -200% 0; }
289
+ }
290
+
291
+ .question-streaming {
292
+ border-left: 3px solid #1677ff;
293
+ }
294
+
295
+ .quiz-streaming-footer {
296
+ text-align: center;
297
+ padding: 0.75rem;
298
+ color: rgba(0, 0, 0, 0.65);
299
+ font-size: 0.875rem;
300
+ }
301
+ </style>
@@ -0,0 +1,180 @@
1
+ <script lang="ts">
2
+ import { normalizeRemoteChoices, parseChoice, parseChoices, type RemoteChoice } from './remoteChoices';
3
+ interface Props {
4
+ /** 选项:字符串数组(每项支持 key:label 格式)或对象数组({key|value,label},取值为 key/value,展示 label) */
5
+ choices: string[] | Array<{ value?: string; key?: string; label?: string }>;
6
+ label?: string;
7
+ default?: string | string[];
8
+ /** 按钮样式(参考 antd Radio optionType:button):渲染为按钮组而非圆点单选 */
9
+ button?: boolean;
10
+ /** 远端数据源 URL:设置后运行时 fetch 拉取选项,choices 作为初始/后备选项 */
11
+ url?: string;
12
+ /** 选项排列方向:vertical 纵向(默认)/ horizontal 横向;按钮样式默认 horizontal */
13
+ direction?: 'vertical' | 'horizontal';
14
+ /** 自定义布局:每行列数(设置后按 N 列网格排列,优先于 direction) */
15
+ columns?: number;
16
+ hint?: string;
17
+ /** 是否必填 */
18
+ required?: boolean;
19
+ /** 校验失败提示文案 */
20
+ message?: string;
21
+ /** 外部(form 提交时)传入的错误信息 */
22
+ error?: string;
23
+ /** 是否禁用 */
24
+ disabled?: boolean;
25
+ onchange: (value: string) => void;
26
+ }
27
+ let {
28
+ choices,
29
+ label = '',
30
+ default: initial,
31
+ button = false,
32
+ url = '',
33
+ direction,
34
+ columns = 0,
35
+ hint = '',
36
+ required = false,
37
+ message = '',
38
+ error = '',
39
+ disabled = false,
40
+ onchange
41
+ }: Props = $props();
42
+
43
+ // 初始选中:default 可能是 key、label 或 key:label 原文,统一解析为 key 体系;
44
+ // 对象数组 choices 时 default 写 label 也映射回 value;无 default 时选中第一项(与 button-group 一致)
45
+ const localItems = parseChoices(choices);
46
+ const mapInitial = (v: any): string => {
47
+ const s = String(v);
48
+ const hit = localItems.find(it => it.value === s || it.label === s);
49
+ return hit ? hit.value : parseChoice(s).value;
50
+ };
51
+ let val = $state(
52
+ initial !== undefined && initial !== null && initial !== ''
53
+ ? mapInitial(Array.isArray(initial) ? initial[0] : initial)
54
+ : (localItems[0]?.value ?? '')
55
+ );
56
+ let localError = $state('');
57
+
58
+ // 远端选项:拉取成功后覆盖本地 choices(value=表单取值,label=展示文案)
59
+ // 加载失败静默处理:回落本地 choices,均无数据时显示灰色"暂无数据"
60
+ let remoteChoices = $state<RemoteChoice[] | null>(null);
61
+ let loading = $state(false);
62
+
63
+ $effect(() => {
64
+ if (!url) { remoteChoices = null; return; }
65
+ loading = true;
66
+ let cancelled = false;
67
+ (async () => {
68
+ try {
69
+ const res = await fetch(url);
70
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
71
+ const json: any = await res.json();
72
+ if (cancelled) return;
73
+ const list = normalizeRemoteChoices(json);
74
+ if (list.length === 0) throw new Error('响应中没有可用选项');
75
+ remoteChoices = list;
76
+ // 选中值处理:当前值(可能是本地 label)映射到远端 value,不在远端取值中则回落第一项
77
+ const mapped = resolveMapped(val, list);
78
+ if (mapped !== val) {
79
+ val = mapped;
80
+ onchange(mapped);
81
+ }
82
+ } catch {
83
+ // 加载失败:静默回落本地 choices
84
+ } finally {
85
+ if (!cancelled) loading = false;
86
+ }
87
+ })();
88
+ return () => { cancelled = true; };
89
+ });
90
+
91
+ /** 当前值映射到远端取值体系:命中返回远端 value,未命中回落第一项 */
92
+ function resolveMapped(v: string, list: RemoteChoice[]): string {
93
+ const hit = list.find(r => r.value === v || r.label === v);
94
+ if (hit) return hit.value;
95
+ return list[0]?.value ?? '';
96
+ }
97
+
98
+ function validate(v: string): string {
99
+ if (required && v === '') return message || (label ? `请填写${label}` : '请至少选择一项');
100
+ return '';
101
+ }
102
+
103
+ function pick(item: RemoteChoice) {
104
+ if (disabled) return;
105
+ val = item.value;
106
+ localError = validate(val);
107
+ onchange(item.value);
108
+ }
109
+
110
+ const shownError = $derived(error || localError);
111
+ /** 展示条目:远端拉取成功用 {value,label};否则本地 choices(支持 key:label 格式与对象数组) */
112
+ const items = $derived(remoteChoices ?? localItems);
113
+ </script>
114
+
115
+ {#if button}
116
+ <!-- 按钮样式(antd Radio optionType:button):复用 widget-button-group 视觉 -->
117
+ <div class="widget-button-group" class:widget-field-error={!!shownError}>
118
+ {#if label}<label>{label}</label>{/if}
119
+ <div
120
+ role="group"
121
+ aria-label={label}
122
+ class="radio-button-group"
123
+ data-direction={columns >= 1 ? 'grid' : (direction || 'horizontal')}
124
+ style={columns >= 1 ? `grid-template-columns: repeat(${columns}, minmax(0, 1fr))` : ''}
125
+ >
126
+ {#if items.length === 0}
127
+ <div style="color: rgba(0, 0, 0, 0.45); font-size: 13px;">{loading ? '加载中…' : '暂无数据'}</div>
128
+ {/if}
129
+ {#each items as item (item.value)}
130
+ <button
131
+ type="button"
132
+ aria-pressed={val === item.value}
133
+ {disabled}
134
+ onclick={() => pick(item)}
135
+ >
136
+ {item.label}
137
+ </button>
138
+ {/each}
139
+ </div>
140
+ {#if shownError}<div class="widget-error-text" role="alert">{shownError}</div>{/if}
141
+ </div>
142
+ {:else}
143
+ <!-- 默认圆点单选样式(antd Radio) -->
144
+ <div class="selector-multi" class:widget-field-error={!!shownError}>
145
+ {#if label || hint || required}
146
+ <div class="widget-label-row">
147
+ {#if required}<span class="widget-required-mark" aria-hidden="true">*</span>{/if}
148
+ {#if label}<div class="selector-multi-label">{label}</div>{/if}
149
+ {#if hint}<span class="widget-hint" data-hint={hint}>ⓘ</span>{/if}
150
+ </div>
151
+ {/if}
152
+ <div class="widget-field-content">
153
+ <!-- 选项布局:columns 自定义列数(grid)优先;direction 横向/纵向(默认纵向) -->
154
+ <div
155
+ class="radio-group"
156
+ role="radiogroup"
157
+ aria-label={label}
158
+ data-direction={columns >= 1 ? 'grid' : (direction || 'vertical')}
159
+ style={columns >= 1 ? `grid-template-columns: repeat(${columns}, minmax(0, 1fr))` : ''}
160
+ >
161
+ {#if items.length === 0}
162
+ <div style="color: rgba(0, 0, 0, 0.45); font-size: 13px;">{loading ? '加载中…' : '暂无数据'}</div>
163
+ {/if}
164
+ {#each items as item (item.value)}
165
+ <label class="radio-item">
166
+ <input
167
+ type="radio"
168
+ value={item.value}
169
+ checked={val === item.value}
170
+ {disabled}
171
+ onchange={() => pick(item)}
172
+ />
173
+ <span>{item.label}</span>
174
+ </label>
175
+ {/each}
176
+ </div>
177
+ {#if shownError}<div class="widget-error-text" role="alert">{shownError}</div>{/if}
178
+ </div>
179
+ </div>
180
+ {/if}