update
This commit is contained in:
56
frontend/src/components/Badge.vue
Normal file
56
frontend/src/components/Badge.vue
Normal 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>
|
||||
118
frontend/src/components/Button.vue
Normal file
118
frontend/src/components/Button.vue
Normal 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>
|
||||
51
frontend/src/components/Card.vue
Normal file
51
frontend/src/components/Card.vue
Normal 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>
|
||||
120
frontend/src/components/DataTable.vue
Normal file
120
frontend/src/components/DataTable.vue
Normal 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>
|
||||
138
frontend/src/components/FormInput.vue
Normal file
138
frontend/src/components/FormInput.vue
Normal 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>
|
||||
108
frontend/src/components/Modal.vue
Normal file
108
frontend/src/components/Modal.vue
Normal 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">×</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>
|
||||
6
frontend/src/components/index.js
Normal file
6
frontend/src/components/index.js
Normal 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'
|
||||
Reference in New Issue
Block a user