Merge remote-tracking branch 'origin/main' into board-view-content

This commit is contained in:
kimdaae328 2025-02-18 13:22:46 +09:00
commit d45a63f088
42 changed files with 1969 additions and 671 deletions

View File

@ -81,3 +81,9 @@
filter: grayscale(100%);
}
/* scrollbar 안보이게 */
.scrollbar-none {
scrollbar-width: none;
}

View File

@ -1,60 +1,105 @@
<template>
<div>
<BoardProfile :profileName="comment.author" :showDetail="false" :author="true" :isChild="isChild" />
<BoardProfile
:unknown="unknown"
:boardId="comment.boardId"
:profileName="comment.author"
:date="comment.createdAt"
:comment="comment"
:showDetail="false"
:author="true"
:isLike="!isLike"
:isPassword="isPassword"
@editClick="editClick"
@deleteClick="deleteClick"
@submitPassword="submitPassword"
@updateReaction="handleUpdateReaction"
@toggleEdit="emit('toggleEdit', comment.commentId, true)"
/>
<div class="mt-6">
<p class="m-0">{{ comment.content }}</p>
<template v-if="isEditTextarea">
<textarea v-model="editedContent" class="form-control"></textarea>
<div class="mt-2 d-flex justify-content-end">
<button class="btn btn-secondary me-2" @click="emit('toggleEdit', comment.commentId, false)">취소</button>
<button class="btn btn-primary" @click="submitEdit">수정 완료</button>
</div>
</template>
<template v-else>
<p class="m-0">{{ comment.content }}</p>
</template>
</div>
<PlusButton v-if="isPlusButton" @click="toggleComment" class="mt-6"/>
<BoardComentArea v-if="isComment" @submit="submitComment"/>
<BoardCommentArea v-if="isComment" @submitComment="submitComment"/>
<!-- 대댓글 -->
<ul v-if="comment.children && comment.children.length" class="list-unstyled">
<li
v-for="child in comment.children"
:key="child.id"
class="pt-8 ps-10"
:key="child.commentId"
class="mt-8 pt-6 ps-10 border-top"
>
<BoardComment :comment="child" :isPlusButton="false" :isChild="true" @submitComment="addChildComment" />
<BoardComment
:comment="child"
:unknown="unknown"
:isPlusButton="false"
:isLike="true"
@submitComment="submitComment"
@updateReaction="handleUpdateReaction"
/>
</li>
</ul>
<!-- <ul class="list-unstyled twoDepth">
<li>
<BoardProfile profileName=곤데리2 :showDetail="false" />
<div class="mt-2">저도 궁금합니다.</div>
<BoardComentArea v-if="comment" />
<BoardCommentArea v-if="comment" />
</li>
</ul> -->
<!-- <BoardProfile profileName=곤데리 :showDetail="false" />
<div class="mt-2">저도 궁금합니다.</div>
<PlusButton @click="toggleComment"/>
<BoardComentArea v-if="comment" /> -->
<BoardCommentArea v-if="comment" /> -->
</div>
</template>
<script setup>
import { defineProps, defineEmits, ref } from 'vue';
import BoardProfile from './BoardProfile.vue';
import BoardComentArea from './BoardComentArea.vue';
import BoardCommentArea from './BoardCommentArea.vue';
import PlusButton from '../button/PlusBtn.vue';
import { ref } from 'vue';
import { defineEmits } from 'vue';
const props = defineProps({
comment: {
type: Object,
required: true,
},
unknown: {
type: Boolean,
default: true,
},
isPlusButton: {
type: Boolean,
default: true,
},
isChild: {
isLike: {
type: Boolean,
default: false,
},
isEditTextarea: {
type: Boolean,
default: false
},
isPassword: {
type: Boolean,
default: false,
},
});
// emits
const emit = defineEmits(['submitComment']);
const emit = defineEmits(['submitComment', 'updateReaction', 'toggleEdit', 'editClick']);
//
const isComment = ref(false);
@ -63,29 +108,31 @@ const toggleComment = () => {
};
//
const addChildComment = (parentId, newComment) => {
emit('submitComment', parentId, newComment);
const submitComment = (newComment) => {
emit('submitComment', { parentId: props.comment.commentId, ...newComment });
isComment.value = false;
};
// ,
const handleUpdateReaction = (reactionData) => {
emit('updateReaction', {
boardId: props.comment.boardId,
commentId: props.comment.commentId,
...reactionData
});
};
//
const editClick = (data) => {
emit('editClick', data);
};
//
const editedContent = ref(props.comment.content);
const submitEdit = () => {
emit('submitComment', { commentId: props.comment.commentId, content: editedContent.value });
emit('toggleEdit', props.comment.commentId, false); //
};
</script>
<style scoped>
/* .twoDepth {
margin-top: 10px;
padding-left: 40px;
}
.list-unstyled > li ~ li {
margin-top: 10px;
}
.btn-text-primary {
padding-left: 0;
}
.btn-text-primary:hover,
.btn-text-primary:active,
.btn-text-primary:focus {
background-color: transparent
} */
</style>

View File

@ -2,47 +2,66 @@
<ul class="list-unstyled mt-10">
<li
v-for="comment in comments"
:key="comment.id"
class="mt-8"
:key="comment.commentId"
class="mt-6 border-bottom pb-6"
>
<BoardComment :comment="comment" @submitComment="addComment" />
<BoardComment
:unknown="unknown"
:comment="comment"
:isPassword="isPassword"
@editClick="editClick"
@deleteClick="deleteClick"
@submitPassword="submitPassword"
@submitComment="submitComment"
@updateReaction="(reactionData) => handleUpdateReaction(reactionData, comment.commentId)"
/>
<!-- @updateReaction="handleUpdateReaction" -->
</li>
</ul>
</template>
<script setup>
import { ref } from 'vue';
import { defineProps, defineEmits } from 'vue';
import BoardComment from './BoardComment.vue'
const comments = ref([
{
id: 1,
author: '홍길동',
content: '저도 궁금합니다.',
children: [
{
id: 2,
author: '사용자1',
content: '저도요!',
const props = defineProps({
comments: {
type: Array,
required: true,
default: () => []
},
{
id: 3,
author: '사용자2',
content: '저도..',
unknown: {
type: Boolean,
default: true,
},
],
isPassword: {
type: Boolean,
default: false,
},
{
id: 4,
author: '사용자4',
content: '흥미로운 주제네요.',
children: [],
},
{
id: 5,
author: '사용자5',
content: '우오아아아아아앙',
children: [],
},
]);
});
const emit = defineEmits(['submitComment', 'updateReaction', 'editClick']);
const submitComment = (replyData) => {
emit('submitComment', replyData);
};
const handleUpdateReaction = (reactionData, commentId) => {
// console.log('📢 BoardCommentList :', reactionData);
// console.log('📌 ID>>>>:', commentId);
const updatedReactionData = {
...reactionData,
commentId: commentId
};
// console.log('🚀 :', updatedReactionData);
emit('updateReaction', updatedReactionData);
}
const editClick = (data) => {
emit('editClick', data);
};
</script>

View File

@ -1,7 +1,7 @@
<template>
<div class="d-flex align-items-center flex-wrap">
<div class="d-flex align-items-center">
<div class="avatar me-2" v-if="unknown">
<div v-if="!unknown" class="avatar me-2">
<img src="/img/avatars/2.png" alt="Avatar" class="rounded-circle" />
</div>
<div class="me-2">
@ -13,19 +13,30 @@
<i class="fa-regular fa-eye"></i> {{ views }}
</span>
<span>
<i class="bx bx-comment"></i> {{ comments }}
<i class="bx bx-comment"></i> {{ commentNum }}
</span>
</template>
</div>
</div>
</div>
<div class="ms-auto btn-area">
<template v-if="showDetail">
<div class="text-end">
<EditButton @click="handleEdit" />
<DeleteButton @click="handleDelete" />
<!-- 버튼 영역 -->
<div class="ms-auto text-end">
<!-- 수정, 삭제 버튼 -->
<template v-if="author || showDetail">
<EditButton @click.stop="editClick" />
<DeleteButton @click.stop="deleteClick" />
</template>
<div class="mt-3" v-if="isPassword && unknown">
<!-- 좋아요, 싫어요 버튼 (댓글에서만 표시) -->
<BoardRecommendBtn
v-if="isLike"
:boardId="boardId"
:comment="props.comment"
@updateReaction="handleUpdateReaction"
/>
<!-- 비밀번호 입력창 (익명일 경우) -->
<div v-if="isPassword && unknown" class="mt-3">
<div class="input-group">
<input
type="password"
@ -33,53 +44,40 @@
v-model="password"
placeholder="비밀번호 입력"
/>
<button class="btn btn-primary" type="button" @click="handleSubmit">확인</button>
<button class="btn btn-primary" @click="$emit('submitPassword', password)">확인</button>
</div>
<span v-if="passwordAlert" class="invalid-feedback d-block text-start">{{ passwordAlert }}</span>
<span v-if="props.passwordAlert" class="invalid-feedback d-block text-start">{{ props.passwordAlert }}</span>
</div>
</div>
</template>
<template v-else>
<template v-if="author">
<EditButton />
<DeleteButton />
<!-- <button class="btn author btn-label-primary btn-icon" @click="handleEdit">
<i class='bx bx-edit-alt'></i>
</button>
<button class="btn author btn-label-primary btn-icon" @click="handleDelete">
<i class='bx bx-trash'></i>
</button> -->
</template>
<BoardRecommendBtn v-if="!isChild" :isRecommend="false" />
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, defineProps } from 'vue';
import { useRouter } from 'vue-router';
import axios from '@api';
import { ref, defineProps, defineEmits } from 'vue';
import DeleteButton from '../button/DeleteBtn.vue';
import EditButton from '../button/EditBtn.vue';
import BoardRecommendBtn from '../button/BoardRecommendBtn.vue';
// Vue Router
const router = useRouter();
const isPassword = ref(false);
const password = ref('');
const passwordAlert = ref(false);
const lastClickedButton = ref('');
// Props
const props = defineProps({
comment: {
type: Object,
required: true,
},
boardId: {
type: Number,
required: true
required: false
},
commentId: {
type: Number,
required: false,
},
profileName: {
type: String,
default: '익명',
default: '익명 사용자',
},
unknown: {
type: Boolean,
@ -89,6 +87,7 @@ const props = defineProps({
type: Boolean,
default: true,
},
// :
author: {
type: Boolean,
default: false,
@ -101,114 +100,50 @@ const props = defineProps({
type: Number,
default: 0,
},
comments: {
commentNum: {
type: Number,
default: 0,
},
isChild: {
isLike: {
type: Boolean,
default: false,
},
isPassword: {
type: Boolean,
default: false,
},
passwordAlert: {
type: String,
default: false,
}
});
const emit = defineEmits(['togglePasswordInput']);
const emit = defineEmits(['togglePasswordInput', 'updateReaction', 'editClick', 'deleteClick', 'updatePasswordAlert']);
//
const handleEdit = () => {
if (props.unknown) {
togglePassword('edit');
} else {
router.push({ name: 'BoardEdit', params: { id: props.boardId } });
}
//
const editClick = () => {
emit('editClick', props.unknown);
};
//
const handleDelete = () => {
if (props.unknown) {
togglePassword('delete');
} else {
deletePost();
}
//
const deleteClick = () => {
emit('deleteClick', props.unknown);
};
//
const togglePassword = (button) => {
if (lastClickedButton.value === button) {
isPassword.value = !isPassword.value;
} else {
isPassword.value = true;
}
lastClickedButton.value = button;
};
const handleUpdateReaction = (reactionData) => {
// console.log("🔥 BoardProfile / ");
// console.log("📌 ID:", props.boardId);
// console.log("📌 ID ( ):", props.comment?.commentId);
// console.log("📌 reactionData:", reactionData);
//
const handleSubmit = async () => {
if (!password.value) {
passwordAlert.value = '비밀번호를 입력해주세요.';
return;
}
try {
const requestData = {
LOCBRDPWD: password.value,
LOCBRDSEQ: 288
}
const postResponse = await axios.post(`board/${props.boardId}/password`, requestData);
if (postResponse.data.code === 200) {
if (postResponse.data.data === true) {
isPassword.value = false;
if (lastClickedButton.value === 'edit') {
router.push({ name: 'BoardEdit', params: { id: props.boardId } });
} else if (lastClickedButton.value === 'delete') {
await deletePost();
}
lastClickedButton.value = null;
} else {
passwordAlert.value = '비밀번호가 일치하지 않습니다.';
}
} else {
passwordAlert.value = '비밀번호가 일치하지 않습니다.';
}
} catch (error) {
// 401
if (error.response && error.response.status === 401) {
passwordAlert.value = '비밀번호가 일치하지 않습니다.';
} else if (error.response) {
alert(`오류 발생: ${error.response.data.message || '서버 오류'}`);
} else {
alert('네트워크 오류가 발생했습니다. 다시 시도해주세요.');
}
}
};
const deletePost = async () => {
if (confirm('정말 삭제하시겠습니까?')) {
try {
const response = await axios.delete(`board/${props.boardId}`, {
data: { LOCBRDSEQ: props.boardId }
emit("updateReaction", {
boardId: props.boardId,
commentId: props.comment?.commentId,
...reactionData,
});
if (response.data.code === 200) {
alert('게시물이 삭제되었습니다.');
router.push({ name: 'BoardList' });
} else {
alert('삭제 실패: ' + response.data.message);
}
} catch (error) {
if (error.response) {
alert(`삭제 실패: ${error.response.data.message || '서버 오류'}`);
} else {
alert('네트워크 오류가 발생했습니다. 다시 시도해주세요.');
}
}
}
};
</script>
<style scoped>

View File

@ -1,6 +1,6 @@
<template>
<ul class="cate-list list-unstyled d-flex flex-wrap mb-0">
<li v-for="category in lists" :key="category.value" class="mt-2 mx-1">
<li v-for="category in lists" :key="category.value" class="mt-2 me-2">
<button
type="button"
class="btn"
@ -39,9 +39,7 @@ const selectCategory = (cate) => {
<style scoped>
.cate-list {
margin-left: -0.25rem;
}
@media (max-width: 768px) {
.cate-list {

View File

@ -46,7 +46,6 @@
</div>
<!-- 에디터가 표시될 div -->
<div ref="editor"></div>
<!-- Alert 메시지 표시 -->
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">내용을 확인해주세요.</div>
</div>
@ -64,6 +63,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
initialData: {
type: String,
default: () => null,
},
});
const editor = ref(null); // DOM
@ -108,8 +111,14 @@ onMounted(() => {
watch([font, fontSize], () => {
quillInstance.format('font', font.value);
quillInstance.format('size', fontSize.value);
});
// , HTML
if (props.initialData) {
quillInstance.setContents(JSON.parse(props.initialData));
}
//
let imageUrls = new Set(); // URL
quillInstance.getModule('toolbar').addHandler('image', () => {

View File

@ -1,14 +1,16 @@
<template>
<div class="mb-2">
<div class="d-flex align-items-center">
<div class="mb-2 row" >
<div class="d-flex">
<label :for="name" class="col-md-2 col-form-label">
{{ title }}
<span :class="isEssential ? 'link-danger' : 'd-none'">*</span>
</label>
<button type="button" class="btn btn-sm btn-primary" @click="openAddressSearch">주소찾기</button>
<div class="align-content-center col-md-10 text-end ms-auto">
<button type="button" class="btn btn-sm btn-primary" :class="isRow ? '' : 'ms-auto'" @click="openAddressSearch">주소찾기</button>
</div>
</div>
<div class="col-md-12">
<div :class="isRow ? 'col-md-10 ms-auto' : 'col-md-12'">
<div class="d-flex mb-3">
<input
:id="name"
@ -48,6 +50,10 @@
<script setup>
import { ref, watch } from 'vue';
const postcode = ref('');
const address = ref('');
const detailAddress = ref('');
const props = defineProps({
title: {
type: String,
@ -69,19 +75,33 @@ const props = defineProps({
default: 30,
required: false,
},
isRow: {
type: Boolean,
default: false,
required: false,
},
isAlert: {
type: Boolean,
default: false,
required: false,
},
modelValue: {
type: Object,
default: () => ({}),
required: false
}
});
const emits = defineEmits(['update:data', 'update:alert']);
// watch
watch(() => props.modelValue, (newValue) => {
if (newValue) {
postcode.value = newValue.PROJCTZIP || '';
address.value = newValue.PROJCTARR || '';
detailAddress.value = newValue.PROJCTDTL || '';
}
}, { immediate: true });
//
const postcode = ref('');
const address = ref('');
const detailAddress = ref('');
const emits = defineEmits(['update:data', 'update:alert']);
//
const openAddressSearch = () => {

View File

@ -40,26 +40,26 @@ const props = defineProps({
required: true,
},
value: {
type: String,
type: [String, Number],
default: '0',
require: false,
},
isAlert : {
isAlert: {
type: Boolean,
default: false,
required: false,
},
isLabel : {
isLabel: {
type: Boolean,
default: true,
required: false,
},
isRow : {
isRow: {
type: Boolean,
default: true,
required: false,
},
isCommon : {
isCommon: {
type: Boolean,
default: false,
required: false,
@ -69,12 +69,20 @@ const props = defineProps({
const emit = defineEmits(['update:data']);
const selectData = ref(props.value);
// data
// props.value watch
watch(() => props.value, (newValue) => {
selectData.value = newValue;
}, { immediate: true });
// data
watch(() => props.data, (newData) => {
if (props.isCommon && newData.length > 0) {
// value prop '0'()
if (props.value === '0') {
selectData.value = newData[0].value;
emit('update:data', selectData.value);
}
}
}, { immediate: true });
// selectData

View File

@ -1,63 +1,126 @@
<template>
<div class="card mb-3 shadow-sm border">
<div class="row g-0">
<!-- 게시물 내용 섹션 -->
<div>
<div class="card-body">
<!-- 제목 -->
<h5 class="card-title">
{{ title }}
<span class="text-muted me-3" v-if="attachment">
<i class="fa-solid fa-paperclip"></i>
</span>
</h5>
<!-- 본문 -->
<div class="card-text line-clamp-2 my-5">{{ content }}</div>
<!-- 날짜 -->
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start">
<small class="text-muted">{{ formattedDate }}</small>
<div class="d-flex flex-column flex-sm-row align-items-center pb-2">
<i class="bx bx-calendar"></i>
<div class="ms-2">날짜</div>
<div class="ms-12">{{ strdate }} ~ {{ enddate }}</div>
</div>
<!-- 참여자 -->
<div class="d-flex flex-column flex-sm-row align-items-center pb-2">
<i class="bx bxs-user"></i>
<div class="ms-2">참여자</div>
<UserList :projctSeq="projctSeq" class="ms-8 mb-0" />
</div>
<!-- 설명 -->
<div class="d-flex flex-column flex-sm-row align-items-center pb-2">
<i class="bx bx-detail"></i>
<div class="ms-2">설명</div>
<div class="ms-12">{{ description }}</div>
</div>
<!-- 주소 -->
<div class="d-flex flex-column flex-sm-row align-items-center pb-2">
<i class="bx bxs-map"></i>
<div class="ms-2">주소</div>
<div class="ms-12">{{ address }}</div>
<button type="button" class="btn ms-auto text-white" :style="`background-color: ${projctCol} !important;`" @click.stop="openModal">log</button>
</div>
</div>
</div>
</div>
<CenterModal :display="isModalOpen" @close="closeModal">
<template #title> Log </template>
<template #body>
<div class="ms-4 mt-2" v-if="logData">
<p class="mb-1">{{ logData.createDate }}</p>
<strong class="">[{{ logData.creator }}] 프로젝트 등록</strong>
</div>
<div class="log-item" v-if="logData?.updateDate">
<div class="d-flex align-items-center">
<i class="bx bx-edit me-2"></i>
<strong>수정 정보</strong>
</div>
<div class="ms-4 mt-2">
<p class="mb-1">{{ logData.updateDate }}</p>
<p class="mb-0 text-muted">[{{ logData.updater }}] 프로젝트 수정</p>
</div>
</div>
</template>
<template #footer>
<button type="button" class="btn btn-secondary" @click="closeModal">닫기</button>
</template>
</CenterModal>
</template>
<script setup>
import { computed } from 'vue';
import { defineProps } from 'vue';
import { defineProps, ref } from 'vue';
import UserList from '@c/user/UserList.vue';
import CenterModal from '../modal/CenterModal.vue';
import $api from '@api';
// Props
const props = defineProps({
category: {
type: String,
required: false,
},
title: {
type: String,
required: true,
},
content: {
strdate: {
type: String,
required: true,
},
date: {
enddate: {
type: String,
required: true,
default: "",
},
description: {
type: String,
required: false,
},
address: {
type: String,
required: true,
},
attachment: {
type: Boolean,
default: false,
}
projctSeq: {
type: Number,
required: false
},
projctCol: {
type: String,
required: false
},
});
// formattedDate computed
const formattedDate = computed(() => {
const date = new Date(props.date);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
date.getDate()
).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(
date.getMinutes()
).padStart(2, "0")}`;
});
defineEmits(['click']);
const isModalOpen = ref(false);
const logData = ref(null);
const fetchLogData = async () => {
try {
const response = await $api.get(`project/log/${props.projctSeq}`);
logData.value = response.data.data.length > 0 ? response.data.data[0] : {};
} catch (error) {
console.error('로그 정보 조회 실패:', error);
}
};
const openModal = async () => {
await fetchLogData();
isModalOpen.value = true;
};
const closeModal = () => {
isModalOpen.value = false;
};
</script>

View File

@ -1,49 +1,181 @@
<template>
<div class="mt-4">
<div v-if="posts.length === 0" class="text-center">
<div v-if="projectList.length === 0" class="text-center">
<p class="text-muted mt-4">게시물이 없습니다.</p>
</div>
<div v-for="post in posts" :key="post.id" @click="goDetail(post.id)">
<div v-for="post in projectList" :key="post.PROJCTSEQ" @click="openModal(post)" class="cursor-pointer">
<ProjectCard
:title="post.title"
:content="post.content"
:date="post.date"
:attachment="post.attachment || false"
:title="post.PROJCTNAM"
:description="post.PROJCTDES"
:strdate="post.PROJCTSTR"
:enddate="post.PROJCTEND"
:address="post.PROJCTARR + ' ' + post.PROJCTDTL"
:projctSeq="post.PROJCTSEQ"
:projctCol="post.projctcolor"
/>
</div>
<CenterModal :display="isModalOpen" @close="closeModal">
<template #title> 프로젝트 수정 </template>
<template #body>
<FormInput
title="이름"
name="name"
:is-essential="true"
:is-alert="nameAlert"
v-model="selectedProject.PROJCTNAM"
/>
<FormSelect
title="컬러"
name="color"
:is-essential="true"
:is-label="true"
:is-common="true"
:data="allColors"
v-model="selectedProject.projctcolor"
/>
<FormInput
title="시작일"
type="date"
name="startDay"
v-model="selectedProject.PROJCTSTR"
:is-essential="true"
/>
<FormInput
title="종료일"
type="date"
name="endDay"
v-model="selectedProject.PROJCTEND"
/>
<FormInput
title="설명"
name="description"
v-model="selectedProject.PROJCTDES"
/>
<ArrInput
title="주소"
name="address"
:is-essential="true"
:is-row="true"
v-model="selectedProject"
@update:data="updateAddress"
/>
</template>
<template #footer>
<button class="btn btn-secondary" @click="closeModal">Close</button>
<button class="btn btn-primary" @click="handleSubmit">Save</button>
</template>
</CenterModal>
</div>
</template>
<script setup>
import { ref, defineEmits } from 'vue';
import ProjectCard from './ProjectCard.vue';
import { computed, ref } from 'vue';
import ProjectCard from './ProjectCard.vue';
import { onMounted } from 'vue';
import $api from '@api';
import CenterModal from '@c/modal/CenterModal.vue';
import FormInput from '@c/input/FormInput.vue';
import FormSelect from '@c/input/FormSelect.vue';
import commonApi from '@/common/commonApi';
import ArrInput from '../input/ArrInput.vue';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
//
const posts = ref([
{
id: 1,
title: 'Vue 3 Composition API 소개',
content: 'Composition API를 사용하여 Vue 3에서 효율적으로 개발하는 방법을 알아봅니다. Composition API를 사용하여 Vue 3에서 효율적으로 개발하는 방법을 알아봅니다. Composition API를 사용하여 Vue 3에서 효율적으로 개발하는 방법을 알아봅니다. Composition API를 사용하여 Vue 3에서 효율적으로 개발하는 방법을 알아봅니다. Composition API를 사용하여 Vue 3에서 효율적으로 개발하는 방법을 알아봅니다. Composition API를 사용하여 Vue 3에서 효율적으로 개발하는 방법을 알아봅니다.',
date: '2025-02-10',
comments: 4,
attachment: true
},
{
id: 2,
title: 'Spring Boot로 REST API 만들기',
content: 'Spring Boot를 사용하여 간단한 RESTful API를 구현하는 방법을 다룹니다.',
date: '2025-02-09',
comments: 2,
attachment: false
}
]);
const projectList = ref([]);
const isModalOpen = ref(false);
const emit = defineEmits(['click']);
const userStore = useUserInfoStore();
const user = ref(null);
//
const goDetail = (id) => {
emit('click', id);
const nameAlert = ref(false);
const selectedProject = ref({
PROJCTNAM: '',
projctcolor: '',
PROJCTSTR: '',
PROJCTEND: '',
PROJCTZIP: '',
PROJCTARR: '',
PROJCTDTL: '',
PROJCTDES: '',
PROJCTCOL: '',
});
const { colorList } = commonApi({
loadColor: true,
colorType: 'YNP',
});
onMounted(async () => {
getProjectList();
await userStore.userInfo(); //
user.value = userStore.user;
});
//
const getProjectList = () => {
$api.get('project/select').then(res => {
projectList.value = res.data.data.projectList;
console.log(projectList.value);
});
};
const openModal = (post) => {
isModalOpen.value = true;
selectedProject.value = { ...post };
};
const closeModal = () => {
isModalOpen.value = false;
};
// +
const allColors = computed(() => {
//
const existingColor = { value: selectedProject.value.PROJCTCOL, label: selectedProject.value.projctcolor };
// colorList
return [existingColor, ...colorList.value];
});
const updateAddress = (addressData) => {
selectedProject.value = {
...selectedProject.value,
PROJCTZIP: addressData.postcode,
PROJCTARR: addressData.address,
PROJCTDTL: addressData.detailAddress
};
};
console.log(projectList.PROJCTSEQ)
const handleSubmit = () => {
$api.patch('project/update', {
projctSeq: projectList.PROJCTSEQ,
projctNam: selectedProject.value.PROJCTNAM,
projctCol: selectedProject.value.projctcolor,
projctArr: selectedProject.value.PROJCTARR,
projctDtl: selectedProject.value.PROJCTDTL,
projctZip: selectedProject.value.PROJCTZIP,
projctStr: selectedProject.value.PROJCTSTR,
projctEnd: selectedProject.value.PROJCTEND,
projctDes: selectedProject.value.PROJCTDES,
projctUmb: user.value.name,
}).then(res => {
if (res.status === 200) {
toastStore.onToast('수정이 완료 되었습니다.', 's');
closeModal();
location.reload();
}
})
};
</script>

View File

@ -1,10 +1,10 @@
<template>
<div @click="closeModal" class="modal fade" :class="{ 'show': display, 'display-block': display , 'modal-back' : display }" id="modalCenter" tabindex="-1" aria-modal="true" role="dialog">
<div @click="closeModal" class="modal fade scrollbar-none" :class="{ 'show': display, 'display-block': display , 'modal-back' : display }" id="modalCenter" tabindex="-1" aria-modal="true" role="dialog">
<div @click.stop class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalCenterTitle">
<h5 class="modal-title m-auto fw-bold" id="modalCenterTitle">
<slot name="title">Modal Title</slot>
</h5>
<button type="button" class="btn-close" @click="closeModal" aria-label="Close"></button>

View File

@ -8,13 +8,13 @@
<p>해당 직원에게 부여할 연차 개수를 선택하세요. (남은 개수: {{ availableQuota }})</p>
<div class="vacation-control">
<button @click="decreaseCount" :disabled="grantCount <= 0" class="count-btn">-</button>
<button @click="decreaseCount" :disabled="grantCount < 2" class="count-btn">-</button>
<span class="grant-count">{{ grantCount }}</span>
<button @click="increaseCount" :disabled="grantCount >= availableQuota" class="count-btn">+</button>
</div>
<button class="gift-btn" @click="saveVacationGrant" :disabled="grantCount === 0">
<i class="bx bx-gift"></i> <!-- 선물상자 아이콘 -->
<i class="bx bx-gift"></i>
</button>
</div>
</div>
@ -26,65 +26,60 @@
import axios from "@api";
const props = defineProps({
isOpen: Boolean, //
targetUser: Object, //
isOpen: Boolean,
targetUser: Object,
});
const emit = defineEmits(["close", "updateVacation"]);
const grantCount = ref(0);
const maxQuota = 2; // 1
const sentCount = ref(0); //
const availableQuota = ref(2); //
const maxQuota = 2;
const sentCount = ref(0);
const availableQuota = ref(2);
//
const fetchSentVacationCount = async () => {
try {
const payload = { receiverId: props.targetUser.MEMBERSEQ };
const response = await axios.get(`vacation/sent`,{ params: payload });
console.log(response.data.data[0].count)
sentCount.value = response.data.data[0].count || 0; //
availableQuota.value = Math.max(maxQuota - sentCount.value, 0); // (0 )
console.log(`✅ 보낸 개수: ${sentCount.value}, 남은 개수: ${availableQuota.value}`);
const response = await axios.get("vacation/sent", { params: payload });
sentCount.value = response.data.data[0].count || 0;
availableQuota.value = Math.max(maxQuota - sentCount.value, 0);
grantCount.value = availableQuota.value; //
} catch (error) {
console.error("🚨 연차 전송 기록 조회 실패:", error);
availableQuota.value = maxQuota;
grantCount.value = maxQuota; //
}
};
//
const increaseCount = () => {
if (grantCount.value < availableQuota.value) {
grantCount.value++;
}
};
//
const decreaseCount = () => {
if (grantCount.value > 0) {
grantCount.value--;
}
};
// (saveVacations API )
const saveVacationGrant = async () => {
try {
const payload = [
{
date: new Date().toISOString().split("T")[0], //
type: "700103", //
senderId: props.targetUser.senderId, // ID
receiverId: props.targetUser.MEMBERSEQ, // ID
count: grantCount.value, //
date: new Date().toISOString().split("T")[0],
type: "700103",
senderId: props.targetUser.senderId,
receiverId: props.targetUser.MEMBERSEQ,
count: grantCount.value,
},
];
const response = await axios.post("vacation/save", payload);
const response = await axios.post("vacation", payload);
console.log(response)
if (response.data && response.data.status === "OK") {
alert("✅ 연차가 부여되었습니다.");
await fetchSentVacationCount(); //
emit("updateVacation"); //
await fetchSentVacationCount();
emit("updateVacation");
closeModal();
} else {
alert("🚨 연차 추가 중 오류가 발생했습니다.");
@ -95,36 +90,30 @@
}
};
//
const closeModal = () => {
emit("close");
};
//
watch(
() => props.isOpen,
async (newVal) => {
if (newVal && props.targetUser && props.targetUser.MEMBERSEQ) {
console.log("🟢 모달이 열렸습니다. 데이터를 로드합니다.");
grantCount.value = 0; //
await fetchSentVacationCount(); //
await fetchSentVacationCount();
}
}
);
// targetUser fetchSentVacationCount
watch(
() => props.targetUser,
async (newUser) => {
if (newUser && newUser.MEMBERSEQ) {
console.log(`🔄 새로운 대상(${newUser.name})이 선택되었습니다.`);
await fetchSentVacationCount();
}
},
{ deep: true }
);
//
onMounted(async () => {
if (props.isOpen && props.targetUser && props.targetUser.MEMBERSEQ) {
await fetchSentVacationCount();
@ -132,6 +121,7 @@
});
</script>
<style scoped>
/* 모달 본문 */

View File

@ -1,5 +1,5 @@
<template>
<div v-if="isOpen" class="modal-dialog" @click.self="closeModal">
<div v-if="isOpen" class="modal-dialog" @click.self="closeModal">
<div class="modal-content modal-scroll">
<h5 class="modal-title">📅 연차 사용 내역</h5>
<button class="close-btn" @click="closeModal"></button>
@ -13,7 +13,7 @@
class="vacation-item"
>
<span v-if="vacation.type === 'used'" class="vacation-index">
{{ totalUsedVacationCount - usedVacations.findIndex(v => v.date === vacation.date) }})
{{ totalUsedVacationCount - usedVacations.findIndex(v => v.date === vacation.date) }} )
</span>
<span :class="vacation.type === 'used' ? 'minus-symbol' : 'plus-symbol'">
{{ vacation.type === 'used' ? '-' : '+' }}
@ -29,53 +29,57 @@
🚫 사용한 연차가 없습니다.
</p>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { defineProps, defineEmits, computed } from "vue";
<script setup>
import { defineProps, defineEmits, computed } from "vue";
const props = defineProps({
isOpen: Boolean,
myVacations: {
const props = defineProps({
isOpen: Boolean,
myVacations: {
type: Array,
default: () => [],
},
receivedVacations: {
},
receivedVacations: {
type: Array,
default: () => [],
},
userColors: {
},
userColors: {
type: Object,
default: () => ({}),
},
});
},
});
const emit = defineEmits(["close"]);
const emit = defineEmits(["close"]);
//
const totalUsedVacationCount = computed(() => props.myVacations.length);
//
const totalUsedVacationCount = computed(() => props.myVacations.length);
// +
const usedVacations = computed(() => props.myVacations.map(v => ({ ...v, type: "used" })));
const receivedVacations = computed(() => props.receivedVacations.map(v => ({ ...v, type: "received" })));
// +
const usedVacations = computed(() => props.myVacations.map(v => ({ ...v, type: "used" })));
const receivedVacations = computed(() => props.receivedVacations
.filter(v => !v.senderId) //
.map(v => ({ ...v, type: "received" }))
);
const mergedVacations = computed(() => {
return [...usedVacations.value, ...receivedVacations.value].sort(
const mergedVacations = computed(() => {
return [...usedVacations.value, ...receivedVacations.value].sort(
(a, b) => new Date(b.date) - new Date(a.date)
);
});
);
});
// (YYYY-MM-DD)
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toISOString().split("T")[0]; // YYYY-MM-DD
};
// (YYYY-MM-DD)
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toISOString().split("T")[0]; // YYYY-MM-DD
};
const closeModal = () => {
emit("close");
};
</script>
const closeModal = () => {
emit("close");
};
</script>
<style scoped>
/* 모달 스타일 */

View File

@ -66,51 +66,55 @@
const props = defineProps({
currentPage: {
type: Number,
required: true
required: false
},
pages: {
type: Number,
required: true
required: false
},
prePage: {
type: Number,
required: true
required: false
},
nextPage: {
type: Number,
required: true
required: false
},
isFirstPage: {
type: Boolean,
required: true
required: false
},
isLastPage: {
type: Boolean,
required: true
required: false
},
hasPreviousPage: {
type: Boolean,
required: true
required: false
},
hasNextPage: {
type: Boolean,
required: true
required: false
},
navigatePages: {
type: Number,
required: true
required: false
},
navigatepageNums: {
type: Array,
required: true
required: false
},
navigateFirstPage: {
type: Number,
required: true
required: false
},
navigateLastPage: {
type: Number,
required: true
required: false
},
PageData:{
type:Array,
required:false,
}
});

View File

@ -1,19 +1,163 @@
<template>
<SearchBar />
<CategoryBtn :lists="yearCategory" />
<div class="d-flex align-items-center">
<CategoryBtn :lists="yearCategory" v-model:selectedCategory="selectedCategory" />
<WriteBtn class="mt-2 ms-auto" @click="openModal" />
<CenterModal :display="isModalOpen" @close="closeModal">
<template #title> 프로젝트 등록 </template>
<template #body>
<FormInput
title="이름"
name="name"
:is-essential="true"
:is-alert="nameAlert"
@update:modelValue="name = $event"
/>
<FormSelect
title="컬러"
name="color"
:is-essential="true"
:is-label="true"
:is-common="true"
:data="colorList"
@update:data="color = $event"
/>
<FormInput
title="시작 일"
type="date"
name="startDay"
v-model="startDay"
:is-essential="true"
/>
<FormInput
title="종료 일"
name="endDay"
:type="'date'"
@update:modelValue="endDay = $event"
/>
<FormInput
title="설명"
name="description"
@update:modelValue="description = $event"
/>
<ArrInput
title="주소"
name="address"
:isEssential="true"
:is-row="true"
:is-alert="addressAlert"
@update:data="handleAddressUpdate"
@update:alert="addressAlert = $event"
/>
</template>
<template #footer>
<button class="btn btn-secondary" @click="closeModal">Close</button>
<button class="btn btn-primary" @click="handleSubmit">Save</button>
</template>
</CenterModal>
</div>
<ProjectCardList :category="selectedCategory" />
</template>
<script setup>
import SearchBar from '@c/search/SearchBar.vue';
import ProjectCardList from '@c/list/ProjectCardList.vue';
import CategoryBtn from '@c/category/CategoryBtn.vue';
import commonApi from '@/common/commonApi'
import { ref } from 'vue';
import SearchBar from '@c/search/SearchBar.vue';
import ProjectCardList from '@c/list/ProjectCardList.vue';
import CategoryBtn from '@c/category/CategoryBtn.vue';
import commonApi from '@/common/commonApi';
import { inject, onMounted, ref } from 'vue';
import WriteBtn from '@c/button/WriteBtn.vue';
import CenterModal from '@c/modal/CenterModal.vue';
import FormSelect from '@c/input/FormSelect.vue';
import FormInput from '@c/input/FormInput.vue';
import ArrInput from '@c/input/ArrInput.vue';
import { useToastStore } from '@s/toastStore';
import $api from '@api';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
const selectedCategory = ref(null);
const dayjs = inject('dayjs');
const { yearCategory } = commonApi();
const today = dayjs().format('YYYY-MM-DD');
const toastStore = useToastStore();
const userStore = useUserInfoStore();
const user = ref(null);
const name = ref('');
const color = ref('');
const address = ref('');
const detailAddress = ref('');
const postcode = ref('');
const startDay = ref(today);
const endDay = ref('');
const description = ref('');
const isModalOpen = ref(false);
const nameAlert = ref(false);
const addressAlert = ref(false);
const openModal = () => {
isModalOpen.value = true;
};
const closeModal = () => {
isModalOpen.value = false;
};
const selectedCategory = ref(null);
const { yearCategory, colorList } = commonApi({
loadColor: true,
colorType: 'YNP',
loadYearCategory: true,
});
//
const handleAddressUpdate = addressData => {
address.value = addressData.address;
detailAddress.value = addressData.detailAddress;
postcode.value = addressData.postcode;
};
onMounted(async () => {
await userStore.userInfo(); //
user.value = userStore.user;
});
const handleSubmit = async () => {
nameAlert.value = name.value.trim() === '';
addressAlert.value = address.value.trim() === '';
if (nameAlert.value || addressAlert.value ) {
return;
}
$api.post('project/insert', {
projctNam: name.value,
projctCol: color.value,
projctStr: startDay.value,
projctEnd: endDay.value || null,
projctDes: description.value || null,
projctArr: address.value,
projctDtl: detailAddress.value,
projctZip: postcode.value,
projctCmb: user.value.name,
})
.then(res => {
if (res.status === 200) {
toastStore.onToast('프로젝트가 등록되었습니다.', 's');
closeModal();
location.reload();
}
})
};
</script>

View File

@ -1,5 +1,4 @@
<template>
<form @submit.prevent="handleSubmit">
<div class="col-xl-12">
<UserFormInput title="아이디" name="id" :is-alert="idAlert" :useInputGroup="true" @update:data="handleIdChange" :value="id" />
@ -13,7 +12,7 @@
/>
<div class="d-grid gap-2 mt-7 mb-5">
<button type="submit" class="btn btn-primary">로그인</button>
<button type="submit" @click="handleSubmit" class="btn btn-primary">로그인</button>
</div>
<div class="mb-3 d-flex justify-content-around">
@ -25,7 +24,6 @@
<RouterLink class="text-dark fw-bold" to="/pw">비밀번호 찾기</RouterLink>
</div>
</div>
</form>
</template>
<script setup>
@ -34,7 +32,7 @@
import { useRoute } from 'vue-router';
import { ref } from 'vue';
import UserFormInput from '@c/input/UserFormInput.vue';
import { useUserStore } from '@s/useUserStore';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
const id = ref('');
const password = ref('');
@ -42,7 +40,7 @@
const passwordAlert = ref(false);
const remember = ref(false);
const userStore = useUserStore();
const userStore = useUserInfoStore();
const route = useRoute();
const handleIdChange = value => {
@ -55,21 +53,21 @@
passwordAlert.value = false;
};
const handleSubmit = async () => {
$api.post(
'user/login',
{
const handleSubmit = async () => {
idAlert.value = id.value.trim() === '';
passwordAlert.value = password.value.trim() === '';
if (idAlert.value || passwordAlert.value) {
return;
}
$api.post('user/login', {
loginId: id.value,
password: password.value,
remember: remember.value,
},
{ headers: { 'X-Page-Route': route.path } },
).then(res => {
}, { headers: { 'X-Page-Route': route.path } })
.then(res => {
if (res.status === 200) {
// const sessionCookie = res.data.data;
// document.cookie = `JSESSIONID=${sessionCookie};path=/;expires=-1;`;
// document.cookie = `JSESSIONID=${sessionCookie};path=/;HttpOnly=true;samesite=lax`;
userStore.userInfo();
router.push('/');
}

View File

@ -252,7 +252,11 @@
};
// , mbti,
const { colorList, mbtiList, pwhintList } = commonApi();
const { colorList, mbtiList, pwhintList } = commonApi({
loadColor: true, colorType: 'YON',
loadMbti: true,
loadPwhint: true,
});
//
const handleAddressUpdate = (addressData) => {

View File

@ -4,7 +4,7 @@
v-for="(user, index) in userList"
:key="index"
class="avatar pull-up"
:class="{ 'opacity-100': user.disabled }"
:class="{ 'opacity-100': isUserDisabled(user) }"
@click="toggleDisable(index)"
data-bs-toggle="tooltip"
data-popup="tooltip-custom"
@ -14,13 +14,12 @@
>
<img
class="rounded-circle user-avatar border border-3"
:class="{ 'grayscaleImg': user.disabled }"
:class="{ 'grayscaleImg': isUserDisabled(user) }"
:src="`${baseUrl}upload/img/profile/${user.MEMBERPRF}`"
:style="`border-color: ${user.usercolor} !important;`"
alt="user"
/>
</li>
</ul>
</template>
@ -29,16 +28,41 @@ import { onMounted, ref, nextTick } from 'vue';
import { useUserStore } from '@s/userList';
import $api from '@api';
const emit = defineEmits();
const emit = defineEmits(['user-list-update']);
const userStore = useUserStore();
const userList = ref([]);
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const props = defineProps({
projctSeq: {
type: Number,
required: false,
}
});
//
const fetchProjectParticipation = async () => {
if (props.projctSeq) {
const response = await $api.get(`project/members/${props.projctSeq}`);
if (response.status === 200) {
const projectMembers = response.data.data;
userList.value = userList.value.map(user => ({
...user,
PROJCTYON: projectMembers.find(pm => pm.MEMBERSEQ === user.MEMBERSEQ)?.PROJCTYON ?? '1'
}));
}
}
};
//
onMounted(async () => {
await userStore.fetchUserList();
userList.value = userStore.userList;
if (props.projctSeq) {
await fetchProjectParticipation();
}
nextTick(() => {
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach((tooltip) => {
@ -47,20 +71,40 @@ onMounted(async () => {
});
});
// /
const toggleDisable = (index) => {
//
const isUserDisabled = (user) => {
return props.projctSeq ? user.PROJCTYON === '0' : user.disabled;
};
// / DB
const toggleDisable = async (index) => {
const user = userList.value[index];
if (user) {
user.disabled = !user.disabled;
const newParticipationStatus = props.projctSeq
? user.PROJCTYON === '1'
: !user.disabled;
if (props.projctSeq) {
const response = await $api.patch('project/updateYon', {
memberSeq: user.MEMBERSEQ,
projctSeq: props.projctSeq,
projctYon: newParticipationStatus ? '0' : '1'
});
if (response.status === 200) {
user.PROJCTYON = newParticipationStatus ? '0' : '1';
}
} else {
user.disabled = newParticipationStatus;
emitUserListUpdate();
}
}
};
// emit
const emitUserListUpdate = () => {
const activeUsers = userList.value.filter((user) => !user.disabled);
const disabledUsers = userList.value.filter((user) => user.disabled);
const activeUsers = userList.value.filter(user => !isUserDisabled(user));
const disabledUsers = userList.value.filter(user => isUserDisabled(user));
emit('user-list-update', { activeUsers, disabledUsers });
};
@ -68,6 +112,3 @@ const getTooltipTitle = (user) => {
return user.MEMBERSEQ === userStore.userInfo.id ? '나' : user.MEMBERNAM;
};
</script>
<style scoped>
</style>

View File

@ -27,8 +27,8 @@
<script setup>
import { onMounted, ref, computed, nextTick } from "vue";
import { useUserStore } from "@s/useUserStore";
import { useUserStore as useUserListStore } from "@s/userList";
import { useUserInfoStore } from "@s/useUserInfoStore";
import { useUserStore } from "@s/userList";
import $api from "@api";
defineEmits(["profileClick"]);
@ -37,8 +37,8 @@
remainingVacationData: Object,
});
const userStore = useUserStore();
const userListStore = useUserListStore();
const userStore = useUserInfoStore();
const userListStore = useUserStore();
const userList = ref([]);
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, "");

View File

@ -2,17 +2,24 @@
<div class="card mb-6">
<div class="card-body">
<h5 class="card-title mb-1"><div class="list-group-item list-group-item-action d-flex align-items-center cursor-pointer">
<img src="/img/avatars/1.png" class="rounded-circle me-3 w-px-40" >
<img
class="rounded-circle user-avatar border border-3 w-px-40"
:src="`${baseUrl}upload/img/profile/${data.localVote.MEMBERPRF}`"
:style="`border-color: ${data.localVote.usercolor} !important;`"
alt="user"
/>
<div class="w-100">
<div class="d-flex justify-content-between">
<div class="user-info">
<h6 class="mb-1">공공이</h6>
<h6 class="mb-1">{{ data.localVote.MEMBERNAM }}</h6>
</div>
<div class="add-btn">
<!-- 투표완료시 -->
<i class="bx bxs-check-circle link-success"></i>
<!-- 투표작성자만 수정/삭제/종료 가능 -->
<button type="button" class="bx btn btn-danger">종료</button>
<button
v-if="userStore.user.id === data.localVote.LOCVOTREG"
type="button" class="bx btn btn-danger">종료</button>
<EditBtn />
<DeleteBtn />
</div>
@ -20,14 +27,21 @@
</div>
</div>
</h5>
<div class="mb-1">회식장소 고릅시다.</div>
<div class="mb-1">24.12.12 11:02 ~ 24.12.12 16:02</div>
<h5>{{ data.localVote.LOCVOTTTL }}</h5>
<div class="mb-1">{{ data.localVote.formatted_LOCVOTRDT }} ~ {{ data.localVote.formatted_LOCVOTEDT }}</div>
<!-- 투표완료시-->
<vote-revote-end />
<button v-if="data.yesVotetotal > 0" class="btn btn-primary btn-sm" >재투표</button>
<!-- 투표안했을시-->
<vote-card-check />
<vote-card-check
v-if="data.yesVotetotal == 0"
@addContents="addContents"
@checkedNames="checkedNames"
:data="data.voteDetails"
:voteInfo="data.localVote"
:total="data.voteDetails.length "/>
<!-- 투표완/미완 인원 -->
<vote-user-list />
<vote-user-list
:data="data.voteMembers"/>
<!-- 투표 결과 -->
<vote-result-list />
</div>
@ -41,8 +55,25 @@ import DeleteBtn from '@c/button/DeleteBtn.vue';
import voteUserList from '@c/voteboard/voteUserList.vue';
import voteResultList from '@c/voteboard/voteResultList.vue';
import voteCardCheck from '@c/voteboard/voteCardCheck.vue';
import voteRevoteEnd from '@c/voteboard/voteRevoteEnd.vue';
import { useUserInfoStore } from '@s/useUserInfoStore';
import $api from '@api';
const props = defineProps({
data: {
type: Object,
required: true,
},
});
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const userStore = useUserInfoStore();
const emit = defineEmits(['addContents','checkedNames']);
const addContents = (itemList, voteId) =>{
emit('addContents',itemList,voteId)
}
const checkedNames = (numList) =>{
emit('checkedNames',numList);
}
</script>
<style scoped>

View File

@ -2,24 +2,96 @@
<div class="card-text">
<div class="demo-inline-spacing mt-4">
<!-- 투표리스트 -->
<vote-card-check-list />
<div class="d-flex align-items-center">
<PlusBtn/>
<FormInput title="추가항목" name="addContent" :isLabel="false" :is-essential="true" :is-alert="titleAlert" @update:data="title = $event" />
<button class="btn btn-primary ms-1">저장</button>
<div v-for="(item, index) in data"
:key="index">
<vote-card-check-list
:data="item"
:multiIs = voteInfo.LOCVOTMUL
:selectedValues="checkedNames"
@update:selectedValues="updateCheckedNames"
/>
<div v-if="voteInfo.LOCVOTADD ==='1' && index === data.length - 1" class="d-flex align-items-center">
<div class="d-flex flex-column gap-2">
<div v-for="(item, index) in itemList" :key="index" class="d-flex align-items-center">
<form-input
class="flex-grow-1 me-2"
:title="'항목 ' + (index + data.length + 1)"
:name="'content' + index"
:is-essential="false"
:is-alert="contentAlerts[index]"
v-model="item.content"
/>
<link-input v-model="item.url" />
<delete-btn @click="removeItem(index)" class="ms-2" />
</div>
<div class="mb-4 d-flex justify-content">
<plus-btn @click="addItem" :disabled="total >= 10" class="mb-3" />
<button class="btn btn-primary btn-icon mb-3" @click="addContentSave(item.LOCVOTSEQ)" :disabled="isSaveDisabled">
<i class="bx bx-check"></i>
</button>
</div>
</div>
</div>
<button class="btn btn-primary btn-sm">투표하기</button>
</div>
</div>
</div>
<button class="btn btn-primary btn-sm" @click="selectVote">투표하기</button>
</template>
<script setup>
import $api from '@api';
import PlusBtn from '@c/button/PlusBtn.vue';
import FormInput from '@c/input/FormInput.vue';
import voteCardCheckList from '@c/voteboard/voteCardCheckList.vue';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import LinkInput from "@/components/voteboard/voteLinkInput.vue";
import { voteCommon } from '@s/voteCommon';
import DeleteBtn from "@c/button/DeleteBtn.vue";
import { useToastStore } from '@s/toastStore';
import router from '@/router';
const toastStore = useToastStore();
const contentAlerts = ref(false);
const titleAlert = ref(false);
const title = ref('');
const rink = ref('');
const { itemList, addItem, removeItem } = voteCommon(true);
const total = computed(() => props.total + itemList.value.length);
const isSaveDisabled = computed(() => {
return itemList.value.length === 0 || itemList.value.every(item => !item.content.trim());
});
const props = defineProps({
data: {
type: Array,
required: true,
},
voteInfo: {
type: Object,
required: true,
},
total: {
type: Number,
required: true,
},
});
const emit = defineEmits(['addContents','checkedNames']);
//
const addContentSave = (voteId) =>{
emit('addContents',itemList.value,voteId);
itemList.value = [{ content: "", url: "" }];
}
const checkedNames = ref([]); //
const updateCheckedNames = (newValues) => {
checkedNames.value = newValues;
};
const selectVote = () =>{
emit('checkedNames',checkedNames.value);
}
</script>
<style >

View File

@ -1,14 +1,57 @@
<template>
<div class="list-group">
<label class="list-group-item">
<input class="form-check-input me-1" type="checkbox" value="">
case1
<input
class="form-check-input me-1"
:name="data.LOCVOTSEQ"
:type="multiIs === '1' ? 'checkbox' : 'radio'"
:value="data.VOTDETSEQ"
:checked="selectedValues.includes(data.VOTDETSEQ)"
@change="handleChange"
>
{{ data.LOCVOTCON }}
<a v-if="data.LOCVOTLIK" :href="data.LOCVOTLIK.startsWith('http') ? data.LOCVOTLIK : 'http://' + data.LOCVOTLIK" target="_blank">
{{ data.LOCVOTLIK }}
</a>
</label>
</div>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({
data: {
type: Object,
required: true,
},
multiIs: {
type: String,
required: true,
},
selectedValues: {
type: Array,
required: true,
},
})
const emit = defineEmits(["update:selectedValues"]);
const handleChange = (event) => {
const value = event.target.value;
let updatedValues = [];
//
if (props.multiIs === "1") {
updatedValues = event.target.checked
? [...props.selectedValues, { VOTDETSEQ: value, LOCVOTSEQ: props.data.LOCVOTSEQ, LOCVOTCON: props.data.LOCVOTCON }]
: props.selectedValues.filter(v => v.VOTDETSEQ !== value);
} else {
//
updatedValues = [{ VOTDETSEQ: value, LOCVOTSEQ: props.data.LOCVOTSEQ, LOCVOTCON: props.data.LOCVOTCON }];
}
emit("update:selectedValues", updatedValues);
};
</script>
<style>

View File

@ -1,13 +1,33 @@
<template>
<div>
<card />
<card
@addContents="addContents"
@checkedNames="checkedNames"
v-for="(item, index) in data"
:key="index"
:data="item" />
</div>
</template>
<script setup>
import card from '@c/voteboard/voteCard.vue'
const props = defineProps({
data: {
type: Array,
required: true,
},
});
const emit = defineEmits(['addContents','checkedNames']);
const addContents = (itemList ,voteId) =>{
emit('addContents',itemList ,voteId);
}
const checkedNames = (numList) =>{
emit('checkedNames',numList);
}
</script>
<style scoped>
</style>

View File

@ -1,11 +1,10 @@
<template>
<div class="d-flex align-items-center">
<i class='bx bxs-user-check link-info'></i>
<div class="d-flex align-items-center ">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap border-top-0 p-0">
<div class="d-flex flex-wrap align-items-center">
<ul class="list-unstyled users-list d-flex align-items-center avatar-group m-0 me-2">
<vote-complete-user-list-card />
<vote-complete-user-list-card :data="data"/>
</ul>
</div>
</li>
@ -15,7 +14,12 @@
<script setup>
import voteCompleteUserListCard from '@c/voteboard/voteCompleteUserListCard.vue';
const props = defineProps({
data: {
type: Object,
required: true,
},
});
</script>
<style lang="scss" scoped>

View File

@ -1,10 +1,45 @@
<template>
<li data-bs-toggle="tooltip" data-popup="tooltip-custom" data-bs-placement="top" class="avatar pull-up" aria-label="Vinnie Mostowy" data-bs-original-title="Vinnie Mostowy">
<img class="rounded-circle" src="/img/avatars/1.png" alt="Avatar">
<li
data-bs-toggle="tooltip"
data-popup="tooltip-custom"
data-bs-placement="top"
class="avatar pull-up"
:aria-label="data.MEMBERSEQ"
:data-bs-original-title="getTooltipTitle(data)">
<img
class="rounded-circle user-avatar border border-3"
:src="`${baseUrl}upload/img/profile/${data.MEMBERPRF}`"
:style="`border-color: ${data.usercolor} !important;`"
alt="user"
/>
</li>
</template>
<script setup>
import { useUserInfoStore } from '@s/useUserInfoStore';
import $api from '@api';
import { nextTick, onMounted } from 'vue';
const props = defineProps({
data: {
type: Object,
required: true,
},
});
const userStore = useUserInfoStore();
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
onMounted(async () => {
nextTick(() => {
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach((tooltip) => {
new bootstrap.Tooltip(tooltip);
});
});
});
const getTooltipTitle = (user) => {
return user.MEMBERSEQ === userStore.user.id ? '나' : user.MEMBERNAM;
};
</script>

View File

@ -1,11 +1,10 @@
<template>
<div class="d-flex align-items-center">
<i class='bx bxs-user-x link-danger'></i>
<div class="d-flex align-items-center ">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap border-top-0 p-0">
<div class="d-flex flex-wrap align-items-center">
<ul class="list-unstyled users-list d-flex align-items-center avatar-group m-0 me-2">
<vote-in-complete-user-list-card />
<vote-in-complete-user-list-card :data="data" />
</ul>
</div>
</li>
@ -15,7 +14,12 @@
<script setup>
import voteInCompleteUserListCard from '@c/voteboard/voteInCompleteUserListCard.vue';
const props = defineProps({
data: {
type: Object,
required: true,
},
});
</script>
<style lang="scss" scoped>

View File

@ -1,10 +1,45 @@
<template>
<li data-bs-toggle="tooltip" data-popup="tooltip-custom" data-bs-placement="top" class="avatar pull-up" aria-label="Vinnie Mostowy" data-bs-original-title="Vinnie Mostowy">
<img class="rounded-circle" src="/img/avatars/3.png" alt="Avatar">
<li
data-bs-toggle="tooltip"
data-popup="tooltip-custom"
data-bs-placement="top"
class="avatar pull-up"
:aria-label="data.MEMBERSEQ"
:data-bs-original-title="getTooltipTitle(data)">
<img
class="rounded-circle user-avatar border border-3"
:src="`${baseUrl}upload/img/profile/${data.MEMBERPRF}`"
:style="`border-color: ${data.usercolor} !important;`"
alt="user"
/>
</li>
</template>
<script setup>
import { useUserInfoStore } from '@s/useUserInfoStore';
import $api from '@api';
import { nextTick, onMounted } from 'vue';
const props = defineProps({
data: {
type: Object,
required: true,
},
});
const userStore = useUserInfoStore();
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
onMounted(async () => {
nextTick(() => {
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach((tooltip) => {
new bootstrap.Tooltip(tooltip);
});
});
});
const getTooltipTitle = (user) => {
return user.MEMBERSEQ === userStore.user.id ? '나' : user.MEMBERNAM;
};
</script>

View File

@ -0,0 +1,80 @@
<template>
<div class="position-relative me-2">
<i class="bx bx-link-alt" @click="togglePopover"></i>
<!-- 링크 팝업 -->
<div
v-if="isPopoverVisible"
class="popover bs-popover-auto fade show d-flex align-items-center"
role="tooltip"
:style="popoverStyle"
>
<div class="popover-arrow"></div>
<input
v-model="link"
placeholder="URL을 입력해주세요"
class="form-control me-2 flex-grow-1"
style="min-width: 200px;"
/>
<button type="button" class="btn btn-sm btn-primary ms-2" @click="saveLink">
등록
</button>
</div>
<!-- 등록된 링크 표시 -->
<div v-if="link" class="mt-1">
<a :href="formattedLink" target="_blank" rel="noopener noreferrer">{{ link }}</a>
</div>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
const props = defineProps({
modelValue: String,
});
const emit = defineEmits(["update:modelValue"]);
const isPopoverVisible = ref(false);
const link = ref(props.modelValue || "");
const popoverStyle = ref({});
const formattedLink = computed(() => {
return link.value.startsWith("http") ? link.value : "http://" + link.value;
});
const togglePopover = (event) => {
const buttonRect = event.target.getBoundingClientRect();
const parentRect = event.target.parentElement.getBoundingClientRect();
popoverStyle.value = {
position: "absolute",
top: `${buttonRect.bottom - parentRect.top + 5}px`,
left: `${buttonRect.left - parentRect.left}px`,
zIndex: "1050",
display: "flex",
alignItems: "center",
};
isPopoverVisible.value = !isPopoverVisible.value;
};
const saveLink = () => {
emit("update:modelValue", link.value);
isPopoverVisible.value = false;
};
</script>
<style scoped>
.popover {
max-width: 300px;
border-radius: 6px;
padding: 5px;
}
.popover-arrow {
position: absolute;
top: -5px;
left: 50%;
transform: translateX(-50%);
}
</style>

View File

@ -1,16 +0,0 @@
<template>
<div class="user-status">
<span class="badge badge-dot bg-warning"></span>
<small>소고기 </small>
<button class="btn btn-primary btn-sm">재투표</button>
</div>
</template>
<script setup>
</script>
<style>
</style>

View File

@ -1,17 +1,42 @@
<template>
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<!--투표한 사람 목록 -->
<vote-complete-user-list />
<div class="d-flex align-items-center gap-2">
<i class='bx bxs-user-check link-info'></i>
<vote-complete-user-list
v-for="(item, index) in voetedUsers"
:key="index"
:data="item"
/>
</div>
<!-- 투표안한 사람 목록 -->
<vote-in-complete-user-list />
<div class="d-flex align-items-center gap-2 ms-auto">
<i class='bx bxs-user-x link-danger'></i>
<vote-in-complete-user-list
v-for="(item, index) in noVoetedUsers"
:key="index"
:data="item"/>
</div>
</div>
</template>
<script setup>
import voteCompleteUserList from '@c/voteboard/voteCompleteUserList.vue';
import voteInCompleteUserList from '@c/voteboard/voteInCompleteUserList.vue';
import { computed } from 'vue';
const props = defineProps({
data: {
type: Array,
required: true,
},
});
const voetedUsers = computed(()=>{
return props.data.filter(user => user.voted === 1);
})
const noVoetedUsers = computed(()=>{
return props.data.filter(user => user.voted === 0);
})
</script>
<style scoped>

View File

@ -1,7 +1,7 @@
<template>
<div>
<ul class="alphabet-list list-unstyled d-flex flex-wrap mb-0">
<li v-for="char in koreanChars" :key="char" class="mt-2 mx-1">
<li v-for="char in koreanChars" :key="char" class="mt-2 me-2">
<button
type="button"
class="btn"
@ -13,7 +13,7 @@
</li>
</ul>
<ul class="alphabet-list list-unstyled d-flex flex-wrap mb-0">
<li v-for="char in englishChars" :key="char" class="mt-2 mx-1">
<li v-for="char in englishChars" :key="char" class="mt-2 me-2">
<button
type="button"
class="btn"
@ -43,9 +43,6 @@ const selectAlphabet = (alphabet) => {
</script>
<style scoped>
.alphabet-list {
margin-left: -0.25rem;
}
@media (max-width: 768px) {
.alphabet-list {

View File

@ -1,11 +1,23 @@
<template>
<li class="mt-5 card p-5">
<DictWrite
v-if="isWriteVisible"
@close="isWriteVisible = false"
:dataList="cateList"
@addCategory="addCategory"
@addWord="editWord"
:NumValue="item.WRDDICSEQ"
:formValue="item.WRDDICCAT"
:titleValue="item.WRDDICTTL"
:contentValue="item.WRDDICCON"
/>
<div v-else>
<div class="d-flex align-items-center">
<div class="w-100 d-flex align-items-center">
<span class="btn btn-primary pe-none">{{ item.category }}</span>
<strong class="mx-2 w-75">{{ item.WRDDICTTL }}</strong>
</div>
<EditBtn />
</div>
<p class="mt-5" v-html="$common.contentToHtml(item.WRDDICCON)"></p>
<div class="d-flex justify-content-between flex-wrap gap-2 mb-2">
@ -15,8 +27,7 @@
class="rounded-circle user-avatar"
:src="getProfileImage(item.author.profileImage)"
alt="최초 작성자"
:style="{ borderColor: item.author.color}"
/>
:style="{ borderColor: item.author.color}"/>
</div>
<div>
<p class="mb-0 small fw-medium">{{ formatDate(item.author.createdAt) }}</p>
@ -30,40 +41,147 @@
class="rounded-circle user-avatar"
:src="getProfileImage(item.lastEditor.profileImage)"
alt="최근 작성자"
:style="{ borderColor: item.lastEditor.color}"
/>
:style="{ borderColor: item.lastEditor.color}"/>
</div>
<div>
<p class="mb-0 small fw-medium">{{ formatDate(item.lastEditor.updatedAt) }}</p>
</div>
</div>
</div>
</div>
<div class="edit-btn">
<EditBtn @click="toggleWriteVisible" />
</div>
</li>
</template>
<script setup>
import axios from "@api";
import { useToastStore } from '@s/toastStore';
import { ref, toRefs, getCurrentInstance, } from 'vue';
import EditBtn from '@/components/button/EditBtn.vue';
import $api from '@api';
import DictWrite from './DictWrite.vue';
const toastStore = useToastStore();
const { appContext } = getCurrentInstance();
const $common = appContext.config.globalProperties.$common;
// Props
const props = defineProps({
item: {
type: Object,
required: true,
required: true
},
cateList: {
type: Array,
required: false,
}
});
//
const localCateList = ref([...props.cateList]);
//
const selectedCategory = ref('');
// cateList emit
const emit = defineEmits(['update:cateList']);
//
const isWriteVisible = ref(false);
// toggle
const toggleWriteVisible = () => {
isWriteVisible.value = !isWriteVisible.value;
};
//
const addCategory = (data) => {
try {
const lastCategory = localCateList.value.length > 0
? localCateList.value[localCateList.value.length - 1]
: null;
const newValue = lastCategory ? lastCategory.value + 1 : 600101;
axios.post('worddict/insertCategory', {
CMNCODNAM: data
}).then(res => {
if(res.data.data === 1){
toastStore.onToast('카테고리가 추가 등록 되었습니다.', 's');
const newCategory = { label: data, value: newValue };
localCateList.value = [newCategory, ...localCateList.value];
selectedCategory.value = newCategory.value;
//
emit('update:cateList', localCateList.value);
}
}).catch(err => {
console.error('카테고리 추가 중 오류:', err);
});
} catch (err) {
console.error('카테고리 추가 함수 오류:', err);
}
}
//
const editWord = (data) => {
console.log('📌 수정할 데이터:', data);
console.log('📌 수정할 데이터:', data.id);
console.log('📌 수정할 데이터:', data.category);
console.log('📌 수정할 데이터:', data.title);
console.log('📌 수정할 데이터:', $common.deltaAsJson(data.content));
if (!data.id) {
console.error('❌ 수정할 데이터의 ID가 없습니다.');
toastStore.onToast('수정할 용어의 ID가 필요합니다.', 'e');
return;
}
axios.patch('worddict/updateWord', {
WRDDICSEQ: data.id,
WRDDICCAT: 600104,
WRDDICTTL: data.title,
WRDDICRIK: $common.deltaAsJson(data.content),
})
.then((res) => {
if (res.data.data === '1') {
toastStore.onToast('✅ 용어가 수정되었습니다.', 's');
isWriteVisible.value = false; //
// getwordList(); //
} else {
console.warn('⚠️ 서버 응답이 예상과 다릅니다:', res.data);
toastStore.onToast('용어 수정이 정상적으로 처리되지 않았습니다.', 'e');
}
})
.catch((err) => {
console.error('❌ 용어 수정 중 오류 발생:', err.response?.data || err.message);
toastStore.onToast(`용어 수정 실패: ${err.response?.data?.message || '알 수 없는 오류'}`, 'e');
});
};
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
//
//
const formatDate = (dateString) => new Date(dateString).toLocaleString();
//
//
const getProfileImage = (imagePath) =>
imagePath ? `${baseUrl}upload/img/profile/${imagePath}` : '/img/avatars/default-Profile.jpg';
imagePath ? `${baseUrl}upload/img/profile/${imagePath}` : '/img/avatars/default-Profile.jpg';
</script>
<style scoped>
/* 동그란 테두리 설정 */
.user-avatar {
border: 3px solid; /* 테두리 */
padding: 0.1px; /* 테두리와 이미지 사이의 간격 */
border: 3px solid; /* 테두리 */
padding: 0.1px; /* 테두리와 이미지 사이의 간격 */
}
.edit-btn {
position: absolute;
right: 0.7rem;
top: 1.2rem;
}
</style>

View File

@ -9,6 +9,7 @@
:is-common="true"
@update:data="selectCategory = $event"
@change="onChange"
:value="formValue"
/>
</div>
<div class="col-2 btn-margin">
@ -34,11 +35,12 @@
name="word"
:is-essential="true"
:is-alert="wordTitleAlert"
:modelValue="titleValue"
@update:modelValue="wordTitle = $event"
/>
</div>
<div>
<QEditor @update:data="content = $event" @update:imageUrls="imageUrls = $event" :is-alert="wordContentAlert" />
<QEditor @update:data="content = $event" @update:imageUrls="imageUrls = $event" :is-alert="wordContentAlert" :initialData="contentValue"/>
<div class="text-end mt-5">
<button class="btn btn-primary" @click="saveWord">
<i class="bx bx-check"></i>
@ -75,17 +77,20 @@ const props = defineProps({
dataList: {
type: Array,
default: () => []
},
NumValue : {
type: Number
},
formValue : {
type:[String, Number]
},
titleValue : {
type:String,
},contentValue : {
type:String
}
});
//
// const formattedDataList = computed(() =>
// props.dataList.map(item => ({
// label: item.CMNCODNAM,
// value: item.CMNCODVAL
// }))
// );
//
const showInput = ref(false);
@ -126,6 +131,7 @@ const saveWord = () => {
}
const wordData = {
id: props.NumValue || null,
title: wordTitle.value,
category: selectCategory.value,
content: content.value,

View File

@ -228,7 +228,7 @@
</template>
<script setup>
import { useAuthStore } from '@s/useAuthStore';
import { useUserStore } from '@s/useUserStore';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useRouter } from 'vue-router';
import { useThemeStore } from '@s/darkmode';
import { onMounted, ref } from 'vue';
@ -236,7 +236,7 @@ import { onMounted, ref } from 'vue';
const user = ref(null);
const authStore = useAuthStore();
const userStore = useUserStore();
const userStore = useUserInfoStore();
const router = useRouter();
const { isDarkMode, switchToDarkMode, switchToLightMode } = useThemeStore();

View File

@ -1,8 +1,16 @@
/*
작성자 : 박지윤
작성일 : 2025-02-04
수정자 :
수정일 :
설명 : 로그인 사용자 정보
*/
import { ref } from 'vue';
import { defineStore } from 'pinia';
import $api from "@api";
export const useUserStore = defineStore('userInfo', () => {
export const useUserInfoStore = defineStore('userInfo', () => {
const user = ref(null);
// 사용자 정보 가져오기

26
src/stores/voteCommon.js Normal file
View File

@ -0,0 +1,26 @@
// voteCommon.js
import { ref } from "vue";
export function voteCommon(isVOte= false) {
const itemList = ref(isVOte ? [] : [{ content: "", url: "" }, { content: "", url: "" }]);
const addItem = () => {
if (itemList.value.length < 10) {
itemList.value.push({ content: "", url: "" });
}
};
const removeItem = (index) => {
if (!isVOte && index >= 2) {
itemList.value.splice(index, 1);
} else if (isVOte && itemList.value.length > 0) {
itemList.value.splice(index, 1);
}
};
return {
itemList,
addItem,
removeItem,
};
}

View File

@ -8,9 +8,17 @@
<BoardProfile
:boardId="currentBoardId"
:profileName="profileName"
:unknown="unknown"
:author="isAuthor"
:views="views"
:comments="comments"
:commentNum="commentNum"
:date="formattedBoardDate"
:isLike="false"
:isPassword="isPassword"
:passwordAlert="passwordAlert"
@editClick="editClick"
@deleteClick="deleteClick"
@submitPassword="submitPassword"
class="pb-6 border-bottom"
/>
</div>
@ -64,17 +72,28 @@
</li>
</ul> -->
<!-- 댓글 영역 -->
<BoardComentArea />
<!-- 수정 버튼 -->
<!-- <button class="btn btn-primary" @click="goToEditPage">
수정
</button> -->
<!-- 댓글 입력 영역 -->
<BoardCommentArea
:profileName="profileName"
:unknown="unknown"
@submitComment="handleCommentSubmit"
/>
<!-- <BoardCommentArea :profileName="profileName" :unknown="unknown" /> -->
</div>
<div class="card-footer">
<BoardCommentList/>
<!-- 댓글 목록 -->
<div class="card-footer">
<BoardCommentList
:unknown="unknown"
:comments="comments"
:isEditTextarea="isEditTextarea"
:isPassword="isPassword"
@editClick="editClick"
@deleteClick="deleteClick"
@submitPassword="submitPassword"
@updateReaction="handleUpdateReaction"
@submitComment="handleCommentReply"
/>
<Pagination/>
</div>
</div>
@ -84,17 +103,17 @@
</template>
<script setup>
import BoardComentArea from '@c/board/BoardComentArea.vue';
import BoardCommentArea from '@c/board/BoardCommentArea.vue';
import BoardProfile from '@c/board/BoardProfile.vue';
import BoardCommentList from '@/components/board/BoardCommentList.vue';
import BoardRecommendBtn from '@/components/button/BoardRecommendBtn.vue';
import Pagination from '@/components/pagination/Pagination.vue';
import BoardCommentList from '@c/board/BoardCommentList.vue';
import BoardRecommendBtn from '@c/button/BoardRecommendBtn.vue';
import Pagination from '@c/pagination/Pagination.vue';
import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import axios from '@api';
//
const profileName = ref('익명 사용자');
const profileName = ref('');
const boardTitle = ref('제목 없음');
const boardContent = ref('');
const date = ref('');
@ -103,17 +122,25 @@ const likes = ref(0);
const dislikes = ref(0);
const likeClicked = ref(false);
const dislikeClicked = ref(false);
const comments = ref(0);
const commentNum = ref(0);
const attachment = ref(false);
const comments = ref([]);
// ID
const route = useRoute();
const router = useRouter();
const currentBoardId = ref(Number(route.params.id));
const unknown = computed(() => profileName.value === '익명 사용자');
const currentUserId = ref('김자바'); // id
const authorId = ref(null); // id
const isAuthor = computed(() => currentUserId.value === authorId.value);
const isEditTextarea = ref({});
const passwordAlert = ref("");
const isPassword = ref(false);
const lastClickedButton = ref("");
//
const goToEditPage = () => {
router.push({ name: 'BoardEdit', params: { id: currentBoardId.value } });
};
//
const fetchBoardDetails = async () => {
@ -125,6 +152,13 @@ const fetchBoardDetails = async () => {
// const boardDetail = data.boardDetail || {};
profileName.value = data.author || '익명 사용자';
//
profileName.value = '익명 사용자';
// :
authorId.value = data.author;
boardTitle.value = data.title || '제목 없음';
boardContent.value = data.content || '';
date.value = data.date || '';
@ -132,10 +166,7 @@ const fetchBoardDetails = async () => {
likes.value = data.likeCount || 0;
dislikes.value = data.dislikeCount || 0;
attachment.value = data.hasAttachment || null;
comments.value = data.commentCount || 0;
// const response2 = await axios.post(`board/${currentBoardId.value}/password`);
// console.log(response2)
commentNum.value = data.commentCount || 0;
} catch (error) {
alert('게시물 데이터를 불러오는 중 오류가 발생했습니다.');
@ -144,50 +175,215 @@ const fetchBoardDetails = async () => {
// ,
const handleUpdateReaction = async ({ boardId, commentId, isLike, isDislike }) => {
// console.log(type, boardId)
try {
const requestData = {
LOCBRDSEQ: boardId,
LOCCMTSEQ: commentId,
MEMBERSEQ: 1, // 1
const aa = await axios.post(`/board/${boardId}/${commentId}/reaction`, {
LOCBRDSEQ: boardId, // id
LOCCMTSEQ: commentId, // id
// MEMBERSEQ: 1, // 1
LOCGOBGOD: isLike ? 'T' : 'F',
LOCGOBBAD: isDislike ? 'T' : 'F'
};
});
console.log("좋아요 API 응답 데이터:", aa.data);
console.log(requestData)
const postResponse = await axios.post(`/board/${boardId}/${commentId}/reaction`, requestData);
// await axios.post(`board/${boardId}/${commentId}/reaction`, { type });
const response = await axios.get(`board/${boardId}`);
const updatedData = response.data.data;
console.log('post요청 결과:', postResponse.data);
console.log('get요청 결과(좋아요):', response.data.data.likeCount);
likes.value = updatedData.likeCount;
dislikes.value = updatedData.dislikeCount;
likeClicked.value = isLike;
dislikeClicked.value = isDislike;
console.log('반응 결과:', postResponse.data);
// console.log(updatedData)
} catch (error) {
alert('반응을 업데이트하는 중 오류 발생');
}
};
//
const formattedBoardDate = computed(() => {
const dateObj = new Date(date.value);
return `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')} ${String(dateObj.getHours()).padStart(2, '0')}:${String(dateObj.getMinutes()).padStart(2, '0')}`;
});
//
const fetchComments = async () => {
try {
const response = await axios.get(`board/${currentBoardId.value}/comments`, {
params: { LOCBRDSEQ: currentBoardId.value }
});
console.log("목록 API 응답 데이터:", response.data);
let allComments = response.data.data.list.map(comment => ({
commentId: comment.LOCCMTSEQ, // id
boardId: comment.LOCBRDSEQ,
parentId: comment.LOCCMTPNT, // id
author: comment.author || "익명 사용자", //
content: comment.LOCCMTRPY, //
createdAt: formattedDate(comment.LOCCMTRDT), //
children: []
}));
allComments.sort((a, b) => b.commentId - a.commentId);
let commentMap = {};
let rootComments = [];
allComments.forEach(comment => {
commentMap[comment.commentId] = comment;
});
allComments.forEach(comment => {
if (comment.parentId && commentMap[comment.parentId]) {
commentMap[comment.parentId].children.push(comment);
} else {
rootComments.push(comment);
}
});
comments.value = rootComments;
// console.log(" comments :", comments.value);
} catch (error) {
console.error('댓글 목록 불러오기 오류:', error);
}
};
//
const handleCommentSubmit = async ({ comment, password }) => {
try {
const response = await axios.post(`board/${currentBoardId.value}/comment`, {
LOCBRDSEQ: currentBoardId.value,
LOCCMTRPY: comment,
LOCCMTPWD: password || null,
LOCCMTPNT: 1
});
// console.log('📥 :', response.data);
if (response.status === 200) {
console.log('댓글 작성 성공:', response.data.message);
await fetchComments();
} else {
console.error('댓글 작성 실패:', response.data.message);
}
} catch (error) {
console.error('댓글 작성 중 오류 발생:', error);
}
};
const handleCommentReply = async (reply) => {
const response = await axios.post(`board/${currentBoardId.value}/comment`, {
LOCBRDSEQ: currentBoardId.value,
LOCCMTRPY: reply.comment,
LOCCMTPWD: reply.password || null,
LOCCMTPNT: reply.parentId
});
if (response.status === 200) {
console.log('대댓글 작성 성공:', response.data.message);
await fetchComments();
} else {
console.error('대댓글 작성 실패:', response.data.message);
}
}
const editClick = (unknown) => {
if (unknown) {
togglePassword("edit");
} else {
router.push({ name: "BoardEdit", params: { id: currentBoardId.value } });
}
};
const deleteClick = (unknown) => {
if (unknown) {
togglePassword("delete");
} else {
deletePost();
}
};
const togglePassword = (button) => {
if (lastClickedButton.value === button) {
isPassword.value = !isPassword.value;
} else {
isPassword.value = true;
}
lastClickedButton.value = button;
};
const submitPassword = async (inputPassword) => {
console.log(inputPassword)
if (!inputPassword) {
passwordAlert.value = "비밀번호를 입력해주세요.";
return;
}
try {
const requestData = {
LOCBRDPWD: inputPassword,
LOCBRDSEQ: 288
};
const postResponse = await axios.post(`board/${currentBoardId.value}/password`, requestData);
if (postResponse.data.code === 200 && postResponse.data.data === true) {
isPassword.value = false;
passwordAlert.value = "";
if (lastClickedButton.value === "edit") {
router.push({ name: "BoardEdit", params: { id: currentBoardId.value } });
} else if (lastClickedButton.value === "delete") {
await deletePost();
}
lastClickedButton.value = null;
} else {
passwordAlert.value = "비밀번호가 일치하지 않습니다.";
}
} catch (error) {
if (error.response && error.response.status === 401) {
passwordAlert.value = "비밀번호가 일치하지 않습니다.";
} else if (error.response) {
alert(`오류 발생: ${error.response.data.message || "서버 오류"}`);
} else {
alert("네트워크 오류가 발생했습니다. 다시 시도해주세요.");
}
}
};
const deletePost = async () => {
if (confirm("정말 삭제하시겠습니까?")) {
try {
const response = await axios.delete(`board/${currentBoardId.value}`, {
data: { LOCBRDSEQ: currentBoardId.value }
});
if (response.data.code === 200) {
alert("게시물이 삭제되었습니다.");
router.push({ name: "BoardList" });
} else {
alert("삭제 실패: " + response.data.message);
}
} catch (error) {
if (error.response) {
alert(`삭제 실패: ${error.response.data.message || "서버 오류"}`);
} else {
alert("네트워크 오류가 발생했습니다. 다시 시도해주세요.");
}
}
}
};
//
const formattedDate = (dateString) => {
if (!dateString) return "날짜 없음";
const dateObj = new Date(dateString);
return `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')} ${String(dateObj.getHours()).padStart(2, '0')}:${String(dateObj.getMinutes()).padStart(2, '0')}`;
};
const formattedBoardDate = computed(() => formattedDate(date.value));
//
onMounted(() => {
fetchBoardDetails();
fetchBoardDetails()
fetchComments()
});
</script>

View File

@ -56,13 +56,13 @@ import axios from "@api";
import "bootstrap-icons/font/bootstrap-icons.css";
import HalfDayButtons from "@c/button/HalfDayButtons.vue";
import ProfileList from "@c/vacation/ProfileList.vue";
import { useUserStore as useUserListStore } from "@s/userList";
import { useUserStore } from "@s/userList";
import VacationModal from "@c/modal/VacationModal.vue"
import { useUserStore } from "@s/useUserStore";
import { useUserInfoStore } from "@s/useUserInfoStore";
import VacationGrantModal from "@c/modal/VacationGrantModal.vue";
const userStore = useUserStore();
const userListStore = useUserListStore();
const userStore = useUserInfoStore();
const userListStore = useUserStore();
const userList = ref([]);
const userColors = ref({});
const myVacations = ref([]); //

View File

@ -4,7 +4,6 @@
<div class="mt-8">
<!-- 투표 작성 -->
<router-link to="/voteboard/write"><WriteBtn /></router-link>
<!-- 내가한 투표 보기 -->
<div class="d-flex align-items-center">
<div class="form-check me-3">
@ -14,50 +13,87 @@
<!-- 투표마감/투표중 셀렉트 -->
<FormSelect class="col-3" name="cate" :isLabel="false" title="투표상태" :data="categoryList" @update:data="category = $event" />
</div>
<!-- <QEditor @update:data="content = $event" @update:imageUrls="imageUrls = $event" :is-alert="true" />
<button type="button" class="btn btn-primary ms-1" @click="registerContent"><i class="bx bx-check"></i></button> -->
<!-- 투표리스트 -->
<vote-list />
<vote-list
:data="voteListCardData"
@addContents="addContents"
@checkedNames="checkedNames"/>
</div>
</div>
<!-- 페이지네이션 -->
<div class="row g-3">
<div class="mt-8">
<Pagination
v-if="PageData.pages"
v-bind="PageData"
@update:currentPage="handlePageChange"
/>
</div>
</div>
<!-- <div class="mt-8">
<pagination />
</div> -->
</div>
</template>
<script setup>
import { getCurrentInstance, onMounted, ref } from 'vue';
// import Pagination from '@/components/pagination/Pagination.vue';
import Pagination from '@c/pagination/Pagination.vue';
import router from '@/router';
import FormSelect from '@c/input/FormSelect.vue';
import { useToastStore } from '@s/toastStore';
import QEditor from '@c/editor/QEditor.vue';
import $api from '@api';
import BoardCard from '@c/list/BoardCard.vue';
import Quill from 'quill';
import WriteBtn from '@c/button/WriteBtn.vue';
import voteList from '@c/voteboard/voteCardList.vue';
const toastStore = useToastStore();
const category = ref('0');
const categoryList = ['전체','투표마감', '투표중'];
const boardList = ref([]);
const PageData = ref([]);
const voteListCardData = ref([]);
const titleAlert = ref(false);
const addContent = ref('');
onMounted(()=>{
getBoardList();
})
const getBoardList = () =>{
$api.get('worddict/getWordList').then((res)=>{
boardList.value = res.data.data.data;
})
const currentPage = ref(1);
onMounted(async () => {
getvoteList();
});
//
const getvoteList = async () => {
console.log('pagee',currentPage.value)
const response = await $api.get('vote/getVoteList',{
params: { page: currentPage.value }
});
if (response.data.status === "OK") {
PageData.value = response.data.data;
voteListCardData.value = response.data.data.list;
}
};
//
const addContents = (itemList, voteId) =>{
$api.post('vote/insertWord',{
itemList :itemList
,voteId :voteId
}).then((res)=>{
if(res.data.status === 'OK'){
toastStore.onToast('항목이 등록되었습니다.', 's');
getvoteList();
}
})
}
//
const checkedNames = (numList) =>{
$api.post('vote/insertCheckedNums',{
checkedList :numList
,votenum : numList[0].LOCVOTSEQ
}).then((res)=>{
if(res.data.status === 'OK'){
toastStore.onToast('투표가 완료되었습니다.', 's');
getvoteList();
}
})
}
//
const handlePageChange = async (page) => {
currentPage.value=page;
await getvoteList();
};
</script>
<style></style>

View File

@ -1,5 +1,5 @@
<template>
<div class="container-xxl flex-grow-1 container-p-y">
<div class="container-xxl flex-grow-1 container-p-y">
<div class="card mb-6">
<div class="card-body">
<div class="user-list-container">
@ -10,30 +10,185 @@
<div class="timeline-header mb-2">
<h6 class="mb-0">투표 인원</h6>
</div>
<UserList @user-list-update="handleUserListUpdate" />
<UserList @userListInfo="userSet" @user-list-update="handleUserListUpdate" class="mb-3" />
<div v-if="UserListAlert" class="red">2명이상 선택해주세요 </div>
<form-input
title="제목"
name="title"
:is-essential="true"
:is-alert="titleAlert"
v-model="title"
/>
<form-input
title="종료날짜"
name="endDate"
type="date"
:is-essential="true"
:is-alert="endDateAlert"
v-model="endDate"
/>
<!-- 항목 입력 반복 -->
<div v-for="(item, index) in itemList" :key="index" class="d-flex align-items-center mb-2 position-relative">
<form-input
class="flex-grow-1 me-2"
:title="'항목 ' + (index + 1)"
:name="'content' + index"
:is-essential="index < 2"
:is-alert="contentAlerts[index]"
v-model="item.content"
/>
<link-input v-model="item.url" />
<delete-btn @click="removeItem(index)" :disabled="index < 2" class="ms-2" />
</div>
<plus-btn @click="addItem" :disabled="itemList.length >= 10" class="mb-3" />
<div>
<label class="list-group-item">
<input
class="form-check-input me-1"
type="checkbox"
id="addvoteitem"
v-model="addvoteitem"
/>
항목 추가여부
</label>
<label class="list-group-item">
<input
class="form-check-input me-1"
type="checkbox"
id="addvotemulti"
v-model="addvotemulti"
/>
다중투표 허용여부
</label>
</div>
</div>
</li>
</ul>
<div class="mb-4 d-flex justify-content-end">
<button type="button" class="btn btn-info" @click="goList">
<i class="bx bx-left-arrow-alt"></i>
</button>
<button type="button" class="btn btn-primary ms-1" @click="saveValid">
<i class="bx bx-check"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { ref, toRaw } from "vue";
import UserList from "@c/user/UserList.vue";
import formInput from "@c/input/FormInput.vue";
import { useToastStore } from "@s/toastStore";
import PlusBtn from "@c/button/PlusBtn.vue";
import DeleteBtn from "@c/button/DeleteBtn.vue";
import $api from "@api";
import router from "@/router";
import LinkInput from "@/components/voteboard/voteLinkInput.vue";
import { voteCommon } from '@s/voteCommon';
const activeUsers = ref([]); //
const disabledUsers = ref([]); //
const toastStore = useToastStore();
const activeUserList = ref([]);
const disabledUsers = ref([]);
const titleAlert = ref(false);
const endDateAlert = ref(false);
const contentAlerts = ref([false, false]);
const UserListAlert = ref(false);
const title = ref("");
const endDate = ref("");
const { itemList, addItem, removeItem } = voteCommon();
// UserList
const handleUserListUpdate = ({ activeUsers, disabledUsers }) => {
activeUsers.value = activeUsers;
disabledUsers.value = disabledUsers;
console.log('활성화목록>>',activeUsers)
console.log('비활성목록>>',disabledUsers)
const userListTotal = ref(0);
const addvoteitem = ref(false);
const addvotemulti= ref(false);
const userSet = ({ userList, userTotal }) => {
activeUserList.value = userList;
userListTotal.value = userTotal;
};
const handleUserListUpdate = ({ activeUsers, disabledUsers: updatedDisabledUsers }) => {
activeUserList.value = activeUsers;
disabledUsers.value = updatedDisabledUsers;
userListTotal.value = activeUsers.length;
};
const saveValid = () => {
let valid = true;
if (title.value === '') {
titleAlert.value = true;
valid = false;
} else {
titleAlert.value = false;
}
if (endDate.value === '') {
endDateAlert.value = true;
valid = false;
} else {
endDateAlert.value = false;
}
if (itemList.value[0].content === '') {
contentAlerts.value[0] = true;
valid = false;
} else {
contentAlerts.value[0] = false;
}
if (itemList.value[1].content === '') {
contentAlerts.value[1] = true;
valid = false;
} else {
contentAlerts.value[1] = false;
}
if (activeUserList.value.length < 2) {
UserListAlert.value = true;
valid = false;
} else {
UserListAlert.value = false;
}
if (valid) {
saveVote();
}
};
const saveVote = () => {
console.log('activeUserList',activeUserList.value)
const unwrappedUserList = toRaw(activeUserList.value);
const listId = unwrappedUserList.map(item => ({
id: item.MEMBERSEQ,
}));
$api.post('vote/insertWord',{
addvoteIs :addvoteitem ? '1' :'0'
,votemMltiIs :addvotemulti ? '1' :'0'
,title :title.value
,endDate :endDate.value
,itemList :itemList.value
,activeUserList :listId
}).then((res)=>{
if(res.data.status == 'OK'){
toastStore.onToast('투표가 등록되었습니다.', 's');
goList();
}
})
};
const goList = () => {
router.push('/voteboard');
};
</script>
<style scoped>
.item-input {
max-width: 200px;
}
</style>

View File

@ -48,6 +48,7 @@
v-for="item in wordList"
:key="item.WRDDICSEQ"
:item="item"
:cateList="cateList"
/>
</ul>
@ -91,6 +92,7 @@
const { cateList } = commonApi({
loadCateList: true
});
const selectedCategory = ref('');
const selectCategory = ref('');
@ -167,14 +169,13 @@
if(res.data.data == '1'){
toastStore.onToast('카테고리가 추가 등록 되었습니다.', 's');
const newCategory = { label: data, value: newValue };
cateList.value.unshift(newCategory);
selectCategory.value = newCategory.CMNCODVAL;
cateList.value = [newCategory, ...cateList.value];
selectedCategory.value = newCategory.value;
}
})
}
//
const addWord = (wordData) => {
axios.post('worddict/insertWord',{
WRDDICCAT : wordData.category,
WRDDICTTL : wordData.title,