| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653 |
- <template>
- <div class="admin--field-list">
- <!-- 상단 검색/액션 영역 -->
- <div class="admin--search-box type2">
- <div class="admin--search--inner--box">
- <div class="admin--search-form">
- <select v-model="searchField" class="admin--form-select admin--search-select">
- <option value="">전체</option>
- <option value="username">아이디</option>
- <option value="name">이름</option>
- <option value="email">이메일</option>
- </select>
- <input
- v-model="searchQuery"
- type="text"
- placeholder="검색어 입력"
- @keyup.enter="onSearch"
- class="admin--form-input admin--search-input"
- />
- <select v-model="filterRole" @change="onSearch" class="admin--form-select admin--search-select">
- <option value="">전체 권한</option>
- <option value="super_admin">슈퍼 관리자</option>
- <option value="admin">관리자</option>
- </select>
- <select v-model="filterStatus" @change="onSearch" class="admin--form-select admin--search-select">
- <option value="">전체 상태</option>
- <option value="active">활성</option>
- <option value="inactive">휴면</option>
- <option value="suspended">정지</option>
- </select>
- <button @click="onSearch" class="admin--btn-small admin--btn-small-primary">검색</button>
- <button @click="resetSearch" class="admin--btn-small admin--btn-small-secondary">초기화</button>
- </div>
- </div>
- <div class="admin--search--inner--box">
- <button
- v-if="isSuperAdmin"
- :class="[viewMode=== 'active' ? 'admin--btn-small admin--btn-small-primary' : 'admin--btn-small admin--btn-small-secondary']"
- @click="toggleViewMode"
- >
- {{ viewMode === 'active' ? '삭제 관리자 관리' : '← 목록으로' }}
- </button>
- <div class="admin--search-actions">
- <button
- v-if="viewMode === 'active'"
- class="admin--btn-small admin--btn-small-excel"
- :disabled="isExporting"
- @click="handleExportCsv"
- >
- {{ isExporting ? "내보내는 중..." : "CSV 내보내기" }}<span></span>
- </button>
- <!-- 활성 모드 액션 -->
- <template v-if="viewMode === 'active'">
- <button
- class="admin--btn-small admin--btn-small-secondary"
- :disabled="selectedIds.length === 0 || isProcessing"
- @click="bulkDelete"
- >선택 삭제</button>
- <button
- class="admin--btn-small admin--btn-small-secondary"
- :disabled="selectedIds.length === 0 || isProcessing"
- @click="bulkSetStatus('active')"
- >선택 활성</button>
- <button
- class="admin--btn-small admin--btn-small-secondary"
- :disabled="selectedIds.length === 0 || isProcessing"
- @click="bulkSetStatus('inactive')"
- >선택 휴면</button>
- <button
- class="admin--btn-small admin--btn-small-secondary"
- :disabled="selectedIds.length === 0 || isProcessing"
- @click="bulkSetStatus('suspended')"
- >선택 정지</button>
- <button class="admin--btn-add" @click="goToCreate">+ 관리자 추가</button>
- </template>
- <!-- 삭제 모드 액션 -->
- <template v-else>
- <button
- class="admin--btn-small admin--btn-small-secondary"
- :disabled="selectedIds.length === 0 || isProcessing"
- @click="bulkRestore"
- >선택 복구</button>
- <button
- class="admin--btn-add"
- :disabled="selectedIds.length === 0 || isProcessing"
- @click="bulkHardDelete"
- >선택 영구 삭제</button>
- </template>
- </div>
- </div>
- </div>
- <!-- 테이블 -->
- <div class="admin--table-wrapper">
- <table class="admin--table">
- <thead>
- <tr>
- <th style="width: 48px;">
- <div class="input--wrap">
- <input
- type="checkbox"
- :checked="isAllSelected"
- :indeterminate.prop="isPartialSelected"
- :disabled="selectableAdmins.length === 0"
- @change="toggleAll($event.target.checked)"
- aria-label="전체 선택"
- />
- </div>
- </th>
- <th style="width: 140px;">아이디</th>
- <th style="width: 140px;">이름</th>
- <th>핸드폰</th>
- <th>이메일</th>
- <th style="width: 200px;">권한</th>
- <th style="width: 140px;">최근 로그인</th>
- <th style="width: 100px;">상태</th>
- <th style="width: 120px;">관리</th>
- </tr>
- </thead>
- <tbody>
- <tr v-if="isLoading">
- <td colspan="9" class="admin--table-loading">데이터를 불러오는 중...</td>
- </tr>
- <tr v-else-if="!admins || admins.length === 0">
- <td colspan="9" class="admin--table-empty">
- {{ viewMode === 'deleted' ? '삭제된 관리자가 없습니다.' : '등록된 관리자가 없습니다.' }}
- </td>
- </tr>
- <tr
- v-else
- v-for="item in admins"
- :key="item.id"
- :class="viewMode === 'active' ? 'admin--table-row-clickable' : ''"
- @click="viewMode === 'active' && goToDetail(item.id)"
- >
- <td @click.stop>
- <div class="input--wrap">
- <input
- type="checkbox"
- :value="item.id"
- v-model="selectedIds"
- :disabled="!canSelect(item)"
- :title="!canSelect(item) ? '슈퍼 관리자 또는 본인 계정은 선택할 수 없습니다.' : ''"
- />
- </div>
- </td>
- <td class="admin--table-title">{{ item.username }}</td>
- <td>{{ item.name || "-" }}</td>
- <td>{{ item.phone || "-" }}</td>
- <td>{{ item.email || "-" }}</td>
- <td class="left">
- <span v-if="item.role === 'super_admin'" class="admin--badge admin--badge-super">
- 슈퍼 관리자
- </span>
- <div v-else class="admin--perm-list">
- <span v-for="p in visiblePerms(item)" :key="p" class="admin--badge admin--badge-perm">
- {{ permLabel(p) }}
- </span>
- <span v-if="extraPermCount(item) > 0" class="admin--badge admin--badge-more">
- +{{ extraPermCount(item) }}
- </span>
- <span v-if="!visiblePerms(item).length" class="admin--badge admin--badge-ended">
- 권한 없음
- </span>
- </div>
- </td>
- <td class="date">{{ formatDateTime(item.last_login) }}</td>
- <td cl>
- <span :class="['admin--badge', getStatusBadgeClass(item.status)]">
- {{ getStatusLabel(item.status) }}
- </span>
- </td>
- <td>
- <div class="admin--table-actions">
- <button
- v-if="viewMode === 'active' && canModify(item)"
- class="admin--btn-small admin--btn-blue"
- @click.stop="goToEdit(item.id)"
- >수정</button>
- </div>
- </td>
- </tr>
- </tbody>
- </table>
- </div>
- <!-- 페이지네이션 -->
- <div v-if="totalPages > 1" class="admin--pagination">
- <button
- v-if="totalPages > 2"
- class="admin--pagination-btn"
- :disabled="currentPage === 1"
- @click="changePage(1)"
- title="처음"
- >◀◀</button>
- <button
- class="admin--pagination-btn"
- :disabled="currentPage === 1"
- @click="changePage(currentPage - 1)"
- title="이전"
- >◀</button>
- <button
- v-for="page in visiblePages"
- :key="page"
- class="admin--pagination-btn"
- :class="{ 'is-active': page === currentPage }"
- @click="changePage(page)"
- >{{ page }}</button>
- <button
- class="admin--pagination-btn"
- :disabled="currentPage === totalPages"
- @click="changePage(currentPage + 1)"
- title="다음"
- >▶</button>
- <button
- v-if="totalPages > 2"
- class="admin--pagination-btn"
- :disabled="currentPage === totalPages"
- @click="changePage(totalPages)"
- title="끝"
- >▶▶</button>
- </div>
- <!-- 알림 모달 -->
- <AdminAlertModal
- v-if="alertModal.show"
- :title="alertModal.title"
- :message="alertModal.message"
- :type="alertModal.type"
- @confirm="handleAlertConfirm"
- @cancel="handleAlertCancel"
- @close="closeAlertModal"
- />
- </div>
- </template>
- <script setup>
- import { ref, computed, onMounted } from "vue";
- import { useRouter } from "vue-router";
- import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
- definePageMeta({
- layout: "admin",
- middleware: ["auth"],
- });
- const router = useRouter();
- const { get, post, put, del } = useApi();
- const { user, isSuperAdmin } = useAuth();
- // 일반 admin은 슈퍼관리자 행 수정 불가
- const canModify = (item) => isSuperAdmin.value || item.role !== "super_admin";
- // 선택 가능 여부 — 모드별 분기
- const canSelect = (item) => {
- const isMe = Number(item.id) === Number(user.value?.id);
- if (isMe) return false;
- // 삭제된 관리자 관리 모드: 슈퍼관리자가 영구삭제/복구 모두 가능
- if (viewMode.value === "deleted") return true;
- // 활성 모드: 슈퍼관리자 대상은 제외
- return canModify(item);
- };
- // 권한 라벨 매핑 (admin.vue menuItems와 동일)
- const PERM_LABELS = {
- admin: "관리자",
- field: "분야/지역",
- fishing: "선상/낚시터",
- challenge: "챌린지",
- quest: "퀘스트",
- item: "아이템",
- species: "어종",
- user: "회원",
- };
- const MAX_VISIBLE_PERMS = 2;
- const permLabel = (id) => PERM_LABELS[id] || id;
- const permsArray = (item) =>
- Array.isArray(item.permissions) ? item.permissions : [];
- const visiblePerms = (item) => permsArray(item).slice(0, MAX_VISIBLE_PERMS);
- const extraPermCount = (item) =>
- Math.max(0, permsArray(item).length - MAX_VISIBLE_PERMS);
- const isLoading = ref(false);
- const isProcessing = ref(false);
- const isExporting = ref(false);
- // 보기 모드 — 'active' (정상 목록) | 'deleted' (삭제된 관리자 관리)
- const viewMode = ref("active");
- const toggleViewMode = () => {
- viewMode.value = viewMode.value === "active" ? "deleted" : "active";
- selectedIds.value = [];
- currentPage.value = 1;
- loadAdmins();
- };
- const admins = ref([]);
- const currentPage = ref(1);
- const perPage = ref(10);
- const totalCount = ref(0);
- const totalPages = ref(0);
- const searchField = ref(""); // '' | username | name | email
- const searchQuery = ref("");
- const filterRole = ref(""); // '' | super_admin | admin
- const filterStatus = ref(""); // '' | active | inactive | suspended
- // 체크박스 선택 — 선택 가능한 행 기준
- const selectedIds = ref([]);
- const selectableAdmins = computed(() => admins.value.filter((it) => canSelect(it)));
- const isAllSelected = computed(
- () => selectableAdmins.value.length > 0 && selectableAdmins.value.every((it) => selectedIds.value.includes(it.id))
- );
- const isPartialSelected = computed(
- () => selectedIds.value.length > 0 && !isAllSelected.value
- );
- const toggleAll = (checked) => {
- if (checked) {
- const ids = selectableAdmins.value.map((it) => it.id);
- selectedIds.value = Array.from(new Set([...selectedIds.value, ...ids]));
- } else {
- const ids = new Set(selectableAdmins.value.map((it) => it.id));
- selectedIds.value = selectedIds.value.filter((id) => !ids.has(id));
- }
- };
- // 보이는 페이지 번호
- const visiblePages = computed(() => {
- const pages = [];
- const maxVisible = 5;
- let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2));
- let end = Math.min(totalPages.value, start + maxVisible - 1);
- if (end - start < maxVisible - 1) start = Math.max(1, end - maxVisible + 1);
- for (let i = start; i <= end; i++) pages.push(i);
- return pages;
- });
- // 데이터 로드
- const loadAdmins = async () => {
- isLoading.value = true;
- const params = {
- page: currentPage.value,
- per_page: perPage.value,
- };
- if (viewMode.value === "deleted") params.deleted = 1;
- if (searchQuery.value) {
- params.search = searchQuery.value;
- if (searchField.value) params.search_field = searchField.value;
- }
- if (filterRole.value) params.role = filterRole.value;
- if (filterStatus.value) params.status = filterStatus.value;
- const { data, error } = await get("/admin", { params });
- if (error) {
- console.error("[AdminList] 목록 로드 실패:", error);
- admins.value = [];
- totalCount.value = 0;
- totalPages.value = 0;
- } else if (data?.success && data?.data) {
- admins.value = data.data.items || [];
- totalCount.value = data.data.total || 0;
- totalPages.value = data.data.total_pages || 0;
- }
- isLoading.value = false;
- };
- const onSearch = () => {
- currentPage.value = 1;
- loadAdmins();
- };
- const resetSearch = () => {
- searchField.value = "";
- searchQuery.value = "";
- filterRole.value = "";
- filterStatus.value = "";
- currentPage.value = 1;
- loadAdmins();
- };
- const changePage = (page) => {
- if (page < 1 || page > totalPages.value) return;
- currentPage.value = page;
- loadAdmins();
- window.scrollTo({ top: 0, behavior: "smooth" });
- };
- // 이동
- const goToCreate = () => router.push("/site-manager/admin/create");
- const goToDetail = (id) => router.push(`/site-manager/admin/detail/${id}`);
- const goToEdit = (id) => router.push(`/site-manager/admin/edit/${id}`);
- // 라벨 / 뱃지
- const getRoleLabel = (role) => {
- if (role === "super_admin") return "슈퍼 관리자";
- if (role === "admin") return "관리자";
- return "-";
- };
- const getStatusLabel = (status) => {
- if (status === "active") return "활성";
- if (status === "inactive") return "휴면";
- if (status === "suspended") return "정지";
- return "-";
- };
- const getStatusBadgeClass = (status) => {
- if (status === "active") return "admin--badge-active";
- if (status === "inactive") return "admin--badge-ended";
- if (status === "suspended") return "admin--badge-sus";
- return "";
- };
- // 일시 포맷 (24시 표기)
- const formatDateTime = (dateString) => {
- if (!dateString) return "-";
- const date = new Date(dateString.replace(" ", "T"));
- if (isNaN(date.getTime())) return dateString;
- return date.toLocaleString("ko-KR", {
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- hour: "2-digit",
- minute: "2-digit",
- hour12: false,
- });
- };
- // 알림 모달
- const alertModal = ref({ show: false, title: "알림", message: "", type: "alert", onConfirm: null });
- const showAlert = (message, title = "알림") => {
- alertModal.value = { show: true, title, message, type: "alert", onConfirm: null };
- };
- const showConfirm = (message, onConfirm, title = "확인") => {
- alertModal.value = { show: true, title, message, type: "confirm", onConfirm };
- };
- const closeAlertModal = () => { alertModal.value.show = false; };
- const handleAlertConfirm = () => {
- if (alertModal.value.onConfirm) alertModal.value.onConfirm();
- closeAlertModal();
- };
- const handleAlertCancel = () => closeAlertModal();
- // 선택 삭제
- const bulkDelete = () => {
- if (selectedIds.value.length === 0) return;
- showConfirm(
- `선택한 ${selectedIds.value.length}명의 관리자를 삭제하시겠습니까?`,
- async () => {
- isProcessing.value = true;
- const ids = [...selectedIds.value];
- let success = 0;
- let failMessages = [];
- for (const id of ids) {
- const { data, error } = await del(`/admin/${id}`);
- if (error || !data?.success) {
- failMessages.push(`ID ${id}: ${error?.message || data?.message || "실패"}`);
- } else {
- success++;
- }
- }
- isProcessing.value = false;
- selectedIds.value = [];
- await loadAdmins();
- if (failMessages.length > 0) {
- showAlert(`${success}건 성공 / ${failMessages.length}건 실패\n\n${failMessages.join("\n")}`, "결과");
- } else {
- showAlert(`${success}건 삭제되었습니다.`, "성공");
- }
- },
- "선택 삭제"
- );
- };
- // 선택 휴면/정지 — status 일괄 변경
- const bulkSetStatus = (status) => {
- if (selectedIds.value.length === 0) return;
- const label = status === "active" ? "활성" : status === "inactive" ? "휴면" : status === "suspended" ? "정지" : status;
- showConfirm(
- `선택한 ${selectedIds.value.length}명을 '${label}' 상태로 변경하시겠습니까?`,
- async () => {
- isProcessing.value = true;
- const ids = [...selectedIds.value];
- let success = 0;
- let failMessages = [];
- for (const id of ids) {
- const { data, error } = await put(`/admin/${id}`, { status });
- if (error || !data?.success) {
- failMessages.push(`ID ${id}: ${error?.message || data?.message || "실패"}`);
- } else {
- success++;
- }
- }
- isProcessing.value = false;
- selectedIds.value = [];
- await loadAdmins();
- if (failMessages.length > 0) {
- showAlert(`${success}건 성공 / ${failMessages.length}건 실패\n\n${failMessages.join("\n")}`, "결과");
- } else {
- showAlert(`${success}명을 ${label} 상태로 변경했습니다.`, "성공");
- }
- },
- `선택 ${label}`
- );
- };
- // 선택 복구 — 삭제된 관리자만
- const bulkRestore = () => {
- if (selectedIds.value.length === 0) return;
- showConfirm(
- `선택한 ${selectedIds.value.length}명의 관리자를 복구하시겠습니까?`,
- async () => {
- isProcessing.value = true;
- const ids = [...selectedIds.value];
- let success = 0;
- let failMessages = [];
- for (const id of ids) {
- const { data, error } = await post(`/admin/${id}/restore`, {});
- if (error || !data?.success) {
- failMessages.push(`ID ${id}: ${error?.message || data?.message || "실패"}`);
- } else {
- success++;
- }
- }
- isProcessing.value = false;
- selectedIds.value = [];
- await loadAdmins();
- if (failMessages.length > 0) {
- showAlert(`${success}건 성공 / ${failMessages.length}건 실패\n\n${failMessages.join("\n")}`, "결과");
- } else {
- showAlert(`${success}명이 복구되었습니다.`, "성공");
- }
- },
- "선택 복구"
- );
- };
- // 선택 영구 삭제 — 되돌릴 수 없음
- const bulkHardDelete = () => {
- if (selectedIds.value.length === 0) return;
- showConfirm(
- `⚠️ 선택한 ${selectedIds.value.length}명의 관리자를 영구 삭제합니다.\n\n이 작업은 절대 되돌릴 수 없으며, 권한 정보와 토큰까지 모두 삭제됩니다.\n\n정말 진행하시겠습니까?`,
- async () => {
- isProcessing.value = true;
- const ids = [...selectedIds.value];
- let success = 0;
- let failMessages = [];
- for (const id of ids) {
- const { data, error } = await del(`/admin/${id}/hard`);
- if (error || !data?.success) {
- failMessages.push(`ID ${id}: ${error?.message || data?.message || "실패"}`);
- } else {
- success++;
- }
- }
- isProcessing.value = false;
- selectedIds.value = [];
- await loadAdmins();
- if (failMessages.length > 0) {
- showAlert(`${success}건 성공 / ${failMessages.length}건 실패\n\n${failMessages.join("\n")}`, "결과");
- } else {
- showAlert(`${success}명이 영구 삭제되었습니다.`, "성공");
- }
- },
- "영구 삭제"
- );
- };
- // CSV 내보내기 — 선택된 게 있으면 그것만, 없으면 전체 (현재 필터 반영)
- const csvEscape = (val) => {
- const s = String(val ?? "");
- if (s.includes(",") || s.includes("\"") || s.includes("\n") || s.includes("\r")) {
- return `"${s.replace(/"/g, '""')}"`;
- }
- return s;
- };
- const fileTimestamp = () => {
- const d = new Date();
- const p = (n) => String(n).padStart(2, "0");
- return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}`;
- };
- const handleExportCsv = async () => {
- isExporting.value = true;
- try {
- let rows = [];
- if (selectedIds.value.length > 0) {
- // 선택된 것만 — 현재 페이지의 데이터에서 필터
- rows = admins.value.filter((a) => selectedIds.value.includes(a.id));
- } else {
- // 전체 — 현재 필터 그대로 + per_page 크게 호출
- const params = { page: 1, per_page: 10000 };
- if (searchQuery.value) {
- params.search = searchQuery.value;
- if (searchField.value) params.search_field = searchField.value;
- }
- if (filterRole.value) params.role = filterRole.value;
- if (filterStatus.value) params.status = filterStatus.value;
- const { data, error } = await get("/admin", { params });
- if (error || !data?.success) {
- showAlert(error?.message || data?.message || "데이터를 가져오지 못했습니다.", "오류");
- return;
- }
- rows = data.data.items || [];
- }
- if (rows.length === 0) {
- showAlert("내보낼 데이터가 없습니다.", "알림");
- return;
- }
- const headers = ["번호", "아이디", "이름", "핸드폰", "이메일", "권한", "최근 로그인", "상태"];
- const lines = [headers.map(csvEscape).join(",")];
- rows.forEach((row, index) => {
- const cells = [
- index + 1,
- row.username || "",
- row.name || "",
- row.phone || "",
- row.email || "",
- getRoleLabel(row.role),
- formatDateTime(row.last_login),
- getStatusLabel(row.status),
- ].map(csvEscape);
- lines.push(cells.join(","));
- });
- // UTF-8 BOM (Excel 한글 깨짐 방지) + 줄바꿈
- const csv = "" + lines.join("\r\n");
- const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = `관리자_목록_${fileTimestamp()}.csv`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- } catch (e) {
- console.error("[CSV Export] 실패:", e);
- showAlert("CSV 내보내기 중 오류가 발생했습니다.", "오류");
- } finally {
- isExporting.value = false;
- }
- };
- onMounted(() => {
- loadAdmins();
- });
- </script>
|