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