list.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  1. <template>
  2. <div class="admin--field-list">
  3. <!-- 상단 검색/액션 영역 -->
  4. <div class="admin--search-box type2">
  5. <div class="admin--search--inner--box">
  6. <div class="admin--search-form">
  7. <select v-model="searchField" class="admin--form-select admin--search-select">
  8. <option value="">전체</option>
  9. <option value="username">아이디</option>
  10. <option value="name">이름</option>
  11. <option value="email">이메일</option>
  12. </select>
  13. <input
  14. v-model="searchQuery"
  15. type="text"
  16. placeholder="검색어 입력"
  17. @keyup.enter="onSearch"
  18. class="admin--form-input admin--search-input"
  19. />
  20. <select v-model="filterRole" @change="onSearch" class="admin--form-select admin--search-select">
  21. <option value="">전체 권한</option>
  22. <option value="super_admin">슈퍼 관리자</option>
  23. <option value="admin">관리자</option>
  24. </select>
  25. <select v-model="filterStatus" @change="onSearch" class="admin--form-select admin--search-select">
  26. <option value="">전체 상태</option>
  27. <option value="active">활성</option>
  28. <option value="inactive">휴면</option>
  29. <option value="suspended">정지</option>
  30. </select>
  31. <button @click="onSearch" class="admin--btn-small admin--btn-small-primary">검색</button>
  32. <button @click="resetSearch" class="admin--btn-small admin--btn-small-secondary">초기화</button>
  33. </div>
  34. </div>
  35. <div class="admin--search--inner--box">
  36. <button
  37. v-if="isSuperAdmin"
  38. :class="[viewMode=== 'active' ? 'admin--btn-small admin--btn-small-primary' : 'admin--btn-small admin--btn-small-secondary']"
  39. @click="toggleViewMode"
  40. >
  41. {{ viewMode === 'active' ? '삭제 관리자 관리' : '← 목록으로' }}
  42. </button>
  43. <div class="admin--search-actions">
  44. <button
  45. v-if="viewMode === 'active'"
  46. class="admin--btn-small admin--btn-small-excel"
  47. :disabled="isExporting"
  48. @click="handleExportCsv"
  49. >
  50. {{ isExporting ? "내보내는 중..." : "CSV 내보내기" }}<span></span>
  51. </button>
  52. <!-- 활성 모드 액션 -->
  53. <template v-if="viewMode === 'active'">
  54. <button
  55. class="admin--btn-small admin--btn-small-secondary"
  56. :disabled="selectedIds.length === 0 || isProcessing"
  57. @click="bulkDelete"
  58. >선택 삭제</button>
  59. <button
  60. class="admin--btn-small admin--btn-small-secondary"
  61. :disabled="selectedIds.length === 0 || isProcessing"
  62. @click="bulkSetStatus('active')"
  63. >선택 활성</button>
  64. <button
  65. class="admin--btn-small admin--btn-small-secondary"
  66. :disabled="selectedIds.length === 0 || isProcessing"
  67. @click="bulkSetStatus('inactive')"
  68. >선택 휴면</button>
  69. <button
  70. class="admin--btn-small admin--btn-small-secondary"
  71. :disabled="selectedIds.length === 0 || isProcessing"
  72. @click="bulkSetStatus('suspended')"
  73. >선택 정지</button>
  74. <button class="admin--btn-add" @click="goToCreate">+ 관리자 추가</button>
  75. </template>
  76. <!-- 삭제 모드 액션 -->
  77. <template v-else>
  78. <button
  79. class="admin--btn-small admin--btn-small-secondary"
  80. :disabled="selectedIds.length === 0 || isProcessing"
  81. @click="bulkRestore"
  82. >선택 복구</button>
  83. <button
  84. class="admin--btn-add"
  85. :disabled="selectedIds.length === 0 || isProcessing"
  86. @click="bulkHardDelete"
  87. >선택 영구 삭제</button>
  88. </template>
  89. </div>
  90. </div>
  91. </div>
  92. <!-- 테이블 -->
  93. <div class="admin--table-wrapper">
  94. <table class="admin--table">
  95. <thead>
  96. <tr>
  97. <th style="width: 48px;">
  98. <div class="input--wrap">
  99. <input
  100. type="checkbox"
  101. :checked="isAllSelected"
  102. :indeterminate.prop="isPartialSelected"
  103. :disabled="selectableAdmins.length === 0"
  104. @change="toggleAll($event.target.checked)"
  105. aria-label="전체 선택"
  106. />
  107. </div>
  108. </th>
  109. <th style="width: 140px;">아이디</th>
  110. <th style="width: 140px;">이름</th>
  111. <th>핸드폰</th>
  112. <th>이메일</th>
  113. <th style="width: 200px;">권한</th>
  114. <th style="width: 140px;">최근 로그인</th>
  115. <th style="width: 100px;">상태</th>
  116. <th style="width: 120px;">관리</th>
  117. </tr>
  118. </thead>
  119. <tbody>
  120. <tr v-if="isLoading">
  121. <td colspan="9" class="admin--table-loading">데이터를 불러오는 중...</td>
  122. </tr>
  123. <tr v-else-if="!admins || admins.length === 0">
  124. <td colspan="9" class="admin--table-empty">
  125. {{ viewMode === 'deleted' ? '삭제된 관리자가 없습니다.' : '등록된 관리자가 없습니다.' }}
  126. </td>
  127. </tr>
  128. <tr
  129. v-else
  130. v-for="item in admins"
  131. :key="item.id"
  132. :class="viewMode === 'active' ? 'admin--table-row-clickable' : ''"
  133. @click="viewMode === 'active' && goToDetail(item.id)"
  134. >
  135. <td @click.stop>
  136. <div class="input--wrap">
  137. <input
  138. type="checkbox"
  139. :value="item.id"
  140. v-model="selectedIds"
  141. :disabled="!canSelect(item)"
  142. :title="!canSelect(item) ? '슈퍼 관리자 또는 본인 계정은 선택할 수 없습니다.' : ''"
  143. />
  144. </div>
  145. </td>
  146. <td class="admin--table-title">{{ item.username }}</td>
  147. <td>{{ item.name || "-" }}</td>
  148. <td>{{ item.phone || "-" }}</td>
  149. <td>{{ item.email || "-" }}</td>
  150. <td class="left">
  151. <span v-if="item.role === 'super_admin'" class="admin--badge admin--badge-super">
  152. 슈퍼 관리자
  153. </span>
  154. <div v-else class="admin--perm-list">
  155. <span v-for="p in visiblePerms(item)" :key="p" class="admin--badge admin--badge-perm">
  156. {{ permLabel(p) }}
  157. </span>
  158. <span v-if="extraPermCount(item) > 0" class="admin--badge admin--badge-more">
  159. +{{ extraPermCount(item) }}
  160. </span>
  161. <span v-if="!visiblePerms(item).length" class="admin--badge admin--badge-ended">
  162. 권한 없음
  163. </span>
  164. </div>
  165. </td>
  166. <td class="date">{{ formatDateTime(item.last_login) }}</td>
  167. <td cl>
  168. <span :class="['admin--badge', getStatusBadgeClass(item.status)]">
  169. {{ getStatusLabel(item.status) }}
  170. </span>
  171. </td>
  172. <td>
  173. <div class="admin--table-actions">
  174. <button
  175. v-if="viewMode === 'active' && canModify(item)"
  176. class="admin--btn-small admin--btn-blue"
  177. @click.stop="goToEdit(item.id)"
  178. >수정</button>
  179. </div>
  180. </td>
  181. </tr>
  182. </tbody>
  183. </table>
  184. </div>
  185. <!-- 페이지네이션 -->
  186. <div v-if="totalPages > 1" class="admin--pagination">
  187. <button
  188. v-if="totalPages > 2"
  189. class="admin--pagination-btn"
  190. :disabled="currentPage === 1"
  191. @click="changePage(1)"
  192. title="처음"
  193. >◀◀</button>
  194. <button
  195. class="admin--pagination-btn"
  196. :disabled="currentPage === 1"
  197. @click="changePage(currentPage - 1)"
  198. title="이전"
  199. >◀</button>
  200. <button
  201. v-for="page in visiblePages"
  202. :key="page"
  203. class="admin--pagination-btn"
  204. :class="{ 'is-active': page === currentPage }"
  205. @click="changePage(page)"
  206. >{{ page }}</button>
  207. <button
  208. class="admin--pagination-btn"
  209. :disabled="currentPage === totalPages"
  210. @click="changePage(currentPage + 1)"
  211. title="다음"
  212. >▶</button>
  213. <button
  214. v-if="totalPages > 2"
  215. class="admin--pagination-btn"
  216. :disabled="currentPage === totalPages"
  217. @click="changePage(totalPages)"
  218. title="끝"
  219. >▶▶</button>
  220. </div>
  221. <!-- 알림 모달 -->
  222. <AdminAlertModal
  223. v-if="alertModal.show"
  224. :title="alertModal.title"
  225. :message="alertModal.message"
  226. :type="alertModal.type"
  227. @confirm="handleAlertConfirm"
  228. @cancel="handleAlertCancel"
  229. @close="closeAlertModal"
  230. />
  231. </div>
  232. </template>
  233. <script setup>
  234. import { ref, computed, onMounted } from "vue";
  235. import { useRouter } from "vue-router";
  236. import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
  237. definePageMeta({
  238. layout: "admin",
  239. middleware: ["auth"],
  240. });
  241. const router = useRouter();
  242. const { get, post, put, del } = useApi();
  243. const { user, isSuperAdmin } = useAuth();
  244. // 일반 admin은 슈퍼관리자 행 수정 불가
  245. const canModify = (item) => isSuperAdmin.value || item.role !== "super_admin";
  246. // 선택 가능 여부 — 모드별 분기
  247. const canSelect = (item) => {
  248. const isMe = Number(item.id) === Number(user.value?.id);
  249. if (isMe) return false;
  250. // 삭제된 관리자 관리 모드: 슈퍼관리자가 영구삭제/복구 모두 가능
  251. if (viewMode.value === "deleted") return true;
  252. // 활성 모드: 슈퍼관리자 대상은 제외
  253. return canModify(item);
  254. };
  255. // 권한 라벨 매핑 (admin.vue menuItems와 동일)
  256. const PERM_LABELS = {
  257. admin: "관리자",
  258. field: "분야/지역",
  259. fishing: "선상/낚시터",
  260. challenge: "챌린지",
  261. quest: "퀘스트",
  262. item: "아이템",
  263. species: "어종",
  264. user: "회원",
  265. };
  266. const MAX_VISIBLE_PERMS = 2;
  267. const permLabel = (id) => PERM_LABELS[id] || id;
  268. const permsArray = (item) =>
  269. Array.isArray(item.permissions) ? item.permissions : [];
  270. const visiblePerms = (item) => permsArray(item).slice(0, MAX_VISIBLE_PERMS);
  271. const extraPermCount = (item) =>
  272. Math.max(0, permsArray(item).length - MAX_VISIBLE_PERMS);
  273. const isLoading = ref(false);
  274. const isProcessing = ref(false);
  275. const isExporting = ref(false);
  276. // 보기 모드 — 'active' (정상 목록) | 'deleted' (삭제된 관리자 관리)
  277. const viewMode = ref("active");
  278. const toggleViewMode = () => {
  279. viewMode.value = viewMode.value === "active" ? "deleted" : "active";
  280. selectedIds.value = [];
  281. currentPage.value = 1;
  282. loadAdmins();
  283. };
  284. const admins = ref([]);
  285. const currentPage = ref(1);
  286. const perPage = ref(10);
  287. const totalCount = ref(0);
  288. const totalPages = ref(0);
  289. const searchField = ref(""); // '' | username | name | email
  290. const searchQuery = ref("");
  291. const filterRole = ref(""); // '' | super_admin | admin
  292. const filterStatus = ref(""); // '' | active | inactive | suspended
  293. // 체크박스 선택 — 선택 가능한 행 기준
  294. const selectedIds = ref([]);
  295. const selectableAdmins = computed(() => admins.value.filter((it) => canSelect(it)));
  296. const isAllSelected = computed(
  297. () => selectableAdmins.value.length > 0 && selectableAdmins.value.every((it) => selectedIds.value.includes(it.id))
  298. );
  299. const isPartialSelected = computed(
  300. () => selectedIds.value.length > 0 && !isAllSelected.value
  301. );
  302. const toggleAll = (checked) => {
  303. if (checked) {
  304. const ids = selectableAdmins.value.map((it) => it.id);
  305. selectedIds.value = Array.from(new Set([...selectedIds.value, ...ids]));
  306. } else {
  307. const ids = new Set(selectableAdmins.value.map((it) => it.id));
  308. selectedIds.value = selectedIds.value.filter((id) => !ids.has(id));
  309. }
  310. };
  311. // 보이는 페이지 번호
  312. const visiblePages = computed(() => {
  313. const pages = [];
  314. const maxVisible = 5;
  315. let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2));
  316. let end = Math.min(totalPages.value, start + maxVisible - 1);
  317. if (end - start < maxVisible - 1) start = Math.max(1, end - maxVisible + 1);
  318. for (let i = start; i <= end; i++) pages.push(i);
  319. return pages;
  320. });
  321. // 데이터 로드
  322. const loadAdmins = async () => {
  323. isLoading.value = true;
  324. const params = {
  325. page: currentPage.value,
  326. per_page: perPage.value,
  327. };
  328. if (viewMode.value === "deleted") params.deleted = 1;
  329. if (searchQuery.value) {
  330. params.search = searchQuery.value;
  331. if (searchField.value) params.search_field = searchField.value;
  332. }
  333. if (filterRole.value) params.role = filterRole.value;
  334. if (filterStatus.value) params.status = filterStatus.value;
  335. const { data, error } = await get("/admin", { params });
  336. if (error) {
  337. console.error("[AdminList] 목록 로드 실패:", error);
  338. admins.value = [];
  339. totalCount.value = 0;
  340. totalPages.value = 0;
  341. } else if (data?.success && data?.data) {
  342. admins.value = data.data.items || [];
  343. totalCount.value = data.data.total || 0;
  344. totalPages.value = data.data.total_pages || 0;
  345. }
  346. isLoading.value = false;
  347. };
  348. const onSearch = () => {
  349. currentPage.value = 1;
  350. loadAdmins();
  351. };
  352. const resetSearch = () => {
  353. searchField.value = "";
  354. searchQuery.value = "";
  355. filterRole.value = "";
  356. filterStatus.value = "";
  357. currentPage.value = 1;
  358. loadAdmins();
  359. };
  360. const changePage = (page) => {
  361. if (page < 1 || page > totalPages.value) return;
  362. currentPage.value = page;
  363. loadAdmins();
  364. window.scrollTo({ top: 0, behavior: "smooth" });
  365. };
  366. // 이동
  367. const goToCreate = () => router.push("/site-manager/admin/create");
  368. const goToDetail = (id) => router.push(`/site-manager/admin/detail/${id}`);
  369. const goToEdit = (id) => router.push(`/site-manager/admin/edit/${id}`);
  370. // 라벨 / 뱃지
  371. const getRoleLabel = (role) => {
  372. if (role === "super_admin") return "슈퍼 관리자";
  373. if (role === "admin") return "관리자";
  374. return "-";
  375. };
  376. const getStatusLabel = (status) => {
  377. if (status === "active") return "활성";
  378. if (status === "inactive") return "휴면";
  379. if (status === "suspended") return "정지";
  380. return "-";
  381. };
  382. const getStatusBadgeClass = (status) => {
  383. if (status === "active") return "admin--badge-active";
  384. if (status === "inactive") return "admin--badge-ended";
  385. if (status === "suspended") return "admin--badge-sus";
  386. return "";
  387. };
  388. // 일시 포맷 (24시 표기)
  389. const formatDateTime = (dateString) => {
  390. if (!dateString) return "-";
  391. const date = new Date(dateString.replace(" ", "T"));
  392. if (isNaN(date.getTime())) return dateString;
  393. return date.toLocaleString("ko-KR", {
  394. year: "numeric",
  395. month: "2-digit",
  396. day: "2-digit",
  397. hour: "2-digit",
  398. minute: "2-digit",
  399. hour12: false,
  400. });
  401. };
  402. // 알림 모달
  403. const alertModal = ref({ show: false, title: "알림", message: "", type: "alert", onConfirm: null });
  404. const showAlert = (message, title = "알림") => {
  405. alertModal.value = { show: true, title, message, type: "alert", onConfirm: null };
  406. };
  407. const showConfirm = (message, onConfirm, title = "확인") => {
  408. alertModal.value = { show: true, title, message, type: "confirm", onConfirm };
  409. };
  410. const closeAlertModal = () => { alertModal.value.show = false; };
  411. const handleAlertConfirm = () => {
  412. if (alertModal.value.onConfirm) alertModal.value.onConfirm();
  413. closeAlertModal();
  414. };
  415. const handleAlertCancel = () => closeAlertModal();
  416. // 선택 삭제
  417. const bulkDelete = () => {
  418. if (selectedIds.value.length === 0) return;
  419. showConfirm(
  420. `선택한 ${selectedIds.value.length}명의 관리자를 삭제하시겠습니까?`,
  421. async () => {
  422. isProcessing.value = true;
  423. const ids = [...selectedIds.value];
  424. let success = 0;
  425. let failMessages = [];
  426. for (const id of ids) {
  427. const { data, error } = await del(`/admin/${id}`);
  428. if (error || !data?.success) {
  429. failMessages.push(`ID ${id}: ${error?.message || data?.message || "실패"}`);
  430. } else {
  431. success++;
  432. }
  433. }
  434. isProcessing.value = false;
  435. selectedIds.value = [];
  436. await loadAdmins();
  437. if (failMessages.length > 0) {
  438. showAlert(`${success}건 성공 / ${failMessages.length}건 실패\n\n${failMessages.join("\n")}`, "결과");
  439. } else {
  440. showAlert(`${success}건 삭제되었습니다.`, "성공");
  441. }
  442. },
  443. "선택 삭제"
  444. );
  445. };
  446. // 선택 휴면/정지 — status 일괄 변경
  447. const bulkSetStatus = (status) => {
  448. if (selectedIds.value.length === 0) return;
  449. const label = status === "active" ? "활성" : status === "inactive" ? "휴면" : status === "suspended" ? "정지" : status;
  450. showConfirm(
  451. `선택한 ${selectedIds.value.length}명을 '${label}' 상태로 변경하시겠습니까?`,
  452. async () => {
  453. isProcessing.value = true;
  454. const ids = [...selectedIds.value];
  455. let success = 0;
  456. let failMessages = [];
  457. for (const id of ids) {
  458. const { data, error } = await put(`/admin/${id}`, { status });
  459. if (error || !data?.success) {
  460. failMessages.push(`ID ${id}: ${error?.message || data?.message || "실패"}`);
  461. } else {
  462. success++;
  463. }
  464. }
  465. isProcessing.value = false;
  466. selectedIds.value = [];
  467. await loadAdmins();
  468. if (failMessages.length > 0) {
  469. showAlert(`${success}건 성공 / ${failMessages.length}건 실패\n\n${failMessages.join("\n")}`, "결과");
  470. } else {
  471. showAlert(`${success}명을 ${label} 상태로 변경했습니다.`, "성공");
  472. }
  473. },
  474. `선택 ${label}`
  475. );
  476. };
  477. // 선택 복구 — 삭제된 관리자만
  478. const bulkRestore = () => {
  479. if (selectedIds.value.length === 0) return;
  480. showConfirm(
  481. `선택한 ${selectedIds.value.length}명의 관리자를 복구하시겠습니까?`,
  482. async () => {
  483. isProcessing.value = true;
  484. const ids = [...selectedIds.value];
  485. let success = 0;
  486. let failMessages = [];
  487. for (const id of ids) {
  488. const { data, error } = await post(`/admin/${id}/restore`, {});
  489. if (error || !data?.success) {
  490. failMessages.push(`ID ${id}: ${error?.message || data?.message || "실패"}`);
  491. } else {
  492. success++;
  493. }
  494. }
  495. isProcessing.value = false;
  496. selectedIds.value = [];
  497. await loadAdmins();
  498. if (failMessages.length > 0) {
  499. showAlert(`${success}건 성공 / ${failMessages.length}건 실패\n\n${failMessages.join("\n")}`, "결과");
  500. } else {
  501. showAlert(`${success}명이 복구되었습니다.`, "성공");
  502. }
  503. },
  504. "선택 복구"
  505. );
  506. };
  507. // 선택 영구 삭제 — 되돌릴 수 없음
  508. const bulkHardDelete = () => {
  509. if (selectedIds.value.length === 0) return;
  510. showConfirm(
  511. `⚠️ 선택한 ${selectedIds.value.length}명의 관리자를 영구 삭제합니다.\n\n이 작업은 절대 되돌릴 수 없으며, 권한 정보와 토큰까지 모두 삭제됩니다.\n\n정말 진행하시겠습니까?`,
  512. async () => {
  513. isProcessing.value = true;
  514. const ids = [...selectedIds.value];
  515. let success = 0;
  516. let failMessages = [];
  517. for (const id of ids) {
  518. const { data, error } = await del(`/admin/${id}/hard`);
  519. if (error || !data?.success) {
  520. failMessages.push(`ID ${id}: ${error?.message || data?.message || "실패"}`);
  521. } else {
  522. success++;
  523. }
  524. }
  525. isProcessing.value = false;
  526. selectedIds.value = [];
  527. await loadAdmins();
  528. if (failMessages.length > 0) {
  529. showAlert(`${success}건 성공 / ${failMessages.length}건 실패\n\n${failMessages.join("\n")}`, "결과");
  530. } else {
  531. showAlert(`${success}명이 영구 삭제되었습니다.`, "성공");
  532. }
  533. },
  534. "영구 삭제"
  535. );
  536. };
  537. // CSV 내보내기 — 선택된 게 있으면 그것만, 없으면 전체 (현재 필터 반영)
  538. const csvEscape = (val) => {
  539. const s = String(val ?? "");
  540. if (s.includes(",") || s.includes("\"") || s.includes("\n") || s.includes("\r")) {
  541. return `"${s.replace(/"/g, '""')}"`;
  542. }
  543. return s;
  544. };
  545. const fileTimestamp = () => {
  546. const d = new Date();
  547. const p = (n) => String(n).padStart(2, "0");
  548. return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}`;
  549. };
  550. const handleExportCsv = async () => {
  551. isExporting.value = true;
  552. try {
  553. let rows = [];
  554. if (selectedIds.value.length > 0) {
  555. // 선택된 것만 — 현재 페이지의 데이터에서 필터
  556. rows = admins.value.filter((a) => selectedIds.value.includes(a.id));
  557. } else {
  558. // 전체 — 현재 필터 그대로 + per_page 크게 호출
  559. const params = { page: 1, per_page: 10000 };
  560. if (searchQuery.value) {
  561. params.search = searchQuery.value;
  562. if (searchField.value) params.search_field = searchField.value;
  563. }
  564. if (filterRole.value) params.role = filterRole.value;
  565. if (filterStatus.value) params.status = filterStatus.value;
  566. const { data, error } = await get("/admin", { params });
  567. if (error || !data?.success) {
  568. showAlert(error?.message || data?.message || "데이터를 가져오지 못했습니다.", "오류");
  569. return;
  570. }
  571. rows = data.data.items || [];
  572. }
  573. if (rows.length === 0) {
  574. showAlert("내보낼 데이터가 없습니다.", "알림");
  575. return;
  576. }
  577. const headers = ["번호", "아이디", "이름", "핸드폰", "이메일", "권한", "최근 로그인", "상태"];
  578. const lines = [headers.map(csvEscape).join(",")];
  579. rows.forEach((row, index) => {
  580. const cells = [
  581. index + 1,
  582. row.username || "",
  583. row.name || "",
  584. row.phone || "",
  585. row.email || "",
  586. getRoleLabel(row.role),
  587. formatDateTime(row.last_login),
  588. getStatusLabel(row.status),
  589. ].map(csvEscape);
  590. lines.push(cells.join(","));
  591. });
  592. // UTF-8 BOM (Excel 한글 깨짐 방지) + 줄바꿈
  593. const csv = "" + lines.join("\r\n");
  594. const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
  595. const url = URL.createObjectURL(blob);
  596. const a = document.createElement("a");
  597. a.href = url;
  598. a.download = `관리자_목록_${fileTimestamp()}.csv`;
  599. document.body.appendChild(a);
  600. a.click();
  601. document.body.removeChild(a);
  602. URL.revokeObjectURL(url);
  603. } catch (e) {
  604. console.error("[CSV Export] 실패:", e);
  605. showAlert("CSV 내보내기 중 오류가 발생했습니다.", "오류");
  606. } finally {
  607. isExporting.value = false;
  608. }
  609. };
  610. onMounted(() => {
  611. loadAdmins();
  612. });
  613. </script>