chore: improve admin logs user cards
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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": "Не удалось отправить ссылку",
|
||||
|
||||
Reference in New Issue
Block a user