softleader-nuxt-core 1.2.0 → 2.2.0
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/bin/init.mjs +266 -244
- package/components/templates/Welcome.vue +16 -8
- package/components/uiInterface/IButton.vue +466 -466
- package/composables/useFeatureFlag.ts +5 -2
- package/composables/useModules.ts +18 -18
- package/core/config/theme-tokens.ts +0 -2
- package/layouts/default.vue +23 -22
- package/nuxt.config.ts +18 -1
- package/package.json +3 -2
- package/repositories/index.ts +18 -0
- package/repositories/modules/auth.ts +29 -0
- package/repositories/modules/user.ts +100 -0
- package/scripts/product-loader.ts +86 -13
- package/scripts/release.mjs +24 -24
- package/stores/app.ts +25 -2
|
@@ -1,466 +1,466 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { computed } from 'vue'
|
|
3
|
-
import IIcon from './IIcon.vue'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Component: IButton (按鈕元件)
|
|
7
|
-
*
|
|
8
|
-
* 介面層 (Interface Layer) 標準元件。
|
|
9
|
-
* 負責將統一的 Props 轉換為底層 UI 框架 (Vuetify) 的屬性。
|
|
10
|
-
* 內部保留了「與 UI 框架解耦」的能力,可隨時切換回原生或其他框架。
|
|
11
|
-
*
|
|
12
|
-
* 設計風格遵循 Corporate Trust (企業信賴) 風格指南:
|
|
13
|
-
* - Primary: 漸層背景 (Indigo to Violet),圓角 (Rounded Full/Lg),懸浮時微幅上浮。
|
|
14
|
-
* - Shadows: 使用帶有藍紫色的陰影,增加深度感。
|
|
15
|
-
*
|
|
16
|
-
* @example
|
|
17
|
-
* <IButton variant="primary" size="large" loading prepend-icon="mdi-check">提交</IButton>
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
// ====================================================
|
|
21
|
-
// 框架切換開關 (可改為 inject 或 config)
|
|
22
|
-
// ====================================================
|
|
23
|
-
const USE_FRAMEWORK = true
|
|
24
|
-
|
|
25
|
-
// 1. 定義標準 Props
|
|
26
|
-
interface Props {
|
|
27
|
-
/**
|
|
28
|
-
* 按鈕樣式變體
|
|
29
|
-
* @default 'primary'
|
|
30
|
-
*/
|
|
31
|
-
variant?:
|
|
32
|
-
| 'primary'
|
|
33
|
-
| 'secondary'
|
|
34
|
-
| 'success'
|
|
35
|
-
| 'danger'
|
|
36
|
-
| 'warning'
|
|
37
|
-
| 'info'
|
|
38
|
-
| 'text'
|
|
39
|
-
| 'outlined'
|
|
40
|
-
| 'plain' // 新增 Vuetify 常用變體
|
|
41
|
-
| 'tonal'
|
|
42
|
-
| 'flat'
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* 尺寸
|
|
46
|
-
* @default 'medium'
|
|
47
|
-
*/
|
|
48
|
-
size?: 'x-small' | 'small' | 'medium' | 'large' | 'x-large'
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* 是否為區塊按鈕 (全寬)
|
|
52
|
-
* @default false
|
|
53
|
-
*/
|
|
54
|
-
block?: boolean
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* 是否處於載入狀態
|
|
58
|
-
* @default false
|
|
59
|
-
*/
|
|
60
|
-
loading?: boolean
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* 是否禁用
|
|
64
|
-
* @default false
|
|
65
|
-
*/
|
|
66
|
-
disabled?: boolean
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* 連結目標 URL (若存在則渲染為 <a>)
|
|
70
|
-
*/
|
|
71
|
-
href?: string
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* 連結開啟目標 (_blank, _self, etc.)
|
|
75
|
-
*/
|
|
76
|
-
target?: string
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* 自訂顏色 (Hex, RGB, 或 CSS 變數)
|
|
80
|
-
* 若未指定,將根據 variant 自動決定
|
|
81
|
-
*/
|
|
82
|
-
color?: string
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* 前置圖示名稱 (支援 mdi, fa, svg-softleader)
|
|
86
|
-
*/
|
|
87
|
-
prependIcon?: string
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* 後置圖示名稱
|
|
91
|
-
*/
|
|
92
|
-
appendIcon?: string
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* 圓角設定 (符合設計系統)
|
|
96
|
-
* Primary 按鈕通常使用圓角較大的風格
|
|
97
|
-
*/
|
|
98
|
-
rounded?: string | number | boolean
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* 陰影設定
|
|
102
|
-
* @default undefined
|
|
103
|
-
*/
|
|
104
|
-
elevation?: string | number
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const props = withDefaults(defineProps<Props>(), {
|
|
108
|
-
variant: 'primary',
|
|
109
|
-
size: 'medium',
|
|
110
|
-
block: false,
|
|
111
|
-
loading: false,
|
|
112
|
-
disabled: false,
|
|
113
|
-
target: undefined,
|
|
114
|
-
color: undefined,
|
|
115
|
-
href: undefined,
|
|
116
|
-
prependIcon: undefined,
|
|
117
|
-
appendIcon: undefined,
|
|
118
|
-
rounded: undefined,
|
|
119
|
-
elevation: undefined
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
// 定義事件
|
|
123
|
-
interface Emits {
|
|
124
|
-
(e: 'click', event: MouseEvent): void
|
|
125
|
-
}
|
|
126
|
-
const emit = defineEmits<Emits>()
|
|
127
|
-
|
|
128
|
-
// ====================================================
|
|
129
|
-
// 2. 屬性對照表 (Adapter Pattern)
|
|
130
|
-
// ====================================================
|
|
131
|
-
const vuetifyBindings = computed(() => {
|
|
132
|
-
const bindings: Record<string, any> = {}
|
|
133
|
-
|
|
134
|
-
// [Size Mapping]
|
|
135
|
-
// Vuetify 3 支援: x-small, small, default, large, x-large
|
|
136
|
-
const sizeMap: Record<string, string> = {
|
|
137
|
-
'x-small': 'x-small',
|
|
138
|
-
small: 'small',
|
|
139
|
-
medium: 'default', // Mapping medium to default
|
|
140
|
-
large: 'large',
|
|
141
|
-
'x-large': 'x-large'
|
|
142
|
-
}
|
|
143
|
-
bindings.size = sizeMap[props.size] || props.size
|
|
144
|
-
|
|
145
|
-
// [Variant & Color Mapping]
|
|
146
|
-
const colorMap: Record<string, string> = {
|
|
147
|
-
danger: 'error',
|
|
148
|
-
success: 'success',
|
|
149
|
-
warning: 'warning',
|
|
150
|
-
info: 'info',
|
|
151
|
-
secondary: 'secondary',
|
|
152
|
-
primary: 'primary'
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// 處理特殊 Variant 映射
|
|
156
|
-
if (['text', 'outlined', 'plain', 'tonal', 'flat'].includes(props.variant)) {
|
|
157
|
-
bindings.variant = props.variant
|
|
158
|
-
// 若無指定顏色,則使用 Primary 或預設
|
|
159
|
-
bindings.color = props.color || (props.variant === 'outlined' ? 'primary' : undefined)
|
|
160
|
-
} else {
|
|
161
|
-
// 實心類按鈕 (Elevated)
|
|
162
|
-
bindings.variant = 'elevated'
|
|
163
|
-
// 映射語意顏色
|
|
164
|
-
bindings.color = props.color || colorMap[props.variant] || props.variant
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// 處理圓角
|
|
168
|
-
// 只有在 props.rounded 真的是 undefined 時才使用預設值
|
|
169
|
-
// 空字串 '' 代表使用者明確選擇 "Default",應該傳遞給 Vuetify
|
|
170
|
-
if (props.rounded !== undefined) {
|
|
171
|
-
// 如果是空字串,轉換為 undefined 讓 Vuetify 使用預設值
|
|
172
|
-
bindings.rounded = props.rounded === '' ? undefined : props.rounded
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// 處理 Elevation
|
|
176
|
-
if (props.elevation !== undefined) {
|
|
177
|
-
bindings.elevation = props.elevation
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
return bindings
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
// ====================================================
|
|
184
|
-
// 3. 樣式類別計算 (Classes)
|
|
185
|
-
// ====================================================
|
|
186
|
-
const buttonClasses = computed(() => {
|
|
187
|
-
const classes: any[] = []
|
|
188
|
-
|
|
189
|
-
// Design System: Primary Button Gradients & Shadows
|
|
190
|
-
if (props.variant === 'primary' && !props.disabled && !props.loading && !props.color) {
|
|
191
|
-
classes.push(
|
|
192
|
-
'bg-
|
|
193
|
-
'hover:shadow-[0_10px_25px_-
|
|
194
|
-
'hover:-translate-y-0.5', // Lift effect
|
|
195
|
-
'transition-all duration-200 ease-out'
|
|
196
|
-
)
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Custom Outline Style
|
|
200
|
-
if (props.variant === 'outlined' && !props.color) {
|
|
201
|
-
classes.push('border-slate-200 text-slate-700 hover:bg-slate-50 hover:border-slate-300')
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return classes
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
// ====================================================
|
|
208
|
-
// 4. 原生實作邏輯 (Native Fallback)
|
|
209
|
-
// ====================================================
|
|
210
|
-
const handleClick = (event: MouseEvent) => {
|
|
211
|
-
if (!props.disabled && !props.loading) {
|
|
212
|
-
emit('click', event)
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const isLink = computed(() => !!props.href)
|
|
217
|
-
const componentTag = computed(() => (isLink.value ? 'a' : 'button'))
|
|
218
|
-
|
|
219
|
-
const nativeStyle = computed(() => {
|
|
220
|
-
if (!props.color) return {}
|
|
221
|
-
const colorValue =
|
|
222
|
-
props.color.startsWith('var(') || props.color.startsWith('#') || props.color.startsWith('rgb')
|
|
223
|
-
? props.color
|
|
224
|
-
: `var(--color-${props.color}, ${props.color})`
|
|
225
|
-
|
|
226
|
-
const isOutlinedOrText = ['text', 'outlined', 'plain'].includes(props.variant)
|
|
227
|
-
|
|
228
|
-
return {
|
|
229
|
-
color: isOutlinedOrText ? colorValue : '#ffffff',
|
|
230
|
-
backgroundColor: isOutlinedOrText ? 'transparent' : colorValue,
|
|
231
|
-
borderColor: props.variant === 'outlined' ? colorValue : 'transparent'
|
|
232
|
-
}
|
|
233
|
-
})
|
|
234
|
-
</script>
|
|
235
|
-
|
|
236
|
-
<template>
|
|
237
|
-
<!--
|
|
238
|
-
實作 A: 底層框架 (Vuetify)
|
|
239
|
-
原則:屬性透傳 ($attrs) 讓 Vue 自動處理剩下的 80% 屬性
|
|
240
|
-
-->
|
|
241
|
-
<v-btn
|
|
242
|
-
v-if="USE_FRAMEWORK"
|
|
243
|
-
v-bind="{ ...vuetifyBindings, ...$attrs }"
|
|
244
|
-
:block="block"
|
|
245
|
-
:loading="loading"
|
|
246
|
-
:disabled="disabled"
|
|
247
|
-
:href="href"
|
|
248
|
-
:target="target"
|
|
249
|
-
:class="buttonClasses"
|
|
250
|
-
@click="handleClick"
|
|
251
|
-
>
|
|
252
|
-
<!-- Prepend Slot -->
|
|
253
|
-
<template
|
|
254
|
-
v-if="prependIcon"
|
|
255
|
-
#prepend
|
|
256
|
-
>
|
|
257
|
-
<slot name="prepend">
|
|
258
|
-
<IIcon :icon="prependIcon" />
|
|
259
|
-
</slot>
|
|
260
|
-
</template>
|
|
261
|
-
<template
|
|
262
|
-
v-else-if="$slots.prepend"
|
|
263
|
-
#prepend
|
|
264
|
-
>
|
|
265
|
-
<slot name="prepend" />
|
|
266
|
-
</template>
|
|
267
|
-
|
|
268
|
-
<!-- Default Slot -->
|
|
269
|
-
<slot />
|
|
270
|
-
|
|
271
|
-
<!-- Append Slot -->
|
|
272
|
-
<template
|
|
273
|
-
v-if="appendIcon"
|
|
274
|
-
#append
|
|
275
|
-
>
|
|
276
|
-
<slot name="append">
|
|
277
|
-
<IIcon :icon="appendIcon" />
|
|
278
|
-
</slot>
|
|
279
|
-
</template>
|
|
280
|
-
<template
|
|
281
|
-
v-else-if="$slots.append"
|
|
282
|
-
#append
|
|
283
|
-
>
|
|
284
|
-
<slot name="append" />
|
|
285
|
-
</template>
|
|
286
|
-
|
|
287
|
-
<!-- Loader Slot (Optional customization) -->
|
|
288
|
-
<template
|
|
289
|
-
v-if="$slots.loader"
|
|
290
|
-
#loader
|
|
291
|
-
>
|
|
292
|
-
<slot name="loader" />
|
|
293
|
-
</template>
|
|
294
|
-
</v-btn>
|
|
295
|
-
|
|
296
|
-
<!--
|
|
297
|
-
實作 B: 原生 HTML/CSS
|
|
298
|
-
完全不依賴任何第三方 UI 庫,證明介面層解耦能力
|
|
299
|
-
-->
|
|
300
|
-
<component
|
|
301
|
-
:is="componentTag"
|
|
302
|
-
v-else
|
|
303
|
-
:type="isLink ? undefined : 'button'"
|
|
304
|
-
:href="isLink ? href : undefined"
|
|
305
|
-
:target="isLink ? target : undefined"
|
|
306
|
-
:disabled="isLink ? undefined : disabled || loading"
|
|
307
|
-
:class="[
|
|
308
|
-
'i-button',
|
|
309
|
-
`i-button--${variant}`,
|
|
310
|
-
`i-button--${size}`,
|
|
311
|
-
{
|
|
312
|
-
'i-button--block': block,
|
|
313
|
-
'i-button--loading': loading,
|
|
314
|
-
'i-button--disabled': disabled
|
|
315
|
-
},
|
|
316
|
-
...buttonClasses // Apply same utility classes where possible
|
|
317
|
-
]"
|
|
318
|
-
:style="nativeStyle"
|
|
319
|
-
@click="handleClick"
|
|
320
|
-
>
|
|
321
|
-
<!-- Loading -->
|
|
322
|
-
<span
|
|
323
|
-
v-if="loading"
|
|
324
|
-
class="i-button__loading"
|
|
325
|
-
>
|
|
326
|
-
<svg
|
|
327
|
-
class="animate-spin h-5 w-5 text-current"
|
|
328
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
329
|
-
fill="none"
|
|
330
|
-
viewBox="0 0 24 24"
|
|
331
|
-
>
|
|
332
|
-
<circle
|
|
333
|
-
class="opacity-25"
|
|
334
|
-
cx="12"
|
|
335
|
-
cy="12"
|
|
336
|
-
r="10"
|
|
337
|
-
stroke="currentColor"
|
|
338
|
-
stroke-width="4"
|
|
339
|
-
></circle>
|
|
340
|
-
<path
|
|
341
|
-
class="opacity-75"
|
|
342
|
-
fill="currentColor"
|
|
343
|
-
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
344
|
-
></path>
|
|
345
|
-
</svg>
|
|
346
|
-
</span>
|
|
347
|
-
|
|
348
|
-
<!-- Prepend Icon -->
|
|
349
|
-
<span
|
|
350
|
-
v-if="!loading && (prependIcon || $slots.prepend)"
|
|
351
|
-
class="i-button__prepend mr-2"
|
|
352
|
-
>
|
|
353
|
-
<slot name="prepend">
|
|
354
|
-
<IIcon :icon="prependIcon!" />
|
|
355
|
-
</slot>
|
|
356
|
-
</span>
|
|
357
|
-
|
|
358
|
-
<!-- Content -->
|
|
359
|
-
<slot />
|
|
360
|
-
|
|
361
|
-
<!-- Append Icon -->
|
|
362
|
-
<span
|
|
363
|
-
v-if="!loading && (appendIcon || $slots.append)"
|
|
364
|
-
class="i-button__append ml-2"
|
|
365
|
-
>
|
|
366
|
-
<slot name="append">
|
|
367
|
-
<IIcon :icon="appendIcon!" />
|
|
368
|
-
</slot>
|
|
369
|
-
</span>
|
|
370
|
-
</component>
|
|
371
|
-
</template>
|
|
372
|
-
|
|
373
|
-
<style scoped>
|
|
374
|
-
/*
|
|
375
|
-
Native Implementation Styles
|
|
376
|
-
Only loaded when USE_FRAMEWORK is false
|
|
377
|
-
*/
|
|
378
|
-
.i-button {
|
|
379
|
-
display: inline-flex;
|
|
380
|
-
align-items: center;
|
|
381
|
-
justify-content: center;
|
|
382
|
-
padding: 0.5rem 1rem;
|
|
383
|
-
border: 1px solid transparent; /* Ensure border exists for layout */
|
|
384
|
-
border-radius: 0.5rem; /* rounded-lg */
|
|
385
|
-
font-size: 1rem; /* text-base */
|
|
386
|
-
font-weight: 500; /* font-medium */
|
|
387
|
-
cursor: pointer;
|
|
388
|
-
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
389
|
-
text-decoration: none;
|
|
390
|
-
line-height: 1.5;
|
|
391
|
-
outline: none;
|
|
392
|
-
user-select: none;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
.i-button:focus-visible {
|
|
396
|
-
box-shadow:
|
|
397
|
-
0 0 0 2px white,
|
|
398
|
-
0 0 0 4px var(--color-primary, #4f46e5);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/* Sizes */
|
|
402
|
-
.i-button--x-small {
|
|
403
|
-
padding: 0.125rem 0.5rem;
|
|
404
|
-
font-size: 0.75rem;
|
|
405
|
-
}
|
|
406
|
-
.i-button--small {
|
|
407
|
-
padding: 0.25rem 0.75rem;
|
|
408
|
-
font-size: 0.875rem;
|
|
409
|
-
}
|
|
410
|
-
.i-button--medium {
|
|
411
|
-
padding: 0.5rem 1rem;
|
|
412
|
-
font-size: 0.875rem;
|
|
413
|
-
} /* Default is often text-sm in systems */
|
|
414
|
-
.i-button--large {
|
|
415
|
-
padding: 0.75rem 1.5rem;
|
|
416
|
-
font-size: 1rem;
|
|
417
|
-
}
|
|
418
|
-
.i-button--x-large {
|
|
419
|
-
padding: 1rem 2rem;
|
|
420
|
-
font-size: 1.125rem;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/* Block */
|
|
424
|
-
.i-button--block {
|
|
425
|
-
display: flex;
|
|
426
|
-
width: 100%;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/* Disabled */
|
|
430
|
-
.i-button--disabled {
|
|
431
|
-
opacity: 0.5;
|
|
432
|
-
cursor: not-allowed;
|
|
433
|
-
pointer-events: none;
|
|
434
|
-
box-shadow: none !important;
|
|
435
|
-
transform: none !important;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/* Native Variants (Fallbacks) */
|
|
439
|
-
.i-button--primary {
|
|
440
|
-
/* Handled by utility classes mostly, but fallback: */
|
|
441
|
-
background-color: #4f46e5;
|
|
442
|
-
color: white;
|
|
443
|
-
}
|
|
444
|
-
.i-button--secondary {
|
|
445
|
-
background-color: #64748b;
|
|
446
|
-
color: white;
|
|
447
|
-
}
|
|
448
|
-
.i-button--danger {
|
|
449
|
-
background-color: #ef4444;
|
|
450
|
-
color: white;
|
|
451
|
-
}
|
|
452
|
-
.i-button--text {
|
|
453
|
-
background-color: transparent;
|
|
454
|
-
color: inherit;
|
|
455
|
-
}
|
|
456
|
-
.i-button--outlined {
|
|
457
|
-
background-color: transparent;
|
|
458
|
-
border: 1px solid currentColor;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
.i-button__loading {
|
|
462
|
-
margin-right: 0.5rem;
|
|
463
|
-
display: flex;
|
|
464
|
-
align-items: center;
|
|
465
|
-
}
|
|
466
|
-
</style>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import IIcon from './IIcon.vue'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Component: IButton (按鈕元件)
|
|
7
|
+
*
|
|
8
|
+
* 介面層 (Interface Layer) 標準元件。
|
|
9
|
+
* 負責將統一的 Props 轉換為底層 UI 框架 (Vuetify) 的屬性。
|
|
10
|
+
* 內部保留了「與 UI 框架解耦」的能力,可隨時切換回原生或其他框架。
|
|
11
|
+
*
|
|
12
|
+
* 設計風格遵循 Corporate Trust (企業信賴) 風格指南:
|
|
13
|
+
* - Primary: 漸層背景 (Indigo to Violet),圓角 (Rounded Full/Lg),懸浮時微幅上浮。
|
|
14
|
+
* - Shadows: 使用帶有藍紫色的陰影,增加深度感。
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* <IButton variant="primary" size="large" loading prepend-icon="mdi-check">提交</IButton>
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ====================================================
|
|
21
|
+
// 框架切換開關 (可改為 inject 或 config)
|
|
22
|
+
// ====================================================
|
|
23
|
+
const USE_FRAMEWORK = true
|
|
24
|
+
|
|
25
|
+
// 1. 定義標準 Props
|
|
26
|
+
interface Props {
|
|
27
|
+
/**
|
|
28
|
+
* 按鈕樣式變體
|
|
29
|
+
* @default 'primary'
|
|
30
|
+
*/
|
|
31
|
+
variant?:
|
|
32
|
+
| 'primary'
|
|
33
|
+
| 'secondary'
|
|
34
|
+
| 'success'
|
|
35
|
+
| 'danger'
|
|
36
|
+
| 'warning'
|
|
37
|
+
| 'info'
|
|
38
|
+
| 'text'
|
|
39
|
+
| 'outlined'
|
|
40
|
+
| 'plain' // 新增 Vuetify 常用變體
|
|
41
|
+
| 'tonal'
|
|
42
|
+
| 'flat'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 尺寸
|
|
46
|
+
* @default 'medium'
|
|
47
|
+
*/
|
|
48
|
+
size?: 'x-small' | 'small' | 'medium' | 'large' | 'x-large'
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 是否為區塊按鈕 (全寬)
|
|
52
|
+
* @default false
|
|
53
|
+
*/
|
|
54
|
+
block?: boolean
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 是否處於載入狀態
|
|
58
|
+
* @default false
|
|
59
|
+
*/
|
|
60
|
+
loading?: boolean
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 是否禁用
|
|
64
|
+
* @default false
|
|
65
|
+
*/
|
|
66
|
+
disabled?: boolean
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 連結目標 URL (若存在則渲染為 <a>)
|
|
70
|
+
*/
|
|
71
|
+
href?: string
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 連結開啟目標 (_blank, _self, etc.)
|
|
75
|
+
*/
|
|
76
|
+
target?: string
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 自訂顏色 (Hex, RGB, 或 CSS 變數)
|
|
80
|
+
* 若未指定,將根據 variant 自動決定
|
|
81
|
+
*/
|
|
82
|
+
color?: string
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 前置圖示名稱 (支援 mdi, fa, svg-softleader)
|
|
86
|
+
*/
|
|
87
|
+
prependIcon?: string
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 後置圖示名稱
|
|
91
|
+
*/
|
|
92
|
+
appendIcon?: string
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 圓角設定 (符合設計系統)
|
|
96
|
+
* Primary 按鈕通常使用圓角較大的風格
|
|
97
|
+
*/
|
|
98
|
+
rounded?: string | number | boolean
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 陰影設定
|
|
102
|
+
* @default undefined
|
|
103
|
+
*/
|
|
104
|
+
elevation?: string | number
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
108
|
+
variant: 'primary',
|
|
109
|
+
size: 'medium',
|
|
110
|
+
block: false,
|
|
111
|
+
loading: false,
|
|
112
|
+
disabled: false,
|
|
113
|
+
target: undefined,
|
|
114
|
+
color: undefined,
|
|
115
|
+
href: undefined,
|
|
116
|
+
prependIcon: undefined,
|
|
117
|
+
appendIcon: undefined,
|
|
118
|
+
rounded: undefined,
|
|
119
|
+
elevation: undefined
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// 定義事件
|
|
123
|
+
interface Emits {
|
|
124
|
+
(e: 'click', event: MouseEvent): void
|
|
125
|
+
}
|
|
126
|
+
const emit = defineEmits<Emits>()
|
|
127
|
+
|
|
128
|
+
// ====================================================
|
|
129
|
+
// 2. 屬性對照表 (Adapter Pattern)
|
|
130
|
+
// ====================================================
|
|
131
|
+
const vuetifyBindings = computed(() => {
|
|
132
|
+
const bindings: Record<string, any> = {}
|
|
133
|
+
|
|
134
|
+
// [Size Mapping]
|
|
135
|
+
// Vuetify 3 支援: x-small, small, default, large, x-large
|
|
136
|
+
const sizeMap: Record<string, string> = {
|
|
137
|
+
'x-small': 'x-small',
|
|
138
|
+
small: 'small',
|
|
139
|
+
medium: 'default', // Mapping medium to default
|
|
140
|
+
large: 'large',
|
|
141
|
+
'x-large': 'x-large'
|
|
142
|
+
}
|
|
143
|
+
bindings.size = sizeMap[props.size] || props.size
|
|
144
|
+
|
|
145
|
+
// [Variant & Color Mapping]
|
|
146
|
+
const colorMap: Record<string, string> = {
|
|
147
|
+
danger: 'error',
|
|
148
|
+
success: 'success',
|
|
149
|
+
warning: 'warning',
|
|
150
|
+
info: 'info',
|
|
151
|
+
secondary: 'secondary',
|
|
152
|
+
primary: 'primary'
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 處理特殊 Variant 映射
|
|
156
|
+
if (['text', 'outlined', 'plain', 'tonal', 'flat'].includes(props.variant)) {
|
|
157
|
+
bindings.variant = props.variant
|
|
158
|
+
// 若無指定顏色,則使用 Primary 或預設
|
|
159
|
+
bindings.color = props.color || (props.variant === 'outlined' ? 'primary' : undefined)
|
|
160
|
+
} else {
|
|
161
|
+
// 實心類按鈕 (Elevated)
|
|
162
|
+
bindings.variant = 'elevated'
|
|
163
|
+
// 映射語意顏色
|
|
164
|
+
bindings.color = props.color || colorMap[props.variant] || props.variant
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 處理圓角
|
|
168
|
+
// 只有在 props.rounded 真的是 undefined 時才使用預設值
|
|
169
|
+
// 空字串 '' 代表使用者明確選擇 "Default",應該傳遞給 Vuetify
|
|
170
|
+
if (props.rounded !== undefined) {
|
|
171
|
+
// 如果是空字串,轉換為 undefined 讓 Vuetify 使用預設值
|
|
172
|
+
bindings.rounded = props.rounded === '' ? undefined : props.rounded
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 處理 Elevation
|
|
176
|
+
if (props.elevation !== undefined) {
|
|
177
|
+
bindings.elevation = props.elevation
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return bindings
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// ====================================================
|
|
184
|
+
// 3. 樣式類別計算 (Classes)
|
|
185
|
+
// ====================================================
|
|
186
|
+
const buttonClasses = computed(() => {
|
|
187
|
+
const classes: any[] = []
|
|
188
|
+
|
|
189
|
+
// Design System: Primary Button Gradients & Shadows
|
|
190
|
+
if (props.variant === 'primary' && !props.disabled && !props.loading && !props.color) {
|
|
191
|
+
classes.push(
|
|
192
|
+
'bg-[var(--color-primary,#4f46e5)] text-white',
|
|
193
|
+
'hover:shadow-[0_10px_25px_-5px_var(--color-primary-alpha,rgba(79,70,229,0.4))]', // Enhanced Colored Shadow
|
|
194
|
+
'hover:-translate-y-0.5', // Lift effect
|
|
195
|
+
'transition-all duration-200 ease-out'
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Custom Outline Style
|
|
200
|
+
if (props.variant === 'outlined' && !props.color) {
|
|
201
|
+
classes.push('border-slate-200 text-slate-700 hover:bg-slate-50 hover:border-slate-300')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return classes
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// ====================================================
|
|
208
|
+
// 4. 原生實作邏輯 (Native Fallback)
|
|
209
|
+
// ====================================================
|
|
210
|
+
const handleClick = (event: MouseEvent) => {
|
|
211
|
+
if (!props.disabled && !props.loading) {
|
|
212
|
+
emit('click', event)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const isLink = computed(() => !!props.href)
|
|
217
|
+
const componentTag = computed(() => (isLink.value ? 'a' : 'button'))
|
|
218
|
+
|
|
219
|
+
const nativeStyle = computed(() => {
|
|
220
|
+
if (!props.color) return {}
|
|
221
|
+
const colorValue =
|
|
222
|
+
props.color.startsWith('var(') || props.color.startsWith('#') || props.color.startsWith('rgb')
|
|
223
|
+
? props.color
|
|
224
|
+
: `var(--color-${props.color}, ${props.color})`
|
|
225
|
+
|
|
226
|
+
const isOutlinedOrText = ['text', 'outlined', 'plain'].includes(props.variant)
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
color: isOutlinedOrText ? colorValue : '#ffffff',
|
|
230
|
+
backgroundColor: isOutlinedOrText ? 'transparent' : colorValue,
|
|
231
|
+
borderColor: props.variant === 'outlined' ? colorValue : 'transparent'
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
</script>
|
|
235
|
+
|
|
236
|
+
<template>
|
|
237
|
+
<!--
|
|
238
|
+
實作 A: 底層框架 (Vuetify)
|
|
239
|
+
原則:屬性透傳 ($attrs) 讓 Vue 自動處理剩下的 80% 屬性
|
|
240
|
+
-->
|
|
241
|
+
<v-btn
|
|
242
|
+
v-if="USE_FRAMEWORK"
|
|
243
|
+
v-bind="{ ...vuetifyBindings, ...$attrs }"
|
|
244
|
+
:block="block"
|
|
245
|
+
:loading="loading"
|
|
246
|
+
:disabled="disabled"
|
|
247
|
+
:href="href"
|
|
248
|
+
:target="target"
|
|
249
|
+
:class="buttonClasses"
|
|
250
|
+
@click="handleClick"
|
|
251
|
+
>
|
|
252
|
+
<!-- Prepend Slot -->
|
|
253
|
+
<template
|
|
254
|
+
v-if="prependIcon"
|
|
255
|
+
#prepend
|
|
256
|
+
>
|
|
257
|
+
<slot name="prepend">
|
|
258
|
+
<IIcon :icon="prependIcon" />
|
|
259
|
+
</slot>
|
|
260
|
+
</template>
|
|
261
|
+
<template
|
|
262
|
+
v-else-if="$slots.prepend"
|
|
263
|
+
#prepend
|
|
264
|
+
>
|
|
265
|
+
<slot name="prepend" />
|
|
266
|
+
</template>
|
|
267
|
+
|
|
268
|
+
<!-- Default Slot -->
|
|
269
|
+
<slot />
|
|
270
|
+
|
|
271
|
+
<!-- Append Slot -->
|
|
272
|
+
<template
|
|
273
|
+
v-if="appendIcon"
|
|
274
|
+
#append
|
|
275
|
+
>
|
|
276
|
+
<slot name="append">
|
|
277
|
+
<IIcon :icon="appendIcon" />
|
|
278
|
+
</slot>
|
|
279
|
+
</template>
|
|
280
|
+
<template
|
|
281
|
+
v-else-if="$slots.append"
|
|
282
|
+
#append
|
|
283
|
+
>
|
|
284
|
+
<slot name="append" />
|
|
285
|
+
</template>
|
|
286
|
+
|
|
287
|
+
<!-- Loader Slot (Optional customization) -->
|
|
288
|
+
<template
|
|
289
|
+
v-if="$slots.loader"
|
|
290
|
+
#loader
|
|
291
|
+
>
|
|
292
|
+
<slot name="loader" />
|
|
293
|
+
</template>
|
|
294
|
+
</v-btn>
|
|
295
|
+
|
|
296
|
+
<!--
|
|
297
|
+
實作 B: 原生 HTML/CSS
|
|
298
|
+
完全不依賴任何第三方 UI 庫,證明介面層解耦能力
|
|
299
|
+
-->
|
|
300
|
+
<component
|
|
301
|
+
:is="componentTag"
|
|
302
|
+
v-else
|
|
303
|
+
:type="isLink ? undefined : 'button'"
|
|
304
|
+
:href="isLink ? href : undefined"
|
|
305
|
+
:target="isLink ? target : undefined"
|
|
306
|
+
:disabled="isLink ? undefined : disabled || loading"
|
|
307
|
+
:class="[
|
|
308
|
+
'i-button',
|
|
309
|
+
`i-button--${variant}`,
|
|
310
|
+
`i-button--${size}`,
|
|
311
|
+
{
|
|
312
|
+
'i-button--block': block,
|
|
313
|
+
'i-button--loading': loading,
|
|
314
|
+
'i-button--disabled': disabled
|
|
315
|
+
},
|
|
316
|
+
...buttonClasses // Apply same utility classes where possible
|
|
317
|
+
]"
|
|
318
|
+
:style="nativeStyle"
|
|
319
|
+
@click="handleClick"
|
|
320
|
+
>
|
|
321
|
+
<!-- Loading -->
|
|
322
|
+
<span
|
|
323
|
+
v-if="loading"
|
|
324
|
+
class="i-button__loading"
|
|
325
|
+
>
|
|
326
|
+
<svg
|
|
327
|
+
class="animate-spin h-5 w-5 text-current"
|
|
328
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
329
|
+
fill="none"
|
|
330
|
+
viewBox="0 0 24 24"
|
|
331
|
+
>
|
|
332
|
+
<circle
|
|
333
|
+
class="opacity-25"
|
|
334
|
+
cx="12"
|
|
335
|
+
cy="12"
|
|
336
|
+
r="10"
|
|
337
|
+
stroke="currentColor"
|
|
338
|
+
stroke-width="4"
|
|
339
|
+
></circle>
|
|
340
|
+
<path
|
|
341
|
+
class="opacity-75"
|
|
342
|
+
fill="currentColor"
|
|
343
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
344
|
+
></path>
|
|
345
|
+
</svg>
|
|
346
|
+
</span>
|
|
347
|
+
|
|
348
|
+
<!-- Prepend Icon -->
|
|
349
|
+
<span
|
|
350
|
+
v-if="!loading && (prependIcon || $slots.prepend)"
|
|
351
|
+
class="i-button__prepend mr-2"
|
|
352
|
+
>
|
|
353
|
+
<slot name="prepend">
|
|
354
|
+
<IIcon :icon="prependIcon!" />
|
|
355
|
+
</slot>
|
|
356
|
+
</span>
|
|
357
|
+
|
|
358
|
+
<!-- Content -->
|
|
359
|
+
<slot />
|
|
360
|
+
|
|
361
|
+
<!-- Append Icon -->
|
|
362
|
+
<span
|
|
363
|
+
v-if="!loading && (appendIcon || $slots.append)"
|
|
364
|
+
class="i-button__append ml-2"
|
|
365
|
+
>
|
|
366
|
+
<slot name="append">
|
|
367
|
+
<IIcon :icon="appendIcon!" />
|
|
368
|
+
</slot>
|
|
369
|
+
</span>
|
|
370
|
+
</component>
|
|
371
|
+
</template>
|
|
372
|
+
|
|
373
|
+
<style scoped>
|
|
374
|
+
/*
|
|
375
|
+
Native Implementation Styles
|
|
376
|
+
Only loaded when USE_FRAMEWORK is false
|
|
377
|
+
*/
|
|
378
|
+
.i-button {
|
|
379
|
+
display: inline-flex;
|
|
380
|
+
align-items: center;
|
|
381
|
+
justify-content: center;
|
|
382
|
+
padding: 0.5rem 1rem;
|
|
383
|
+
border: 1px solid transparent; /* Ensure border exists for layout */
|
|
384
|
+
border-radius: 0.5rem; /* rounded-lg */
|
|
385
|
+
font-size: 1rem; /* text-base */
|
|
386
|
+
font-weight: 500; /* font-medium */
|
|
387
|
+
cursor: pointer;
|
|
388
|
+
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
389
|
+
text-decoration: none;
|
|
390
|
+
line-height: 1.5;
|
|
391
|
+
outline: none;
|
|
392
|
+
user-select: none;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
.i-button:focus-visible {
|
|
396
|
+
box-shadow:
|
|
397
|
+
0 0 0 2px white,
|
|
398
|
+
0 0 0 4px var(--color-primary, #4f46e5);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/* Sizes */
|
|
402
|
+
.i-button--x-small {
|
|
403
|
+
padding: 0.125rem 0.5rem;
|
|
404
|
+
font-size: 0.75rem;
|
|
405
|
+
}
|
|
406
|
+
.i-button--small {
|
|
407
|
+
padding: 0.25rem 0.75rem;
|
|
408
|
+
font-size: 0.875rem;
|
|
409
|
+
}
|
|
410
|
+
.i-button--medium {
|
|
411
|
+
padding: 0.5rem 1rem;
|
|
412
|
+
font-size: 0.875rem;
|
|
413
|
+
} /* Default is often text-sm in systems */
|
|
414
|
+
.i-button--large {
|
|
415
|
+
padding: 0.75rem 1.5rem;
|
|
416
|
+
font-size: 1rem;
|
|
417
|
+
}
|
|
418
|
+
.i-button--x-large {
|
|
419
|
+
padding: 1rem 2rem;
|
|
420
|
+
font-size: 1.125rem;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/* Block */
|
|
424
|
+
.i-button--block {
|
|
425
|
+
display: flex;
|
|
426
|
+
width: 100%;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/* Disabled */
|
|
430
|
+
.i-button--disabled {
|
|
431
|
+
opacity: 0.5;
|
|
432
|
+
cursor: not-allowed;
|
|
433
|
+
pointer-events: none;
|
|
434
|
+
box-shadow: none !important;
|
|
435
|
+
transform: none !important;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/* Native Variants (Fallbacks) */
|
|
439
|
+
.i-button--primary {
|
|
440
|
+
/* Handled by utility classes mostly, but fallback: */
|
|
441
|
+
background-color: #4f46e5;
|
|
442
|
+
color: white;
|
|
443
|
+
}
|
|
444
|
+
.i-button--secondary {
|
|
445
|
+
background-color: #64748b;
|
|
446
|
+
color: white;
|
|
447
|
+
}
|
|
448
|
+
.i-button--danger {
|
|
449
|
+
background-color: #ef4444;
|
|
450
|
+
color: white;
|
|
451
|
+
}
|
|
452
|
+
.i-button--text {
|
|
453
|
+
background-color: transparent;
|
|
454
|
+
color: inherit;
|
|
455
|
+
}
|
|
456
|
+
.i-button--outlined {
|
|
457
|
+
background-color: transparent;
|
|
458
|
+
border: 1px solid currentColor;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
.i-button__loading {
|
|
462
|
+
margin-right: 0.5rem;
|
|
463
|
+
display: flex;
|
|
464
|
+
align-items: center;
|
|
465
|
+
}
|
|
466
|
+
</style>
|