@c2-markdown-ui/vue 0.0.0-stage → 0.4.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.
- package/README.md +112 -2
- package/dist/MarkdownUI.vue.d.ts +15 -0
- package/dist/favicon.ico +0 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +10843 -0
- package/dist/index.umd.cjs +53 -0
- package/dist/widgets/Button.vue.d.ts +19 -0
- package/dist/widgets/ButtonGroup.vue.d.ts +13 -0
- package/dist/widgets/Cascader.vue.d.ts +48 -0
- package/dist/widgets/Chart.vue.d.ts +24 -0
- package/dist/widgets/DatePicker.vue.d.ts +42 -0
- package/dist/widgets/Form.vue.d.ts +59 -0
- package/dist/widgets/Incomplete.vue.d.ts +2 -0
- package/dist/widgets/MultipleChoiceQuestion.vue.d.ts +20 -0
- package/dist/widgets/NumberInput.vue.d.ts +37 -0
- package/dist/widgets/Quiz.vue.d.ts +39 -0
- package/dist/widgets/RadioGroup.vue.d.ts +43 -0
- package/dist/widgets/Select.vue.d.ts +27 -0
- package/dist/widgets/SelectMulti.vue.d.ts +38 -0
- package/dist/widgets/ShortAnswerQuestion.vue.d.ts +22 -0
- package/dist/widgets/Slider.vue.d.ts +21 -0
- package/dist/widgets/Switch.vue.d.ts +28 -0
- package/dist/widgets/Text.vue.d.ts +22 -0
- package/dist/widgets/TextInput.vue.d.ts +32 -0
- package/dist/widgets/TreeSelect.vue.d.ts +109 -0
- package/dist/widgets/Widget.vue.d.ts +8 -0
- package/dist/widgets/chartRemoteData.d.ts +17 -0
- package/dist/widgets/index.d.ts +20 -0
- package/dist/widgets/remoteChoices.d.ts +22 -0
- package/dist/widgets/treeSelectTypes.d.ts +43 -0
- package/package.json +46 -4
- package/src/App.vue +11 -0
- package/src/lib/MarkdownUI.vue +105 -0
- package/src/lib/index.ts +28 -0
- package/src/lib/widgets/Button.vue +47 -0
- package/src/lib/widgets/ButtonGroup.vue +43 -0
- package/src/lib/widgets/Cascader.vue +475 -0
- package/src/lib/widgets/Chart.vue +287 -0
- package/src/lib/widgets/DatePicker.vue +659 -0
- package/src/lib/widgets/Form.vue +479 -0
- package/src/lib/widgets/Incomplete.vue +56 -0
- package/src/lib/widgets/MultipleChoiceQuestion.vue +73 -0
- package/src/lib/widgets/NumberInput.vue +179 -0
- package/src/lib/widgets/Quiz.vue +205 -0
- package/src/lib/widgets/RadioGroup.vue +237 -0
- package/src/lib/widgets/Select.vue +279 -0
- package/src/lib/widgets/SelectMulti.vue +145 -0
- package/src/lib/widgets/ShortAnswerQuestion.vue +79 -0
- package/src/lib/widgets/Slider.vue +61 -0
- package/src/lib/widgets/Switch.vue +68 -0
- package/src/lib/widgets/Text.vue +59 -0
- package/src/lib/widgets/TextInput.vue +83 -0
- package/src/lib/widgets/TreeSelect.vue +872 -0
- package/src/lib/widgets/Widget.vue +185 -0
- package/src/lib/widgets/chartRemoteData.ts +84 -0
- package/src/lib/widgets/index.ts +20 -0
- package/src/lib/widgets/remoteChoices.ts +64 -0
- package/src/lib/widgets/treeSelectTypes.ts +49 -0
- package/src/lib/widgets.css +1754 -0
- package/src/main.ts +4 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="widget-number-input" :class="{ 'widget-field-error': !!shownError }">
|
|
3
|
+
<div v-if="label || hint || required" class="widget-label-row">
|
|
4
|
+
<span v-if="required" class="widget-required-mark" aria-hidden="true">*</span>
|
|
5
|
+
<label v-if="label">{{ label }}</label>
|
|
6
|
+
<span v-if="hint" class="widget-hint" :data-hint="hint">ⓘ</span>
|
|
7
|
+
</div>
|
|
8
|
+
<div class="widget-field-content">
|
|
9
|
+
<div class="ni-wrap">
|
|
10
|
+
<input
|
|
11
|
+
type="text"
|
|
12
|
+
inputmode="decimal"
|
|
13
|
+
autocomplete="off"
|
|
14
|
+
v-model="text"
|
|
15
|
+
:class="{ 'widget-input-error': !!shownError }"
|
|
16
|
+
:placeholder="placeholder"
|
|
17
|
+
:disabled="disabled"
|
|
18
|
+
@keydown="onKeydown"
|
|
19
|
+
@focusout="commit"
|
|
20
|
+
@input="onInput"
|
|
21
|
+
/>
|
|
22
|
+
<span class="ni-steps" aria-hidden="true">
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
class="ni-step-up"
|
|
26
|
+
@click="stepBy(1)"
|
|
27
|
+
:disabled="disabled || atMax"
|
|
28
|
+
aria-label="增加"
|
|
29
|
+
tabindex="-1"
|
|
30
|
+
>
|
|
31
|
+
<svg viewBox="0 0 10 6" width="10" height="6"><path d="M5 0 L10 6 H0 Z" fill="currentColor" /></svg>
|
|
32
|
+
</button>
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
class="ni-step-down"
|
|
36
|
+
@click="stepBy(-1)"
|
|
37
|
+
:disabled="disabled || atMin"
|
|
38
|
+
aria-label="减少"
|
|
39
|
+
tabindex="-1"
|
|
40
|
+
>
|
|
41
|
+
<svg viewBox="0 0 10 6" width="10" height="6"><path d="M5 6 L0 0 H10 Z" fill="currentColor" /></svg>
|
|
42
|
+
</button>
|
|
43
|
+
</span>
|
|
44
|
+
</div>
|
|
45
|
+
<div v-if="shownError" class="widget-error-text" role="alert">{{ shownError }}</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</template>
|
|
49
|
+
|
|
50
|
+
<script setup lang="ts">
|
|
51
|
+
import { ref, computed, defineProps, defineEmits, watch } from 'vue'
|
|
52
|
+
|
|
53
|
+
interface NumberInputProps {
|
|
54
|
+
label?: string
|
|
55
|
+
/** 最小值 */
|
|
56
|
+
min?: number
|
|
57
|
+
/** 最大值 */
|
|
58
|
+
max?: number
|
|
59
|
+
/** 步长(点击上下按钮/方向键的增减量),默认 1 */
|
|
60
|
+
step?: number
|
|
61
|
+
/** 数值精度(小数位数) */
|
|
62
|
+
precision?: number
|
|
63
|
+
default?: number
|
|
64
|
+
placeholder?: string
|
|
65
|
+
hint?: string
|
|
66
|
+
/** 是否必填:空值提交校验不通过 */
|
|
67
|
+
required?: boolean
|
|
68
|
+
/** 校验失败提示文案 */
|
|
69
|
+
message?: string
|
|
70
|
+
/** 外部(form 提交时)传入的错误信息,优先于组件内部即时校验 */
|
|
71
|
+
error?: string
|
|
72
|
+
/** 是否禁用 */
|
|
73
|
+
disabled?: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface NumberInputEvents {
|
|
77
|
+
change: [value: number | null]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const props = withDefaults(defineProps<NumberInputProps>(), {
|
|
81
|
+
label: '',
|
|
82
|
+
step: 1,
|
|
83
|
+
placeholder: '',
|
|
84
|
+
hint: '',
|
|
85
|
+
required: false,
|
|
86
|
+
message: '',
|
|
87
|
+
error: '',
|
|
88
|
+
disabled: false
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const emit = defineEmits<NumberInputEvents>()
|
|
92
|
+
|
|
93
|
+
/** 显示格式化:设置精度时固定位数小数(如 18 → "18.00",antd 行为) */
|
|
94
|
+
function format(v: number): string {
|
|
95
|
+
if (props.precision !== undefined && props.precision >= 0) return v.toFixed(props.precision)
|
|
96
|
+
return String(v)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 文本 → 数字:空串或输入中途态返回 null,非法文本返回 null */
|
|
100
|
+
function parse(v: string): number | null {
|
|
101
|
+
const s = v.trim()
|
|
102
|
+
if (!s || s === '-' || s === '.' || s === '-.' || s === '+' || s === '+.') return null
|
|
103
|
+
const n = Number(s)
|
|
104
|
+
return isNaN(n) ? null : n
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 应用精度四舍五入 + min/max 钳制 */
|
|
108
|
+
function normalize(n: number): number {
|
|
109
|
+
if (props.precision !== undefined && props.precision >= 0) n = Number(n.toFixed(props.precision))
|
|
110
|
+
if (props.min !== undefined && n < props.min) n = props.min
|
|
111
|
+
if (props.max !== undefined && n > props.max) n = props.max
|
|
112
|
+
return n
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 校验当前值:返回错误文案或空串 */
|
|
116
|
+
function validate(v: number | null): string {
|
|
117
|
+
if (props.required && v === null) return props.message || (props.label ? `请填写${props.label}` : '该项为必填项')
|
|
118
|
+
return ''
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 输入框显示的原始文本:允许输入中途的临时态(如 "-" / "1."),失焦/回车时规范化
|
|
122
|
+
const text = ref(props.default !== undefined && props.default !== null ? format(props.default) : '')
|
|
123
|
+
const localError = ref('')
|
|
124
|
+
|
|
125
|
+
// default 变化时同步显示(未交互前 DSL 默认值调整实时生效)
|
|
126
|
+
watch(
|
|
127
|
+
() => props.default,
|
|
128
|
+
(v) => {
|
|
129
|
+
text.value = v !== undefined && v !== null ? format(v) : ''
|
|
130
|
+
localError.value = ''
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
/** 提交:规范化显示文本并对外发值(空/非法 → null) */
|
|
135
|
+
function commit() {
|
|
136
|
+
const n = parse(text.value)
|
|
137
|
+
if (n === null) {
|
|
138
|
+
text.value = ''
|
|
139
|
+
localError.value = validate(null)
|
|
140
|
+
emit('change', null)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const v = normalize(n)
|
|
144
|
+
text.value = format(v)
|
|
145
|
+
localError.value = ''
|
|
146
|
+
emit('change', v)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 步进:direction 为 1(加)/ -1(减);空值从 0(或 min/max)起步 */
|
|
150
|
+
function stepBy(direction: 1 | -1) {
|
|
151
|
+
const base = parse(text.value)
|
|
152
|
+
const start = base ?? 0
|
|
153
|
+
const v = normalize(start + (props.step ?? 1) * direction)
|
|
154
|
+
text.value = format(v)
|
|
155
|
+
localError.value = ''
|
|
156
|
+
emit('change', v)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function onKeydown(e: KeyboardEvent) {
|
|
160
|
+
if (e.key === 'ArrowUp') {
|
|
161
|
+
e.preventDefault()
|
|
162
|
+
stepBy(1)
|
|
163
|
+
} else if (e.key === 'ArrowDown') {
|
|
164
|
+
e.preventDefault()
|
|
165
|
+
stepBy(-1)
|
|
166
|
+
} else if (e.key === 'Enter') {
|
|
167
|
+
commit()
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function onInput() {
|
|
172
|
+
if (localError.value) localError.value = validate(parse(text.value))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const current = computed(() => parse(text.value))
|
|
176
|
+
const atMax = computed(() => props.max !== undefined && current.value !== null && current.value >= props.max)
|
|
177
|
+
const atMin = computed(() => props.min !== undefined && current.value !== null && current.value <= props.min)
|
|
178
|
+
const shownError = computed(() => props.error || localError.value)
|
|
179
|
+
</script>
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="widget-quiz">
|
|
3
|
+
<div class="quiz-header">
|
|
4
|
+
<h2 class="quiz-title">{{ title }}</h2>
|
|
5
|
+
<div class="quiz-stats">
|
|
6
|
+
<div v-if="showProgress" class="quiz-progress">
|
|
7
|
+
<span class="quiz-progress-text">
|
|
8
|
+
Progress: {{ questionsAnswered }}/{{ questions.length }} questions
|
|
9
|
+
</span>
|
|
10
|
+
<div class="quiz-progress-bar">
|
|
11
|
+
<div class="quiz-progress-fill" :style="{ width: `${progressPercentage}%` }" />
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
<div v-if="showScore" class="quiz-score">
|
|
15
|
+
<span class="quiz-score-text">
|
|
16
|
+
Score: {{ totalScore }}/{{ maxScore }} ({{ scorePercentage }}%)
|
|
17
|
+
</span>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div class="quiz-questions">
|
|
23
|
+
<div v-for="q in questions" :key="q.id">
|
|
24
|
+
<div v-if="q.type === 'mcq'" class="quiz-question">
|
|
25
|
+
<div class="quiz-question-header">
|
|
26
|
+
<h3 class="quiz-question-text">{{ q.question }}</h3>
|
|
27
|
+
<span class="quiz-question-points">{{ q.points }} pts</span>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="quiz-choices">
|
|
30
|
+
<button
|
|
31
|
+
v-for="(choice, idx) in q.choices || []"
|
|
32
|
+
:key="idx"
|
|
33
|
+
:class="mcqChoiceClass(q, choice)"
|
|
34
|
+
:disabled="answers[q.id]?.submitted"
|
|
35
|
+
@click="onAnswer(q.id, choice)"
|
|
36
|
+
>
|
|
37
|
+
{{ choice }}
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
<div v-if="answers[q.id]?.submitted">
|
|
41
|
+
<div v-if="typeof q.correctAnswer !== 'undefined'" :class="['quiz-feedback', answers[q.id].isCorrect ? 'correct' : 'incorrect']">
|
|
42
|
+
{{ answers[q.id].isCorrect ? `✓ Correct! (+${answers[q.id].points} pts)` : `✗ Incorrect. The correct answer is: ${q.correctAnswer}` }}
|
|
43
|
+
</div>
|
|
44
|
+
<div v-else class="quiz-feedback">
|
|
45
|
+
You selected: {{ answers[q.id].answer }}
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div v-else-if="q.type === 'short-answer'" class="quiz-question">
|
|
51
|
+
<div class="quiz-question-header">
|
|
52
|
+
<h3 class="quiz-question-text">{{ q.question }}</h3>
|
|
53
|
+
<span class="quiz-question-points">{{ q.points }} pts</span>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="quiz-input-container">
|
|
56
|
+
<input
|
|
57
|
+
type="text"
|
|
58
|
+
:value="answers[q.id]?.submitted ? answers[q.id].answer : (draftInputs[q.id] || '')"
|
|
59
|
+
:placeholder="q.placeholder || 'Type your answer here...'"
|
|
60
|
+
:disabled="answers[q.id]?.submitted"
|
|
61
|
+
:class="['quiz-input', answers[q.id]?.submitted ? ((q.correctAnswers && q.correctAnswers.length) ? (answers[q.id].isCorrect ? 'quiz-input-correct' : 'quiz-input-incorrect') : '') : '']"
|
|
62
|
+
@input="(e: any) => !answers[q.id]?.submitted && (draftInputs[q.id] = e.target.value)"
|
|
63
|
+
@keydown.enter.prevent="!answers[q.id]?.submitted && submitShort(q)"
|
|
64
|
+
/>
|
|
65
|
+
<button
|
|
66
|
+
v-if="!answers[q.id]?.submitted"
|
|
67
|
+
class="quiz-submit"
|
|
68
|
+
:disabled="!(draftInputs[q.id] || '').trim()"
|
|
69
|
+
@click="submitShort(q)"
|
|
70
|
+
>
|
|
71
|
+
Submit
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
<div v-if="answers[q.id]?.submitted">
|
|
75
|
+
<div v-if="q.correctAnswers && q.correctAnswers.length" :class="['quiz-feedback', answers[q.id].isCorrect ? 'correct' : 'incorrect']">
|
|
76
|
+
{{ answers[q.id].isCorrect ? `✓ Correct! (+${answers[q.id].points} pts)` : `✗ Incorrect. Accepted answers: ${(q.correctAnswers || []).join(', ') || 'N/A'}` }}
|
|
77
|
+
</div>
|
|
78
|
+
<div v-else class="quiz-feedback">
|
|
79
|
+
Your answer: {{ answers[q.id].answer }}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div v-if="isCompleted" class="quiz-summary">
|
|
87
|
+
<h3>Quiz Complete!</h3>
|
|
88
|
+
<div class="quiz-final-score">
|
|
89
|
+
Final Score: {{ totalScore }}/{{ maxScore }} points ({{ scorePercentage }}%)
|
|
90
|
+
</div>
|
|
91
|
+
<div v-if="passingScore !== undefined" :class="['quiz-result', isPassing ? 'passed' : 'failed']">
|
|
92
|
+
{{ isPassing ? '🎉 Passed!' : '❌ Failed' }} (Passing: {{ passingScore }}%)
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</template>
|
|
97
|
+
|
|
98
|
+
<script setup lang="ts">
|
|
99
|
+
import { reactive, computed, withDefaults, defineProps, defineEmits } from 'vue'
|
|
100
|
+
|
|
101
|
+
interface QuizQuestion {
|
|
102
|
+
id: string
|
|
103
|
+
type: 'mcq' | 'short-answer'
|
|
104
|
+
question: string
|
|
105
|
+
points: number
|
|
106
|
+
choices?: string[]
|
|
107
|
+
correctAnswer?: string
|
|
108
|
+
placeholder?: string
|
|
109
|
+
correctAnswers?: string[]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface QuizAnswer {
|
|
113
|
+
answer: string
|
|
114
|
+
isCorrect: boolean
|
|
115
|
+
points: number
|
|
116
|
+
submitted: boolean
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface QuizState {
|
|
120
|
+
answers: Record<string, QuizAnswer>
|
|
121
|
+
totalScore: number
|
|
122
|
+
maxScore: number
|
|
123
|
+
questionsAnswered: number
|
|
124
|
+
isCompleted: boolean
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface Props {
|
|
128
|
+
title: string
|
|
129
|
+
questions: QuizQuestion[]
|
|
130
|
+
showScore?: boolean
|
|
131
|
+
showProgress?: boolean
|
|
132
|
+
passingScore?: number
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface Emits {
|
|
136
|
+
change: [value: QuizState]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
140
|
+
showScore: true,
|
|
141
|
+
showProgress: true
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const emit = defineEmits<Emits>()
|
|
145
|
+
|
|
146
|
+
const answers = reactive<Record<string, QuizAnswer>>({})
|
|
147
|
+
const draftInputs = reactive<Record<string, string>>({})
|
|
148
|
+
|
|
149
|
+
const maxScore = computed(() => props.questions.reduce((s, q) => s + q.points, 0))
|
|
150
|
+
const totalScore = computed(() => Object.values(answers).reduce((s, a) => s + a.points, 0))
|
|
151
|
+
const questionsAnswered = computed(() => Object.keys(answers).length)
|
|
152
|
+
const isCompleted = computed(() => questionsAnswered.value === props.questions.length)
|
|
153
|
+
const progressPercentage = computed(() => Math.round((questionsAnswered.value / props.questions.length) * 100))
|
|
154
|
+
const scorePercentage = computed(() => maxScore.value > 0 ? Math.round((totalScore.value / maxScore.value) * 100) : 0)
|
|
155
|
+
const isPassing = computed(() => props.passingScore !== undefined ? scorePercentage.value >= (props.passingScore as number) : false)
|
|
156
|
+
|
|
157
|
+
function emitChange() {
|
|
158
|
+
emit('change', {
|
|
159
|
+
answers: { ...answers },
|
|
160
|
+
totalScore: totalScore.value,
|
|
161
|
+
maxScore: maxScore.value,
|
|
162
|
+
questionsAnswered: questionsAnswered.value,
|
|
163
|
+
isCompleted: isCompleted.value
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function onAnswer(questionId: string, userAnswer: string) {
|
|
168
|
+
const q = props.questions.find(q => q.id === questionId)
|
|
169
|
+
if (!q || answers[questionId]?.submitted) return
|
|
170
|
+
|
|
171
|
+
let correct = false
|
|
172
|
+
if (q.type === 'mcq') {
|
|
173
|
+
correct = q.correctAnswer === userAnswer
|
|
174
|
+
} else if (q.type === 'short-answer') {
|
|
175
|
+
if (q.correctAnswers) {
|
|
176
|
+
correct = q.correctAnswers.some(a => a.toLowerCase() === userAnswer.toLowerCase())
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
answers[questionId] = {
|
|
181
|
+
answer: userAnswer,
|
|
182
|
+
isCorrect: correct,
|
|
183
|
+
points: correct ? q.points : 0,
|
|
184
|
+
submitted: true
|
|
185
|
+
}
|
|
186
|
+
if (draftInputs[questionId]) delete draftInputs[questionId]
|
|
187
|
+
emitChange()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function submitShort(q: QuizQuestion) {
|
|
191
|
+
const val = (draftInputs[q.id] || '').trim()
|
|
192
|
+
if (!val) return
|
|
193
|
+
onAnswer(q.id, val)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function mcqChoiceClass(q: QuizQuestion, choice: string) {
|
|
197
|
+
const ans = answers[q.id]
|
|
198
|
+
let cls = 'quiz-choice'
|
|
199
|
+
if (ans?.submitted && typeof q.correctAnswer !== 'undefined') {
|
|
200
|
+
if (choice === q.correctAnswer) cls += ' quiz-choice-correct'
|
|
201
|
+
else if (choice === ans.answer && choice !== q.correctAnswer) cls += ' quiz-choice-incorrect'
|
|
202
|
+
}
|
|
203
|
+
return cls
|
|
204
|
+
}
|
|
205
|
+
</script>
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- 按钮样式(antd Radio optionType:button):复用 widget-button-group 视觉 -->
|
|
3
|
+
<div v-if="button" class="widget-button-group" :class="{ 'widget-field-error': !!shownError }">
|
|
4
|
+
<label v-if="label">{{ label }}</label>
|
|
5
|
+
<div
|
|
6
|
+
role="group"
|
|
7
|
+
:aria-label="label"
|
|
8
|
+
class="radio-button-group"
|
|
9
|
+
:data-direction="columns >= 1 ? 'grid' : (direction || 'horizontal')"
|
|
10
|
+
:style="columns >= 1 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined"
|
|
11
|
+
>
|
|
12
|
+
<div v-if="items.length === 0" style="color: rgba(0, 0, 0, 0.45); font-size: 13px;">{{ loading ? '加载中…' : '暂无数据' }}</div>
|
|
13
|
+
<button
|
|
14
|
+
v-for="it in items"
|
|
15
|
+
:key="it.value"
|
|
16
|
+
type="button"
|
|
17
|
+
:aria-pressed="val === it.value"
|
|
18
|
+
:disabled="disabled"
|
|
19
|
+
@click="pick(it.value)"
|
|
20
|
+
>
|
|
21
|
+
{{ it.label }}
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
<div v-if="shownError" class="widget-error-text" role="alert">{{ shownError }}</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<!-- 默认圆点单选样式(antd Radio) -->
|
|
28
|
+
<div v-else class="selector-multi" :class="{ 'widget-field-error': !!shownError }">
|
|
29
|
+
<div v-if="label || hint || required" class="widget-label-row">
|
|
30
|
+
<span v-if="required" class="widget-required-mark" aria-hidden="true">*</span>
|
|
31
|
+
<div v-if="label" class="selector-multi-label">{{ label }}</div>
|
|
32
|
+
<span v-if="hint" class="widget-hint" :data-hint="hint">ⓘ</span>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="widget-field-content">
|
|
35
|
+
<!-- 选项布局:columns 自定义列数(grid)优先;direction 横向/纵向(默认纵向) -->
|
|
36
|
+
<div
|
|
37
|
+
class="radio-group"
|
|
38
|
+
role="radiogroup"
|
|
39
|
+
:aria-label="label"
|
|
40
|
+
:data-direction="columns >= 1 ? 'grid' : (direction || 'vertical')"
|
|
41
|
+
:style="columns >= 1 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined"
|
|
42
|
+
>
|
|
43
|
+
<div v-if="items.length === 0" style="color: rgba(0, 0, 0, 0.45); font-size: 13px;">{{ loading ? '加载中…' : '暂无数据' }}</div>
|
|
44
|
+
<label v-for="it in items" :key="it.value" class="radio-item">
|
|
45
|
+
<input
|
|
46
|
+
type="radio"
|
|
47
|
+
:value="it.value"
|
|
48
|
+
:checked="val === it.value"
|
|
49
|
+
:disabled="disabled"
|
|
50
|
+
@change="pick(it.value)"
|
|
51
|
+
/>
|
|
52
|
+
<span>{{ it.label }}</span>
|
|
53
|
+
</label>
|
|
54
|
+
</div>
|
|
55
|
+
<div v-if="shownError" class="widget-error-text" role="alert">{{ shownError }}</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</template>
|
|
59
|
+
|
|
60
|
+
<script setup lang="ts">
|
|
61
|
+
import { ref, computed, onMounted } from 'vue'
|
|
62
|
+
|
|
63
|
+
interface ChoiceItem {
|
|
64
|
+
value: string
|
|
65
|
+
label: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface RadioGroupProps {
|
|
69
|
+
/** 选项:字符串数组(每项支持 key:label 格式)或对象数组({key|value,label},取值为 key/value,展示 label) */
|
|
70
|
+
choices: string[] | Array<{ value?: string; key?: string; label?: string }>
|
|
71
|
+
label?: string
|
|
72
|
+
default?: string | string[]
|
|
73
|
+
/** 按钮样式(参考 antd Radio optionType:button):渲染为按钮组而非圆点单选 */
|
|
74
|
+
button?: boolean
|
|
75
|
+
/** 远端数据源 URL:设置后运行时 fetch 拉取选项,choices 作为初始/后备选项 */
|
|
76
|
+
url?: string
|
|
77
|
+
/** 选项排列方向:vertical 纵向(默认)/ horizontal 横向;按钮样式默认 horizontal */
|
|
78
|
+
direction?: 'vertical' | 'horizontal'
|
|
79
|
+
/** 自定义布局:每行列数(设置后按 N 列网格排列,优先于 direction) */
|
|
80
|
+
columns?: number
|
|
81
|
+
hint?: string
|
|
82
|
+
/** 是否必填 */
|
|
83
|
+
required?: boolean
|
|
84
|
+
/** 校验失败提示文案 */
|
|
85
|
+
message?: string
|
|
86
|
+
/** 外部(form 提交时)传入的错误信息 */
|
|
87
|
+
error?: string
|
|
88
|
+
/** 是否禁用 */
|
|
89
|
+
disabled?: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface RadioGroupEvents {
|
|
93
|
+
change: [value: string]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const props = withDefaults(defineProps<RadioGroupProps>(), {
|
|
97
|
+
label: '',
|
|
98
|
+
button: false,
|
|
99
|
+
url: '',
|
|
100
|
+
columns: 0,
|
|
101
|
+
hint: '',
|
|
102
|
+
required: false,
|
|
103
|
+
message: '',
|
|
104
|
+
error: '',
|
|
105
|
+
disabled: false
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const emit = defineEmits<RadioGroupEvents>()
|
|
109
|
+
|
|
110
|
+
/** 解析 choices 条目的 key:label 格式:"bj:北京" → {value:"bj", label:"北京"};无冒号则 value=label=原文
|
|
111
|
+
* 判定规则:按首个冒号分割,仅当 key 非空且不含空白、非纯数字,且 label 非空、不以 // 开头时才视为 key:label */
|
|
112
|
+
function parseChoice(c: string): ChoiceItem {
|
|
113
|
+
const s = String(c ?? '')
|
|
114
|
+
const idx = s.indexOf(':')
|
|
115
|
+
if (idx > 0) {
|
|
116
|
+
const key = s.slice(0, idx)
|
|
117
|
+
const label = s.slice(idx + 1)
|
|
118
|
+
if (key && label && !/\s/.test(key) && !/^\d+$/.test(key) && !label.startsWith('//')) {
|
|
119
|
+
return { value: key, label }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { value: s, label: s }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 本地条目:对象条目 value > key 取值(表单实际取值),label 缺省回落到 value;
|
|
126
|
+
* 字符串条目走 key:label 解析 */
|
|
127
|
+
const localItems = computed<ChoiceItem[]>(() => {
|
|
128
|
+
const list: ChoiceItem[] = []
|
|
129
|
+
for (const c of props.choices || []) {
|
|
130
|
+
if (c !== null && typeof c === 'object') {
|
|
131
|
+
const value = (c as any).value ?? (c as any).key
|
|
132
|
+
if (value === undefined || value === null || value === '') continue
|
|
133
|
+
const valueStr = String(value)
|
|
134
|
+
const label = (c as any).label !== undefined && (c as any).label !== null && (c as any).label !== ''
|
|
135
|
+
? String((c as any).label)
|
|
136
|
+
: valueStr
|
|
137
|
+
list.push({ value: valueStr, label })
|
|
138
|
+
} else {
|
|
139
|
+
list.push(parseChoice(c as string))
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return list
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
/** 展示条目:远端拉取成功用远端;否则本地 choices */
|
|
146
|
+
const items = computed<ChoiceItem[]>(() => remoteItems.value ?? localItems.value)
|
|
147
|
+
|
|
148
|
+
/** 初始选中:default 可能是 key、label 或 key:label 原文,统一解析为 key 体系;
|
|
149
|
+
* 对象数组 choices 时 default 写 label 也映射回 value;无 default 时选中第一项(与 button-group 一致) */
|
|
150
|
+
function mapInitial(v: any): string {
|
|
151
|
+
const s = String(v)
|
|
152
|
+
const hit = localItems.value.find(it => it.value === s || it.label === s)
|
|
153
|
+
return hit ? hit.value : parseChoice(s).value
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const val = ref<string>(
|
|
157
|
+
props.default !== undefined && props.default !== null && props.default !== ''
|
|
158
|
+
? mapInitial(Array.isArray(props.default) ? props.default[0] : props.default)
|
|
159
|
+
: (localItems.value[0]?.value ?? '')
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
const localError = ref('')
|
|
163
|
+
|
|
164
|
+
/** 必填校验 */
|
|
165
|
+
function validate(v: string): string {
|
|
166
|
+
if (props.required && v === '') {
|
|
167
|
+
return props.message || (props.label ? `请填写${props.label}` : '请至少选择一项')
|
|
168
|
+
}
|
|
169
|
+
return ''
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function pick(v: string) {
|
|
173
|
+
if (props.disabled) return
|
|
174
|
+
val.value = v
|
|
175
|
+
localError.value = validate(v)
|
|
176
|
+
emit('change', v)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------- url 远端模式 ----------
|
|
180
|
+
// 加载失败静默处理:回落本地 choices,均无数据时显示灰色"暂无数据"
|
|
181
|
+
const remoteItems = ref<ChoiceItem[] | null>(null)
|
|
182
|
+
const loading = ref(false)
|
|
183
|
+
|
|
184
|
+
/** 远端响应归一化为选项数组:兼容裸数组与 {data|options|choices|items} 包裹 */
|
|
185
|
+
function normalizeRemoteChoices(json: any): ChoiceItem[] {
|
|
186
|
+
let list = json
|
|
187
|
+
if (!Array.isArray(json)) {
|
|
188
|
+
list = Array.isArray(json?.data) ? json.data
|
|
189
|
+
: Array.isArray(json?.options) ? json.options
|
|
190
|
+
: Array.isArray(json?.choices) ? json.choices
|
|
191
|
+
: Array.isArray(json?.items) ? json.items
|
|
192
|
+
: []
|
|
193
|
+
}
|
|
194
|
+
const out: ChoiceItem[] = []
|
|
195
|
+
for (const item of (list || [])) {
|
|
196
|
+
if (item === null || item === undefined) continue
|
|
197
|
+
if (typeof item === 'string' || typeof item === 'number') {
|
|
198
|
+
const s = String(item)
|
|
199
|
+
if (s) out.push({ value: s, label: s })
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
if (typeof item === 'object') {
|
|
203
|
+
const value = (item as any).value ?? (item as any).key ?? (item as any).id ?? (item as any).name
|
|
204
|
+
if (value === undefined || value === null || value === '') continue
|
|
205
|
+
const label = (item as any).label ?? (item as any).name ?? (item as any).text ?? String(value)
|
|
206
|
+
out.push({ value: String(value), label: String(label) })
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
onMounted(async () => {
|
|
213
|
+
if (!props.url) return
|
|
214
|
+
loading.value = true
|
|
215
|
+
try {
|
|
216
|
+
const res = await fetch(props.url)
|
|
217
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
218
|
+
const json: any = await res.json()
|
|
219
|
+
const list = normalizeRemoteChoices(json)
|
|
220
|
+
if (list.length === 0) throw new Error('响应中没有可用选项')
|
|
221
|
+
remoteItems.value = list
|
|
222
|
+
// 选中值处理:当前值(可能是本地 label)映射到远端 value,不在远端取值中则回落第一项
|
|
223
|
+
const hit = list.find(r => r.value === val.value || r.label === val.value)
|
|
224
|
+
const mapped = hit ? hit.value : (list[0]?.value ?? '')
|
|
225
|
+
if (mapped !== val.value) {
|
|
226
|
+
val.value = mapped
|
|
227
|
+
emit('change', mapped)
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
// 加载失败:静默回落本地 choices
|
|
231
|
+
} finally {
|
|
232
|
+
loading.value = false
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const shownError = computed(() => props.error || localError.value)
|
|
237
|
+
</script>
|