chore: improve admin logs user cards

This commit is contained in:
3252a8
2026-05-30 22:15:33 +03:00
parent ea4ee4c4a7
commit c1e2fe2c95
8 changed files with 200 additions and 49 deletions
+49 -12
View File
@@ -143,12 +143,18 @@ def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Option
return None, None
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
"""Human-facing name for payments tables: TG profile name, else email, else user id."""
if loaded_user is None:
return str(payment_user_id)
def _user_display_label(
loaded_user: Any,
fallback_user_id: Optional[int],
*,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
username: Optional[str] = None,
email: Optional[str] = None,
) -> Optional[str]:
"""Human-facing name: TG profile name, else email, else user id."""
tid = getattr(loaded_user, "telegram_id", None)
if tid is not None:
if loaded_user is not None and tid is not None:
fn = (getattr(loaded_user, "first_name", None) or "").strip()
ln = (getattr(loaded_user, "last_name", None) or "").strip()
full = f"{fn} {ln}".strip()
@@ -157,10 +163,30 @@ def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
un = (getattr(loaded_user, "username", None) or "").strip()
if un:
return un if un.startswith("@") else f"@{un}"
return str(payment_user_id)
email = (getattr(loaded_user, "email", None) or "").strip()
if email:
return email
elif loaded_user is not None:
email = (getattr(loaded_user, "email", None) or "").strip()
if email:
return email
fn = (first_name or "").strip()
ln = (last_name or "").strip()
full = f"{fn} {ln}".strip()
if full:
return full
un = (username or "").strip()
if un:
return un if un.startswith("@") else f"@{un}"
email_value = (email or "").strip()
if email_value:
return email_value
if fallback_user_id is None:
return None
return str(fallback_user_id)
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
label = _user_display_label(loaded_user, payment_user_id)
if label:
return label
return str(payment_user_id)
@@ -229,16 +255,27 @@ def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None)
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
author_user = entry.__dict__.get("author_user")
target_user = entry.__dict__.get("target_user")
user_id = int(entry.user_id) if entry.user_id is not None else None
target_user_id = int(entry.target_user_id) if entry.target_user_id is not None else None
return {
"log_id": int(entry.log_id),
"user_id": int(entry.user_id) if entry.user_id else None,
"user_id": user_id,
"user_label": _user_display_label(
author_user,
user_id,
first_name=entry.telegram_first_name,
username=entry.telegram_username,
),
"telegram_username": entry.telegram_username,
"telegram_first_name": entry.telegram_first_name,
"email": getattr(getattr(entry, "author_user", None), "email", None),
"email": getattr(author_user, "email", None),
"event_type": entry.event_type,
"content": entry.content,
"is_admin_event": bool(entry.is_admin_event),
"target_user_id": int(entry.target_user_id) if entry.target_user_id else None,
"target_user_id": target_user_id,
"target_user_label": _user_display_label(target_user, target_user_id),
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
}
+16 -1
View File
@@ -389,6 +389,21 @@
usersStore.openUser(uid, { pathContext: "payments" });
}
function openLogsUserCard(userId) {
const uid = Number(userId);
if (!Number.isFinite(uid) || uid === 0) return;
const next = normalizeSection("logs");
sidebarOpen = false;
if (active !== next) {
active = next;
paymentsStore.closePayment({ skipPush: true });
supportStore.closeTicketView({ skipPush: true });
onSectionChange(next);
}
usersStore.setActive(next);
usersStore.openUser(uid, { skipPush: true, pathContext: "logs" });
}
function openUserCard(userId) {
const uid = Number(userId);
if (!Number.isFinite(uid) || uid === 0) return;
@@ -801,7 +816,7 @@
{/if}
{#if active === "logs"}
<LogsSection {at} {fmtDate} />
<LogsSection {at} {fmtDate} onOpenUserCard={openLogsUserCard} />
{/if}
{#if active === "support"}
+116 -7
View File
@@ -8,9 +8,11 @@
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
import { User } from "$components/ui/icons.js";
export let at;
export let fmtDate;
export let onOpenUserCard = () => {};
const logsStore = getContext("logsStore");
@@ -25,6 +27,25 @@
at("content", {}, "Контент"),
];
function userDisplay(entry, kind) {
const id = kind === "target" ? entry.target_user_id : entry.user_id;
const label = kind === "target" ? entry.target_user_label : entry.user_label;
if (label) return label;
if (kind !== "target") {
if (entry.telegram_first_name) return entry.telegram_first_name;
if (entry.telegram_username) {
const username = String(entry.telegram_username);
return username.startsWith("@") ? username : `@${username}`;
}
if (entry.email) return entry.email;
}
return id || "—";
}
function userId(entry, kind) {
return kind === "target" ? entry.target_user_id : entry.user_id;
}
onMount(() => {
logsStore.loadLogs();
});
@@ -65,7 +86,7 @@
<AdminTableSkeleton
headers={logHeaders}
rows={10}
widths={["120px", "120px", "58px", "58px", "220px"]}
widths={["120px", "120px", "160px", "160px", "220px"]}
/>
{:else if !logs.length}
<AdminEmptyState tone="card"
@@ -89,12 +110,50 @@
<td class="admin-cell-mono" data-label={at("event", {}, "Событие")}
>{entry.event_type}</td
>
<td class="admin-cell-mono" data-label={at("user_short", {}, "User")}
>{entry.user_id || "—"}</td
>
<td class="admin-cell-mono" data-label={at("target_short", {}, "Target")}
>{entry.target_user_id || "—"}</td
>
<td class="admin-logs-user-cell" data-label={at("user_short", {}, "User")}>
{#if userId(entry, "user")}
<span class="admin-logs-user">
<AdminButton
class="admin-logs-user-btn"
variant="ghost"
size="icon"
title={at("payments_open_user", {}, "Open user card")}
aria-label={at("payments_open_user", {}, "Open user card")}
onclick={() => onOpenUserCard(userId(entry, "user"))}
>
<User size={14} />
</AdminButton>
<span class="admin-logs-user-meta">
<span class="admin-logs-user-name">{userDisplay(entry, "user")}</span>
<span class="admin-logs-user-id">ID {userId(entry, "user")}</span>
</span>
</span>
{:else}
<span class="admin-muted"></span>
{/if}
</td>
<td class="admin-logs-user-cell" data-label={at("target_short", {}, "Target")}>
{#if userId(entry, "target")}
<span class="admin-logs-user">
<AdminButton
class="admin-logs-user-btn"
variant="ghost"
size="icon"
title={at("payments_open_user", {}, "Open user card")}
aria-label={at("payments_open_user", {}, "Open user card")}
onclick={() => onOpenUserCard(userId(entry, "target"))}
>
<User size={14} />
</AdminButton>
<span class="admin-logs-user-meta">
<span class="admin-logs-user-name">{userDisplay(entry, "target")}</span>
<span class="admin-logs-user-id">ID {userId(entry, "target")}</span>
</span>
</span>
{:else}
<span class="admin-muted"></span>
{/if}
</td>
<td class="admin-cell-wrap" data-label={at("content", {}, "Контент")}
>{entry.content || ""}</td
>
@@ -118,3 +177,53 @@
logsStore.setPage(logsPage + 1);
}}
/>
<style>
.admin-logs-user-cell {
min-width: 150px;
}
.admin-logs-user {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.admin-logs-user-meta {
display: grid;
gap: 2px;
min-width: 0;
}
.admin-logs-user-name {
min-width: 0;
overflow: hidden;
color: var(--admin-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-logs-user-id {
color: var(--admin-dim);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.2;
white-space: nowrap;
}
.admin-logs-user-cell :global(.admin-logs-user-btn.admin-btn) {
width: 30px;
height: 30px;
min-width: 30px;
min-height: 30px;
flex-shrink: 0;
padding: 0;
border-radius: 7px;
}
.admin-logs-user-cell :global(.admin-logs-user-btn svg) {
width: 14px;
height: 14px;
}
</style>
@@ -81,11 +81,7 @@
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
$: openedUserTelegramProfileHint =
openedUserTelegramProfileLinkKind === "id"
? at(
"user_open_tg_profile_id_hint",
{},
"Профиль будет открыт по Telegram ID. Telegram может заблокировать переход из-за настроек приватности пользователя или ограничений клиента."
)
? at("user_open_tg_profile_id_hint", {}, "Бот отправит кнопку профиля в Telegram")
: at("user_open_tg_profile_hint", {}, "Открыть профиль Telegram");
$: if (openedUser && userDetailTab === "logs" && !userLogsLoading && !userLogsLoaded) {
@@ -189,11 +185,6 @@
{at("user_open_tg_profile", {}, "Открыть Telegram")}
</AdminButton>
</div>
{#if openedUserTelegramProfileLinkKind === "id"}
<small class="admin-user-telegram-profile-note"
>{openedUserTelegramProfileHint}</small
>
{/if}
</div>
</div>
@@ -1168,12 +1159,6 @@
gap: 8px;
margin-top: 6px;
}
.admin-user-telegram-profile-note {
display: block;
margin-top: 2px;
color: var(--admin-dim);
line-height: 1.35;
}
:global(.admin-avatar-dialog) {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
+6 -10
View File
@@ -101636,22 +101636,18 @@ export const DEMO_DATASET = {
audience: "internal",
values: {
ru: {
base: "Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
fallback:
"Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
effective:
"Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
base: "Бот отправит кнопку профиля в Telegram.",
fallback: "Бот отправит кнопку профиля в Telegram.",
effective: "Бот отправит кнопку профиля в Telegram.",
override: "",
overridden: false,
updated_at: null,
updated_by: null,
},
en: {
base: "This user has no username: the bot will send you a Telegram message with a profile button.",
fallback:
"Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
effective:
"This user has no username: the bot will send you a Telegram message with a profile button.",
base: "The bot will send a profile button in Telegram.",
fallback: "Бот отправит кнопку профиля в Telegram.",
effective: "The bot will send a profile button in Telegram.",
override: "",
overridden: false,
updated_at: null,
+10 -1
View File
@@ -2760,6 +2760,15 @@
max-height: min(100%, 760px);
}
.admin-user-dialog .dialog-body-scroll {
margin-right: -10px;
padding-right: 10px;
}
.admin-user-dialog .dialog-body-scroll > .scroll-area__viewport {
padding-right: 10px;
}
.admin-user-dialog-body {
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -4131,7 +4140,7 @@
.admin-user-summary {
display: grid;
grid-template-columns: 56px minmax(0, 1fr);
align-items: center;
align-items: start;
gap: 14px;
padding: 14px 14px 16px;
border-radius: 12px;
+1 -1
View File
@@ -1200,7 +1200,7 @@
"admin_user_avatar_title": "Avatar",
"admin_user_open_tg_profile": "Open Telegram",
"admin_user_open_tg_profile_hint": "Open Telegram profile",
"admin_user_open_tg_profile_id_hint": "This user has no username: the bot will send you a Telegram message with a profile button.",
"admin_user_open_tg_profile_id_hint": "The bot will send a profile button in Telegram.",
"admin_user_tg_profile_unavailable": "Telegram profile link is unavailable",
"admin_user_tg_profile_link_sent": "Link sent to Telegram",
"admin_user_tg_profile_link_failed": "Failed to send link",
+1 -1
View File
@@ -1200,7 +1200,7 @@
"admin_user_avatar_title": "Аватар",
"admin_user_open_tg_profile": "Открыть Telegram",
"admin_user_open_tg_profile_hint": "Открыть профиль Telegram",
"admin_user_open_tg_profile_id_hint": "Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
"admin_user_open_tg_profile_id_hint": "Бот отправит кнопку профиля в Telegram.",
"admin_user_tg_profile_unavailable": "Ссылка на профиль Telegram недоступна",
"admin_user_tg_profile_link_sent": "Ссылка отправлена в Telegram",
"admin_user_tg_profile_link_failed": "Не удалось отправить ссылку",