@c2-markdown-ui/vue 0.0.0-stage → 0.4.3

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.
Files changed (61) hide show
  1. package/README.md +112 -2
  2. package/dist/MarkdownUI.vue.d.ts +15 -0
  3. package/dist/favicon.ico +0 -0
  4. package/dist/index.css +1 -0
  5. package/dist/index.d.ts +23 -0
  6. package/dist/index.js +10843 -0
  7. package/dist/index.umd.cjs +53 -0
  8. package/dist/widgets/Button.vue.d.ts +19 -0
  9. package/dist/widgets/ButtonGroup.vue.d.ts +13 -0
  10. package/dist/widgets/Cascader.vue.d.ts +48 -0
  11. package/dist/widgets/Chart.vue.d.ts +24 -0
  12. package/dist/widgets/DatePicker.vue.d.ts +42 -0
  13. package/dist/widgets/Form.vue.d.ts +59 -0
  14. package/dist/widgets/Incomplete.vue.d.ts +2 -0
  15. package/dist/widgets/MultipleChoiceQuestion.vue.d.ts +20 -0
  16. package/dist/widgets/NumberInput.vue.d.ts +37 -0
  17. package/dist/widgets/Quiz.vue.d.ts +39 -0
  18. package/dist/widgets/RadioGroup.vue.d.ts +43 -0
  19. package/dist/widgets/Select.vue.d.ts +27 -0
  20. package/dist/widgets/SelectMulti.vue.d.ts +38 -0
  21. package/dist/widgets/ShortAnswerQuestion.vue.d.ts +22 -0
  22. package/dist/widgets/Slider.vue.d.ts +21 -0
  23. package/dist/widgets/Switch.vue.d.ts +28 -0
  24. package/dist/widgets/Text.vue.d.ts +22 -0
  25. package/dist/widgets/TextInput.vue.d.ts +32 -0
  26. package/dist/widgets/TreeSelect.vue.d.ts +109 -0
  27. package/dist/widgets/Widget.vue.d.ts +8 -0
  28. package/dist/widgets/chartRemoteData.d.ts +17 -0
  29. package/dist/widgets/index.d.ts +20 -0
  30. package/dist/widgets/remoteChoices.d.ts +22 -0
  31. package/dist/widgets/treeSelectTypes.d.ts +43 -0
  32. package/package.json +46 -4
  33. package/src/App.vue +11 -0
  34. package/src/lib/MarkdownUI.vue +105 -0
  35. package/src/lib/index.ts +28 -0
  36. package/src/lib/widgets/Button.vue +47 -0
  37. package/src/lib/widgets/ButtonGroup.vue +43 -0
  38. package/src/lib/widgets/Cascader.vue +475 -0
  39. package/src/lib/widgets/Chart.vue +287 -0
  40. package/src/lib/widgets/DatePicker.vue +659 -0
  41. package/src/lib/widgets/Form.vue +479 -0
  42. package/src/lib/widgets/Incomplete.vue +56 -0
  43. package/src/lib/widgets/MultipleChoiceQuestion.vue +73 -0
  44. package/src/lib/widgets/NumberInput.vue +179 -0
  45. package/src/lib/widgets/Quiz.vue +205 -0
  46. package/src/lib/widgets/RadioGroup.vue +237 -0
  47. package/src/lib/widgets/Select.vue +279 -0
  48. package/src/lib/widgets/SelectMulti.vue +145 -0
  49. package/src/lib/widgets/ShortAnswerQuestion.vue +79 -0
  50. package/src/lib/widgets/Slider.vue +61 -0
  51. package/src/lib/widgets/Switch.vue +68 -0
  52. package/src/lib/widgets/Text.vue +59 -0
  53. package/src/lib/widgets/TextInput.vue +83 -0
  54. package/src/lib/widgets/TreeSelect.vue +872 -0
  55. package/src/lib/widgets/Widget.vue +185 -0
  56. package/src/lib/widgets/chartRemoteData.ts +84 -0
  57. package/src/lib/widgets/index.ts +20 -0
  58. package/src/lib/widgets/remoteChoices.ts +64 -0
  59. package/src/lib/widgets/treeSelectTypes.ts +49 -0
  60. package/src/lib/widgets.css +1754 -0
  61. package/src/main.ts +4 -0
@@ -0,0 +1,279 @@
1
+ <template>
2
+ <div class="selector" ref="rootEl">
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 widget-select-content">
9
+ <!-- 自定义下拉(antd 风格):searchable 带搜索框,普通模式纯选项列表 -->
10
+ <button
11
+ type="button"
12
+ class="cas-input"
13
+ :disabled="disabled"
14
+ :aria-expanded="open"
15
+ @click="toggleOpen"
16
+ >
17
+ <span class="cas-input-text" :class="{ 'cas-placeholder': !display }">
18
+ {{ loading ? '加载中…' : display || '请选择' }}
19
+ </span>
20
+ <span class="cas-arrow" :class="{ 'cas-arrow-open': open }" aria-hidden="true">▾</span>
21
+ </button>
22
+ <div v-if="open" class="cas-panel cas-panel-single">
23
+ <div v-if="searchable" class="cas-search">
24
+ <input
25
+ ref="searchEl"
26
+ type="text"
27
+ class="cas-search-input"
28
+ v-model="query"
29
+ placeholder="搜索选项…"
30
+ />
31
+ </div>
32
+ <div class="cas-menu cas-menu-full" role="menu">
33
+ <button
34
+ v-for="it in filtered"
35
+ :key="it.value"
36
+ type="button"
37
+ class="cas-option"
38
+ :class="{ 'cas-option-active': value === it.value }"
39
+ @click="pick(it.value)"
40
+ >
41
+ <span class="cas-option-label">{{ it.label }}</span>
42
+ </button>
43
+ <div v-if="filtered.length === 0" class="cas-empty">{{ loading ? '加载中…' : query.trim() ? '无匹配选项' : '暂无数据' }}</div>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ </template>
49
+
50
+ <script setup lang="ts">
51
+ import { ref, computed, nextTick, onMounted, onBeforeUnmount, watch } from 'vue'
52
+ import { normalizeRemoteChoices, parseChoice, resolveChoiceValue, type RemoteChoice } from './remoteChoices'
53
+
54
+ interface SelectProps {
55
+ choices: string[]
56
+ label?: string
57
+ default?: string
58
+ /** 远端数据源 URL:设置后运行时 fetch 拉取选项,choices 作为初始/后备选项 */
59
+ url?: string
60
+ /** 是否可搜索:开启后下拉面板内置搜索框,按 key/value 与 label 过滤选项 */
61
+ searchable?: boolean
62
+ /** 是否禁用 */
63
+ disabled?: boolean
64
+ hint?: string
65
+ /** 是否必填 */
66
+ required?: boolean
67
+ }
68
+
69
+ interface SelectEvents {
70
+ change: [value: string | string[]]
71
+ }
72
+
73
+ const props = withDefaults(defineProps<SelectProps>(), {
74
+ label: '',
75
+ url: '',
76
+ searchable: false,
77
+ disabled: false,
78
+ hint: '',
79
+ required: false
80
+ })
81
+
82
+ const emit = defineEmits<SelectEvents>()
83
+
84
+ /** 解析 choices 条目的 key:label 格式:"bj:北京" → {value:"bj", label:"北京"};无冒号则 value=label=原文
85
+ * 判定规则:按首个冒号分割,仅当 key 非空且不含空白、非纯数字,且 label 非空、不以 // 开头时才视为 key:label */
86
+
87
+ /** 展示条目(value=表单取值即 key,label=展示文案):远端拉取成功用 {value,label},否则本地 choices */
88
+ const remoteItems = ref<RemoteChoice[] | null>(null)
89
+ const items = computed(() => remoteItems.value ?? (props.choices || []).map(parseChoice))
90
+
91
+ // 初始选中值:default 解析出 key → choices[0] 的 key
92
+ const value = ref(props.default !== undefined ? parseChoice(props.default).value : (items.value[0]?.value ?? ''))
93
+
94
+ // ---------- url 远端模式:拉取成功后覆盖本地 choices,失败静默回落本地 ----------
95
+ const loading = ref(false)
96
+
97
+ async function loadRemote() {
98
+ if (!props.url) return
99
+ loading.value = true
100
+ try {
101
+ const res = await fetch(props.url)
102
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
103
+ const json: any = await res.json()
104
+ const list = normalizeRemoteChoices(json)
105
+ if (list.length === 0) throw new Error('响应中没有可用选项')
106
+ remoteItems.value = list
107
+ // 当前值不在远端取值中时:default/本地值按 label 映射到远端 value,否则回落第一项
108
+ if (!list.some(r => r.value === value.value)) {
109
+ const mapped = props.default !== undefined ? resolveChoiceValue(props.default, list) : value.value
110
+ const next = list.some(r => r.value === mapped) ? mapped : list[0].value
111
+ if (next !== value.value) {
112
+ value.value = next
113
+ emit('change', next)
114
+ }
115
+ }
116
+ } catch {
117
+ // 加载失败:静默回落本地 choices
118
+ } finally {
119
+ loading.value = false
120
+ }
121
+ }
122
+
123
+ onMounted(loadRemote)
124
+ watch(() => props.url, loadRemote)
125
+
126
+ // ---------- 自定义下拉(antd 风格) ----------
127
+ const rootEl = ref<HTMLElement | null>(null)
128
+ const searchEl = ref<HTMLInputElement | null>(null)
129
+ const open = ref(false)
130
+ /** 搜索关键字(按 value/key 与 label 过滤,大小写不敏感) */
131
+ const query = ref('')
132
+
133
+ /** 当前选中项展示文案(value 与 label 不同时优先 label) */
134
+ const display = computed(() => items.value.find(it => it.value === value.value)?.label ?? '')
135
+
136
+ /** 展示用的选项列表:searchable 模式按关键字过滤,普通模式全量 */
137
+ const filtered = computed(() => {
138
+ if (!props.searchable) return items.value
139
+ const q = query.value.trim().toLowerCase()
140
+ if (!q) return items.value
141
+ return items.value.filter(it => it.value.toLowerCase().includes(q) || it.label.toLowerCase().includes(q))
142
+ })
143
+
144
+ function onDocClick(e: MouseEvent) {
145
+ if (rootEl.value && !rootEl.value.contains(e.target as Node)) open.value = false
146
+ }
147
+ onMounted(() => document.addEventListener('mousedown', onDocClick))
148
+ onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick))
149
+
150
+ function toggleOpen() {
151
+ if (props.disabled) return
152
+ open.value = !open.value
153
+ if (open.value && props.searchable) {
154
+ // 打开面板时清空关键字并聚焦搜索框(面板渲染完成后)
155
+ query.value = ''
156
+ nextTick(() => searchEl.value?.focus())
157
+ }
158
+ }
159
+
160
+ /** 选中某选项:回填 value 并关闭面板 */
161
+ function pick(v: string) {
162
+ if (props.disabled) return
163
+ value.value = v
164
+ open.value = false
165
+ emit('change', v)
166
+ }
167
+ </script>
168
+
169
+ <style scoped>
170
+ .widget-select-content {
171
+ position: relative;
172
+ display: flex;
173
+ flex-direction: column;
174
+ }
175
+ .cas-input {
176
+ display: flex;
177
+ align-items: center;
178
+ gap: 0.375rem;
179
+ width: 100%;
180
+ padding: 0.625rem 0.75rem;
181
+ border: 1px solid #d9d9d9;
182
+ border-radius: 6px;
183
+ font-size: 0.875rem;
184
+ background-color: #ffffff;
185
+ cursor: pointer;
186
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
187
+ text-align: left;
188
+ }
189
+ .cas-input:hover { border-color: #4096ff; }
190
+ .cas-input:focus {
191
+ outline: none;
192
+ border-color: #1677ff;
193
+ box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
194
+ }
195
+ /* 禁用态:置灰 + 禁止交互 */
196
+ .cas-input:disabled {
197
+ background-color: #f5f5f5;
198
+ color: rgba(0, 0, 0, 0.25);
199
+ border-color: #d9d9d9;
200
+ cursor: not-allowed;
201
+ }
202
+ .cas-input:disabled:hover { border-color: #d9d9d9; }
203
+
204
+ .cas-input-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 auto; min-width: 0; }
205
+ .cas-placeholder { color: rgba(0, 0, 0, 0.45); }
206
+ .cas-arrow { color: rgba(0, 0, 0, 0.45); font-size: 0.75rem; transition: transform 0.2s ease; }
207
+ .cas-arrow-open { transform: rotate(180deg); }
208
+
209
+ /* 可搜索面板:纵向排搜索框 + 选项列表 */
210
+ .cas-panel {
211
+ position: absolute;
212
+ z-index: 50;
213
+ top: calc(100% + 4px);
214
+ left: 0;
215
+ display: flex;
216
+ background: #ffffff;
217
+ border: 1px solid #d9d9d9;
218
+ border-radius: 8px;
219
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
220
+ }
221
+ .cas-panel-single {
222
+ flex-direction: column;
223
+ min-width: 100%;
224
+ width: auto;
225
+ }
226
+ .cas-search {
227
+ padding: 0.5rem;
228
+ border-bottom: 1px solid #f0f0f0;
229
+ }
230
+ .cas-search-input {
231
+ width: 100%;
232
+ padding: 0.375rem 0.625rem;
233
+ border: 1px solid #d9d9d9;
234
+ border-radius: 4px;
235
+ font-size: 0.8125rem;
236
+ box-sizing: border-box;
237
+ }
238
+ .cas-search-input:focus {
239
+ outline: none;
240
+ border-color: #1677ff;
241
+ box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
242
+ }
243
+ .cas-menu {
244
+ max-height: 240px;
245
+ overflow-y: auto;
246
+ padding: 0.375rem;
247
+ }
248
+ .cas-menu-full {
249
+ width: 100%;
250
+ flex: 1 1 auto;
251
+ min-width: 0;
252
+ }
253
+ .cas-option {
254
+ display: flex;
255
+ align-items: center;
256
+ justify-content: space-between;
257
+ gap: 0.5rem;
258
+ width: 100%;
259
+ padding: 0.375rem 0.5rem;
260
+ border: none;
261
+ border-radius: 4px;
262
+ background: transparent;
263
+ font-size: 0.875rem;
264
+ color: rgba(0, 0, 0, 0.88);
265
+ cursor: pointer;
266
+ text-align: left;
267
+ transition: background 0.15s ease;
268
+ }
269
+ .cas-option:hover { background: #f5f5f5; }
270
+ .cas-option-active { background: #e6f4ff; color: #1677ff; font-weight: 500; }
271
+ .cas-option-active:hover { background: #bae0ff; }
272
+ .cas-option-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
273
+ .cas-empty {
274
+ padding: 0.75rem 0.5rem;
275
+ font-size: 0.8125rem;
276
+ color: rgba(0, 0, 0, 0.45);
277
+ text-align: center;
278
+ }
279
+ </style>
@@ -0,0 +1,145 @@
1
+ <template>
2
+ <div class="selector-multi" :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
+ <div v-if="label" class="selector-multi-label">{{ label }}</div>
6
+ <span v-if="hint" class="widget-hint" :data-hint="hint">ⓘ</span>
7
+ </div>
8
+ <div class="widget-field-content">
9
+ <!-- 选项布局:columns 自定义列数(grid)优先;direction 横向/纵向(默认纵向) -->
10
+ <div
11
+ class="checkbox-group"
12
+ :data-direction="columns >= 1 ? 'grid' : (direction || 'vertical')"
13
+ :style="columns >= 1 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined"
14
+ >
15
+ <div v-if="items.length === 0" style="color: rgba(0, 0, 0, 0.45); font-size: 13px;">暂无选项</div>
16
+ <label v-for="it in items" :key="it.value" class="checkbox-item">
17
+ <input
18
+ type="checkbox"
19
+ :value="it.value"
20
+ :checked="value.includes(it.value)"
21
+ :disabled="disabled"
22
+ @change="handleChange(it.value, ($event.target as HTMLInputElement).checked)"
23
+ />
24
+ <span>{{ it.label }}</span>
25
+ </label>
26
+ </div>
27
+ <div v-if="shownError" class="widget-error-text" role="alert">{{ shownError }}</div>
28
+ </div>
29
+ </div>
30
+ </template>
31
+
32
+ <script setup lang="ts">
33
+ import { ref, computed } from 'vue'
34
+
35
+ interface ChoiceItem {
36
+ value: string
37
+ label: string
38
+ }
39
+
40
+ interface SelectMultiProps {
41
+ /** 选项:字符串数组(每项支持 key:label 格式)或对象数组({key|value,label},取值为 key/value,展示 label) */
42
+ choices: string[] | Array<{ value?: string; key?: string; label?: string }>
43
+ label?: string
44
+ default?: string | string[]
45
+ /** 选项排列方向:vertical 纵向(默认)/ horizontal 横向 */
46
+ direction?: 'vertical' | 'horizontal'
47
+ /** 自定义布局:每行列数(设置后按 N 列网格排列,优先于 direction) */
48
+ columns?: number
49
+ hint?: string
50
+ /** 是否必填:至少勾选一项 */
51
+ required?: boolean
52
+ /** 校验失败提示文案 */
53
+ message?: string
54
+ /** 外部(form 提交时)传入的错误信息 */
55
+ error?: string
56
+ /** 是否禁用 */
57
+ disabled?: boolean
58
+ }
59
+
60
+ interface SelectMultiEvents {
61
+ change: [value: string | string[]]
62
+ }
63
+
64
+ const props = withDefaults(defineProps<SelectMultiProps>(), {
65
+ label: '',
66
+ direction: 'vertical',
67
+ columns: 0,
68
+ hint: '',
69
+ required: false,
70
+ message: '',
71
+ error: '',
72
+ disabled: false
73
+ })
74
+
75
+ const emit = defineEmits<SelectMultiEvents>()
76
+
77
+ /** 解析 choices 条目的 key:label 格式:"bj:北京" → {value:"bj", label:"北京"};无冒号则 value=label=原文
78
+ * 判定规则:按首个冒号分割,仅当 key 非空且不含空白、非纯数字,且 label 非空、不以 // 开头时才视为 key:label */
79
+ function parseChoice(c: string): ChoiceItem {
80
+ const s = String(c ?? '')
81
+ const idx = s.indexOf(':')
82
+ if (idx > 0) {
83
+ const key = s.slice(0, idx)
84
+ const label = s.slice(idx + 1)
85
+ if (key && label && !/\s/.test(key) && !/^\d+$/.test(key) && !label.startsWith('//')) {
86
+ return { value: key, label }
87
+ }
88
+ }
89
+ return { value: s, label: s }
90
+ }
91
+
92
+ /** 展示条目:对象条目 value > key 取值(表单实际取值),label 缺省回落到 value;
93
+ * 字符串条目走 key:label 解析 */
94
+ const items = computed<ChoiceItem[]>(() => {
95
+ const list: ChoiceItem[] = []
96
+ for (const c of props.choices || []) {
97
+ if (c !== null && typeof c === 'object') {
98
+ const value = (c as any).value ?? (c as any).key
99
+ if (value === undefined || value === null || value === '') continue
100
+ const valueStr = String(value)
101
+ const label = (c as any).label !== undefined && (c as any).label !== null && (c as any).label !== ''
102
+ ? String((c as any).label)
103
+ : valueStr
104
+ list.push({ value: valueStr, label })
105
+ } else {
106
+ list.push(parseChoice(c as string))
107
+ }
108
+ }
109
+ return list
110
+ })
111
+
112
+ /** 初始勾选值:default 可能是 key、label 或 key:label 原文,统一解析为 key 体系;
113
+ * 对象数组 choices 时 default 写 label 也映射回 value */
114
+ function mapInitial(v: any): string {
115
+ const s = String(v)
116
+ const hit = items.value.find(it => it.value === s || it.label === s)
117
+ return hit ? hit.value : parseChoice(s).value
118
+ }
119
+
120
+ const value = ref<string[]>(
121
+ Array.isArray(props.default)
122
+ ? props.default.map(mapInitial)
123
+ : (props.default ? [mapInitial(props.default)] : [])
124
+ )
125
+
126
+ const localError = ref('')
127
+
128
+ const shownError = computed(() => props.error || localError.value)
129
+
130
+ /** 必填校验:至少勾选一项 */
131
+ function validate(v: string[]): string {
132
+ if (props.required && v.length === 0) {
133
+ return props.message || (props.label ? `请填写${props.label}` : '请至少选择一项')
134
+ }
135
+ return ''
136
+ }
137
+
138
+ function handleChange(choice: string, checked: boolean) {
139
+ value.value = checked
140
+ ? [...value.value, choice]
141
+ : value.value.filter(v => v !== choice)
142
+ localError.value = validate(value.value)
143
+ emit('change', value.value)
144
+ }
145
+ </script>
@@ -0,0 +1,79 @@
1
+ <template>
2
+ <div class="widget-saq">
3
+ <div class="saq-question">{{ question }}</div>
4
+ <div class="saq-input-container">
5
+ <input
6
+ type="text"
7
+ v-model="answer"
8
+ :placeholder="placeholder || 'Type your answer here...'"
9
+ :disabled="hasAnswered && showFeedback"
10
+ :class="['saq-input', (hasAnswered && showFeedback) ? (isCorrect ? 'saq-input-correct' : 'saq-input-incorrect') : '']"
11
+ @keydown.enter.prevent="!hasAnswered && submit()"
12
+ />
13
+ <button
14
+ v-if="!hasAnswered"
15
+ class="saq-submit"
16
+ :disabled="!answer.trim()"
17
+ @click="submit"
18
+ >
19
+ Submit
20
+ </button>
21
+ </div>
22
+ <div
23
+ v-if="hasAnswered && showFeedback && feedbackMessage"
24
+ :class="['saq-feedback', isCorrect ? 'correct' : 'incorrect']"
25
+ >
26
+ {{ feedbackMessage }}
27
+ </div>
28
+ </div>
29
+ </template>
30
+
31
+ <script setup lang="ts">
32
+ import { ref, computed, withDefaults, defineProps, defineEmits } from 'vue'
33
+
34
+ interface Props {
35
+ question: string
36
+ placeholder?: string
37
+ correctAnswer?: string
38
+ correctAnswers?: string[]
39
+ showFeedback?: boolean
40
+ }
41
+
42
+ interface Emits {
43
+ change: [value: { answer: string; isCorrect?: boolean }]
44
+ }
45
+
46
+ const props = withDefaults(defineProps<Props>(), {
47
+ placeholder: 'Type your answer here...',
48
+ showFeedback: false
49
+ })
50
+
51
+ const emit = defineEmits<Emits>()
52
+
53
+ const answer = ref('')
54
+ const hasAnswered = ref(false)
55
+ const isCorrect = ref<boolean | undefined>(undefined)
56
+
57
+ function submit() {
58
+ if (!answer.value.trim()) return
59
+ hasAnswered.value = true
60
+ const normalized = (props.correctAnswers && props.correctAnswers.length > 0)
61
+ ? props.correctAnswers
62
+ : (typeof props.correctAnswer === 'string' ? [props.correctAnswer] : undefined)
63
+ const correct = normalized
64
+ ? normalized.some(a => a.toLowerCase() === answer.value.trim().toLowerCase())
65
+ : undefined
66
+ isCorrect.value = correct
67
+ emit('change', { answer: answer.value.trim(), isCorrect: correct })
68
+ }
69
+
70
+ const feedbackMessage = computed(() => {
71
+ if (!hasAnswered.value || !props.showFeedback) return ''
72
+ const hasAnswers = (props.correctAnswers && props.correctAnswers.length > 0) || typeof props.correctAnswer !== 'undefined'
73
+ if (!hasAnswers) return `Your answer: ${answer.value.trim()}`
74
+ if (props.correctAnswers && props.correctAnswers.length > 0) {
75
+ return isCorrect.value ? '✓ Correct!' : `✗ Incorrect. Accepted answers: ${(props.correctAnswers || []).join(', ')}`
76
+ }
77
+ return isCorrect.value ? '✓ Correct!' : `✗ Incorrect. The correct answer is: ${props.correctAnswer}`
78
+ })
79
+ </script>
@@ -0,0 +1,61 @@
1
+ <template>
2
+ <div class="widget-slider">
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="slider-container">
10
+ <div class="slider-values">
11
+ <span class="min-value">{{ min }}</span>
12
+ <span class="current-value">{{ value }}</span>
13
+ <span class="max-value">{{ max }}</span>
14
+ </div>
15
+ <input
16
+ type="range"
17
+ v-model="value"
18
+ :min="min"
19
+ :max="max"
20
+ :step="step"
21
+ @mouseup="handleChange"
22
+ @touchend="handleChange"
23
+ />
24
+ </div>
25
+ </div>
26
+ </div>
27
+ </template>
28
+
29
+ <script setup lang="ts">
30
+ import { ref, defineProps, defineEmits } from 'vue'
31
+
32
+ interface SliderProps {
33
+ min: number
34
+ max: number
35
+ step?: number
36
+ label?: string
37
+ default?: number
38
+ hint?: string
39
+ /** 是否必填(slider 始终有值,仅语义标记) */
40
+ required?: boolean
41
+ }
42
+
43
+ interface SliderEvents {
44
+ change: [value: number]
45
+ }
46
+
47
+ const props = withDefaults(defineProps<SliderProps>(), {
48
+ step: 1,
49
+ label: '',
50
+ hint: '',
51
+ required: false
52
+ })
53
+
54
+ const emit = defineEmits<SliderEvents>()
55
+
56
+ const value = ref(props.default ?? props.min)
57
+
58
+ const handleChange = () => {
59
+ emit('change', Number(value.value))
60
+ }
61
+ </script>
@@ -0,0 +1,68 @@
1
+ <template>
2
+ <div class="widget-switch">
3
+ <div v-if="label || hint || required" class="widget-label-row switch-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
+ <button
10
+ type="button"
11
+ role="switch"
12
+ :aria-checked="checked"
13
+ :aria-label="label"
14
+ class="switch-track"
15
+ :data-checked="checked"
16
+ :disabled="disabled"
17
+ @click="toggle"
18
+ >
19
+ <span class="switch-thumb"></span>
20
+ <span v-if="checkedText" class="switch-text switch-text-checked">{{ checkedText }}</span>
21
+ <span v-if="unCheckedText" class="switch-text switch-text-unchecked">{{ unCheckedText }}</span>
22
+ </button>
23
+ </div>
24
+ </div>
25
+ </template>
26
+
27
+ <script setup lang="ts">
28
+ import { ref } from 'vue'
29
+
30
+ interface SwitchProps {
31
+ /** 显示标签 */
32
+ label?: string
33
+ /** 默认是否选中(缺省 false) */
34
+ default?: boolean
35
+ /** 选中时展示的文案(antd checkedChildren) */
36
+ checkedText?: string
37
+ /** 未选中时展示的文案(antd unCheckedChildren) */
38
+ unCheckedText?: string
39
+ /** 是否禁用 */
40
+ disabled?: boolean
41
+ hint?: string
42
+ /** 是否必填 */
43
+ required?: boolean
44
+ }
45
+
46
+ interface SwitchEvents {
47
+ change: [value: boolean]
48
+ }
49
+
50
+ const props = withDefaults(defineProps<SwitchProps>(), {
51
+ label: '',
52
+ checkedText: '',
53
+ unCheckedText: '',
54
+ disabled: false,
55
+ hint: '',
56
+ required: false
57
+ })
58
+
59
+ const emit = defineEmits<SwitchEvents>()
60
+
61
+ const checked = ref(props.default ?? false)
62
+
63
+ const toggle = () => {
64
+ if (props.disabled) return
65
+ checked.value = !checked.value
66
+ emit('change', checked.value)
67
+ }
68
+ </script>
@@ -0,0 +1,59 @@
1
+ <template>
2
+ <!-- Alert 提示风格(antd Alert) -->
3
+ <div
4
+ v-if="alert"
5
+ class="widget-alert"
6
+ :class="`widget-alert-${alertStyle}`"
7
+ :style="{ textAlign: align || undefined }"
8
+ role="alert"
9
+ >
10
+ <span class="widget-alert-icon" aria-hidden="true">{{ alertIcons[alertStyle] ?? alertIcons.info }}</span>
11
+ <span
12
+ class="widget-alert-message"
13
+ :style="textStyle"
14
+ >{{ text }}</span>
15
+ </div>
16
+ <!-- 普通段落 -->
17
+ <p v-else class="widget-text" :style="textStyle">{{ text }}</p>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ import { computed, defineProps } from 'vue'
22
+
23
+ interface TextProps {
24
+ /** 文字内容 */
25
+ text?: string
26
+ /** 字号(12-48),默认 14 */
27
+ size?: number
28
+ /** 字体颜色(十六进制或 CSS 颜色名) */
29
+ color?: string
30
+ /** 加粗 */
31
+ bold?: boolean
32
+ /** 文字对齐:left / center / right */
33
+ align?: 'left' | 'center' | 'right'
34
+ /** 是否启用 Alert 提示风格(参考 antd Alert) */
35
+ alert?: boolean
36
+ /** Alert 风格:info(默认)/ warning / error */
37
+ alertStyle?: 'info' | 'warning' | 'error'
38
+ }
39
+
40
+ const props = withDefaults(defineProps<TextProps>(), {
41
+ text: '',
42
+ alert: false,
43
+ alertStyle: 'info'
44
+ })
45
+
46
+ // Alert 风格图标(antd 风格:info 圆形 i / warning 圆形感叹 / error 圆形叉)
47
+ const alertIcons: Record<string, string> = {
48
+ info: 'ℹ',
49
+ warning: '⚠',
50
+ error: '✕'
51
+ }
52
+
53
+ const textStyle = computed(() => ({
54
+ fontSize: props.size ? `${props.size}px` : undefined,
55
+ color: props.color || undefined,
56
+ fontWeight: props.bold ? 600 : undefined,
57
+ textAlign: props.align || undefined
58
+ }))
59
+ </script>