This commit is contained in:
khj0414 2025-03-07 16:20:27 +09:00
commit f2c231288c
14 changed files with 346 additions and 304 deletions

View File

@ -22,7 +22,7 @@
border-radius: 2px !important; border-radius: 2px !important;
font-size: 0px !important; font-size: 0px !important;
} }
/* 연차 그래프 (풀) */ /* 연차 그래프 (풀) */
.fc-daygrid-event.full-day { .fc-daygrid-event.full-day {
width: 100% !important; width: 100% !important;
height: 8px !important; height: 8px !important;
@ -44,7 +44,7 @@
.fc-daygrid-day-number { .fc-daygrid-day-number {
margin-right: auto; margin-right: auto;
} }
/* 데이트피커 뾰족없게 */ /* 데이트피커 뾰족없게 */
.flatpickr-calendar:before, .flatpickr-calendar:before,
.flatpickr-calendar:after { .flatpickr-calendar:after {
display: none !important; display: none !important;
@ -65,12 +65,10 @@ background-color: rgba(0, 0, 0, 0.05); /* 연한 배경 효과 */
/* 주말 (토요일, 일요일) 및 공휴일 */ /* 주말 (토요일, 일요일) 및 공휴일 */
.fc-day-sat-sun { .fc-day-sat-sun {
cursor: not-allowed !important; cursor: not-allowed !important;
opacity: 0.6; /* 흐려 보이게 */
} }
/* 과거 날짜 (오늘 이전) */ /* 과거 날짜 (오늘 이전) */
.fc-daygrid-day.past { .fc-daygrid-day.past {
cursor: not-allowed !important; cursor: not-allowed !important;
opacity: 0.6; /* 흐려 보이게 */
} }
/* 기본 이벤트 스타일 */ /* 기본 이벤트 스타일 */
.fc-daygrid-event { .fc-daygrid-event {
@ -213,7 +211,7 @@ opacity: 0.6; /* 흐려 보이게 */
justify-content: flex-start; justify-content: flex-start;
gap: 10px; gap: 10px;
padding: 0; padding: 0;
margin-left: 20px; margin-left: 12%;
} }
.profile-img { .profile-img {
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
@ -252,20 +250,19 @@ opacity: 0.6; /* 흐려 보이게 */
} }
/* 선택된 (눌린) 버튼 */ /* 선택된 (눌린) 버튼 */
.vac-btn.active { .vac-btn.active {
border: 3px solid #ff0000; /* 붉은색 테두리 적용 */ box-shadow: 0px 4px 15px rgba(224, 224, 224, 0.3);
box-shadow: 0px 4px 15px rgba(0, 0, 0, 0.3);
transform: scale(1.1); transform: scale(1.1);
} }
.vac-btn-warning{ .vac-btn-warning{
color: #fff; color: #fff;
background-color: #ffab00; background-color: #ffc144;
border-color: #ffab00; border-color: #ffe605;
box-shadow: 0 0.125rem 0.25rem 0 rgba(255, 171, 0, 0.4); box-shadow: 0 0.125rem 0.25rem 0 rgba(255, 171, 0, 0.4);
} }
/* AM 버튼 (선택된 상태) */ /* AM 버튼 (선택된 상태) */
.vac-btn-warning.active { .vac-btn-warning.active {
background-color: #ffca2c !important; background-color: #ff7300 !important;
color: black; color: #fff;;
} }
.vac-btn-info { .vac-btn-info {
color: #fff; color: #fff;
@ -279,7 +276,7 @@ opacity: 0.6; /* 흐려 보이게 */
color: white; color: white;
} }
/* 버튼 기본 (비활성화일 때 기본 녹색) */ /* 버튼 기본 (비활성화일 때 기본 녹색) */
.btn-success { .vac-btn-success {
font-size: 24px; font-size: 24px;
width: 50px; width: 50px;
height: 50px; height: 50px;
@ -294,7 +291,7 @@ opacity: 0.6; /* 흐려 보이게 */
border: 1px solid transparent; border: 1px solid transparent;
} }
/* 버튼 활성화 */ /* 버튼 활성화 */
.btn-success.active { .vac-btn-success.active {
background-color: #ff0000 !important; background-color: #ff0000 !important;
color: white !important; color: white !important;
border: 3px solid #eb9f9f !important; border: 3px solid #eb9f9f !important;
@ -302,7 +299,8 @@ opacity: 0.6; /* 흐려 보이게 */
transform: scale(1.1); transform: scale(1.1);
} }
/* 버튼 비활성화 */ /* 버튼 비활성화 */
.btn-success.disabled { .vac-btn-success.disabled {
border: 3px solid #e6e4e4; /* 붉은색 테두리 적용 */
background-color: #bbb8b8 !important; background-color: #bbb8b8 !important;
color: white !important; color: white !important;
cursor: not-allowed !important; cursor: not-allowed !important;
@ -353,7 +351,7 @@ opacity: 0.6; /* 흐려 보이게 */
height: 40px; height: 40px;
font-size: 18px; font-size: 18px;
} }
.btn-success { .vac-btn-success {
font-size: 20px; font-size: 20px;
width: 40px; width: 40px;
height: 40px; height: 40px;

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,6 +1,7 @@
<template> <template>
<component :is="layout"> <component :is="layout">
<template #content> <template #content>
<LoadingSpinner :isLoading="loadingStore.isLoading" />
<router-view></router-view> <router-view></router-view>
</template> </template>
</component> </component>
@ -12,7 +13,10 @@ import { useRoute } from 'vue-router';
import NormalLayout from './layouts/NormalLayout.vue'; import NormalLayout from './layouts/NormalLayout.vue';
import NoLayout from './layouts/NoLayout.vue'; import NoLayout from './layouts/NoLayout.vue';
import ToastModal from '@c/modal/ToastModal.vue'; import ToastModal from '@c/modal/ToastModal.vue';
import { useLoadingStore } from "@s/loadingStore";
import LoadingSpinner from "@v/LoadingPage.vue";
const loadingStore = useLoadingStore();
const route = useRoute(); const route = useRoute();
const layout = computed(() => { const layout = computed(() => {

View File

@ -1,6 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useToastStore } from '@s/toastStore'; import { useToastStore } from '@s/toastStore';
import { useLoadingStore } from "@s/loadingStore";
const $api = axios.create({ const $api = axios.create({
baseURL: import.meta.env.VITE_API_URL, baseURL: import.meta.env.VITE_API_URL,
@ -14,6 +15,9 @@ const $api = axios.create({
*/ */
$api.interceptors.request.use( $api.interceptors.request.use(
function (config) { function (config) {
const loadingStore = useLoadingStore();
loadingStore.startLoading();
let contentType = 'application/json'; let contentType = 'application/json';
if (config.isFormData) contentType = 'multipart/form-data'; if (config.isFormData) contentType = 'multipart/form-data';
@ -24,6 +28,8 @@ $api.interceptors.request.use(
return config; return config;
}, },
function (error) { function (error) {
const loadingStore = useLoadingStore();
loadingStore.stopLoading();
// 요청 오류가 있는 작업 수행 // 요청 오류가 있는 작업 수행
return Promise.reject(error); return Promise.reject(error);
}, },
@ -32,17 +38,22 @@ $api.interceptors.request.use(
// 응답 인터셉터 추가하기 // 응답 인터셉터 추가하기
$api.interceptors.response.use( $api.interceptors.response.use(
function (response) { function (response) {
const loadingStore = useLoadingStore();
loadingStore.stopLoading();
// 2xx 범위의 응답 처리 // 2xx 범위의 응답 처리
return response; return response;
}, },
function (error) { function (error) {
const loadingStore = useLoadingStore();
loadingStore.stopLoading();
const toastStore = useToastStore(); const toastStore = useToastStore();
// 오류 응답 처리 // 오류 응답 처리
if (error.response) { if (error.response) {
switch (error.response.status) { switch (error.response.status) {
case 401: case 401:
if (!error.config.headers.isLogin) { if (!error.config.headers.isLogin) {
toastStore.onToast('인증이 필요합니다.', 'e'); // toastStore.onToast('인증이 필요합니다.', 'e');
} }
break; break;
case 403: case 403:

View File

@ -18,7 +18,7 @@
</button> </button>
<!-- 저장 버튼 --> <!-- 저장 버튼 -->
<div class="save-button-container"> <div class="save-button-container">
<button class="btn-success" @click="addVacationRequests" <button class="vac-btn-success" @click="addVacationRequests"
:class="{ active: !isDisabled, disabled: isDisabled }"> :class="{ active: !isDisabled, disabled: isDisabled }">
</button> </button>

View File

@ -14,101 +14,98 @@
:placeholder="title" :placeholder="title"
:disabled="disabled" :disabled="disabled"
:min="min" :min="min"
autocomplete="off"
@focusout="$emit('focusout', modelValue)" @focusout="$emit('focusout', modelValue)"
@input="handleInput" @input="handleInput"
/> />
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''"> <div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }} 확인해주세요.</div>
{{ title }} 확인해주세요.
</div>
<!-- 카테고리 중복 --> <!-- 카테고리 중복 -->
<div class="invalid-feedback" :class="isCateAlert ? 'd-block' : ''"> <div class="invalid-feedback" :class="isCateAlert ? 'd-block' : ''">카테고리 중복입니다.</div>
카테고리 중복입니다.
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
// Props // Props
const props = defineProps({ const props = defineProps({
title: { title: {
type: String, type: String,
default: '라벨', default: '라벨',
required: true, required: true,
}, },
name: { name: {
type: String, type: String,
default: 'nameplz', default: 'nameplz',
required: true, required: true,
}, },
isEssential: { isEssential: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
type: { type: {
type: String, type: String,
default: 'text', default: 'text',
}, },
modelValue: { modelValue: {
type: String, type: String,
default: '', default: '',
}, },
maxlength: { maxlength: {
type: Number, type: Number,
default: 30, default: 30,
}, },
isAlert: { isAlert: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isCateAlert : { isCateAlert: {
type :Boolean, type: Boolean,
default: false, default: false,
}, },
isLabel : { isLabel: {
type: Boolean, type: Boolean,
default: true, default: true,
required: false, required: false,
}, },
disabled: { disabled: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
min: { min: {
type: String, type: String,
default: '', default: '',
required: false, required: false,
} },
}); });
// Emits // Emits
const emits = defineEmits(['update:modelValue', 'focusout', 'update:alert']); const emits = defineEmits(['update:modelValue', 'focusout', 'update:alert']);
// `inputValue`
const inputValue = ref(props.modelValue);
// `inputValue` //
const inputValue = ref(props.modelValue); watch(inputValue, newValue => {
emits('update:modelValue', newValue);
});
// //
watch(inputValue, (newValue) => { watch(
emits('update:modelValue', newValue); () => props.modelValue,
}); newValue => {
if (inputValue.value !== newValue) {
inputValue.value = newValue;
}
},
);
// const handleInput = event => {
watch(() => props.modelValue, (newValue) => { const newValue = event.target.value.slice(0, props.maxlength);
if (inputValue.value !== newValue) {
inputValue.value = newValue;
}
});
const handleInput = (event) => {
const newValue = event.target.value.slice(0, props.maxlength);
if (newValue.trim() !== '') {
emits('update:alert', false);
}
};
if (newValue.trim() !== '') {
emits('update:alert', false);
}
};
</script> </script>

View File

@ -86,15 +86,15 @@ const profileSize = computed(() => {
const totalUsers = userList.value.length; const totalUsers = userList.value.length;
if (windowWidth.value >= 1650) { if (windowWidth.value >= 1650) {
if (totalUsers <= 10) return "68px"; if (totalUsers <= 10) return "60px";
if (totalUsers <= 15) return "55px"; if (totalUsers <= 15) return "53px";
return "45px"; return "45px";
} else if (windowWidth.value >= 1300) { } else if (windowWidth.value >= 1400) {
if (totalUsers <= 10) return "45px"; if (totalUsers <= 10) return "48px";
if (totalUsers <= 15) return "40px"; if (totalUsers <= 15) return "30px";
return "30px"; return "20px";
} else if (windowWidth.value >= 1024) { } else if (windowWidth.value >= 1024) {
if (totalUsers <= 10) return "40px"; if (totalUsers <= 10) return "35px";
if (totalUsers <= 15) return "30px"; if (totalUsers <= 15) return "30px";
return "20px"; return "20px";
} else { } else {

View File

@ -63,8 +63,8 @@
margin-left: 260px; margin-left: 260px;
width: auto !important; width: auto !important;
min-width: auto !important; min-width: auto !important;
right: 24px !important; right: 26px !important;
left: 24px !important; left: 26px !important;
} }
/* 탑바 범위조정(1200px 이하) */ /* 탑바 범위조정(1200px 이하) */
@ -75,8 +75,8 @@
margin-left: 0px; margin-left: 0px;
width: auto !important; width: auto !important;
min-width: auto !important; min-width: auto !important;
right: 24px !important; right: 26px !important;
left: 24px !important; left: 26px !important;
} }
} }
@ -88,8 +88,8 @@
margin-left: 0px; margin-left: 0px;
width: auto !important; width: auto !important;
min-width: auto !important; min-width: auto !important;
right: 24px !important; right: 26px !important;
left: 24px !important; left: 26px !important;
} }
} }
</style> </style>

View File

@ -0,0 +1,22 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export const useLoadingStore = defineStore("loading", () => {
const loadingCount = ref(0); // 요청 개수를 추적
const startLoading = () => {
loadingCount.value++;
};
const stopLoading = () => {
if (loadingCount.value > 0) {
setTimeout(() => {
loadingCount.value--;
}, 200); // 약간의 지연을 추가하여 응답이 동시에 도착해도 안정적으로 감소
}
};
const isLoading = computed(() => loadingCount.value > 0); // 하나라도 요청이 있으면 로딩 활성화
return { isLoading, startLoading, stopLoading };
});

View File

@ -1,30 +1,58 @@
<template> <template>
<div class="loading-container"> <div v-if="isLoading" class="loading-overlay">
<div class="spinner"> <div class="loading-container">
🐰 <div class="spinner">
<img src="/img/icons/loading.png" class="loading-img" />
</div>
<p class="loading-text">LOADING...</p>
</div> </div>
<p class="loading-text">잠시만 기다려 주세요...</p>
</div> </div>
</template> </template>
<script setup> <script setup>
import { defineProps } from "vue";
defineProps({
isLoading: Boolean, //
});
</script> </script>
<style scoped> <style scoped>
/* 회색 배경으로 클릭 방지 */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4); /* 회색 반투명 */
display: flex;
align-items: center;
justify-content: center;
z-index: 9999; /* 가장 위에 위치 */
pointer-events: auto; /* 모든 클릭 방지 */
}
/* 로딩 컨테이너 */ /* 로딩 컨테이너 */
.loading-container { .loading-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100vh; padding: 20px;
background-color: #f9f9f9; background: none;
border-radius: 10px;
} }
.loading-img {
width: 80px; /* 원하는 크기로 조정 */
height: 80px;
}
/* 빙글빙글 돌아가는 스피너 */ /* 빙글빙글 돌아가는 스피너 */
.spinner { .spinner {
font-size: 50px; font-size: 50px;
animation: spin 1.2s linear infinite; animation: spin 2.2s linear infinite;
} }
/* 로딩 텍스트 */ /* 로딩 텍스트 */
@ -32,8 +60,7 @@
margin-top: 10px; margin-top: 10px;
font-size: 18px; font-size: 18px;
font-weight: bold; font-weight: bold;
color: #555; color: #ffffff;
font-family: "Comic Sans MS", "Arial", sans-serif;
} }
/* 회전 애니메이션 */ /* 회전 애니메이션 */

View File

@ -1,7 +1,6 @@
<template> <template>
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<LoadingSpinner v-if="isLoading" /> <div class="row">
<div v-else class="row">
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<!-- 프로필 헤더 --> <!-- 프로필 헤더 -->
@ -26,9 +25,13 @@
<input <input
type="password" type="password"
class="form-control" class="form-control"
autocomplete="off"
v-model="password" v-model="password"
placeholder="비밀번호 입력" placeholder="비밀번호 입력"
@input="password = password.replace(/\s/g, '')" @input="
password = password.replace(/\s/g, '');
inputCheck();
"
/> />
<button class="btn btn-primary" @click="submitPassword">확인</button> <button class="btn btn-primary" @click="submitPassword">확인</button>
</div> </div>
@ -131,9 +134,8 @@
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useUserInfoStore } from '@/stores/useUserInfoStore'; import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useToastStore } from '@s/toastStore';
import axios from '@api'; import axios from '@api';
import LoadingSpinner from "@v/LoadingPage.vue";
const isLoading = ref(true);
// //
const profileName = ref(''); const profileName = ref('');
const boardTitle = ref('제목 없음'); const boardTitle = ref('제목 없음');
@ -151,6 +153,7 @@
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const userStore = useUserInfoStore(); const userStore = useUserInfoStore();
const toastStore = useToastStore();
const currentBoardId = ref(Number(route.params.id)); const currentBoardId = ref(Number(route.params.id));
const unknown = computed(() => profileName.value === '익명'); const unknown = computed(() => profileName.value === '익명');
const currentUserId = computed(() => userStore.user.id); // id const currentUserId = computed(() => userStore.user.id); // id
@ -224,10 +227,12 @@
navigateLastPage: 1, navigateLastPage: 1,
}); });
const inputCheck = () => {
passwordAlert.value = '';
};
// //
const fetchBoardDetails = async () => { const fetchBoardDetails = async () => {
try { try {
isLoading.value = true;
const response = await axios.get(`board/${currentBoardId.value}`); const response = await axios.get(`board/${currentBoardId.value}`);
const data = response.data.data; const data = response.data.data;
@ -245,8 +250,6 @@
attachments.value = data.attachments || []; attachments.value = data.attachments || [];
} catch (error) { } catch (error) {
alert('게시물 데이터를 불러오는 중 오류가 발생했습니다.'); alert('게시물 데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
isLoading.value = false;
} }
}; };
@ -568,6 +571,7 @@
const submitPassword = async () => { const submitPassword = async () => {
if (!password.value.trim()) { if (!password.value.trim()) {
passwordAlert.value = '비밀번호를 입력해주세요.'; passwordAlert.value = '비밀번호를 입력해주세요.';
return; return;
} }
@ -576,7 +580,7 @@
LOCBRDPWD: password.value, LOCBRDPWD: password.value,
LOCBRDSEQ: currentBoardId.value, LOCBRDSEQ: currentBoardId.value,
}); });
console.log('response: ', response);
if (response.data.code === 200 && response.data.data === true) { if (response.data.code === 200 && response.data.data === true) {
password.value = ''; password.value = '';
isPassword.value = false; isPassword.value = false;
@ -591,18 +595,7 @@
passwordAlert.value = '비밀번호가 일치하지 않습니다.'; passwordAlert.value = '비밀번호가 일치하지 않습니다.';
} }
} catch (error) { } catch (error) {
if (error.reponse && error.reponse.status === 401) passwordAlert.value = '비밀번호가 일치하지 않습니다.'; if (error.response && error.response.status === 401) passwordAlert.value = '비밀번호가 일치하지 않습니다.';
// if (error.response) {
// if (error.response.status === 401) {
// passwordAlert.value = ' .';
// } else {
// passwordAlert.value = error.response.data?.message || ' .';
// }
// } else if (error.request) {
// passwordAlert.value = ' . .';
// } else {
// passwordAlert.value = ' .';
// }
} }
}; };
@ -664,7 +657,7 @@
}); });
if (response.data.code === 200) { if (response.data.code === 200) {
alert('게시물이 삭제되었습니다.'); toastStore.onToast('게시물이 삭제되었습니다.');
router.push({ name: 'BoardList' }); router.push({ name: 'BoardList' });
} else { } else {
alert('삭제 실패: ' + response.data.message); alert('삭제 실패: ' + response.data.message);

View File

@ -23,11 +23,7 @@
<div class="mb-4 d-flex align-items-center"> <div class="mb-4 d-flex align-items-center">
<label class="col-md-2 col-form-label">카테고리 <span class="text-danger">*</span></label> <label class="col-md-2 col-form-label">카테고리 <span class="text-danger">*</span></label>
<div class="d-flex flex-wrap align-items-center mt-3 ms-1"> <div class="d-flex flex-wrap align-items-center mt-3 ms-1">
<div <div v-for="(category, index) in categoryList" :key="index" class="form-check me-3">
v-for="(category, index) in categoryList"
:key="index"
class="form-check me-3"
>
<input <input
class="form-check-input" class="form-check-input"
type="radio" type="radio"
@ -41,9 +37,7 @@
</label> </label>
</div> </div>
</div> </div>
<div class="invalid-feedback" :class="categoryAlert ? 'd-block' : 'd-none'"> <div class="invalid-feedback" :class="categoryAlert ? 'd-block' : 'd-none'">카테고리를 선택해주세요.</div>
카테고리를 선택해주세요.
</div>
</div> </div>
<!-- 비밀번호 필드 (익명게시판 선택 활성화) --> <!-- 비밀번호 필드 (익명게시판 선택 활성화) -->
@ -52,6 +46,7 @@
title="비밀번호" title="비밀번호"
name="pw" name="pw"
type="password" type="password"
autocomplete="new-password"
:is-essential="true" :is-essential="true"
:is-alert="passwordAlert" :is-alert="passwordAlert"
v-model="password" v-model="password"
@ -74,7 +69,11 @@
<p v-if="fileError" class="text-danger">{{ fileError }}</p> <p v-if="fileError" class="text-danger">{{ fileError }}</p>
<ul class="list-group mt-2" v-if="attachFiles.length"> <ul class="list-group mt-2" v-if="attachFiles.length">
<li v-for="(file, index) in attachFiles" :key="index" class="list-group-item d-flex justify-content-between align-items-center"> <li
v-for="(file, index) in attachFiles"
:key="index"
class="list-group-item d-flex justify-content-between align-items-center"
>
{{ file.name }} {{ file.name }}
<button class="close-btn" @click="removeFile(index)"></button> <button class="close-btn" @click="removeFile(index)"></button>
</li> </li>
@ -82,15 +81,11 @@
<!-- 내용 입력 (에디터) --> <!-- 내용 입력 (에디터) -->
<div class="mb-4"> <div class="mb-4">
<label class="col-md-2 col-form-label"> <label class="col-md-2 col-form-label"> 내용 <span class="text-danger">*</span> </label>
내용 <span class="text-danger">*</span>
</label>
<div class="col-md-12"> <div class="col-md-12">
<QEditor @update:data="content = $event" /> <QEditor @update:data="content = $event" />
</div> </div>
<div class="invalid-feedback mt-1" :class="contentAlert ? 'd-block' : 'd-none'"> <div class="invalid-feedback mt-1" :class="contentAlert ? 'd-block' : 'd-none'">내용을 입력해주세요.</div>
내용을 입력해주세요.
</div>
</div> </div>
<div class="mb-4 d-flex justify-content-end"> <div class="mb-4 d-flex justify-content-end">
@ -104,162 +99,162 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, getCurrentInstance, watch, computed } from 'vue'; import { ref, onMounted, getCurrentInstance, watch, computed } from 'vue';
import QEditor from '@c/editor/QEditor.vue'; import QEditor from '@c/editor/QEditor.vue';
import FormInput from '@c/input/FormInput.vue'; import FormInput from '@c/input/FormInput.vue';
import FormFile from '@c/input/FormFile.vue'; import FormFile from '@c/input/FormFile.vue';
import SaveButton from '@c/button/SaveBtn.vue'; import SaveButton from '@c/button/SaveBtn.vue';
import BackButton from '@c/button/BackBtn.vue'; import BackButton from '@c/button/BackBtn.vue';
import { useToastStore } from '@s/toastStore'; import { useToastStore } from '@s/toastStore';
import router from '@/router'; import router from '@/router';
import axios from '@api'; import axios from '@api';
const toastStore = useToastStore(); const toastStore = useToastStore();
const categoryList = ref([]); const categoryList = ref([]);
const title = ref(''); const title = ref('');
const password = ref(''); const password = ref('');
const categoryValue = ref(null); const categoryValue = ref(null);
const content = ref({ ops: [] }); const content = ref({ ops: [] });
const isFileValid = ref(true); const isFileValid = ref(true);
const titleAlert = ref(false); const titleAlert = ref(false);
const passwordAlert = ref(false); const passwordAlert = ref(false);
const contentAlert = ref(false); const contentAlert = ref(false);
const categoryAlert = ref(false); const categoryAlert = ref(false);
const attachFilesAlert = ref(false); const attachFilesAlert = ref(false);
const attachFiles = ref([]); const attachFiles = ref([]);
const maxFiles = 5; const maxFiles = 5;
const maxSize = 10 * 1024 * 1024; const maxSize = 10 * 1024 * 1024;
const fileError = ref(''); const fileError = ref('');
const fetchCategories = async () => { const fetchCategories = async () => {
try { try {
const response = await axios.get('board/categories'); const response = await axios.get('board/categories');
categoryList.value = response.data.data; categoryList.value = response.data.data;
const freeCategory = categoryList.value.find(category => category.CMNCODNAM === '자유'); const freeCategory = categoryList.value.find(category => category.CMNCODNAM === '자유');
if (freeCategory) { if (freeCategory) {
categoryValue.value = freeCategory.CMNCODVAL; categoryValue.value = freeCategory.CMNCODVAL;
}
} catch (error) {
console.error('카테고리 불러오기 오류:', error);
} }
} catch (error) { };
console.error('카테고리 불러오기 오류:', error);
}
};
onMounted(() => { onMounted(() => {
fetchCategories(); fetchCategories();
}); });
const fileCount = computed(() => attachFiles.value.length); const fileCount = computed(() => attachFiles.value.length);
const handleFileUpload = (files) => { const handleFileUpload = files => {
const validFiles = files.filter(file => file.size <= maxSize); const validFiles = files.filter(file => file.size <= maxSize);
if (files.some(file => file.size > maxSize)) { if (files.some(file => file.size > maxSize)) {
fileError.value = '파일 크기가 10MB를 초과할 수 없습니다.'; fileError.value = '파일 크기가 10MB를 초과할 수 없습니다.';
return; return;
} }
if (attachFiles.value.length + validFiles.length > maxFiles) { if (attachFiles.value.length + validFiles.length > maxFiles) {
fileError.value = `최대 ${maxFiles}개의 파일만 업로드할 수 있습니다.`; fileError.value = `최대 ${maxFiles}개의 파일만 업로드할 수 있습니다.`;
return; return;
} }
fileError.value = '';
attachFiles.value = [...attachFiles.value, ...validFiles].slice(0, maxFiles);
};
const removeFile = (index) => {
attachFiles.value.splice(index, 1);
if (attachFiles.value.length <= maxFiles) {
fileError.value = ''; fileError.value = '';
} attachFiles.value = [...attachFiles.value, ...validFiles].slice(0, maxFiles);
}; };
watch(attachFiles, () => { const removeFile = index => {
isFileValid.value = attachFiles.value.length <= maxFiles; attachFiles.value.splice(index, 1);
}); if (attachFiles.value.length <= maxFiles) {
fileError.value = '';
}
};
const validateTitle = () => { watch(attachFiles, () => {
titleAlert.value = title.value.trim().length === 0; isFileValid.value = attachFiles.value.length <= maxFiles;
}; });
const validatePassword = () => { const validateTitle = () => {
if (categoryValue.value === 300102) { titleAlert.value = title.value.trim().length === 0;
password.value = password.value.replace(/\s/g, ''); // };
passwordAlert.value = password.value.length === 0;
} else {
passwordAlert.value = false;
}
};
const validateContent = () => { const validatePassword = () => {
if (!content.value?.ops?.length) { if (categoryValue.value === 300102) {
contentAlert.value = true; password.value = password.value.replace(/\s/g, ''); //
return; passwordAlert.value = password.value.length === 0;
} } else {
passwordAlert.value = false;
}
};
// const validateContent = () => {
const hasImage = content.value.ops.some(op => op.insert && typeof op.insert === 'object' && op.insert.image); if (!content.value?.ops?.length) {
// contentAlert.value = true;
const hasText = content.value.ops.some(op => typeof op.insert === 'string' && op.insert.trim().length > 0); return;
//
contentAlert.value = !(hasText || hasImage);
};
/** 글쓰기 */
const write = async () => {
validateTitle();
validatePassword();
validateContent();
categoryAlert.value = categoryValue.value == null;
if (titleAlert.value || passwordAlert.value || contentAlert.value || categoryAlert.value || !isFileValid.value) {
return;
}
try {
const boardData = {
LOCBRDTTL: title.value,
LOCBRDCON: JSON.stringify(content.value), // Delta JSON
LOCBRDPWD: categoryValue.value === 300102 ? password.value : null,
LOCBRDTYP: categoryValue.value
};
const { data: boardResponse } = await axios.post('board', boardData);
const boardId = boardResponse.data;
// ( )
if (attachFiles.value && attachFiles.value.length > 0) {
await Promise.all(attachFiles.value.map(async (file) => {
console.log(file);
const formData = new FormData();
const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
formData.append('CMNBRDSEQ', boardId);
formData.append('CMNFLEORG', fileNameWithoutExt);
formData.append('CMNFLEEXT', file.name.split('.').pop());
formData.append('CMNFLESIZ', file.size);
formData.append('file', file); // 📌
await axios.post(`board/${boardId}/attachments`, formData,
{ isFormData : true }
);
}));
} }
toastStore.onToast('게시물이 작성되었습니다.', 's'); //
goList(); const hasImage = content.value.ops.some(op => op.insert && typeof op.insert === 'object' && op.insert.image);
} catch (error) { //
console.error(error); const hasText = content.value.ops.some(op => typeof op.insert === 'string' && op.insert.trim().length > 0);
toastStore.onToast('게시물 작성 중 오류가 발생했습니다.', 'e');
}
};
/** 목록으로 이동 */ //
const goList = () => { contentAlert.value = !(hasText || hasImage);
router.push('/board'); };
};
/** `content` 변경 감지하여 자동 유효성 검사 실행 */ /** 글쓰기 */
watch(content, () => { const write = async () => {
validateContent(); validateTitle();
}); validatePassword();
validateContent();
categoryAlert.value = categoryValue.value == null;
if (titleAlert.value || passwordAlert.value || contentAlert.value || categoryAlert.value || !isFileValid.value) {
return;
}
try {
const boardData = {
LOCBRDTTL: title.value,
LOCBRDCON: JSON.stringify(content.value), // Delta JSON
LOCBRDPWD: categoryValue.value === 300102 ? password.value : null,
LOCBRDTYP: categoryValue.value,
};
const { data: boardResponse } = await axios.post('board', boardData);
const boardId = boardResponse.data;
// ( )
if (attachFiles.value && attachFiles.value.length > 0) {
await Promise.all(
attachFiles.value.map(async file => {
console.log(file);
const formData = new FormData();
const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
formData.append('CMNBRDSEQ', boardId);
formData.append('CMNFLEORG', fileNameWithoutExt);
formData.append('CMNFLEEXT', file.name.split('.').pop());
formData.append('CMNFLESIZ', file.size);
formData.append('file', file); // 📌
await axios.post(`board/${boardId}/attachments`, formData, { isFormData: true });
}),
);
}
toastStore.onToast('게시물이 작성되었습니다.', 's');
goList();
} catch (error) {
console.error(error);
toastStore.onToast('게시물 작성 중 오류가 발생했습니다.', 'e');
}
};
/** 목록으로 이동 */
const goList = () => {
router.push('/board');
};
/** `content` 변경 감지하여 자동 유효성 검사 실행 */
watch(content, () => {
validateContent();
});
</script> </script>

View File

@ -148,7 +148,7 @@ function handleMonthChange(viewInfo) {
const year = currentDate.getFullYear(); const year = currentDate.getFullYear();
const month = String(currentDate.getMonth() + 1).padStart(2, "0"); const month = String(currentDate.getMonth() + 1).padStart(2, "0");
loadCalendarData(year, month); loadCalendarData(year, month);
} }
// //
function handleDateClick(info) { function handleDateClick(info) {
const clickedDateStr = info.dateStr; const clickedDateStr = info.dateStr;

View File

@ -1,7 +1,6 @@
<template> <template>
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<LoadingSpinner v-if="isLoading" /> <div class="row g-3">
<div v-else class="row g-3">
<div class="mt-8"> <div class="mt-8">
<!-- 투표 작성 --> <!-- 투표 작성 -->
<WriteBtn @click="voteWrite" /> <WriteBtn @click="voteWrite" />
@ -52,8 +51,6 @@ import Quill from 'quill';
import WriteBtn from '@c/button/WriteBtn.vue'; import WriteBtn from '@c/button/WriteBtn.vue';
import voteList from '@c/voteboard/voteCardList.vue'; import voteList from '@c/voteboard/voteCardList.vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import LoadingSpinner from "@v/LoadingPage.vue";
const isLoading = ref(true);
const toastStore = useToastStore(); const toastStore = useToastStore();
const category = ref('0'); const category = ref('0');
@ -81,7 +78,6 @@ const changeCheck = () =>{
} }
// //
const getvoteList = async () => { const getvoteList = async () => {
isLoading.value = true;
const response = await $api.get('vote/getVoteList',{ const response = await $api.get('vote/getVoteList',{
params: params:
{ {
@ -93,7 +89,6 @@ const getvoteList = async () => {
if (response.data.status === "OK") { if (response.data.status === "OK") {
PageData.value = response.data.data; PageData.value = response.data.data;
voteListCardData.value = response.data.data.list; voteListCardData.value = response.data.data.list;
isLoading.value = false;
} }
}; };
const selectHandler = () =>{ const selectHandler = () =>{