feat: show user logs in admin panel user card
This commit is contained in:
@@ -4,8 +4,12 @@
|
|||||||
import {
|
import {
|
||||||
AdminBadge,
|
AdminBadge,
|
||||||
AdminButton,
|
AdminButton,
|
||||||
|
AdminEmptyState,
|
||||||
|
AdminPagination,
|
||||||
AdminSectionHeader,
|
AdminSectionHeader,
|
||||||
AdminSelect,
|
AdminSelect,
|
||||||
|
AdminTable,
|
||||||
|
AdminTableSkeleton,
|
||||||
AdminTrafficCard,
|
AdminTrafficCard,
|
||||||
} from "$components/patterns/admin/index.js";
|
} from "$components/patterns/admin/index.js";
|
||||||
import {
|
import {
|
||||||
@@ -51,7 +55,21 @@
|
|||||||
userBanConfirmOpen,
|
userBanConfirmOpen,
|
||||||
userMessageConfirmOpen,
|
userMessageConfirmOpen,
|
||||||
premiumUnlimitedDraft,
|
premiumUnlimitedDraft,
|
||||||
|
userDetailTab,
|
||||||
|
userLogs,
|
||||||
|
userLogsTotal,
|
||||||
|
userLogsPage,
|
||||||
|
userLogsLoading,
|
||||||
|
userLogsLoaded,
|
||||||
|
userLogsPageSize,
|
||||||
} = $usersStore);
|
} = $usersStore);
|
||||||
|
|
||||||
|
$: userLogsHasMore =
|
||||||
|
Number(userLogsTotal || 0) > (Number(userLogsPage || 0) + 1) * Number(userLogsPageSize || 20);
|
||||||
|
|
||||||
|
$: if (openedUser && userDetailTab === "logs" && !userLogsLoading && !userLogsLoaded) {
|
||||||
|
usersStore.loadUserLogs(0);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -250,6 +268,9 @@
|
|||||||
<Tabs.Trigger value="activity" class="admin-tabs-trigger"
|
<Tabs.Trigger value="activity" class="admin-tabs-trigger"
|
||||||
>{at("user_tab_activity", {}, "Активность")}</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"
|
<Tabs.Trigger value="actions" class="admin-tabs-trigger"
|
||||||
>{at("user_tab_actions", {}, "Действия")}</Tabs.Trigger
|
>{at("user_tab_actions", {}, "Действия")}</Tabs.Trigger
|
||||||
>
|
>
|
||||||
@@ -436,6 +457,94 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Tabs.Content>
|
</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">
|
<Tabs.Content value="actions" class="admin-tabs-content admin-actions-tab">
|
||||||
<div class="admin-user-quick-actions">
|
<div class="admin-user-quick-actions">
|
||||||
<AdminButton
|
<AdminButton
|
||||||
@@ -945,4 +1054,41 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-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>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { writable } from "svelte/store";
|
|||||||
|
|
||||||
export function createUsersStore({ api, onToast, at }) {
|
export function createUsersStore({ api, onToast, at }) {
|
||||||
const USERS_PAGE_SIZE = 25;
|
const USERS_PAGE_SIZE = 25;
|
||||||
|
const USER_LOGS_PAGE_SIZE = 20;
|
||||||
|
|
||||||
const state = writable({
|
const state = writable({
|
||||||
users: [],
|
users: [],
|
||||||
@@ -30,6 +31,14 @@ export function createUsersStore({ api, onToast, at }) {
|
|||||||
regularBonusGbDraft: "",
|
regularBonusGbDraft: "",
|
||||||
grantTrafficGbDraft: "",
|
grantTrafficGbDraft: "",
|
||||||
grantTrafficKindDraft: "regular",
|
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
|
let _activeRef = "stats"; // fallback if active isn't tracked
|
||||||
@@ -96,6 +105,12 @@ export function createUsersStore({ api, onToast, at }) {
|
|||||||
userExtendDays: 30,
|
userExtendDays: 30,
|
||||||
userDetailLoading: true,
|
userDetailLoading: true,
|
||||||
userDetailTab: "subscription",
|
userDetailTab: "subscription",
|
||||||
|
userLogs: [],
|
||||||
|
userLogsTotal: 0,
|
||||||
|
userLogsPage: 0,
|
||||||
|
userLogsLoading: false,
|
||||||
|
userLogsLoaded: false,
|
||||||
|
userLogsUserId: userId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (!opts.skipPush) _pushUserPath(userId);
|
if (!opts.skipPush) _pushUserPath(userId);
|
||||||
@@ -138,11 +153,61 @@ export function createUsersStore({ api, onToast, at }) {
|
|||||||
userDeleteOpen: false,
|
userDeleteOpen: false,
|
||||||
userBanConfirmOpen: false,
|
userBanConfirmOpen: false,
|
||||||
userMessageConfirmOpen: false,
|
userMessageConfirmOpen: false,
|
||||||
|
userLogs: [],
|
||||||
|
userLogsTotal: 0,
|
||||||
|
userLogsPage: 0,
|
||||||
|
userLogsLoading: false,
|
||||||
|
userLogsLoaded: false,
|
||||||
|
userLogsUserId: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (wasOpen && !opts.skipPush) _pushUserPath(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", {}, "Скопировано")) {
|
function copyToClipboard(text, successMessage = at("link_copied", {}, "Скопировано")) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
|
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
|
||||||
@@ -451,5 +516,7 @@ export function createUsersStore({ api, onToast, at }) {
|
|||||||
savePremiumTrafficOverride,
|
savePremiumTrafficOverride,
|
||||||
saveRegularTrafficOverride,
|
saveRegularTrafficOverride,
|
||||||
grantTraffic,
|
grantTraffic,
|
||||||
|
loadUserLogs,
|
||||||
|
setUserLogsPage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1075,7 +1075,11 @@
|
|||||||
"admin_user_ref_link_copied": "Referral link copied",
|
"admin_user_ref_link_copied": "Referral link copied",
|
||||||
"admin_user_tab_subscription": "Subscription",
|
"admin_user_tab_subscription": "Subscription",
|
||||||
"admin_user_tab_activity": "Activity",
|
"admin_user_tab_activity": "Activity",
|
||||||
|
"admin_user_tab_logs": "Logs",
|
||||||
"admin_user_tab_actions": "Actions",
|
"admin_user_tab_actions": "Actions",
|
||||||
|
"admin_user_logs_section_title": "User logs",
|
||||||
|
"admin_user_logs_admin_event": "Admin",
|
||||||
|
"admin_refresh": "Refresh",
|
||||||
"admin_user_label_active_until": "Active until",
|
"admin_user_label_active_until": "Active until",
|
||||||
"admin_user_label_tariff": "Tariff",
|
"admin_user_label_tariff": "Tariff",
|
||||||
"admin_user_label_auto_renew": "Auto-renew",
|
"admin_user_label_auto_renew": "Auto-renew",
|
||||||
|
|||||||
@@ -1075,7 +1075,11 @@
|
|||||||
"admin_user_ref_link_copied": "Реф. ссылка скопирована",
|
"admin_user_ref_link_copied": "Реф. ссылка скопирована",
|
||||||
"admin_user_tab_subscription": "Подписка",
|
"admin_user_tab_subscription": "Подписка",
|
||||||
"admin_user_tab_activity": "Активность",
|
"admin_user_tab_activity": "Активность",
|
||||||
|
"admin_user_tab_logs": "Логи",
|
||||||
"admin_user_tab_actions": "Действия",
|
"admin_user_tab_actions": "Действия",
|
||||||
|
"admin_user_logs_section_title": "Логи пользователя",
|
||||||
|
"admin_user_logs_admin_event": "Админ",
|
||||||
|
"admin_refresh": "Обновить",
|
||||||
"admin_user_label_active_until": "Активна до",
|
"admin_user_label_active_until": "Активна до",
|
||||||
"admin_user_label_tariff": "Тариф",
|
"admin_user_label_tariff": "Тариф",
|
||||||
"admin_user_label_auto_renew": "Авто-продление",
|
"admin_user_label_auto_renew": "Авто-продление",
|
||||||
|
|||||||
Reference in New Issue
Block a user