@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,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>
|