updated accounts page

This commit is contained in:
onysd 2026-08-01 00:25:11 +03:00
parent d7fd75e487
commit 030f859ba3
9 changed files with 221 additions and 48 deletions

View file

@ -1,6 +1,7 @@
import type {
AccountDetail,
AccountListResponse,
AccountStatsResponse,
BotDetail,
BotListResponse,
ChannelDetail,
@ -58,6 +59,7 @@ export const api = {
}),
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
accountStats: () => request<AccountStatsResponse>("/api/accounts/stats"),
account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`),
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),

View file

@ -33,11 +33,13 @@ const translations: Record<Language, Record<string, string>> = {
"common.members": "Members",
"common.messageId": "Message ID",
"common.name": "Name",
"common.next": "Next page",
"common.no": "No",
"common.noResults": "No results",
"common.none": "None",
"common.normal": "Normal",
"common.operations": "Operations",
"common.previous": "Previous page",
"common.owner": "Owner",
"common.platform": "Platform",
"common.refresh": "Refresh",
@ -113,6 +115,8 @@ const translations: Record<Language, Record<string, string>> = {
"account.recentActive": "Recently active accounts",
"account.currentPage": "Accounts on page",
"account.onlineDevices": "Online device records",
"account.totalUsers": "Total users",
"account.onlineNow": "Online now",
"account.premium": "Premium",
"account.frozen": "Frozen",
"account.searchPlaceholder": "User ID / phone / username",
@ -582,11 +586,13 @@ const translations: Record<Language, Record<string, string>> = {
"common.members": "成员",
"common.messageId": "消息 ID",
"common.name": "姓名",
"common.next": "下一页",
"common.no": "否",
"common.noResults": "无结果",
"common.none": "无",
"common.normal": "正常",
"common.operations": "操作",
"common.previous": "上一页",
"common.owner": "所属",
"common.platform": "平台",
"common.refresh": "刷新",
@ -662,6 +668,8 @@ const translations: Record<Language, Record<string, string>> = {
"account.recentActive": "最近活跃账号",
"account.currentPage": "当前页账号",
"account.onlineDevices": "在线设备记录",
"account.totalUsers": "用户总数",
"account.onlineNow": "当前在线",
"account.premium": "会员",
"account.frozen": "冻结",
"account.searchPlaceholder": "用户 ID / 手机号 / 用户名",
@ -1064,11 +1072,13 @@ const translations: Record<Language, Record<string, string>> = {
"common.members": "Участники",
"common.messageId": "ID сообщения",
"common.name": "Имя",
"common.next": "Следующая страница",
"common.no": "Нет",
"common.noResults": "Нет результатов",
"common.none": "Нет",
"common.normal": "Обычный",
"common.operations": "Операции",
"common.previous": "Предыдущая страница",
"common.owner": "Владелец",
"common.platform": "Платформа",
"common.refresh": "Обновить",
@ -1144,6 +1154,8 @@ const translations: Record<Language, Record<string, string>> = {
"account.recentActive": "Недавно активные аккаунты",
"account.currentPage": "Аккаунты на странице",
"account.onlineDevices": "Активные сессии устройств",
"account.totalUsers": "Всего пользователей",
"account.onlineNow": "Сейчас онлайн",
"account.premium": "Premium",
"account.frozen": "Заморожен",
"account.searchPlaceholder": "ID пользователя / телефон / имя пользователя",

View file

@ -4,11 +4,9 @@ export function accountMetrics(rows: AccountRow[]) {
return rows.reduce(
(acc, row) => {
acc.devices += row.DeviceCount;
if (row.PremiumUntil > 0) acc.premium += 1;
if (row.Frozen) acc.frozen += 1;
return acc;
},
{ devices: 0, premium: 0, frozen: 0 }
{ devices: 0 }
);
}

View file

@ -1,4 +1,4 @@
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Avatar } from "../components/Avatar";
@ -8,82 +8,142 @@ import { useI18n } from "../i18n";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
import { accountMetrics } from "../lib/metrics";
import type { Navigate } from "../routing";
import type { AccountListResponse } from "../types";
import type { AccountListResponse, AccountStatsResponse } from "../types";
type Cursor = { beforeID: number; beforeActiveUS: number };
type AccountPageSize = 10 | 20 | 50 | 100;
const zeroCursor: Cursor = { beforeID: 0, beforeActiveUS: 0 };
export function AccountsPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [limit, setLimit] = useState<AccountPageSize>(50);
const [data, setData] = useState<AccountListResponse | null>(null);
const [cursor, setCursor] = useState({ beforeID: 0, beforeActiveUS: 0 });
const [stats, setStats] = useState<AccountStatsResponse | null>(null);
// history holds the cursor used to reach every page before the current
// one, so "Previous" can pop back without re-deriving offsets -- keyset
// pagination has no notion of "page N" to jump back to otherwise.
const [history, setHistory] = useState<Cursor[]>([]);
const [cursor, setCursor] = useState<Cursor>(zeroCursor);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
async function fetchPage(query: string, at: Cursor) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (q.trim()) {
params.set("q", q.trim());
} else if (next) {
params.set("before_id", String(cursor.beforeID));
params.set("before_active_us", String(cursor.beforeActiveUS));
const params = new URLSearchParams({ limit: String(limit) });
if (query.trim()) {
params.set("q", query.trim());
}
if (at.beforeID || at.beforeActiveUS) {
params.set("before_id", String(at.beforeID));
params.set("before_active_us", String(at.beforeActiveUS));
}
try {
const result = await api.accounts(params);
setData(result);
setCursor({
beforeID: result.next_before_id,
beforeActiveUS: result.next_before_active_us
});
return result;
} catch (err) {
setError(errorMessage(err));
return null;
} finally {
setBusy(false);
}
}
async function loadFresh() {
setHistory([]);
setCursor(zeroCursor);
await fetchPage(q, zeroCursor);
}
async function loadNext() {
if (!data?.has_more) return;
const at = { beforeID: data.next_before_id, beforeActiveUS: data.next_before_active_us };
const result = await fetchPage(q, at);
if (result) {
setHistory((prev) => [...prev, cursor]);
setCursor(at);
}
}
async function loadPrev() {
if (history.length === 0) return;
const at = history[history.length - 1];
const result = await fetchPage(q, at);
if (result) {
setHistory((prev) => prev.slice(0, -1));
setCursor(at);
}
}
async function loadStats() {
try {
setStats(await api.accountStats());
} catch {
// Stats are a header nicety; a failure here shouldn't block the list.
}
}
useEffect(() => {
void load(false);
void loadFresh();
void loadStats();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const metrics = accountMetrics(data?.rows ?? []);
const canGoPrev = history.length > 0 && !busy;
const canGoNext = Boolean(data?.has_more) && !busy;
return (
<PageFrame
title={t("account.pageTitle")}
eyebrow={data?.listing === false ? t("account.queryResults") : t("account.recentActive")}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<button
className="btn"
type="button"
onClick={() => {
void loadFresh();
void loadStats();
}}
disabled={busy}
>
<RefreshCw size={15} /> {t("common.refresh")}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={t("account.currentPage")} value={String(data?.rows.length ?? 0)} />
<Metric label={t("account.totalUsers")} value={stats ? String(stats.total) : "…"} />
<Metric label={t("account.onlineNow")} value={stats ? String(stats.online) : "…"} tone="good" />
<Metric label={t("account.onlineDevices")} value={String(metrics.devices)} />
<Metric label={t("account.premium")} value={String(metrics.premium)} tone="good" />
<Metric label={t("account.frozen")} value={String(metrics.frozen)} tone={metrics.frozen > 0 ? "danger" : "neutral"} />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void loadFresh(); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("account.searchPlaceholder")} />
</label>
<label className="field-inline">
<label className="gift-page-size">
<span>{t("common.limit")}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
<select value={String(limit)} onChange={(event) => setLimit(Number(event.target.value) as AccountPageSize)}>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} /> {t("messages.nextPage")}
</button>
)}
<button className="btn icon-text" type="button" onClick={() => void loadPrev()} disabled={!canGoPrev}>
<ChevronLeft size={15} /> {t("common.previous")}
</button>
<button className="btn icon-text" type="button" onClick={() => void loadNext()} disabled={!canGoNext}>
<ChevronRight size={15} /> {t("common.next")}
</button>
</form>
</QueryPanel>
<div className="table-wrap">

View file

@ -354,6 +354,11 @@ export type AccountListResponse = {
listing: boolean;
};
export type AccountStatsResponse = {
total: number;
online: number;
};
export type ChannelListResponse = {
query: string;
limit: number;