v4.0.9: 관리자 회원 목록 2열·덱 공개/전체 통계 추가
관리자가 회원별 저장 덱 이용 현황을 한눈에 볼 수 있도록 API 집계와 UI를 보강한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
27
admin.html
27
admin.html
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="stylesheet" href="./style/output.css?v=4.0.6" />
|
<link rel="stylesheet" href="./style/output.css?v=4.0.9" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" />
|
||||||
<title>관리자 - UNIARE</title>
|
<title>관리자 - UNIARE</title>
|
||||||
</head>
|
</head>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<input type="search" id="user-search" class="box-border min-w-0 flex-1 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/15" placeholder="이메일, 사용자명 검색..." />
|
<input type="search" id="user-search" class="box-border min-w-0 flex-1 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/15" placeholder="이메일, 사용자명 검색..." />
|
||||||
<button type="button" id="user-search-btn" class="cursor-pointer rounded-xl border-0 bg-slate-800 px-5 py-2.5 text-sm font-bold text-white transition hover:bg-slate-700">검색</button>
|
<button type="button" id="user-search-btn" class="cursor-pointer rounded-xl border-0 bg-slate-800 px-5 py-2.5 text-sm font-bold text-white transition hover:bg-slate-700">검색</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="user-list" class="flex flex-col gap-3"></div>
|
<div id="user-list" class="grid grid-cols-1 gap-3 sm:grid-cols-2"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="tab-decks" class="hidden">
|
<section id="tab-decks" class="hidden">
|
||||||
@@ -215,23 +215,26 @@
|
|||||||
const { users, total } = await UaDeckApi.adminGetUsers(q);
|
const { users, total } = await UaDeckApi.adminGetUsers(q);
|
||||||
const list = document.getElementById('user-list');
|
const list = document.getElementById('user-list');
|
||||||
list.innerHTML =
|
list.innerHTML =
|
||||||
`<p class="mb-4 text-xs text-slate-400">총 ${total}명</p>` +
|
`<p class="col-span-full mb-1 text-xs text-slate-400">총 ${total}명</p>` +
|
||||||
users
|
users
|
||||||
.map((u) => {
|
.map((u) => {
|
||||||
const isSelf = u.id === currentAdminId;
|
const isSelf = u.id === currentAdminId;
|
||||||
|
const deckPublic = Number(u.deckPublic ?? 0);
|
||||||
|
const deckTotal = Number(u.deckTotal ?? 0);
|
||||||
return `
|
return `
|
||||||
<article class="rounded-2xl border border-slate-200 bg-white px-[1.125rem] py-4 shadow-sm">
|
<article class="rounded-2xl border border-slate-200 bg-white px-4 py-3.5 shadow-sm">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-start gap-3">
|
||||||
<img src="${escapeHtml(resolveAvatarSrc(u))}" alt="" class="h-12 w-12 shrink-0 rounded-full border-2 border-slate-200 bg-slate-100 object-cover" onerror="this.src='${DEFAULT_AVATAR}'" />
|
<img src="${escapeHtml(resolveAvatarSrc(u))}" alt="" class="h-11 w-11 shrink-0 rounded-full border-2 border-slate-200 bg-slate-100 object-cover" onerror="this.src='${DEFAULT_AVATAR}'" />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<h3 class="mb-1 text-[0.9375rem] font-bold leading-snug text-slate-900">${escapeHtml(u.username)}${isSelf ? ' <span class="rounded-md bg-blue-50 px-2 py-0.5 text-[0.625rem] font-bold text-blue-600">나</span>' : ''}</h3>
|
<h3 class="mb-0.5 text-sm font-bold leading-snug text-slate-900">${escapeHtml(u.username)}${isSelf ? ' <span class="rounded-md bg-blue-50 px-1.5 py-0.5 text-[0.625rem] font-bold text-blue-600">나</span>' : ''}</h3>
|
||||||
<div class="text-xs leading-normal text-slate-400">${escapeHtml(u.email)}</div>
|
<div class="truncate text-xs text-slate-400">${escapeHtml(u.email)}</div>
|
||||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
<div class="mt-1.5 text-xs font-semibold text-slate-600" title="공개 덱 / 전체 저장 덱">덱 ${deckPublic}/${deckTotal} <span class="font-normal text-slate-400">(공개/전체)</span></div>
|
||||||
<span class="rounded-md bg-blue-50 px-2 py-0.5 text-[0.625rem] font-bold text-blue-600">${escapeHtml(u.role)}</span>
|
<div class="mt-1.5 flex flex-wrap gap-1">
|
||||||
<span class="rounded-md px-2 py-0.5 text-[0.625rem] font-bold ${u.isActive ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-500'}">${u.isActive ? '활성' : '비활성'}</span>
|
<span class="rounded-md bg-blue-50 px-1.5 py-0.5 text-[0.625rem] font-bold text-blue-600">${escapeHtml(u.role)}</span>
|
||||||
|
<span class="rounded-md px-1.5 py-0.5 text-[0.625rem] font-bold ${u.isActive ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-500'}">${u.isActive ? '활성' : '비활성'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" data-edit='${JSON.stringify(u).replace(/'/g, ''')}' class="shrink-0 cursor-pointer rounded-lg border-0 bg-slate-100 px-3 py-1.5 text-[0.6875rem] font-bold text-slate-600 hover:bg-slate-200">수정</button>
|
<button type="button" data-edit='${JSON.stringify(u).replace(/'/g, ''')}' class="shrink-0 cursor-pointer rounded-lg border-0 bg-slate-100 px-2.5 py-1.5 text-[0.6875rem] font-bold text-slate-600 hover:bg-slate-200">수정</button>
|
||||||
</div>
|
</div>
|
||||||
</article>`;
|
</article>`;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,41 @@ import {
|
|||||||
} from '../services/previewImage.js';
|
} from '../services/previewImage.js';
|
||||||
import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
|
import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 목록에 저장 덱 수(공개/전체) 부여
|
||||||
|
* @param {import('better-sqlite3').Database} db
|
||||||
|
* @param {Array<{ id: number }>} users
|
||||||
|
* @returns {Array<object>}
|
||||||
|
*/
|
||||||
|
function attachUserDeckCounts(db, users) {
|
||||||
|
if (!users.length) return users;
|
||||||
|
|
||||||
|
const placeholders = users.map(() => '?').join(',');
|
||||||
|
const ids = users.map((u) => u.id);
|
||||||
|
const counts = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT user_id AS userId,
|
||||||
|
COUNT(*) AS deckTotal,
|
||||||
|
SUM(CASE WHEN is_public = 1 THEN 1 ELSE 0 END) AS deckPublic
|
||||||
|
FROM decks
|
||||||
|
WHERE share_type = 'user' AND user_id IN (${placeholders})
|
||||||
|
GROUP BY user_id
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(...ids);
|
||||||
|
const countByUser = new Map(counts.map((row) => [row.userId, row]));
|
||||||
|
|
||||||
|
return users.map((user) => {
|
||||||
|
const stats = countByUser.get(user.id);
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
deckPublic: stats?.deckPublic ?? 0,
|
||||||
|
deckTotal: stats?.deckTotal ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자 라우트
|
* 관리자 라우트
|
||||||
* @param {import('better-sqlite3').Database} db
|
* @param {import('better-sqlite3').Database} db
|
||||||
@@ -49,10 +84,13 @@ export function createAdminRouter(db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
users: rows.map((u) => ({
|
users: attachUserDeckCounts(
|
||||||
...u,
|
db,
|
||||||
avatarUrl: getAvatarUrl(u.id) || u.avatarUrl,
|
rows.map((u) => ({
|
||||||
})),
|
...u,
|
||||||
|
avatarUrl: getAvatarUrl(u.id) || u.avatarUrl,
|
||||||
|
})),
|
||||||
|
),
|
||||||
total,
|
total,
|
||||||
page: Number(page),
|
page: Number(page),
|
||||||
limit: Number(limit),
|
limit: Number(limit),
|
||||||
@@ -70,9 +108,14 @@ export function createAdminRouter(db) {
|
|||||||
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
|
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const deckCount = db.prepare('SELECT COUNT(*) AS c FROM decks WHERE user_id = ?').get(user.id).c;
|
const deckTotal = db.prepare(
|
||||||
|
"SELECT COUNT(*) AS c FROM decks WHERE user_id = ? AND share_type = 'user'",
|
||||||
|
).get(user.id).c;
|
||||||
|
const deckPublic = db.prepare(
|
||||||
|
"SELECT COUNT(*) AS c FROM decks WHERE user_id = ? AND share_type = 'user' AND is_public = 1",
|
||||||
|
).get(user.id).c;
|
||||||
user.avatarUrl = getAvatarUrl(user.id) || user.avatarUrl;
|
user.avatarUrl = getAvatarUrl(user.id) || user.avatarUrl;
|
||||||
res.json({ user, deckCount });
|
res.json({ user, deckCount: deckTotal, deckPublic, deckTotal });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/users/:id', (req, res) => {
|
router.put('/users/:id', (req, res) => {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-06-19 v4.0.9 — 관리자 회원 목록에 덱 이용률 표시
|
||||||
|
|
||||||
|
💡 사이트 이용 현황을 관리자가 한눈에 파악할 수 있어야 하기 때문.
|
||||||
|
🔧 회원 API에 `share_type='user'` 저장 덱의 공개·전체 수를 집계하고, 회원 카드를 2열 그리드로 배치.
|
||||||
|
|
||||||
## 2026-06-23 v4.0.5 — 공통 UI는 Tailwind components 레이어로 통합
|
## 2026-06-23 v4.0.5 — 공통 UI는 Tailwind components 레이어로 통합
|
||||||
|
|
||||||
💡 화면마다 분리된 CSS 파일을 유지하면 Tailwind 빌드 누락·로드 순서 문제가 반복되기 때문.
|
💡 화면마다 분리된 CSS 파일을 유지하면 Tailwind 빌드 누락·로드 순서 문제가 반복되기 때문.
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ script/ads.js
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
관리자·로그인·회원가입
|
관리자·로그인·회원가입
|
||||||
admin.html, login.html, signup.html — Tailwind 유틸리티
|
admin.html (회원 2열·덱 공개/전체 통계), login.html, signup.html — Tailwind 유틸리티
|
||||||
|
|
||||||
내 덱·공개 덱·프로필
|
내 덱·공개 덱·프로필
|
||||||
deckHistory.html, preset.html, profile.html — HTML 클래스 + components.css
|
deckHistory.html, preset.html, profile.html — HTML 클래스 + components.css
|
||||||
|
|||||||
@@ -186,8 +186,8 @@
|
|||||||
|
|
||||||
| Method | Path | 기능 |
|
| Method | Path | 기능 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/api/v1/admin/users` | 회원 목록·검색 |
|
| GET | `/api/v1/admin/users` | 회원 목록·검색 (`deckPublic`, `deckTotal` 포함) |
|
||||||
| GET | `/api/v1/admin/users/:id` | 회원 상세 |
|
| GET | `/api/v1/admin/users/:id` | 회원 상세 (`deckPublic`, `deckTotal`, `deckCount` 호환) |
|
||||||
| PUT | `/api/v1/admin/users/:id` | 회원 정보·역할·활성 상태 변경 |
|
| PUT | `/api/v1/admin/users/:id` | 회원 정보·역할·활성 상태 변경 |
|
||||||
| POST | `/api/v1/admin/users/:id/reset-password` | 비밀번호 초기화 |
|
| POST | `/api/v1/admin/users/:id/reset-password` | 비밀번호 초기화 |
|
||||||
| GET | `/api/v1/admin/decks` | 전체 덱 목록 |
|
| GET | `/api/v1/admin/decks` | 전체 덱 목록 |
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## 2026-06-19 / v4.0.9 — 관리자 회원 목록 2열·덱 이용 통계
|
||||||
|
|
||||||
|
### 수행 작업
|
||||||
|
- `admin.html` 회원 카드: `sm:grid-cols-2` 2열 그리드, 카드 패딩·아바타 크기 축소
|
||||||
|
- 회원별 저장 덱 수 `공개/전체` 형식 표시 (관리자 전용)
|
||||||
|
- `GET /api/v1/admin/users`: `deckPublic`, `deckTotal` 필드 추가 (`share_type='user'` 기준)
|
||||||
|
- `GET /api/v1/admin/users/:id`: `deckPublic`, `deckTotal` 분리 반환
|
||||||
|
- `admin.html` `output.css?v=4.0.9`, `npm run build:css` 재실행
|
||||||
|
|
||||||
## 2026-06-23 / v4.0.8 — nginx 정적 캐시·deck-favorites 배포 수정
|
## 2026-06-23 / v4.0.8 — nginx 정적 캐시·deck-favorites 배포 수정
|
||||||
|
|
||||||
### 수행 작업
|
### 수행 작업
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user