feat: show user logs in admin panel user card

This commit is contained in:
3252a8
2026-05-13 22:51:04 +03:00
parent e065c85a3e
commit 18ba00c98a
4 changed files with 221 additions and 0 deletions
@@ -4,8 +4,12 @@
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminPagination,
AdminSectionHeader,
AdminSelect,
AdminTable,
AdminTableSkeleton,
AdminTrafficCard,
} from "$components/patterns/admin/index.js";
import {
@@ -51,7 +55,21 @@
userBanConfirmOpen,
userMessageConfirmOpen,
premiumUnlimitedDraft,
userDetailTab,
userLogs,
userLogsTotal,
userLogsPage,
userLogsLoading,
userLogsLoaded,
userLogsPageSize,
} = $usersStore);
$: userLogsHasMore =
Number(userLogsTotal || 0) > (Number(userLogsPage || 0) + 1) * Number(userLogsPageSize || 20);
$: if (openedUser && userDetailTab === "logs" && !userLogsLoading && !userLogsLoaded) {
usersStore.loadUserLogs(0);
}
</script>
<Dialog
@@ -250,6 +268,9 @@
<Tabs.Trigger value="activity" class="admin-tabs-trigger"
>{at("user_tab_activity", {}, "Активность")}</Tabs.Trigger
>
<Tabs.Trigger value="logs" class="admin-tabs-trigger"
>{at("user_tab_logs", {}, "Логи")}</Tabs.Trigger
>
<Tabs.Trigger value="actions" class="admin-tabs-trigger"
>{at("user_tab_actions", {}, "Действия")}</Tabs.Trigger
>
@@ -436,6 +457,94 @@
{/if}
</Tabs.Content>
<Tabs.Content value="logs" class="admin-tabs-content admin-user-logs-tab">
<div class="admin-user-logs-head">
<div class="admin-subsection-title">
{at("user_logs_section_title", {}, "Логи пользователя")}
</div>
<div class="admin-user-logs-meta">
<span class="admin-muted">{at("total", {}, "Всего")}</span>
<strong>{userLogsTotal}</strong>
<AdminButton
size="sm"
variant="ghost"
disabled={userLogsLoading}
onclick={() => usersStore.loadUserLogs(userLogsPage)}
title={at("refresh", {}, "Обновить")}
>
<RefreshCw size={14} />
{at("refresh", {}, "Обновить")}
</AdminButton>
</div>
</div>
<div class="admin-user-logs-wrap">
{#if userLogsLoading}
<AdminTableSkeleton
headers={[
at("date", {}, "Дата"),
at("event", {}, "Событие"),
at("content", {}, "Контент"),
]}
rows={6}
widths={["140px", "140px", "60%"]}
/>
{:else if !userLogs.length}
<AdminEmptyState tone="card">
<span class="admin-muted">{at("logs_empty", {}, "Записей нет")}</span>
</AdminEmptyState>
{:else}
<AdminTable>
<thead>
<tr>
<th>{at("date", {}, "Дата")}</th>
<th>{at("event", {}, "Событие")}</th>
<th>{at("content", {}, "Контент")}</th>
</tr>
</thead>
<tbody>
{#each userLogs as entry (entry.log_id)}
<tr>
<td data-label={at("date", {}, "Дата")}>{fmtDate(entry.timestamp)}</td>
<td class="admin-cell-mono" data-label={at("event", {}, "Событие")}>
<span class="admin-user-log-event">
<span>{entry.event_type || "—"}</span>
{#if entry.is_admin_event}
<AdminBadge variant="warning"
>{at("user_logs_admin_event", {}, "Админ")}</AdminBadge
>
{/if}
{#if entry.target_user_id && entry.target_user_id !== openedUser?.user_id}
<small class="admin-muted">→ {entry.target_user_id}</small>
{/if}
</span>
</td>
<td
class="admin-cell-wrap admin-user-log-content"
data-label={at("content", {}, "Контент")}
>
{entry.content || ""}
</td>
</tr>
{/each}
</tbody>
</AdminTable>
{/if}
</div>
{#if userLogsLoaded && userLogsTotal > userLogsPageSize}
<AdminPagination
meta={`${at("page_short", {}, "Стр.")} ${userLogsPage + 1}`}
prevLabel={at("back", {}, "Назад")}
nextLabel={at("next", {}, "Далее")}
prevDisabled={userLogsPage === 0 || userLogsLoading}
nextDisabled={!userLogsHasMore || userLogsLoading}
onPrev={() => usersStore.setUserLogsPage(Math.max(0, userLogsPage - 1))}
onNext={() => usersStore.setUserLogsPage(userLogsPage + 1)}
/>
{/if}
</Tabs.Content>
<Tabs.Content value="actions" class="admin-tabs-content admin-actions-tab">
<div class="admin-user-quick-actions">
<AdminButton
@@ -945,4 +1054,41 @@
width: 100%;
max-width: 100%;
}
:global(.admin-user-dialog .admin-user-logs-tab) {
display: flex;
flex-direction: column;
gap: 12px;
}
.admin-user-logs-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.admin-user-logs-meta {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.admin-user-logs-wrap {
min-height: 120px;
}
.admin-user-log-event {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
:global(.admin-user-log-content) {
white-space: pre-wrap;
word-break: break-word;
max-width: 520px;
}
@media (max-width: 640px) {
:global(.admin-user-log-content) {
max-width: 100%;
}
}
</style>
@@ -2,6 +2,7 @@ import { writable } from "svelte/store";
export function createUsersStore({ api, onToast, at }) {
const USERS_PAGE_SIZE = 25;
const USER_LOGS_PAGE_SIZE = 20;
const state = writable({
users: [],
@@ -30,6 +31,14 @@ export function createUsersStore({ api, onToast, at }) {
regularBonusGbDraft: "",
grantTrafficGbDraft: "",
grantTrafficKindDraft: "regular",
userLogs: [],
userLogsTotal: 0,
userLogsPage: 0,
userLogsLoading: false,
userLogsLoaded: false,
userLogsUserId: null,
userLogsPageSize: USER_LOGS_PAGE_SIZE,
});
let _activeRef = "stats"; // fallback if active isn't tracked
@@ -96,6 +105,12 @@ export function createUsersStore({ api, onToast, at }) {
userExtendDays: 30,
userDetailLoading: true,
userDetailTab: "subscription",
userLogs: [],
userLogsTotal: 0,
userLogsPage: 0,
userLogsLoading: false,
userLogsLoaded: false,
userLogsUserId: userId,
}));
if (!opts.skipPush) _pushUserPath(userId);
@@ -138,11 +153,61 @@ export function createUsersStore({ api, onToast, at }) {
userDeleteOpen: false,
userBanConfirmOpen: false,
userMessageConfirmOpen: false,
userLogs: [],
userLogsTotal: 0,
userLogsPage: 0,
userLogsLoading: false,
userLogsLoaded: false,
userLogsUserId: null,
};
});
if (wasOpen && !opts.skipPush) _pushUserPath(null);
}
async function loadUserLogs(page) {
let s;
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
const userId = s.openedUser.user_id;
const targetPage = Number.isFinite(page) ? Math.max(0, Math.floor(page)) : s.userLogsPage || 0;
state.update((st) => ({
...st,
userLogsLoading: true,
userLogsPage: targetPage,
userLogsUserId: userId,
}));
try {
const params = new URLSearchParams({
page: String(targetPage),
page_size: String(USER_LOGS_PAGE_SIZE),
user_id: String(userId),
});
const data = await api(`/admin/logs?${params.toString()}`);
if (data?.ok) {
state.update((st) => {
if (!st.openedUser || st.openedUser.user_id !== userId) return st;
return {
...st,
userLogs: data.logs || [],
userLogsTotal: Number(data.total || 0),
userLogsLoaded: true,
};
});
} else if (data?.error) {
onToast(data.error);
}
} finally {
state.update((st) => ({ ...st, userLogsLoading: false }));
}
}
function setUserLogsPage(page) {
loadUserLogs(page);
}
function copyToClipboard(text, successMessage = at("link_copied", {}, "Скопировано")) {
if (!text) return;
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
@@ -451,5 +516,7 @@ export function createUsersStore({ api, onToast, at }) {
savePremiumTrafficOverride,
saveRegularTrafficOverride,
grantTraffic,
loadUserLogs,
setUserLogsPage,
};
}