This commit is contained in:
2026-01-06 21:44:36 +09:00
parent ceec1ad7a9
commit 716cf63f73
98 changed files with 6997 additions and 538 deletions

View File

@@ -0,0 +1,56 @@
<template>
<span :class="['badge', `badge-${variant}`]">
<slot>{{ text }}</slot>
</span>
</template>
<script setup>
defineProps({
text: String,
variant: {
type: String,
default: 'default'
// default, critical, error, warn, success, info
}
})
</script>
<style scoped>
.badge {
display: inline-block;
padding: 4px 8px;
font-size: 12px;
font-weight: 500;
border-radius: 4px;
}
.badge-default {
background: #e9ecef;
color: #495057;
}
.badge-critical {
background: #e74c3c;
color: white;
}
.badge-error {
background: #e74c3c;
color: white;
}
.badge-warn {
background: #f39c12;
color: white;
}
.badge-success {
background: #27ae60;
color: white;
}
.badge-info {
background: #3498db;
color: white;
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<button
:type="type"
:class="['btn', `btn-${variant}`, { 'btn-sm': size === 'sm', 'btn-lg': size === 'lg' }]"
:disabled="disabled || loading"
@click="$emit('click', $event)"
>
<span v-if="loading" class="spinner"></span>
<slot></slot>
</button>
</template>
<script setup>
defineProps({
type: {
type: String,
default: 'button'
},
variant: {
type: String,
default: 'primary'
// primary, secondary, danger, success, warning
},
size: {
type: String,
default: 'md'
// sm, md, lg
},
disabled: Boolean,
loading: Boolean
})
defineEmits(['click'])
</script>
<style scoped>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-lg {
padding: 12px 24px;
font-size: 16px;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2980b9;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: #5a6268;
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c0392b;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover:not(:disabled) {
background: #1e8449;
}
.btn-warning {
background: #f39c12;
color: white;
}
.btn-warning:hover:not(:disabled) {
background: #d68910;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<div class="card">
<div v-if="title || $slots.header" class="card-header">
<slot name="header">
<h3>{{ title }}</h3>
</slot>
</div>
<div class="card-body">
<slot></slot>
</div>
<div v-if="$slots.footer" class="card-footer">
<slot name="footer"></slot>
</div>
</div>
</template>
<script setup>
defineProps({
title: String
})
</script>
<style scoped>
.card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
overflow: hidden;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.card-header h3 {
margin: 0;
font-size: 1.1rem;
color: #333;
}
.card-body {
padding: 20px;
}
.card-footer {
padding: 16px 20px;
border-top: 1px solid #eee;
background: #f8f9fa;
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th v-for="col in columns" :key="col.key" :style="{ width: col.width }">
{{ col.label }}
</th>
<th v-if="$slots.actions" class="actions-col">작업</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td :colspan="columns.length + ($slots.actions ? 1 : 0)" class="loading-cell">
로딩 ...
</td>
</tr>
<tr v-else-if="!data || data.length === 0">
<td :colspan="columns.length + ($slots.actions ? 1 : 0)" class="empty-cell">
{{ emptyText }}
</td>
</tr>
<tr v-else v-for="(row, idx) in data" :key="row.id || idx" @click="$emit('row-click', row)">
<td v-for="col in columns" :key="col.key">
<slot :name="col.key" :row="row" :value="row[col.key]">
{{ formatValue(row[col.key], col) }}
</slot>
</td>
<td v-if="$slots.actions" class="actions-col">
<slot name="actions" :row="row"></slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup>
const props = defineProps({
columns: {
type: Array,
required: true
// { key: 'name', label: '이름', width: '100px', type: 'date' }
},
data: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
emptyText: {
type: String,
default: '데이터가 없습니다.'
}
})
defineEmits(['row-click'])
const formatValue = (value, col) => {
if (value === null || value === undefined) return '-'
if (col.type === 'date' && value) {
return new Date(value).toLocaleString('ko-KR')
}
if (col.type === 'boolean') {
return value ? 'Y' : 'N'
}
return value
}
</script>
<style scoped>
.data-table-wrapper {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.data-table th,
.data-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #eee;
}
.data-table th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
}
.data-table tbody tr:hover {
background: #f8f9fa;
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.actions-col {
width: 120px;
text-align: center;
}
.loading-cell,
.empty-cell {
text-align: center;
color: #6c757d;
padding: 40px !important;
}
</style>

View File

@@ -0,0 +1,138 @@
<template>
<div class="form-group">
<label v-if="label" :for="inputId">
{{ label }}
<span v-if="required" class="required">*</span>
</label>
<input
v-if="type !== 'textarea' && type !== 'select'"
:id="inputId"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
class="form-input"
@input="$emit('update:modelValue', $event.target.value)"
/>
<textarea
v-else-if="type === 'textarea'"
:id="inputId"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:rows="rows"
class="form-input"
@input="$emit('update:modelValue', $event.target.value)"
/>
<select
v-else-if="type === 'select'"
:id="inputId"
:value="modelValue"
:disabled="disabled"
class="form-input"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-if="placeholder" value="">{{ placeholder }}</option>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<span v-if="error" class="error-text">{{ error }}</span>
<span v-if="hint" class="hint-text">{{ hint }}</span>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: {
type: [String, Number],
default: ''
},
label: String,
type: {
type: String,
default: 'text'
},
placeholder: String,
required: Boolean,
disabled: Boolean,
readonly: Boolean,
error: String,
hint: String,
rows: {
type: Number,
default: 3
},
options: {
type: Array,
default: () => []
// [{ value: 'a', label: 'A' }]
}
})
defineEmits(['update:modelValue'])
const inputId = computed(() => `input-${Math.random().toString(36).slice(2, 9)}`)
</script>
<style scoped>
.form-group {
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: #333;
}
.required {
color: #e74c3c;
}
.form-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: #3498db;
}
.form-input:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
textarea.form-input {
resize: vertical;
}
select.form-input {
cursor: pointer;
}
.error-text {
display: block;
margin-top: 4px;
font-size: 12px;
color: #e74c3c;
}
.hint-text {
display: block;
margin-top: 4px;
font-size: 12px;
color: #6c757d;
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<Teleport to="body">
<div v-if="modelValue" class="modal-overlay" @click.self="close">
<div class="modal" :style="{ width: width }">
<div class="modal-header">
<h3>{{ title }}</h3>
<button class="close-btn" @click="close">&times;</button>
</div>
<div class="modal-body">
<slot></slot>
</div>
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
width: {
type: String,
default: '500px'
}
})
const emit = defineEmits(['update:modelValue', 'close'])
const close = () => {
emit('update:modelValue', false)
emit('close')
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.modal-header h3 {
margin: 0;
font-size: 1.1rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
padding: 0;
line-height: 1;
}
.close-btn:hover {
color: #333;
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
padding: 16px 20px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,6 @@
export { default as DataTable } from './DataTable.vue'
export { default as Modal } from './Modal.vue'
export { default as FormInput } from './FormInput.vue'
export { default as Button } from './Button.vue'
export { default as Badge } from './Badge.vue'
export { default as Card } from './Card.vue'