활동로그 추가

This commit is contained in:
2025-05-09 10:34:04 +09:00
parent 31740e2e71
commit 2924a61a9d
11 changed files with 793 additions and 565 deletions
+9 -1
View File
@@ -1,7 +1,15 @@
const { Model, DataTypes } = require('sequelize');
const { sequelize } = require('../config/database');
class ActivityLog extends Model {}
class ActivityLog extends Model {
static associate(models) {
// User 모델과의 연관 관계 설정
ActivityLog.belongsTo(models.User, {
foreignKey: 'userId',
as: 'User'
});
}
}
ActivityLog.init({
id: {
+1
View File
@@ -747,6 +747,7 @@ router.get('/activity-logs', async (req, res) => {
const logs = await ActivityLog.findAndCountAll({
include: [{
model: User,
as: 'User',
attributes: ['username', 'name']
}],
order: [['createdAt', 'DESC']],
+518 -520
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -258,6 +258,13 @@ const adminMenuItems = ref([
command: () => {
router.push('/admin/subscriptions');
}
},
{
label: '활동 로그',
icon: 'pi pi-history',
command: () => {
router.push('/admin/activity-logs');
}
}
]);
+3
View File
@@ -13,6 +13,9 @@ dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.tz.setDefault('Asia/Seoul')
// dayjs를 전역 객체로 등록
window.dayjs = dayjs
// PrimeVue
import PrimeVue from 'primevue/config'
import Aura from '@primeuix/themes/aura';
+7
View File
@@ -9,6 +9,7 @@ const AdminDashboard = () => import('@/views/admin/Dashboard.vue');
const ClubManagement = () => import('@/views/admin/ClubManagement.vue');
const UserManagement = () => import('@/views/admin/UserManagement.vue');
const SubscriptionManagement = () => import('@/views/admin/SubscriptionManagement.vue');
const ActivityLogManagement = () => import('@/views/admin/ActivityLogManagement.vue');
// 클럽 뷰
const ClubDashboard = () => import('@/views/club/Dashboard.vue');
@@ -76,6 +77,12 @@ const routes = [
component: SubscriptionManagement,
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: '/admin/activity-logs',
name: 'ActivityLogManagement',
component: ActivityLogManagement,
meta: { requiresAuth: true, requiresAdmin: true }
},
// 클럽 경로
{
+10
View File
@@ -332,6 +332,16 @@ const adminService = {
return apiClient.get('/api/admin/activities/recent');
},
/**
* 활동 로그 목록 조회
* @param {Number} page - 페이지 번호
* @param {Number} limit - 페이지당 활동 로그 수
* @returns {Promise} - 활동 로그 목록
*/
getActivityLogs(page = 1, limit = 20) {
return apiClient.get(`/api/admin/activity-logs?page=${page}&limit=${limit}`);
},
/**
* 통계 데이터 조회
* @returns {Promise} - 통계 데이터
@@ -0,0 +1,173 @@
<template>
<div class="activity-log-container">
<div class="header">
<h2>활동 로그</h2>
</div>
<DataTable
:value="logs"
:loading="loading"
:paginator="true"
:rows="rowsPerPage"
:rowsPerPageOptions="[10, 20, 50, 100]"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
currentPageReportTemplate="{first}에서 {last}까지 {totalRecords} 중"
responsiveLayout="scroll"
stripedRows
:sortField="'createdAt'"
:sortOrder="-1"
:totalRecords="totalRecords"
:lazy="true"
@page="onPageChange"
>
<Column field="id" header="ID" sortable style="width: 5%"></Column>
<Column field="user.name" header="사용자" sortable style="width: 15%">
<template #body="slotProps">
{{ slotProps.data.user ? slotProps.data.user.name : '-' }}
</template>
</Column>
<Column field="type" header="활동 유형" sortable style="width: 15%">
<template #body="slotProps">
<Badge :value="getActionTypeName(slotProps.data.type)" :severity="getActionTypeSeverity(slotProps.data.type)" />
</template>
</Column>
<Column field="description" header="설명" style="width: 50%"></Column>
<Column field="createdAt" header="시간" sortable style="width: 15%">
<template #body="slotProps">
{{ formatDate(slotProps.data.createdAt) }}
</template>
</Column>
</DataTable>
<Toast />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import adminService from '@/services/adminService';
const toast = useToast();
// 상태 변수
const logs = ref([]);
const loading = ref(false);
const totalRecords = ref(0);
const currentPage = ref(1);
const rowsPerPage = ref(20);
// 페이지 로드 시 활동 로그 목록 가져오기
onMounted(async () => {
await fetchLogs();
});
// 활동 로그 목록 가져오기
const fetchLogs = async () => {
loading.value = true;
try {
const response = await adminService.getActivityLogs(currentPage.value, rowsPerPage.value);
logs.value = response.data.logs || [];
totalRecords.value = response.data.totalItems || 0;
} catch (error) {
console.error('활동 로그 목록을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '활동 로그 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
};
// 페이지 변경 이벤트 핸들러
const onPageChange = (event) => {
// PrimeVue DataTable은 0부터 시작하는 페이지 인덱스를 사용하므로 1을 더해줍니다
currentPage.value = event.page + 1;
rowsPerPage.value = event.rows;
fetchLogs();
};
// 활동 유형 이름 가져오기
const getActionTypeName = (actionType) => {
switch (actionType) {
case 'LOGIN':
return '로그인';
case 'LOGOUT':
return '로그아웃';
case 'CREATE_USER':
return '사용자 생성';
case 'UPDATE_USER':
return '사용자 수정';
case 'DELETE_USER':
return '사용자 삭제';
case 'CREATE_CLUB':
return '클럽 생성';
case 'UPDATE_CLUB':
return '클럽 수정';
case 'DELETE_CLUB':
return '클럽 삭제';
case 'ADD_CLUB_MEMBER':
return '클럽 회원 추가';
case 'UPDATE_CLUB_MEMBER':
return '클럽 회원 수정';
case 'DELETE_CLUB_MEMBER':
return '클럽 회원 삭제';
case 'CREATE_EVENT':
return '이벤트 생성';
case 'UPDATE_EVENT':
return '이벤트 수정';
case 'DELETE_EVENT':
return '이벤트 삭제';
case 'ADD_EVENT_PARTICIPANT':
return '이벤트 참가자 추가';
case 'UPDATE_EVENT_PARTICIPANT':
return '이벤트 참가자 수정';
case 'DELETE_EVENT_PARTICIPANT':
return '이벤트 참가자 삭제';
case 'VIEW_ACTIVITY_LOGS':
return '활동 로그 조회';
default:
return actionType;
}
};
// 활동 유형 심각도 가져오기
const getActionTypeSeverity = (actionType) => {
if (actionType.includes('DELETE')) {
return 'danger';
} else if (actionType.includes('CREATE')) {
return 'success';
} else if (actionType.includes('UPDATE')) {
return 'warning';
} else if (actionType === 'LOGIN') {
return 'info';
} else if (actionType === 'LOGOUT') {
return 'secondary';
} else if (actionType.includes('VIEW')) {
return 'info';
} else {
return 'info';
}
};
// 날짜 포맷팅
const formatDate = (date) => {
return date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-';
};
</script>
<style scoped>
.activity-log-container {
padding: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
/* 테이블 스타일 */
:deep(.p-datatable-wrapper) {
overflow-x: auto;
}
</style>
+23 -23
View File
@@ -47,23 +47,23 @@
<Dialog v-model:visible="clubDialog" :style="{width: '450px'}"
:header="dialogMode === 'new' ? '새 클럽 추가' : '클럽 수정'"
:modal="true" class="p-fluid">
<div class="field">
<div class="field mb-3">
<label for="name">클럽명</label>
<InputText id="name" v-model="club.name" required autofocus :class="{'p-invalid': submitted && !club.name}" />
<InputText id="name" v-model="club.name" required autofocus :class="{'p-invalid': submitted && !club.name}" class="w-full"/>
<small class="p-error" v-if="submitted && !club.name">클럽명은 필수입니다.</small>
</div>
<div class="field">
<div class="field mb-3">
<label for="description">클럽 설명</label>
<Textarea id="description" v-model="club.description" rows="3" />
<Textarea id="description" v-model="club.description" rows="3" class="w-full" />
</div>
<div class="field">
<div class="field mb-3">
<label for="ownerEmail">모임장 이메일</label>
<InputText id="ownerEmail" v-model="club.ownerEmail" required :class="{'p-invalid': submitted && !club.ownerEmail}" />
<InputText id="ownerEmail" v-model="club.ownerEmail" required :class="{'p-invalid': submitted && !club.ownerEmail}" class="w-full" />
<small class="p-error" v-if="submitted && !club.ownerEmail">모임장 이메일은 필수입니다.</small>
</div>
<div class="field">
<div class="field mb-3">
<label for="location">볼링장</label>
<InputText id="location" v-model="club.location" />
<InputText id="location" v-model="club.location" class="w-full" />
</div>
<template #footer>
@@ -128,7 +128,7 @@
<Column field="gender" header="성별" sortable style="min-width: 6rem"></Column>
<Column field="memberType" header="회원 유형" sortable style="min-width: 8rem">
<template #body="slotProps">
<Dropdown v-model="slotProps.data.memberType"
<Select v-model="slotProps.data.memberType"
:options="memberTypes"
@change="updateMemberType(slotProps.data)"
:disabled="slotProps.data.memberType === '모임장' && !canChangeOwner" />
@@ -165,36 +165,36 @@
<Dialog v-model:visible="memberFormDialog" :style="{width: '450px'}"
:header="memberDialogMode === 'new' ? '새 회원 추가' : '회원 정보 수정'"
:modal="true" class="p-fluid">
<div class="field">
<div class="field mb-3">
<label for="name">이름</label>
<InputText id="name" v-model="member.name" required autofocus :class="{'p-invalid': memberSubmitted && !member.name}" />
<InputText id="name" v-model="member.name" required autofocus :class="{'p-invalid': memberSubmitted && !member.name}" class="w-full"/>
<small class="p-error" v-if="memberSubmitted && !member.name">이름은 필수입니다.</small>
</div>
<div class="field">
<div class="field mb-3">
<label for="gender">성별</label>
<Dropdown id="gender" v-model="member.gender"
<Select id="gender" v-model="member.gender"
:options="['남성', '여성']" placeholder="성별 선택"
:class="{'p-invalid': memberSubmitted && !member.gender}" />
:class="{'p-invalid': memberSubmitted && !member.gender}" class="w-full" />
<small class="p-error" v-if="memberSubmitted && !member.gender">성별은 필수입니다.</small>
</div>
<div class="field">
<div class="field mb-3">
<label for="memberType">회원 유형</label>
<Dropdown id="memberType" v-model="member.memberType"
<Select id="memberType" v-model="member.memberType"
:options="memberTypes" placeholder="회원 유형 선택"
:class="{'p-invalid': memberSubmitted && !member.memberType}" />
:class="{'p-invalid': memberSubmitted && !member.memberType}" class="w-full" />
<small class="p-error" v-if="memberSubmitted && !member.memberType">회원 유형은 필수입니다.</small>
</div>
<div class="field">
<div class="field mb-3">
<label for="handicap">핸디캡</label>
<InputNumber id="handicap" v-model="member.handicap" :min="0" :max="300" />
<InputNumber id="handicap" v-model="member.handicap" :min="0" :max="300" class="w-full" />
</div>
<div class="field">
<div class="field mb-3">
<label for="phone">연락처</label>
<InputText id="phone" v-model="member.phone" />
<InputText id="phone" v-model="member.phone" class="w-full" />
</div>
<div class="field">
<div class="field mb-3">
<label for="email">이메일</label>
<InputText id="email" v-model="member.email" type="email" />
<InputText id="email" v-model="member.email" type="email" class="w-full" />
</div>
<template #footer>
+15
View File
@@ -53,6 +53,21 @@
</div>
</div>
<div class="col-12 md:col-6 lg:col-3">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-history"></i>
<h3>활동 로그</h3>
</div>
<div class="card-content">
<p>시스템 활동 기록</p>
</div>
<div class="card-footer">
<Button label="활동 로그" icon="pi pi-arrow-right" @click="navigateTo('/admin/activity-logs')" />
</div>
</div>
</div>
<!-- 최근 데이터 -->
<div class="col-12 md:col-6">
<div class="dashboard-card">
+27 -21
View File
@@ -17,8 +17,8 @@
stripedRows
filterDisplay="menu"
v-model:filters="filters"
scrollable
scrollHeight="calc(100vh - 200px)"
:sortField="'id'"
:sortOrder="1"
>
<Column field="id" header="ID" sortable style="width: 5%"></Column>
<Column field="name" header="이름" sortable style="width: 15%">
@@ -36,7 +36,7 @@
<Badge :value="getRoleName(slotProps.data.role)" :severity="getRoleSeverity(slotProps.data.role)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="roleOptions" optionLabel="name" optionValue="value" placeholder="역할 선택" class="p-column-filter" />
<Select v-model="filterModel.value" @change="filterCallback()" :options="roleOptions" optionLabel="name" optionValue="value" placeholder="역할 선택" class="p-column-filter" />
</template>
</Column>
<Column field="status" header="상태" sortable style="width: 10%">
@@ -44,7 +44,7 @@
<Badge :value="getStatusName(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" class="p-column-filter" />
<Select v-model="filterModel.value" @change="filterCallback()" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" class="p-column-filter" />
</template>
</Column>
<Column field="createdAt" header="가입일" sortable style="width: 12%">
@@ -69,34 +69,34 @@
<!-- 사용자 추가/수정 다이얼로그 -->
<Dialog v-model:visible="userDialog" :header="dialogTitle" :style="{ width: '450px' }" :modal="true" class="p-fluid">
<div class="field">
<div class="field mb-3">
<label for="name">이름</label>
<InputText id="name" v-model="user.name" :class="{'p-invalid': submitted && !user.name}" required autofocus />
<InputText id="name" v-model="user.name" :class="{'p-invalid': submitted && !user.name}" required autofocus class="w-full"/>
<small class="p-error" v-if="submitted && !user.name">이름은 필수입니다.</small>
</div>
<div class="field">
<div class="field mb-3">
<label for="email">이메일</label>
<InputText id="email" v-model="user.email" :class="{'p-invalid': submitted && !user.email}" required />
<InputText id="email" v-model="user.email" :class="{'p-invalid': submitted && !user.email}" required class="w-full"/>
<small class="p-error" v-if="submitted && !user.email">이메일은 필수입니다.</small>
</div>
<div class="field">
<div class="field mb-3">
<label for="password">비밀번호</label>
<Password id="password" v-model="user.password" :class="{'p-invalid': submitted && isNewUser && !user.password}" toggleMask :feedback="isNewUser" />
<Password id="password" v-model="user.password" :class="{'p-invalid': submitted && isNewUser && !user.password}" toggleMask :feedback="isNewUser" class="w-full" />
<small class="p-error" v-if="submitted && isNewUser && !user.password">비밀번호는 필수입니다.</small>
<small v-if="!isNewUser">비밀번호를 변경하지 않으려면 비워두세요.</small>
</div>
<div class="field">
<div class="field mb-3">
<label for="role">역할</label>
<Dropdown id="role" v-model="user.role" :options="roleOptions" optionLabel="name" optionValue="value" placeholder="역할 선택" required />
<Select id="role" v-model="user.role" :options="roleOptions" optionLabel="name" optionValue="value" placeholder="역할 선택" required class="w-full" />
<small class="p-error" v-if="submitted && !user.role">역할은 필수입니다.</small>
</div>
<div class="field">
<div class="field mb-3">
<label for="status">상태</label>
<Dropdown id="status" v-model="user.status" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" required />
<Select id="status" v-model="user.status" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" required class="w-full" />
<small class="p-error" v-if="submitted && !user.status">상태는 필수입니다.</small>
</div>
@@ -125,9 +125,6 @@
<script setup>
import { ref, onMounted, reactive, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import { FilterMatchMode } from 'primevue/api';
import Badge from 'primevue/badge';
import dayjs from 'dayjs';
import adminService from '@/services/adminService';
const toast = useToast();
@@ -143,10 +140,10 @@ const isNewUser = ref(false);
//
const filters = ref({
name: { value: null, matchMode: FilterMatchMode.CONTAINS },
email: { value: null, matchMode: FilterMatchMode.CONTAINS },
role: { value: null, matchMode: FilterMatchMode.EQUALS },
status: { value: null, matchMode: FilterMatchMode.EQUALS }
name: { value: null, matchMode: 'contains' },
email: { value: null, matchMode: 'contains' },
role: { value: null, matchMode: 'equals' },
status: { value: null, matchMode: 'equals' }
});
//
@@ -357,4 +354,13 @@ const formatDate = (date) => {
.mr-3 {
margin-right: 0.75rem;
}
/* Password 컴포넌트 스타일 수정 */
:deep(.p-password) {
width: 100%;
}
:deep(.p-password-input) {
width: 100%;
}
</style>