Compare commits

...

1 Commits

Author SHA1 Message Date
953837137a 회원 접속일과 콘텐츠 활동일을 분리한다 2026-04-03 13:20:46 +09:00
10 changed files with 86 additions and 22 deletions

View File

@@ -66,6 +66,7 @@ function mapUserRow(row) {
tierListCount: Number(row.tierlist_count || 0),
followerCount: Number(row.follower_count || 0),
receivedFavoriteCount: Number(row.received_favorite_count || 0),
lastLoginAt: Number(row.last_login_at || 0),
recentActivityAt: Number(row.recent_activity_at || row.created_at || 0),
}
}
@@ -284,6 +285,7 @@ async function ensureSchema() {
email_verified TINYINT(1) NOT NULL DEFAULT 1,
is_admin TINYINT(1) NOT NULL DEFAULT 0,
avatar_src VARCHAR(255) NOT NULL DEFAULT '',
last_login_at BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
@@ -294,6 +296,12 @@ async function ensureSchema() {
await query('UPDATE users SET email_verified = 1 WHERE email_verified IS NULL')
}
const userLastLoginColumns = await query("SHOW COLUMNS FROM users LIKE 'last_login_at'")
if (!userLastLoginColumns.length) {
await query('ALTER TABLE users ADD COLUMN last_login_at BIGINT NOT NULL DEFAULT 0 AFTER avatar_src')
await query('UPDATE users SET last_login_at = created_at WHERE last_login_at = 0')
}
await query(`
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id VARCHAR(64) PRIMARY KEY,
@@ -636,7 +644,7 @@ async function countUsers() {
async function findUserByEmail(email) {
const rows = await query(
'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1',
'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE email = ? LIMIT 1',
[email]
)
const row = rows[0]
@@ -650,7 +658,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
const rows = excludeUserId
? await query(
`
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
FROM users
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ?
LIMIT 1
@@ -659,7 +667,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
)
: await query(
`
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
FROM users
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?))
LIMIT 1
@@ -673,7 +681,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
async function findUserById(id) {
const rows = await query(
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1',
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE id = ? LIMIT 1',
[id]
)
return mapUserRow(rows[0])
@@ -683,14 +691,24 @@ async function createUser({ id, email, nickname, passwordHash, emailVerified = t
const createdAt = now()
await query(
`
INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', createdAt]
[id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt]
)
return findUserById(id)
}
async function touchUserLastLoginAt(userId, timestamp = now()) {
const lastLoginAt = Number(timestamp) || now()
const staleBefore = lastLoginAt - 10 * 60 * 1000
await query(
'UPDATE users SET last_login_at = ? WHERE id = ? AND last_login_at < ?',
[lastLoginAt, userId, staleBefore]
)
return findUserById(userId)
}
async function updateUserPassword({ id, passwordHash }) {
await query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id])
return findUserById(id)
@@ -816,7 +834,7 @@ async function updateUserProfile({ id, nickname, avatarSrc }) {
async function findPrimaryAdminUser() {
const rows = await query(
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
)
return mapUserRow(rows[0])
}
@@ -849,6 +867,10 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' }
? isAsc
? 'received_favorite_count ASC, recent_activity_at ASC, u.email ASC'
: 'received_favorite_count DESC, recent_activity_at DESC, u.email ASC'
: sort === 'lastLogin'
? isAsc
? 'u.last_login_at ASC, recent_activity_at ASC, u.email ASC'
: 'u.last_login_at DESC, recent_activity_at DESC, u.email ASC'
: isAsc
? 'recent_activity_at ASC, u.created_at ASC, u.email ASC'
: 'recent_activity_at DESC, u.created_at ASC, u.email ASC'
@@ -862,6 +884,7 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' }
u.email_verified,
u.is_admin,
u.avatar_src,
u.last_login_at,
u.created_at,
COUNT(DISTINCT t.id) AS tierlist_count,
COUNT(DISTINCT uf.follower_id) AS follower_count,
@@ -875,7 +898,7 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' }
LEFT JOIN user_follows uf ON uf.following_id = u.id
LEFT JOIN favorite_tierlists ft ON ft.tierlist_id = t.id
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
GROUP BY u.id, u.email, u.nickname, u.email_verified, u.is_admin, u.avatar_src, u.created_at
GROUP BY u.id, u.email, u.nickname, u.email_verified, u.is_admin, u.avatar_src, u.last_login_at, u.created_at
ORDER BY ${orderBy}
`,
params
@@ -2987,6 +3010,7 @@ module.exports = {
findUserById,
findUserProfileById,
createUser,
touchUserLastLoginAt,
updateUserPassword,
verifyUserEmail,
createEmailVerificationToken,

View File

@@ -976,7 +976,7 @@ router.delete('/custom-items', requireAdmin, async (req, res) => {
router.get('/users', requireAdmin, async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
sort: z.enum(['recent', 'created', 'tierlists', 'followers', 'favorites']).optional().default('recent'),
sort: z.enum(['recent', 'lastLogin', 'created', 'tierlists', 'followers', 'favorites']).optional().default('recent'),
direction: z.enum(['asc', 'desc']).optional().default('desc'),
})
const parsed = schema.safeParse(req.query)

View File

@@ -10,6 +10,7 @@ const {
findUserByNickname,
findUserById,
createUser,
touchUserLastLoginAt,
updateUserPassword,
verifyUserEmail,
createEmailVerificationToken,
@@ -204,7 +205,8 @@ router.post('/signup', async (req, res) => {
try {
await establishSession(req, user)
res.json({ user: await serializeUser(user), verificationRequired: false })
const touchedUser = await touchUserLastLoginAt(user.id)
res.json({ user: await serializeUser(touchedUser || user), verificationRequired: false })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
@@ -227,7 +229,8 @@ router.post('/login', async (req, res) => {
try {
await establishSession(req, user)
res.json({ user: await serializeUser(user) })
const touchedUser = await touchUserLastLoginAt(user.id)
res.json({ user: await serializeUser(touchedUser || user) })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
@@ -240,7 +243,7 @@ router.post('/logout', async (req, res) => {
router.get('/me', async (req, res) => {
if (!req.session || !req.session.userId) return res.json({ user: null })
const user = await findUserById(req.session.userId)
const user = await touchUserLastLoginAt(req.session.userId)
if (!user) return res.json({ user: null })
res.json({ user: await serializeUser(user) })
})
@@ -265,7 +268,8 @@ router.post('/email/verify', async (req, res) => {
try {
await establishSession(req, user)
res.json({ user: await serializeUser(user) })
const touchedUser = await touchUserLastLoginAt(user.id)
res.json({ user: await serializeUser(touchedUser || user) })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
@@ -321,7 +325,8 @@ router.post('/password-reset/confirm', async (req, res) => {
try {
await establishSession(req, verifiedUser)
res.json({ user: await serializeUser(verifiedUser) })
const touchedUser = await touchUserLastLoginAt(verifiedUser.id)
res.json({ user: await serializeUser(touchedUser || verifiedUser) })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}

View File

@@ -1,5 +1,10 @@
# 의사결정 이력
## 2026-04-03 v1.4.55
- 기존 `최근 활동`은 실제 의미가 “작성한 티어표의 마지막 수정일”에 가까웠는데, 이를 마지막 접속일처럼 읽을 수 있으면 장기 미접속 계정 정리 판단이 틀어질 수 있으므로 `최근 콘텐츠 활동``마지막 접속일`을 아예 분리하는 편이 맞다고 판단했다.
- 마지막 접속일은 로그인 성공 순간만 찍으면 장기 세션 사용자를 놓칠 수 있으므로, 세션이 살아 있는 `/api/auth/me` 확인에서도 일정 간격으로 갱신해 실제 접속 흔적에 더 가깝게 유지하는 쪽으로 정리했다.
- 관리자 회원 카드에서 팔로워/즐겨찾기 수치를 보더라도 실제 공개 프로필과 작성 글 구성을 바로 확인할 진입점이 없으면 운영 판단이 끊기므로, `회원 정보 수정` 옆에 `프로필 보기` 버튼을 같이 두는 편이 자연스럽다고 판단했다.
## 2026-04-03 v1.4.54
- 추천 티어표를 수동으로만 지정할 수 있어도, 운영자가 후보 자체를 찾지 못하면 실무상 큐레이션이 막히므로, 관리자 전체 티어표 목록에 받은 즐겨찾기 수를 직접 보여주고 즐겨찾기 많은 순/최소 즐겨찾기 필터를 먼저 붙이는 편이 맞다고 판단했다.
- 누가 핵심 작성자인지 보는 기준도 작성 티어표 수 하나만으로는 부족하므로, 팔로워 수와 받은 즐겨찾기 수를 회원 관리 카드에 같이 노출하고 이 지표로 정렬할 수 있게 두는 쪽으로 정리했다.

View File

@@ -47,7 +47,7 @@
## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원의 작성 티어표·팔로워·받은 즐겨찾기 지표 확인과 회원 정보·권한 수정, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원의 작성 티어표·팔로워·받은 즐겨찾기·최근 콘텐츠 활동·마지막 접속일 확인과 회원 정보·권한 수정 및 공개 프로필 보기, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
- 연동 API: `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/tierlists/stats`, `PATCH /api/admin/tierlists/:tierListId/featured`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId`
## `/profile`

View File

@@ -67,8 +67,9 @@
- `emailVerified`: boolean
- `isAdmin`: boolean
- `avatarSrc`: string
- `lastLoginAt`: number
- `createdAt`: number
- 관리자 목록 집계 응답에서는 `tierListCount`, `followerCount`, `receivedFavoriteCount`, `recentActivityAt`도 함께 내려준다.
- 관리자 목록 집계 응답에서는 `tierListCount`, `followerCount`, `receivedFavoriteCount`, `lastLoginAt`, `recentActivityAt`도 함께 내려준다.
- `emailVerificationTokens`
- `id`: string
- `userId`: string
@@ -138,18 +139,21 @@
- 이메일 인증이 끝나지 않은 계정은 `email_unverified`로 차단한다.
- `POST /api/auth/logout`
- `GET /api/auth/me`
- 로그인 세션이 살아 있는 사용자의 `last_login_at`을 주기적으로 갱신해, 회원 관리에서 `마지막 접속일`을 따로 볼 수 있게 한다.
- `GET /api/auth/meta`
- `POST /api/auth/profile`
- `POST /api/auth/password`
- 로그인한 사용자가 현재 비밀번호를 확인한 뒤 새 비밀번호로 직접 변경한다.
- `POST /api/auth/email/verify`
- `login?verifyToken=...` 링크에서 받은 토큰으로 이메일 인증을 완료하고 바로 로그인 세션을 만든다.
- 인증 완료 직후 로그인 세션이 열리면서 `last_login_at`도 함께 갱신한다.
- `POST /api/auth/email/resend`
- 미인증 계정의 인증 메일을 다시 발송한다.
- `POST /api/auth/password-reset/request`
- 입력한 이메일로 비밀번호 재설정 링크를 발송한다.
- `POST /api/auth/password-reset/confirm`
- `login?resetToken=...` 링크의 토큰과 새 비밀번호로 비밀번호를 재설정하고 바로 로그인 세션을 만든다.
- 재설정 완료 직후 로그인 세션이 열리면서 `last_login_at`도 함께 갱신한다.
- 주제
- `GET /api/topics`
- `GET /api/topics/:topicId`
@@ -198,7 +202,7 @@
- `DELETE /api/admin/custom-items/:itemId`
- `DELETE /api/admin/custom-items`
- `GET /api/admin/users`
- `sort=recent|created|tierlists|followers|favorites`, `direction=asc|desc`로 회원을 활동/작성량/팔로워/받은 즐겨찾기 기준으로 정렬한다.
- `sort=recent|lastLogin|created|tierlists|followers|favorites`, `direction=asc|desc`로 회원을 콘텐츠 활동/마지막 접속/작성량/팔로워/받은 즐겨찾기 기준으로 정렬한다.
- `PATCH /api/admin/users/:userId`
- `PATCH /api/admin/users/:userId/password`
- `DELETE /api/admin/users/:userId`
@@ -227,8 +231,10 @@
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
- 단, 일반 운영자는 최고 관리자 계정의 프로필 이미지/회원 정보/비밀번호/삭제 버튼을 사용할 수 없고, 최고 관리자만 다른 관리자 권한을 변경할 수 있다.
- 관리자 회원 정보 수정은 운영상 필요한 경우 예약어 닉네임도 저장할 수 있지만, 일반 회원가입과 개인 프로필 수정에서는 운영자 사칭성 예약어 닉네임을 계속 차단한다.
- 회원 관리 카드에는 아바타, 작성 티어표 수, 팔로워 수, 받은 즐겨찾기 수, 최근 활동 시각을 함께 표시한다.
- 회원 관리 카드에는 아바타, 작성 티어표 수, 팔로워 수, 받은 즐겨찾기 수, 최근 콘텐츠 활동, 마지막 접속일을 함께 표시한다.
- 운영자는 회원 목록을 작성 티어표 수뿐 아니라 팔로워 수와 받은 즐겨찾기 수 기준으로도 정렬할 수 있어, 핵심 작성자를 더 빠르게 찾을 수 있다.
- 마지막 접속일은 로그인/세션 확인 기준, 최근 콘텐츠 활동은 작성한 티어표의 마지막 수정일 기준으로 분리해서 보여준다. 따라서 장기 미접속 계정 정리 판단은 마지막 접속일을 우선 사용하고, 콘텐츠 기여가 최근인지 볼 때는 최근 콘텐츠 활동을 사용한다.
- 회원 카드의 `프로필 보기` 버튼은 해당 회원의 `/users/:userId` 공개 프로필 화면으로 이동해, 팔로워/공개 티어표 현황을 관리자 화면 밖에서도 바로 확인할 수 있게 한다.
- 회원 비밀번호를 운영자가 임의로 덮어쓰는 기능은 비상 상황용 API로만 유지하고, 일반 회원 관리 카드에서는 비밀번호 초기화 버튼과 모달을 숨긴다. 평소 사용자 비밀번호 변경은 이메일 재설정 메일과 설정 화면 직접 변경을 우선 사용한다.
## 티어표 접근 메모

View File

@@ -1,6 +1,9 @@
# 할 일 및 이슈
## 단기 확인
- `v1.4.55`에서 회원 카드의 `최근 활동``최근 콘텐츠 활동`으로 바꾸고 `마지막 접속일`을 따로 추가했으므로, 티어표를 수정하지 않고 로그인만 한 계정은 마지막 접속일만 갱신되고 최근 콘텐츠 활동은 유지되는지, 반대로 로그인 없이 과거 티어표만 있던 계정은 두 값이 다르게 보이는지 QA한다.
- `/api/auth/me`에서도 `last_login_at`을 10분 단위 이상 간격으로만 갱신하도록 넣었으므로, 새로고침을 반복해도 과도한 DB 쓰기가 생기지 않으면서 실제 재접속 후에는 마지막 접속일이 자연스럽게 갱신되는지 확인한다.
- 관리자 회원 목록의 `마지막 접속순` 정렬과 회원 카드의 `프로필 보기` 버튼이 정상 동작하고, 버튼 클릭 시 해당 회원의 `/users/:userId` 공개 프로필 화면으로 이동하는지 확인한다.
- `v1.4.54`에서 관리자 전체 티어표 카드에 즐겨찾기 수와 인기순 정렬/최소 즐겨찾기 필터를 붙였으므로, 즐겨찾기 많은 순으로 바꿨을 때 실제 받은 즐겨찾기 수가 큰 글부터 보이고 최소값을 올리면 추천 후보만 남는지 확인한다.
- 관리자 전체 티어표 통계 카드도 최소 즐겨찾기 필터가 적용된 범위 기준으로 `전체/추천/공개/비공개` 숫자가 바뀌는지 QA한다.
- `v1.4.54`에서 회원 관리 카드에 팔로워 수와 받은 즐겨찾기 수를 추가했으므로, 팔로워 많은 순/받은 즐겨찾기 많은 순 정렬이 실제 운영 데이터 순서와 맞고 최고 관리자 보호 로직도 그대로 유지되는지 확인한다.

View File

@@ -1,5 +1,10 @@
# 업데이트 로그
## 2026-04-03 v1.4.55
- 관리자 회원 카드의 `최근 활동`이 실제로는 마지막 접속이 아니라 작성 티어표의 마지막 수정 시각 기준이었으므로, 라벨을 `최근 콘텐츠 활동`으로 분명하게 바꾸고 `마지막 접속일`을 별도 줄로 추가해 두 의미를 분리했다.
- 백엔드 `users``last_login_at`을 추가하고, 로그인/이메일 인증 완료/비밀번호 재설정 완료/세션 기반 `/api/auth/me` 확인 시 해당 시각을 갱신하도록 보강했다. 기존 계정은 마이그레이션 시 `created_at`으로 1차 채워 오래된 계정도 빈 값 없이 정렬할 수 있게 했다.
- 관리자 회원 목록 정렬에 `마지막 접속순`을 추가하고, 회원 카드의 `회원 정보 수정` 옆에 `프로필 보기` 버튼을 붙여 해당 유저의 `/users/:userId` 공개 프로필 화면으로 바로 이동할 수 있게 했다.
## 2026-04-03 v1.4.54
- 관리자 `전체 티어표 관리` 카드에 받은 즐겨찾기 수를 표시하고, 우측 운영 패널에 `최근 수정순 / 최근 생성순 / 즐겨찾기 많은 순` 정렬과 `최소 즐겨찾기 수` 필터를 추가해, 운영자가 추천 후보가 될 만한 인기 티어표를 더 빨리 찾을 수 있게 했다.
- 관리자 회원 관리 카드에 `팔로워 수``받은 즐겨찾기 수`를 추가하고, 정렬 기준에도 `팔로워 많은 순`, `받은 즐겨찾기 많은 순`을 붙여 어떤 작성자가 핵심 기여자인지 운영자가 더 쉽게 파악할 수 있게 했다.

View File

@@ -20,6 +20,7 @@ const props = defineProps({
canDeleteUser: { type: Function, required: true },
roleLabelOf: { type: Function, required: true },
fmt: { type: Function, required: true },
openUserProfile: { type: Function, required: true },
openUserDeleteModal: { type: Function, required: true },
openUserEditModal: { type: Function, required: true },
deleteIcon: { type: String, required: true },
@@ -53,7 +54,8 @@ const userSortDirectionModel = computed({
<div class="toolbar toolbar--secondary">
<input v-model="userQueryModel" class="input toolbar__search" placeholder="이메일, 닉네임 검색" @keydown.enter.prevent="props.submitUserFilters" />
<select v-model="userSortModel" class="select toolbar__select" @change="props.submitUserFilters">
<option value="recent">최근 활동순</option>
<option value="recent">최근 콘텐츠 활동순</option>
<option value="lastLogin">마지막 접속순</option>
<option value="created">가입순</option>
<option value="tierlists">작성 티어표 많은 </option>
<option value="followers">팔로워 많은 </option>
@@ -114,7 +116,8 @@ const userSortDirectionModel = computed({
<div class="userInfoLine"><span>작성 티어표</span><strong>{{ user.tierListCount }}</strong></div>
<div class="userInfoLine"><span>팔로워</span><strong>{{ user.followerCount || 0 }}</strong></div>
<div class="userInfoLine"><span>받은 즐겨찾기</span><strong>{{ user.receivedFavoriteCount || 0 }}</strong></div>
<div class="userInfoLine"><span>최근 활동</span><strong>{{ props.fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
<div class="userInfoLine"><span>최근 콘텐츠 활동</span><strong>{{ props.fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
<div class="userInfoLine"><span>마지막 접속일</span><strong>{{ user.lastLoginAt ? props.fmt(user.lastLoginAt) : '기록 없음' }}</strong></div>
<div class="userInfoLine"><span>계정명</span><strong>{{ user.email }}</strong></div>
<div class="userInfoLine"><span>닉네임</span><strong>{{ user.nickname || '미설정' }}</strong></div>
<div class="userInfoLine"><span>권한</span><strong>{{ props.roleLabelOf(user) }}</strong></div>
@@ -138,6 +141,13 @@ const userSortDirectionModel = computed({
>
회원 정보 수정
</button>
<button
class="btn btn--ghost userSaveButton"
type="button"
@click="props.openUserProfile(user)"
>
프로필 보기
</button>
</div>
</article>
</div>

View File

@@ -2,7 +2,7 @@
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import { editorPath } from '../lib/paths'
import { editorPath, userProfilePath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import deleteIcon from '../assets/icons/delete.svg'
import SvgIcon from '../components/SvgIcon.vue'
@@ -1708,6 +1708,11 @@ function userAvatarFallback(user) {
return (user?.email?.trim()?.[0] || '?').toUpperCase()
}
function openUserProfile(user) {
if (!user?.id) return
router.push(userProfilePath(user.id))
}
</script>
<template>
@@ -1846,6 +1851,7 @@ function userAvatarFallback(user) {
:can-delete-user="canDeleteUser"
:role-label-of="roleLabelOf"
:fmt="fmt"
:open-user-profile="openUserProfile"
:open-user-delete-modal="openUserDeleteModal"
:open-user-edit-modal="openUserEditModal"
:delete-icon="deleteIcon"