feat: show user logs in admin panel user card
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user