활동로그 추가
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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']],
|
||||
|
||||
Generated
+518
-520
File diff suppressed because it is too large
Load Diff
@@ -258,6 +258,13 @@ const adminMenuItems = ref([
|
||||
command: () => {
|
||||
router.push('/admin/subscriptions');
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '활동 로그',
|
||||
icon: 'pi pi-history',
|
||||
command: () => {
|
||||
router.push('/admin/activity-logs');
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
|
||||
// 클럽 경로
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user