chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -0,0 +1,48 @@
import { useEffect, useState } from "react";
import { api, APIError } from "./api";
import { BootScreen, Shell } from "./components/Layout";
import { LoginPage } from "./pages/LoginPage";
import { Routes } from "./pages/Routes";
import { currentRoute, type RouteState } from "./routing";
export function App() {
const [actor, setActor] = useState<string | null | undefined>(undefined);
const [route, setRoute] = useState<RouteState>(() => currentRoute());
useEffect(() => {
const onPopState = () => setRoute(currentRoute());
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
useEffect(() => {
api.session()
.then((session) => setActor(session.actor))
.catch((error) => {
if (error instanceof APIError && error.status === 401) {
setActor(null);
return;
}
setActor(null);
});
}, []);
const navigate = (href: string) => {
window.history.pushState(null, "", href);
setRoute(currentRoute());
};
if (actor === undefined) {
return <BootScreen />;
}
if (actor === null) {
return <LoginPage onLogin={setActor} />;
}
return (
<Shell actor={actor} route={route} navigate={navigate} onLogout={() => setActor(null)}>
<Routes route={route} navigate={navigate} />
</Shell>
);
}

View file

@ -0,0 +1,72 @@
import type {
AccountDetail,
AccountListResponse,
ChannelDetail,
ChannelListResponse,
CommandResult,
GroupMessageDetail,
GroupMessageListResponse,
MessageDetail,
MessageListResponse
} from "./types";
export class APIError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
...(init.headers ?? {})
},
...init
});
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (!response.ok) {
const message = data?.error || data?.Error || data?.message || response.statusText;
throw new APIError(response.status, message);
}
return data as T;
}
export function errorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export const api = {
session: () => request<{ actor: string }>("/api/session"),
login: (secret: string) => request<{ actor: string }>("/api/login", {
method: "POST",
body: JSON.stringify({ secret })
}),
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
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}`),
messages: (params: URLSearchParams) => request<MessageListResponse>(`/api/messages?${params.toString()}`),
message: (ownerUserID: number, msgID: number) => {
const params = new URLSearchParams({ owner_user_id: String(ownerUserID), msg_id: String(msgID) });
return request<MessageDetail>(`/api/messages/detail?${params.toString()}`);
},
groupMessages: (params: URLSearchParams) => request<GroupMessageListResponse>(`/api/messages/groups?${params.toString()}`),
groupMessage: (channelID: number, msgID: number) => {
const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) });
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
},
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
method: "POST",
body: JSON.stringify(payload)
})
};

View file

@ -0,0 +1,146 @@
import { CheckCircle2, CircleAlert, FileJson, Loader2, Play, X } from "lucide-react";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import type { CommandResult } from "../types";
import { Alert, JsonBlock } from "./ui";
type ActionTone = "neutral" | "warn" | "danger";
export function ActionButton({
label,
path,
payload,
icon,
compact = false,
tone = "danger",
onDone
}: {
label: string;
path: string;
payload: () => Record<string, unknown>;
icon?: ReactNode;
compact?: boolean;
tone?: ActionTone;
onDone?: () => void;
}) {
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const [result, setResult] = useState<CommandResult | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
function reset() {
setReason("");
setResult(null);
setError("");
}
async function run(confirm: boolean) {
if (!reason.trim()) {
setError("请填写操作原因");
return;
}
setBusy(true);
setError("");
try {
const body = { ...payload(), reason, confirm };
const commandResult = await api.action(path, body);
setResult(commandResult);
if (confirm) {
onDone?.();
}
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
const canConfirm = result?.dry_run && !result.error;
const triggerClass = `btn ${tone === "danger" ? "danger" : tone === "warn" ? "warn" : ""} ${compact ? "compact-btn" : ""}`;
const previewPayload = useMemo(() => {
try {
return payload();
} catch (err) {
return { payload_error: errorMessage(err) };
}
}, [open, payload]);
return (
<>
<button
className={triggerClass}
type="button"
onClick={() => {
reset();
setOpen(true);
}}
>
{icon}
{label}
</button>
{open && createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={label}>
<div className="modal-head">
<div>
<div className="eyebrow"></div>
<h2>{label}</h2>
</div>
<button className="icon-btn" type="button" onClick={() => setOpen(false)} aria-label="关闭"><X size={15} /></button>
</div>
<div className="command-body">
<div className="command-steps">
<div className={`command-step ${reason.trim() ? "done" : "active"}`}>
<span>1</span><strong></strong>
</div>
<div className={`command-step ${result?.dry_run ? "done" : reason.trim() ? "active" : ""}`}>
<span>2</span><strong></strong>
</div>
<div className={`command-step ${result && !result.dry_run && !result.error ? "done" : canConfirm ? "active" : ""}`}>
<span>3</span><strong></strong>
</div>
</div>
<label className="form-field">
<span></span>
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder="说明本次操作原因" />
</label>
<div className="command-preview">
<div className="preview-head"><FileJson size={14} /> </div>
<JsonBlock value={JSON.stringify(previewPayload, null, 2)} />
</div>
{error && <Alert>{error}</Alert>}
{result && (
<div className="result-box">
<div className="result-title">
{result.error ? <CircleAlert size={16} /> : <CheckCircle2 size={16} />}
<strong>{result.message || result.error || "操作结果"}</strong>
</div>
<div className="result-line"><span> ID</span><strong>{result.command_id}</strong></div>
<div className="result-line"><span></span><strong>{result.status}</strong></div>
<div className="result-line"><span></span><strong>{result.dry_run ? "是" : "否"}</strong></div>
<div className="result-message">{result.message || result.error}</div>
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
</div>
)}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={() => setOpen(false)}></button>
<button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
{result ? "重新预演" : "先预演"}
</button>
<button className="btn danger icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}>
<CheckCircle2 size={15} />
</button>
</div>
</section>
</div>,
document.body
)}
</>
);
}

View file

@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import type { Navigate } from "../routing";
export function AppLink({
href,
navigate,
className,
children
}: {
href: string;
navigate: Navigate;
className?: string;
children: ReactNode;
}) {
return (
<a
className={className}
href={href}
onClick={(event) => {
event.preventDefault();
navigate(href);
}}
>
{children}
</a>
);
}

View file

@ -0,0 +1,82 @@
import { Cable, LogOut, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { formatDate } from "../lib/format";
import type { AuthorizationRow } from "../types";
import { ActionButton } from "./ActionButton";
import { EmptyRow } from "./ui";
export function AuthorizationTable({ rows, userID, onDone }: { rows: AuthorizationRow[]; userID: number; onDone: () => void }) {
const [removedHashes, setRemovedHashes] = useState<Set<number>>(() => new Set());
useEffect(() => {
setRemovedHashes(new Set());
}, [userID]);
const visibleRows = useMemo(
() => rows.filter((row) => !removedHashes.has(row.Hash)),
[rows, removedHashes]
);
function afterRevoke(mutator: (previous: Set<number>) => Set<number>) {
setRemovedHashes((previous) => mutator(previous));
onDone();
}
return (
<div className="authorization-block">
<div className="table-wrap">
<table className="data-table authorization-table">
<thead>
<tr>
<th></th>
<th></th>
<th>IP</th>
<th></th>
<th className="device-actions-head"></th>
</tr>
</thead>
<tbody>
{visibleRows.map((row) => (
<tr key={row.Hash}>
<td className="device-text">{row.DeviceModel} {row.SystemVersion}</td>
<td className="device-text">{row.Platform} {row.AppVersion}</td>
<td>{row.IP}</td>
<td>{formatDate(row.ActiveAt)}</td>
<td className="device-actions-cell">
<div className="device-actions">
<ActionButton
label="撤销当前"
icon={<LogOut size={13} />}
compact
path="/api/actions/revoke-sessions"
payload={() => ({ user_id: userID, hash: row.Hash })}
onDone={() => afterRevoke((previous) => new Set([...previous, row.Hash]))}
/>
<ActionButton
label="保留当前"
icon={<ShieldCheck size={13} />}
compact
path="/api/actions/revoke-sessions"
payload={() => ({ user_id: userID, keep_hash: row.Hash })}
onDone={() => afterRevoke(() => new Set(rows.filter((item) => item.Hash !== row.Hash).map((item) => item.Hash)))}
/>
</div>
</td>
</tr>
))}
{visibleRows.length === 0 && <EmptyRow colSpan={5} />}
</tbody>
</table>
</div>
<div className="danger-zone">
<ActionButton
label="撤销全部设备"
icon={<Cable size={15} />}
path="/api/actions/revoke-sessions"
payload={() => ({ user_id: userID, revoke_all: true })}
onDone={() => afterRevoke(() => new Set(rows.map((item) => item.Hash)))}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,192 @@
import { Check, Loader2, Search, X } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { channelKind, displayName, displayPhone, displayUsername } from "../lib/format";
import type { AccountRow, ChannelRow } from "../types";
import { Badge } from "./ui";
export function UserPicker({
label,
value,
onChange
}: {
label: string;
value: AccountRow | null;
onChange: (row: AccountRow | null) => void;
}) {
const [query, setQuery] = useState("");
const [rows, setRows] = useState<AccountRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function search() {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit: "20" });
if (query.trim()) {
params.set("q", query.trim());
}
try {
const result = await api.accounts(params);
setRows(result.rows);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void search();
}, []);
return (
<div className="entity-picker">
<div className="picker-head">
<span>{label}</span>
{value ? (
<button className="link-button" type="button" onClick={() => onChange(null)}>
<X size={13} />
</button>
) : null}
</div>
{value ? (
<div className="selected-entity">
<Check size={15} />
<div>
<strong>{displayName(value)}</strong>
<span className="mono">{value.ID}</span>
</div>
<span>{displayUsername(value.Username) || displayPhone(value.Phone) || "-"}</span>
</div>
) : null}
<div className="picker-search">
<Search size={15} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void search();
}
}}
placeholder="搜索 user_id / phone / username"
/>
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : "搜索"}
</button>
</div>
{error && <div className="picker-error">{error}</div>}
<div className="picker-results">
{rows.map((row) => (
<button
key={row.ID}
className={`picker-row ${value?.ID === row.ID ? "selected" : ""}`}
type="button"
onClick={() => onChange(row)}
>
<span className="mono">{row.ID}</span>
<strong>{displayName(row)}</strong>
<span>{displayUsername(row.Username) || displayPhone(row.Phone) || "-"}</span>
{row.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}
</button>
))}
{rows.length === 0 && !busy ? <div className="picker-empty"></div> : null}
</div>
</div>
);
}
export function ChannelPicker({
label,
value,
onChange
}: {
label: string;
value: ChannelRow | null;
onChange: (row: ChannelRow | null) => void;
}) {
const [query, setQuery] = useState("");
const [rows, setRows] = useState<ChannelRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function search() {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit: "20" });
if (query.trim()) {
params.set("q", query.trim());
}
try {
const result = await api.channels(params);
setRows(result.rows);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void search();
}, []);
return (
<div className="entity-picker">
<div className="picker-head">
<span>{label}</span>
{value ? (
<button className="link-button" type="button" onClick={() => onChange(null)}>
<X size={13} />
</button>
) : null}
</div>
{value ? (
<div className="selected-entity">
<Check size={15} />
<div>
<strong>{value.Title || "-"}</strong>
<span className="mono">{value.ID}</span>
</div>
<span>{displayUsername(value.Username) || channelKind(value)}</span>
</div>
) : null}
<div className="picker-search">
<Search size={15} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void search();
}
}}
placeholder="搜索 channel_id / username / title"
/>
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : "搜索"}
</button>
</div>
{error && <div className="picker-error">{error}</div>}
<div className="picker-results">
{rows.map((row) => (
<button
key={row.ID}
className={`picker-row ${value?.ID === row.ID ? "selected" : ""}`}
type="button"
onClick={() => onChange(row)}
>
<span className="mono">{row.ID}</span>
<strong>{row.Title || "-"}</strong>
<span>{displayUsername(row.Username) || channelKind(row)}</span>
{row.Verified ? <Badge tone="good"></Badge> : <Badge>{channelKind(row)}</Badge>}
</button>
))}
{rows.length === 0 && !busy ? <div className="picker-empty"></div> : null}
</div>
</div>
);
}

View file

@ -0,0 +1,155 @@
import {
ChevronDown,
Database,
LayoutDashboard,
LogOut,
MessageSquareText,
Server,
Shield,
ShieldCheck,
Users
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
import { type Navigate, type RouteState, routeSubtitle, routeTitle } from "../routing";
import { AppLink } from "./AppLink";
export function BootScreen() {
return (
<div className="boot-screen">
<div className="brand compact brand-elevated">
<span className="brand-mark">T</span>
<span>
<strong>telesrv</strong>
<small></small>
</span>
</div>
<div className="loader-bar" />
</div>
);
}
export function Shell({
actor,
route,
navigate,
onLogout,
children
}: {
actor: string;
route: RouteState;
navigate: Navigate;
onLogout: () => void;
children: ReactNode;
}) {
const messagesActive = route.path.startsWith("/messages");
const [messagesOpen, setMessagesOpen] = useState(messagesActive);
useEffect(() => {
if (messagesActive) {
setMessagesOpen(true);
}
}, [messagesActive]);
async function logout() {
await api.logout().catch(() => undefined);
onLogout();
}
return (
<div className="shell">
<aside className="sidebar">
<AppLink className="brand" href="/" navigate={navigate}>
<span className="brand-mark">T</span>
<span>
<strong>telesrv</strong>
<small></small>
</span>
</AppLink>
<div className="sidebar-label"></div>
<nav className="nav-list" aria-label="主导航">
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}></NavLink>
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}></NavLink>
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>/</NavLink>
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button
className="nav-section-toggle"
type="button"
aria-expanded={messagesOpen}
onClick={() => setMessagesOpen((open) => !open)}
>
<MessageSquareText size={16} />
<span></span>
<ChevronDown className="nav-section-chevron" size={15} />
</button>
{messagesOpen && (
<div className="nav-children">
<NavLink
href="/messages/private"
route={route}
navigate={navigate}
activeWhen={(path) => path === "/messages" || path === "/messages/detail" || path.startsWith("/messages/private")}
>
</NavLink>
<NavLink
href="/messages/groups"
route={route}
navigate={navigate}
activeWhen={(path) => path.startsWith("/messages/groups")}
>
</NavLink>
</div>
)}
</div>
</nav>
<div className="sidebar-status">
<div className="sidebar-label"></div>
<div className="runtime-row"><Server size={14} /><span></span><strong></strong></div>
<div className="runtime-row"><Database size={14} /><span>PG </span><strong></strong></div>
<div className="runtime-row"><Shield size={14} /><span></span><strong></strong></div>
</div>
</aside>
<div className="workspace">
<header className="topbar">
<div>
<div className="eyebrow">{routeSubtitle(route.path)}</div>
<h1>{routeTitle(route.path)}</h1>
</div>
<div className="topbar-actions">
<span className="actor-pill">{actor}</span>
<button className="btn ghost icon-text" type="button" onClick={logout} title="退出">
<LogOut size={16} /> 退
</button>
</div>
</header>
<main className="content">{children}</main>
</div>
</div>
);
}
function NavLink({
href,
route,
navigate,
icon,
children,
activeWhen
}: {
href: string;
route: RouteState;
navigate: Navigate;
icon?: ReactNode;
children: ReactNode;
activeWhen?: (path: string) => boolean;
}) {
const active = activeWhen ? activeWhen(route.path) : href === "/" ? route.path === "/" : route.path.startsWith(href);
return (
<AppLink className={`nav-item ${active ? "active" : ""}`} href={href} navigate={navigate}>
{icon ?? <span aria-hidden="true" className="nav-dot" />}
<span>{children}</span>
</AppLink>
);
}

View file

@ -0,0 +1,128 @@
import { CircleAlert } from "lucide-react";
import type { ReactNode } from "react";
import { formatDate } from "../lib/format";
import type { AuditLogRow } from "../types";
type Tone = "neutral" | "good" | "danger" | "warn";
export function PageFrame({
title,
eyebrow,
children,
actions
}: {
title: string;
eyebrow?: string;
children: ReactNode;
actions?: ReactNode;
}) {
return (
<div className="page-frame">
<div className="page-title-row">
<div>
{eyebrow && <div className="eyebrow">{eyebrow}</div>}
<h2>{title}</h2>
</div>
{actions && <div className="page-actions">{actions}</div>}
</div>
{children}
</div>
);
}
export function QueryPanel({ children }: { children: ReactNode }) {
return <div className="query-panel">{children}</div>;
}
export function SplitLayout({ main, side }: { main: ReactNode; side: ReactNode }) {
return (
<div className="split-layout">
<div className="split-main">{main}</div>
<aside className="split-side">{side}</aside>
</div>
);
}
export function SectionHead({ title, text, action }: { title: string; text?: string; action?: ReactNode }) {
return (
<div className="section-head">
<div>
<h2>{title}</h2>
{text && <p>{text}</p>}
</div>
{action && <div className="section-action">{action}</div>}
</div>
);
}
export function Alert({ children }: { children: ReactNode }) {
return <div className="alert"><CircleAlert size={16} /> <span>{children}</span></div>;
}
export function Badge({ children, tone = "neutral" }: { children: ReactNode; tone?: Tone }) {
return <span className={`badge ${tone}`}>{children}</span>;
}
export function StatusItem({ label, value, tone }: { label: string; value: string; tone: "neutral" | "good" | "warn" }) {
return (
<div className={`status-item ${tone}`}>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
export function Metric({ label, value, tone = "neutral", mono = false }: { label: string; value: string; tone?: Tone; mono?: boolean }) {
return (
<div className={`metric ${tone}`}>
<span>{label}</span>
<strong className={mono ? "mono" : ""}>{value}</strong>
</div>
);
}
export function Summary({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return (
<div className="summary-item">
<span>{label}</span>
<strong className={mono ? "mono" : ""}>{value}</strong>
</div>
);
}
export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
return (
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>ID</th><th> ID</th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td>{row.ID}</td>
<td className="mono">{row.CommandID}</td>
<td>{row.Action}</td>
<td>{row.Actor}</td>
<td>{row.Status}</td>
<td>{row.DryRun ? "是" : "否"}</td>
<td className="truncate">{row.Reason}</td>
<td>{formatDate(row.CreatedAt)}</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
);
}
export function EmptyRow({ colSpan }: { colSpan: number }) {
return <tr><td colSpan={colSpan} className="empty-cell"></td></tr>;
}
export function LoadingSurface({ label }: { label: string }) {
return <section className="surface"><div className="loading-line">{label}</div></section>;
}
export function JsonBlock({ value }: { value: string }) {
return <pre className="json-block">{value || "{}"}</pre>;
}

View file

@ -0,0 +1,56 @@
import type { AccountRow, ChannelRow } from "../types";
export function displayPhone(value: string): string {
const phone = value.trim();
if (!phone || phone.startsWith("+")) return phone;
return /^\d+$/.test(phone) ? `+${phone}` : phone;
}
export function displayUsername(value: string): string {
const username = value.trim();
if (!username) return "";
return username.startsWith("@") ? username : `@${username}`;
}
export function displayName(row: Pick<AccountRow, "FirstName" | "LastName">): string {
return `${row.FirstName || ""} ${row.LastName || ""}`.trim() || "-";
}
export function channelKind(ch: ChannelRow): string {
if (ch.Broadcast && !ch.Megagroup) return "频道";
if (ch.Megagroup && ch.Forum) return "超级群/论坛";
if (ch.Megagroup) return "超级群";
return "频道/群";
}
export function formatDate(value: string): string {
if (!value || value.startsWith("0001-")) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString();
}
export function formatUnix(value: number): string {
if (!value || value <= 0) return "";
const date = new Date(value * 1000);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString();
}
export function toInt(value: string): number {
if (!value.trim()) return 0;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
export function parseIDs(value: string): number[] {
const ids = value
.split(/[\s,]+/)
.map((item) => item.trim())
.filter(Boolean)
.map((item) => Number.parseInt(item, 10));
if (ids.length === 0 || ids.some((id) => !Number.isFinite(id) || id <= 0)) {
throw new Error("msg ids invalid");
}
return ids;
}

View file

@ -0,0 +1,25 @@
import type { AccountRow, ChannelRow } from "../types";
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 }
);
}
export function channelMetrics(rows: ChannelRow[]) {
return rows.reduce(
(acc, row) => {
if (row.Megagroup) acc.megagroups += 1;
if (row.Broadcast) acc.broadcasts += 1;
if (row.Verified) acc.verified += 1;
return acc;
},
{ megagroups: 0, broadcasts: 0, verified: 0 }
);
}

View file

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -0,0 +1,134 @@
import { ArrowLeft, BadgeCheck, CircleAlert, Sparkles } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { AuthorizationTable } from "../components/AuthorizationTable";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountDetail } from "../types";
export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const [detail, setDetail] = useState<AccountDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [months, setMonths] = useState("1");
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.account(id));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load();
}, [id]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={busy ? "加载账号详情" : "等待数据"} />;
}
const account = detail.Account;
return (
<PageFrame
title={`账号 #${account.ID}`}
eyebrow="账号档案"
actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> </button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{displayName(account)}</div>
<div className="entity-subtitle">{displayUsername(account.Username) || "无用户名"} · {displayPhone(account.Phone) || "无手机号"}</div>
</div>
<div className="entity-badges">
{account.PremiumUntil > 0 ? <Badge tone="good"></Badge> : <Badge></Badge>}
{detail.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}
{account.Frozen ? <Badge tone="danger"></Badge> : <Badge></Badge>}
</div>
</section>
<div className="summary-grid">
<Summary label="用户 ID" value={String(account.ID)} mono />
<Summary label="最后在线" value={formatUnix(detail.LastSeenAt) || "-"} />
<Summary label="会员到期" value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : "无"} />
<Summary label="更新时间" value={formatDate(account.UpdatedAt) || "-"} />
<Summary label="授权设备" value={String(detail.Authorizations.length)} />
<Summary label="账号标记" value={`support=${detail.Support} bot=${detail.Bot}`} />
<Summary label="限制状态" value={detail.HasRestriction ? detail.Restriction.Reason || "已限制" : "无"} />
<Summary label="创建时间" value={formatDate(account.CreatedAt) || "-"} />
</div>
{detail.About && <p className="about-text">{detail.About}</p>}
<section className="section-block">
<SectionHead title="授权设备" text={`${detail.Authorizations.length} 个授权`} />
<AuthorizationTable rows={detail.Authorizations} userID={account.ID} onDone={load} />
</section>
<section className="section-block">
<SectionHead title="最近后台操作" text="最近 30 条审计" />
<AuditTable rows={detail.AuditLogs} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title"></div>
<ActionButton
label={account.Frozen ? "解冻发消息" : "冻结发消息"}
icon={<CircleAlert size={15} />}
path="/api/actions/freeze-send"
payload={() => ({ user_id: account.ID, frozen: !account.Frozen })}
onDone={load}
/>
<label className="duration-field">
<span></span>
<input
aria-label="设置会员时长,单位月"
value={months}
onChange={(event) => setMonths(event.target.value)}
type="number"
min="1"
max="120"
/>
</label>
<div className="action-stack">
<ActionButton
label="设置会员"
icon={<Sparkles size={15} />}
tone="warn"
path="/api/actions/grant-premium"
payload={() => ({ user_id: account.ID, months: toInt(months) })}
onDone={load}
/>
<ActionButton
label="取消会员"
icon={<Sparkles size={15} />}
tone="warn"
path="/api/actions/grant-premium"
payload={() => ({ user_id: account.ID, months: 0 })}
onDone={load}
/>
<ActionButton
label={detail.Verified ? "取消认证" : "设置认证"}
icon={<BadgeCheck size={15} />}
tone="warn"
path="/api/actions/set-verified"
payload={() => ({ user_id: account.ID, verified: !detail.Verified })}
onDone={load}
/>
</div>
</section>
}
/>
</PageFrame>
);
}

View file

@ -0,0 +1,124 @@
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
import { accountMetrics } from "../lib/metrics";
import type { Navigate } from "../routing";
import type { AccountListResponse } from "../types";
export function AccountsPage({ navigate }: { navigate: Navigate }) {
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [data, setData] = useState<AccountListResponse | null>(null);
const [cursor, setCursor] = useState({ beforeID: 0, beforeActiveUS: 0 });
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
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));
}
try {
const result = await api.accounts(params);
setData(result);
setCursor({
beforeID: result.next_before_id,
beforeActiveUS: result.next_before_active_us
});
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const metrics = accountMetrics(data?.rows ?? []);
return (
<PageFrame
title="账号"
eyebrow={data?.listing === false ? "查询结果" : "最近活跃账号"}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} />
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label="当前页账号" value={String(data?.rows.length ?? 0)} />
<Metric label="在线设备记录" value={String(metrics.devices)} />
<Metric label="会员" value={String(metrics.premium)} tone="good" />
<Metric label="冻结" value={String(metrics.frozen)} tone={metrics.frozen > 0 ? "danger" : "neutral"} />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder="用户 ID / 手机号 / 用户名" />
</label>
<label className="field-inline">
<span></span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} />
</button>
)}
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th> ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data?.rows.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td>{displayPhone(row.Phone)}</td>
<td>{displayUsername(row.Username)}</td>
<td>{displayName(row)}</td>
<td>{row.DeviceCount}</td>
<td>{formatDate(row.LastActiveAt)}</td>
<td>{row.PremiumUntil > 0 ? <Badge tone="good"> {formatUnix(row.PremiumUntil)}</Badge> : <Badge></Badge>}</td>
<td>{row.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}</td>
<td>{row.Frozen ? <Badge tone="danger"></Badge> : <Badge></Badge>}</td>
<td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}> <ChevronRight size={14} /></button></td>
</tr>
))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={11} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,92 @@
import { ArrowLeft, BadgeCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { channelKind, displayUsername, formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { ChannelDetail } from "../types";
export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const [detail, setDetail] = useState<ChannelDetail | null>(null);
const [error, setError] = useState("");
async function load() {
setError("");
try {
setDetail(await api.channel(id));
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load();
}, [id]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label="加载频道详情" />;
}
const ch = detail.Channel;
return (
<PageFrame
title={`${channelKind(ch)} #${ch.ID}`}
eyebrow="频道档案"
actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> </button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{ch.Title || "-"}</div>
<div className="entity-subtitle">{displayUsername(ch.Username) || "无用户名"} · {ch.CreatorUserID}</div>
</div>
<div className="entity-badges">
<Badge>{channelKind(ch)}</Badge>
{ch.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}
{ch.Deleted ? <Badge tone="danger"></Badge> : <Badge></Badge>}
</div>
</section>
<div className="summary-grid">
<Summary label="频道 ID" value={String(ch.ID)} mono />
<Summary label="access_hash" value={String(ch.AccessHash)} mono />
<Summary label="成员" value={`${ch.ParticipantsCount} / 管理员 ${ch.AdminsCount}`} />
<Summary label="治理状态" value={`封禁 ${ch.BannedCount} / 踢出 ${ch.KickedCount}`} />
<Summary label="频道标记" value={`broadcast=${ch.Broadcast} megagroup=${ch.Megagroup} forum=${ch.Forum}`} />
<Summary label="top / pinned / PTS" value={`${ch.TopMessageID} / ${ch.PinnedMessageID} / ${ch.PTS}`} />
<Summary label="创建时间" value={formatUnix(ch.Date) || "-"} />
<Summary label="更新时间" value={formatDate(ch.UpdatedAt) || "-"} />
</div>
{ch.About && <p className="about-text">{ch.About}</p>}
<section className="section-block">
<SectionHead title="最近后台操作" text="最近 30 条审计" />
<AuditTable rows={detail.AuditLogs} />
</section>
<section className="section-block">
<SectionHead title="频道原始行" text="数据库只读快照" />
<JsonBlock value={detail.ChannelJSON} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title"></div>
<ActionButton
label={ch.Verified ? "取消认证" : "设置认证"}
icon={<BadgeCheck size={15} />}
tone="warn"
path="/api/actions/set-channel-verified"
payload={() => ({ channel_id: ch.ID, verified: !ch.Verified })}
onDone={load}
/>
</section>
}
/>
</PageFrame>
);
}

View file

@ -0,0 +1,122 @@
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { channelKind, displayUsername, formatDate } from "../lib/format";
import { channelMetrics } from "../lib/metrics";
import type { Navigate } from "../routing";
import type { ChannelListResponse } from "../types";
export function ChannelsPage({ navigate }: { navigate: Navigate }) {
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [data, setData] = useState<ChannelListResponse | null>(null);
const [cursor, setCursor] = useState({ beforeID: 0, beforeUpdatedUS: 0 });
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
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_updated_us", String(cursor.beforeUpdatedUS));
}
try {
const result = await api.channels(params);
setData(result);
setCursor({
beforeID: result.next_before_id,
beforeUpdatedUS: result.next_before_updated_us
});
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const metrics = channelMetrics(data?.rows ?? []);
return (
<PageFrame
title="超级群与频道"
eyebrow={data?.listing === false ? "查询结果" : "最近更新"}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} />
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label="当前页实体" value={String(data?.rows.length ?? 0)} />
<Metric label="超级群" value={String(metrics.megagroups)} />
<Metric label="频道" value={String(metrics.broadcasts)} />
<Metric label="已认证" value={String(metrics.verified)} tone="good" />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder="频道 ID / 用户名 / 标题" />
</label>
<label className="field-inline">
<span></span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} />
</button>
)}
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th> ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>PTS</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data?.rows.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td>{channelKind(row)}</td>
<td>{displayUsername(row.Username)}</td>
<td>{row.Title}</td>
<td>{row.ParticipantsCount}</td>
<td>{row.AdminsCount}</td>
<td>{row.PTS}</td>
<td>{row.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}</td>
<td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}> <ChevronRight size={14} /></button></td>
</tr>
))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={10} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,59 @@
import { CheckCircle2, ChevronRight, Clock3, FileJson, KeyRound, MessageSquareText, ShieldCheck, Users } from "lucide-react";
import type { ReactNode } from "react";
import { AppLink } from "../components/AppLink";
import { StatusItem } from "../components/ui";
import type { Navigate } from "../routing";
export function Dashboard({ navigate }: { navigate: Navigate }) {
return (
<div className="dashboard-layout">
<section className="overview-band">
<div>
<div className="eyebrow"></div>
<h2></h2>
</div>
<div className="overview-metrics">
<StatusItem label="读路径" value="PG 只读" tone="neutral" />
<StatusItem label="写路径" value="Admin API" tone="good" />
<StatusItem label="执行策略" value="先预演" tone="warn" />
</div>
</section>
<div className="command-grid">
<Launcher icon={<Users />} title="账号管理" text="账号状态、会员、认证、会话。" href="/accounts" navigate={navigate} />
<Launcher icon={<ShieldCheck />} title="超级群与频道" text="公开实体、成员计数、认证状态。" href="/channels" navigate={navigate} />
<Launcher icon={<MessageSquareText />} title="消息审计" text="消息盒、update、outbox 状态。" href="/messages" navigate={navigate} />
</div>
<section className="work-strip">
<div className="strip-item"><CheckCircle2 size={16} /><span></span></div>
<div className="strip-item"><KeyRound size={16} /><span> token</span></div>
<div className="strip-item"><Clock3 size={16} /><span>使</span></div>
<div className="strip-item"><FileJson size={16} /><span></span></div>
</section>
</div>
);
}
function Launcher({
icon,
title,
text,
href,
navigate
}: {
icon: ReactNode;
title: string;
text: string;
href: string;
navigate: Navigate;
}) {
return (
<AppLink className="launcher" href={href} navigate={navigate}>
<span className="launcher-icon">{icon}</span>
<span className="launcher-copy">
<strong>{title}</strong>
<span>{text}</span>
</span>
<ChevronRight size={16} />
</AppLink>
);
}

View file

@ -0,0 +1,100 @@
import { ArrowLeft } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
import { formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { GroupMessageDetail } from "../types";
export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channelID: number; msgID: number; navigate: Navigate }) {
const [detail, setDetail] = useState<GroupMessageDetail | null>(null);
const [error, setError] = useState("");
async function load() {
setError("");
try {
setDetail(await api.groupMessage(channelID, msgID));
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load();
}, [channelID, msgID]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label="加载群聊消息详情" />;
}
const msg = detail.Message;
return (
<PageFrame
title={`群聊消息 #${msg.ID}`}
eyebrow="消息详情"
actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> </button>}
>
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">/ {msg.ChannelID}</div>
<div className="entity-subtitle"> {msg.SenderUserID} · {formatUnix(msg.Date)}</div>
</div>
<div className="entity-badges">
{msg.Deleted ? <Badge tone="danger"></Badge> : <Badge></Badge>}
{msg.Pinned && <Badge tone="warn"></Badge>}
{msg.Post && <Badge></Badge>}
<Badge>pts {msg.PTS}</Badge>
</div>
</section>
<div className="summary-grid">
<Summary label="消息 ID" value={String(msg.ID)} mono />
<Summary label="频道 / 群" value={String(msg.ChannelID)} mono />
<Summary label="From Peer" value={`${msg.FromPeerType}:${msg.FromPeerID}`} mono />
<Summary label="浏览" value={String(msg.ViewsCount)} />
</div>
<section className="section-block">
<SectionHead title="消息行" text="channel_messages 只读快照" />
<JsonBlock value={detail.MessageJSON} />
</section>
<section className="section-block">
<SectionHead title="频道行" text="channels 只读快照" />
<JsonBlock value={detail.ChannelJSON} />
</section>
<section className="section-block">
<SectionHead title="频道更新事件" text="durable channel_update_events" />
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>PTS</th><th></th><th></th><th> ID</th><th></th><th></th></tr></thead>
<tbody>
{detail.UpdateEvents.map((row) => (
<tr key={`${row.PTS}-${row.Type}-${row.MessageID}`}>
<td>{row.PTS}</td>
<td>{row.PTSCount}</td>
<td>{row.Type}</td>
<td>{row.MessageID}</td>
<td>{row.SenderUserID}</td>
<td>{formatUnix(row.Date)}</td>
</tr>
))}
{detail.UpdateEvents.length === 0 && <EmptyRow colSpan={6} />}
</tbody>
</table>
</div>
</section>
<section className="section-block">
<SectionHead title="事件 JSON" />
<div className="raw-grid">
{detail.UpdateEvents.map((row) => (
<JsonBlock key={`${row.PTS}-${row.Type}-json`} value={row.JSON} />
))}
{detail.UpdateEvents.length === 0 && <div className="empty-panel"></div>}
</div>
</section>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,119 @@
import { ChevronRight, Search } from "lucide-react";
import { useState } from "react";
import { api, errorMessage } from "../api";
import { ChannelPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { channelKind, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { ChannelRow, GroupMessageListResponse } from "../types";
export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
const [channel, setChannel] = useState<ChannelRow | null>(null);
const [beforeDate, setBeforeDate] = useState("");
const [beforeID, setBeforeID] = useState("");
const [limit, setLimit] = useState("100");
const [data, setData] = useState<GroupMessageListResponse | null>(null);
const [error, setError] = useState("");
async function load(next = false) {
setError("");
if (!channel) {
setError("请先搜索并选择超级群或频道");
return;
}
const params = new URLSearchParams({
channel_id: String(channel.ID),
limit
});
if (next && data?.rows.length) {
const last = data.rows[data.rows.length - 1];
params.set("before_date", String(last.Date));
params.set("before_id", String(last.ID));
setBeforeDate(String(last.Date));
setBeforeID(String(last.ID));
} else {
if (beforeDate) params.set("before_date", beforeDate);
if (beforeID) params.set("before_id", beforeID);
}
try {
setData(await api.groupMessages(params));
} catch (err) {
setError(errorMessage(err));
}
}
function changeChannel(row: ChannelRow | null) {
setChannel(row);
setBeforeDate("");
setBeforeID("");
setData(null);
}
const rows = data?.rows ?? [];
return (
<PageFrame title="群聊消息" eyebrow="超级群 / 频道消息">
{error && <Alert>{error}</Alert>}
<QueryPanel>
<div className="message-selector-grid single">
<ChannelPicker label="超级群 / 频道" value={channel} onChange={changeChannel} />
</div>
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder="before_date 游标" />
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder="before_msg_id 游标" />
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder="条数 <= 100" />
<button className="btn primary icon-text" type="submit"><Search size={15} /> </button>
{rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> </button> : null}
</form>
</QueryPanel>
<div className="metric-row">
<Metric label="当前页消息" value={String(rows.length)} />
<Metric label="有媒体" value={String(rows.filter((row) => row.Media && row.Media !== "{}").length)} />
<Metric label="频道帖子" value={String(rows.filter((row) => row.Post).length)} />
<Metric label="频道 / 群" value={channel ? `${channel.Title || channelKind(channel)} (${channel.ID})` : "-"} />
</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th> ID</th>
<th></th>
<th></th>
<th>From Peer</th>
<th>PTS</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.ChannelID}-${row.ID}`}>
<td className="mono">{row.ID}</td>
<td>{formatUnix(row.Date)}</td>
<td className="mono">{row.SenderUserID}</td>
<td className="mono">{row.FromPeerType}:{row.FromPeerID}</td>
<td>{row.PTS}</td>
<td>{row.ViewsCount}</td>
<td>
{row.Deleted ? <Badge tone="danger"></Badge> : row.Pinned ? <Badge tone="warn"></Badge> : <Badge></Badge>}
</td>
<td className="truncate">{row.Body}</td>
<td>
<button
className="row-link"
onClick={() => navigate(`/messages/groups/detail?channel_id=${row.ChannelID}&msg_id=${row.ID}`)}
>
<ChevronRight size={14} />
</button>
</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={9} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,61 @@
import type { FormEvent } from "react";
import { useState } from "react";
import { api, errorMessage } from "../api";
import { Alert } from "../components/ui";
export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
const [secret, setSecret] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
const result = await api.login(secret);
onLogin(result.actor);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<main className="login-page">
<section className="login-panel">
<div className="login-head">
<div className="brand brand-elevated">
<span className="brand-mark">T</span>
<span>
<strong>telesrv</strong>
<small></small>
</span>
</div>
<span className="login-chip">访</span>
</div>
<div className="login-copy">
<h1></h1>
<p></p>
</div>
{error && <Alert>{error}</Alert>}
<form className="form-stack" onSubmit={submit}>
<label>
<span> token</span>
<input
autoFocus
type="password"
value={secret}
autoComplete="current-password"
onChange={(event) => setSecret(event.target.value)}
/>
</label>
<button className="btn primary full" type="submit" disabled={busy}>
{busy ? "登录中" : "登录"}
</button>
</form>
</section>
</main>
);
}

View file

@ -0,0 +1,116 @@
import { ArrowLeft, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { MessageDetail } from "../types";
export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserID: number; msgID: number; navigate: Navigate }) {
const [detail, setDetail] = useState<MessageDetail | null>(null);
const [error, setError] = useState("");
async function load() {
setError("");
try {
setDetail(await api.message(ownerUserID, msgID));
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load();
}, [ownerUserID, msgID]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label="加载消息详情" />;
}
const msg = detail.Message;
return (
<PageFrame
title={`消息 #${msg.BoxID}`}
eyebrow="消息详情"
actions={<button className="btn icon-text" onClick={() => navigate("/messages/private")}><ArrowLeft size={15} /> </button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title"> {msg.OwnerUserID} · {msg.PeerID}</div>
<div className="entity-subtitle"> {msg.FromUserID} · {formatUnix(msg.Date)}</div>
</div>
<div className="entity-badges">
{msg.Deleted ? <Badge tone="danger"></Badge> : <Badge></Badge>}
<Badge>pts {msg.PTS}</Badge>
<Badge>{msg.Outgoing ? "发出" : "收到"}</Badge>
</div>
</section>
<div className="summary-grid">
<Summary label="消息盒 ID" value={String(msg.BoxID)} mono />
<Summary label="私聊消息 ID" value={String(msg.PrivateMessageID)} mono />
<Summary label="发送方" value={String(msg.MessageSenderID)} mono />
<Summary label="时间" value={formatUnix(msg.Date)} />
</div>
<section className="section-block">
<SectionHead title="消息盒" text="message_boxes 只读快照" />
<JsonBlock value={detail.MessageJSON} />
</section>
<div className="raw-grid">
<section className="section-block">
<SectionHead title="会话行" text="dialogs 只读快照" />
<JsonBlock value={detail.DialogJSON} />
</section>
<section className="section-block">
<SectionHead title="私聊消息行" text="private_messages 只读快照" />
<JsonBlock value={detail.PrivateJSON} />
</section>
</div>
<section className="section-block">
<SectionHead title="更新事件" text="durable user_update_events" />
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>PTS</th><th></th><th></th><th></th></tr></thead>
<tbody>
{detail.UpdateEvents.map((row) => <tr key={`${row.PTS}-${row.Type}`}><td>{row.PTS}</td><td>{row.PTSCount}</td><td>{row.Type}</td><td>{formatUnix(row.Date)}</td></tr>)}
{detail.UpdateEvents.length === 0 && <EmptyRow colSpan={4} />}
</tbody>
</table>
</div>
</section>
<section className="section-block">
<SectionHead title="分发队列" text="在线/离线 dispatch_outbox" />
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>ID</th><th></th><th>PTS</th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{detail.Outbox.map((row) => <tr key={row.ID}><td>{row.ID}</td><td>{row.TargetUserID}</td><td>{row.PTS}</td><td>{row.EventType}</td><td>{row.Status}</td><td>{row.Attempts}</td><td>{formatDate(row.UpdatedAt)}</td></tr>)}
{detail.Outbox.length === 0 && <EmptyRow colSpan={7} />}
</tbody>
</table>
</div>
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title"></div>
<ActionButton
label="删除此消息"
icon={<Trash2 size={15} />}
path="/api/actions/delete-messages"
payload={() => ({ owner_user_id: msg.OwnerUserID, peer_id: msg.PeerID, ids: [msg.BoxID], revoke: true })}
onDone={load}
/>
</section>
}
/>
</PageFrame>
);
}

View file

@ -0,0 +1,157 @@
import { ChevronRight, History, Search, Trash2 } from "lucide-react";
import { useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { UserPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { displayName, formatUnix, parseIDs, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountRow, MessageListResponse } from "../types";
export function MessagesPage({ navigate }: { navigate: Navigate }) {
const [owner, setOwner] = useState<AccountRow | null>(null);
const [peer, setPeer] = useState<AccountRow | null>(null);
const [beforeDate, setBeforeDate] = useState("");
const [beforeID, setBeforeID] = useState("");
const [limit, setLimit] = useState("100");
const [ids, setIDs] = useState("");
const [revoke, setRevoke] = useState(true);
const [justClear, setJustClear] = useState(false);
const [maxID, setMaxID] = useState("");
const [maxBatches, setMaxBatches] = useState("1");
const [data, setData] = useState<MessageListResponse | null>(null);
const [error, setError] = useState("");
async function load(next = false) {
setError("");
if (!owner || !peer) {
setError("请先搜索并选择所属用户和对端用户");
return;
}
const params = new URLSearchParams({
owner_user_id: String(owner.ID),
peer_id: String(peer.ID),
limit
});
if (next && data?.rows.length) {
const last = data.rows[data.rows.length - 1];
params.set("before_date", String(last.Date));
params.set("before_id", String(last.BoxID));
setBeforeDate(String(last.Date));
setBeforeID(String(last.BoxID));
} else {
if (beforeDate) params.set("before_date", beforeDate);
if (beforeID) params.set("before_id", beforeID);
}
try {
setData(await api.messages(params));
} catch (err) {
setError(errorMessage(err));
}
}
function changeOwner(row: AccountRow | null) {
setOwner(row);
setBeforeDate("");
setBeforeID("");
setData(null);
}
function changePeer(row: AccountRow | null) {
setPeer(row);
setBeforeDate("");
setBeforeID("");
setData(null);
}
return (
<PageFrame title="私聊消息" eyebrow="私聊消息盒">
{error && <Alert>{error}</Alert>}
<QueryPanel>
<div className="message-selector-grid">
<UserPicker label="所属用户" value={owner} onChange={changeOwner} />
<UserPicker label="对端用户" value={peer} onChange={changePeer} />
</div>
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder="before_date 游标" />
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder="before_msg_id 游标" />
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder="条数 <= 100" />
<button className="btn primary icon-text" type="submit"><Search size={15} /> </button>
{data?.rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> </button> : null}
</form>
</QueryPanel>
<div className="metric-row">
<Metric label="当前页消息" value={String(data?.rows.length ?? 0)} />
<Metric label="已删除" value={String((data?.rows ?? []).filter((row) => row.Deleted).length)} tone="danger" />
<Metric label="发出消息" value={String((data?.rows ?? []).filter((row) => row.Outgoing).length)} />
<Metric label="所属 / 对端" value={owner && peer ? `${displayName(owner)} / ${displayName(peer)}` : "-"} />
</div>
<div className="operation-row">
<div className="operation-box">
<div className="operation-title"><Trash2 size={15} /> </div>
<input value={ids} onChange={(event) => setIDs(event.target.value)} placeholder="消息 ID逗号分隔" />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> </label>
<ActionButton path="/api/actions/delete-messages" label="预演删除" payload={() => ({
owner_user_id: owner?.ID ?? 0,
peer_id: peer?.ID ?? 0,
ids: parseIDs(ids),
revoke
})} />
</div>
<div className="operation-box">
<div className="operation-title"><History size={15} /> </div>
<input value={maxID} onChange={(event) => setMaxID(event.target.value)} placeholder="max_id 截止消息" />
<input value={maxBatches} onChange={(event) => setMaxBatches(event.target.value)} placeholder="max_batches 批次数" />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> </label>
<label className="checkline"><input type="checkbox" checked={justClear} onChange={(event) => setJustClear(event.target.checked)} /> </label>
<ActionButton path="/api/actions/delete-history" label="预演清历史" payload={() => ({
owner_user_id: owner?.ID ?? 0,
peer_id: peer?.ID ?? 0,
max_id: toInt(maxID),
max_batches: toInt(maxBatches),
just_clear: justClear,
revoke
})} />
</div>
</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th> ID</th>
<th></th>
<th></th>
<th></th>
<th>PTS</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data?.rows.map((row) => (
<tr key={`${row.OwnerUserID}-${row.BoxID}`}>
<td className="mono">{row.BoxID}</td>
<td>{formatUnix(row.Date)}</td>
<td className="mono">{row.FromUserID}</td>
<td>{row.Outgoing ? "发出" : "收到"}</td>
<td>{row.PTS}</td>
<td>{row.Deleted ? <Badge tone="danger"></Badge> : <Badge></Badge>}</td>
<td className="truncate">{row.Body}</td>
<td>
<button
className="row-link"
onClick={() => navigate(`/messages/private/detail?owner_user_id=${row.OwnerUserID}&msg_id=${row.BoxID}`)}
>
<ChevronRight size={14} />
</button>
</td>
</tr>
))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,52 @@
import { type Navigate, type RouteState } from "../routing";
import { AccountDetailPage } from "./AccountDetailPage";
import { AccountsPage } from "./AccountsPage";
import { ChannelDetailPage } from "./ChannelDetailPage";
import { ChannelsPage } from "./ChannelsPage";
import { Dashboard } from "./Dashboard";
import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
import { GroupMessagesPage } from "./GroupMessagesPage";
import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
if (accountID) {
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
}
if (channelID) {
return <ChannelDetailPage id={Number(channelID)} navigate={navigate} />;
}
if (route.path === "/accounts") {
return <AccountsPage navigate={navigate} />;
}
if (route.path === "/channels") {
return <ChannelsPage navigate={navigate} />;
}
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
return (
<MessageDetailPage
ownerUserID={Number(route.search.get("owner_user_id") || "0")}
msgID={Number(route.search.get("msg_id") || "0")}
navigate={navigate}
/>
);
}
if (route.path === "/messages/groups/detail") {
return (
<GroupMessageDetailPage
channelID={Number(route.search.get("channel_id") || "0")}
msgID={Number(route.search.get("msg_id") || "0")}
navigate={navigate}
/>
);
}
if (route.path === "/messages/groups") {
return <GroupMessagesPage navigate={navigate} />;
}
if (route.path === "/messages" || route.path === "/messages/private") {
return <MessagesPage navigate={navigate} />;
}
return <Dashboard navigate={navigate} />;
}

View file

@ -0,0 +1,29 @@
export type Navigate = (href: string) => void;
export type RouteState = {
href: string;
path: string;
search: URLSearchParams;
};
export function currentRoute(): RouteState {
return {
href: `${window.location.pathname}${window.location.search}`,
path: window.location.pathname,
search: new URLSearchParams(window.location.search)
};
}
export function routeTitle(pathname: string): string {
if (pathname.startsWith("/accounts")) return "账号管理";
if (pathname.startsWith("/channels")) return "超级群与频道";
if (pathname.startsWith("/messages")) return "消息审计";
return "运维控制台";
}
export function routeSubtitle(pathname: string): string {
if (pathname.startsWith("/accounts")) return "控制台 / 账号";
if (pathname.startsWith("/channels")) return "控制台 / 频道";
if (pathname.startsWith("/messages")) return "控制台 / 消息";
return "控制台 / 总览";
}

View file

@ -0,0 +1,5 @@
@import "./styles/01-foundation.css";
@import "./styles/02-pages-and-forms.css";
@import "./styles/03-entities-and-actions.css";
@import "./styles/04-modal-and-login.css";
@import "./styles/05-responsive.css";

View file

@ -0,0 +1,285 @@
:root {
color-scheme: light;
--bg: #f3f5f7;
--panel: #ffffff;
--panel-subtle: #f8fafb;
--panel-strong: #eef2f5;
--line: #d9e1e8;
--line-strong: #c2ccd6;
--text: #101828;
--muted: #667085;
--muted-2: #98a2b3;
--brand: #176d61;
--brand-2: #245b9d;
--good: #167447;
--warn: #a15c07;
--danger: #b42318;
--sidebar: #11161d;
--sidebar-soft: #1b222b;
--sidebar-line: #2c3541;
--focus: rgba(23, 109, 97, 0.16);
--shadow: 0 18px 52px rgba(16, 24, 40, 0.14);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
color: var(--text);
background: var(--bg);
font: 13px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
textarea {
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}
.shell {
display: grid;
min-height: 100vh;
grid-template-columns: 232px minmax(0, 1fr);
}
.sidebar {
position: sticky;
top: 0;
display: flex;
height: 100vh;
flex-direction: column;
gap: 16px;
padding: 18px 12px;
color: #eef2f6;
background: var(--sidebar);
border-right: 1px solid var(--sidebar-line);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
min-height: 42px;
padding: 0 4px;
}
.brand.compact {
justify-content: center;
}
.brand-elevated .brand-mark {
box-shadow: 0 8px 24px rgba(23, 109, 97, 0.26);
}
.brand-mark {
display: grid;
width: 34px;
height: 34px;
place-items: center;
color: #ffffff;
background: var(--brand);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
font-weight: 800;
}
.brand strong {
display: block;
font-size: 14px;
line-height: 1.1;
}
.brand small {
display: block;
margin-top: 3px;
color: #aeb8c4;
font-size: 11px;
}
.sidebar-label {
padding: 0 8px;
color: #8492a6;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.nav-list {
display: grid;
gap: 4px;
}
.nav-section {
display: grid;
gap: 4px;
}
.nav-section-toggle {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 16px;
width: 100%;
min-height: 38px;
align-items: center;
gap: 9px;
padding: 0 10px;
color: #8fa0b4;
background: transparent;
border: 1px solid transparent;
border-radius: 7px;
cursor: pointer;
font-size: 12px;
font-weight: 800;
text-align: left;
}
.nav-section-toggle:hover,
.nav-section.active .nav-section-toggle {
color: #ffffff;
background: var(--sidebar-soft);
border-color: #34404d;
}
.nav-section-chevron {
justify-self: end;
color: #8fa0b4;
transition: transform 140ms ease;
}
.nav-section.open .nav-section-chevron {
transform: rotate(180deg);
}
.nav-children {
display: grid;
gap: 4px;
padding: 2px 0 2px 18px;
}
.nav-item {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
min-height: 38px;
align-items: center;
gap: 9px;
padding: 0 10px;
color: #c6d0dc;
border: 1px solid transparent;
border-radius: 7px;
}
.nav-dot {
width: 6px;
height: 6px;
justify-self: center;
background: #687789;
border-radius: 999px;
}
.nav-item:hover,
.nav-item.active {
color: #ffffff;
background: var(--sidebar-soft);
border-color: #34404d;
}
.nav-item.active .nav-dot {
background: var(--brand);
}
.sidebar-status {
display: grid;
gap: 7px;
margin-top: auto;
}
.runtime-row {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
min-height: 32px;
align-items: center;
gap: 7px;
padding: 0 8px;
color: #cbd5df;
background: #171d25;
border: 1px solid #27313c;
border-radius: 7px;
}
.runtime-row strong {
color: #ffffff;
font-size: 11px;
}
.workspace {
min-width: 0;
}
.topbar {
position: sticky;
z-index: 20;
top: 0;
display: flex;
min-height: 66px;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 12px 24px;
background: rgba(255, 255, 255, 0.94);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(12px);
}
.topbar h1 {
margin: 2px 0 0;
font-size: 20px;
line-height: 1.2;
}
.topbar-actions,
.page-actions,
.section-action,
.entity-badges,
.row-actions,
.modal-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.actor-pill {
display: inline-flex;
min-height: 30px;
align-items: center;
padding: 0 10px;
color: #344054;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 999px;
}
.content {
display: grid;
gap: 16px;
padding: 18px 24px 30px;
}
.eyebrow {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}

View file

@ -0,0 +1,559 @@
.dashboard-layout,
.stacked-sections {
display: grid;
gap: 14px;
}
.overview-band,
.page-frame {
min-width: 0;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.overview-band {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(420px, 0.9fr);
gap: 16px;
align-items: center;
padding: 16px;
}
.overview-band h2,
.page-title-row h2,
.section-head h2,
.modal h2 {
margin: 0;
font-size: 18px;
line-height: 1.25;
}
.overview-metrics,
.metric-row {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 8px;
}
.overview-metrics {
grid-template-columns: repeat(3, minmax(120px, 1fr));
}
.status-item,
.metric,
.summary-item {
min-width: 0;
padding: 10px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 7px;
}
.status-item span,
.metric span,
.summary-item span {
display: block;
margin-bottom: 6px;
color: var(--muted);
font-size: 11px;
}
.status-item strong,
.metric strong,
.summary-item strong {
display: block;
overflow-wrap: anywhere;
color: var(--text);
font-weight: 800;
}
.status-item.good,
.metric.good {
border-color: #afd8bf;
}
.status-item.warn,
.metric.warn {
border-color: #e7c77e;
}
.metric.danger {
border-color: #efb4ad;
}
.command-grid {
display: grid;
grid-template-columns: repeat(3, minmax(220px, 1fr));
gap: 12px;
}
.launcher {
display: grid;
grid-template-columns: 38px minmax(0, 1fr) 18px;
min-height: 94px;
align-items: center;
gap: 12px;
padding: 14px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.launcher:hover {
border-color: var(--brand);
}
.launcher-icon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
color: var(--brand);
background: #edf7f4;
border: 1px solid #c9e2dc;
border-radius: 8px;
}
.launcher-copy {
display: grid;
gap: 4px;
}
.launcher-copy strong {
font-size: 15px;
}
.launcher-copy span {
color: var(--muted);
}
.work-strip {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
gap: 8px;
}
.strip-item {
display: flex;
min-height: 38px;
align-items: center;
gap: 8px;
padding: 0 10px;
color: #344054;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.page-frame {
display: grid;
gap: 14px;
padding: 14px;
}
.page-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.query-panel {
padding: 10px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.message-query input {
width: 150px;
}
.message-selector-grid {
display: grid;
grid-template-columns: repeat(2, minmax(280px, 1fr));
gap: 10px;
margin-bottom: 10px;
}
.message-selector-grid.single {
grid-template-columns: minmax(320px, 620px);
}
.entity-picker {
display: grid;
min-width: 0;
gap: 8px;
padding: 10px;
background: #ffffff;
border: 1px solid var(--line);
border-radius: 8px;
}
.picker-head {
display: flex;
min-height: 24px;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #344054;
font-weight: 800;
}
.selected-entity {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
min-height: 40px;
align-items: center;
gap: 8px;
padding: 7px 9px;
color: #0f3f38;
background: #eef8f5;
border: 1px solid #b9dcd3;
border-radius: 7px;
}
.selected-entity strong,
.selected-entity span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.selected-entity div {
display: grid;
min-width: 0;
gap: 2px;
}
.selected-entity div span {
color: #52606d;
font-size: 11px;
}
.picker-search {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
height: 34px;
align-items: center;
gap: 7px;
padding: 0 6px 0 9px;
background: var(--panel-subtle);
border: 1px solid var(--line-strong);
border-radius: 7px;
}
.picker-search input {
width: 100%;
height: 30px;
padding: 0;
background: transparent;
border: 0;
box-shadow: none;
}
.picker-results {
display: grid;
max-height: 236px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 7px;
}
.picker-row {
display: grid;
grid-template-columns: 96px minmax(120px, 1fr) minmax(120px, 1fr) auto;
min-height: 36px;
align-items: center;
gap: 8px;
padding: 6px 8px;
color: var(--text);
background: #ffffff;
border: 0;
border-bottom: 1px solid var(--line);
cursor: pointer;
text-align: left;
}
.picker-row:last-child {
border-bottom: 0;
}
.picker-row:hover,
.picker-row.selected {
background: #f3f8f6;
}
.picker-row strong,
.picker-row span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-empty,
.picker-error {
padding: 9px;
color: var(--muted);
text-align: center;
}
.picker-error {
color: var(--danger);
background: #fff2f0;
border: 1px solid #efb4ad;
border-radius: 7px;
}
input,
textarea {
color: var(--text);
background: #ffffff;
border: 1px solid var(--line-strong);
border-radius: 7px;
outline: none;
}
input {
width: 190px;
height: 34px;
padding: 0 10px;
}
textarea {
width: 100%;
padding: 9px 10px;
resize: vertical;
}
input:focus,
textarea:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px var(--focus);
}
.small-input {
width: 88px;
}
.field-inline {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
}
.field-inline span {
font-size: 11px;
font-weight: 700;
}
.searchbox {
display: inline-flex;
align-items: center;
gap: 8px;
width: min(380px, 100%);
height: 34px;
padding: 0 10px;
background: #ffffff;
border: 1px solid var(--line-strong);
border-radius: 7px;
}
.searchbox input {
width: 100%;
height: 30px;
padding: 0;
border: 0;
box-shadow: none;
}
.btn {
display: inline-flex;
min-height: 34px;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 12px;
color: #1d2939;
background: #ffffff;
border: 1px solid var(--line-strong);
border-radius: 7px;
cursor: pointer;
white-space: nowrap;
}
.btn:hover:not(:disabled) {
background: #f7f9fb;
}
.btn:disabled {
color: var(--muted-2);
cursor: not-allowed;
}
.btn.primary {
color: #ffffff;
background: var(--brand);
border-color: var(--brand);
}
.btn.primary:hover:not(:disabled) {
background: #12594f;
}
.btn.ghost {
background: var(--panel-subtle);
}
.btn.danger {
color: var(--danger);
background: #fff7f5;
border-color: #efb4ad;
}
.btn.danger:hover:not(:disabled) {
background: #ffeceb;
}
.btn.warn {
color: var(--warn);
background: #fff8ec;
border-color: #e7c77e;
}
.btn.warn:hover:not(:disabled) {
background: #fff1d6;
}
.btn:disabled,
.btn.primary:disabled,
.btn.warn:disabled,
.btn.danger:disabled {
color: var(--muted-2);
background: #f3f5f7;
border-color: var(--line);
cursor: not-allowed;
}
.btn.full {
width: 100%;
}
.icon-text {
gap: 7px;
}
.compact-btn {
min-height: 28px;
padding: 0 8px;
font-size: 12px;
}
.row-link,
.link-button {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
color: var(--brand-2);
background: transparent;
border: 0;
cursor: pointer;
}
.table-wrap {
width: 100%;
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 8px;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
.data-table th,
.data-table td {
height: 38px;
padding: 7px 9px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: middle;
white-space: nowrap;
}
.data-table th {
position: sticky;
top: 0;
z-index: 0;
color: #475467;
background: var(--panel-strong);
font-weight: 800;
}
.data-table tbody tr:hover {
background: #fbfcfd;
}
.data-table tr:last-child td {
border-bottom: 0;
}
.mono {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
.truncate {
max-width: 380px;
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
display: inline-flex;
min-height: 22px;
align-items: center;
padding: 1px 8px;
color: #4f5b68;
background: #f3f6f8;
border: 1px solid #d7e0e8;
border-radius: 999px;
white-space: nowrap;
}
.badge.good {
color: var(--good);
background: #eef8f2;
border-color: #b9dcc7;
}
.badge.danger {
color: var(--danger);
background: #fff2f0;
border-color: #efb4ad;
}
.badge.warn {
color: var(--warn);
background: #fff8e7;
border-color: #e7c77e;
}
.empty-cell {
color: var(--muted);
text-align: center;
}

View file

@ -0,0 +1,254 @@
.split-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 330px;
gap: 14px;
align-items: start;
}
.split-main,
.split-side {
min-width: 0;
}
.entity-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 14px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
}
.entity-title {
font-size: 20px;
font-weight: 800;
line-height: 1.25;
}
.entity-subtitle {
margin-top: 4px;
color: var(--muted);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 8px;
}
.about-text {
margin: 0;
padding: 10px;
color: #344054;
background: #fbfcfd;
border: 1px solid var(--line);
border-radius: 8px;
}
.section-block,
.action-dock,
.surface {
min-width: 0;
padding: 12px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.section-head p {
margin: 5px 0 0;
color: var(--muted);
}
.action-dock {
position: sticky;
top: 82px;
display: grid;
gap: 10px;
}
.dock-title {
padding-bottom: 4px;
color: #344054;
font-weight: 800;
border-bottom: 1px solid var(--line);
}
.action-dock > .btn,
.action-dock .action-stack .btn {
width: 100%;
justify-content: center;
}
.duration-field {
display: grid;
gap: 4px;
}
.duration-field span {
color: var(--muted);
font-size: 11px;
font-weight: 800;
}
.duration-field input {
width: 100%;
}
.action-stack {
display: grid;
gap: 10px;
}
.action-stack .btn,
.action-dock > .btn {
min-height: 42px;
}
.danger-zone {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--line);
}
.authorization-block {
display: grid;
gap: 10px;
}
.authorization-table {
min-width: 720px;
table-layout: fixed;
}
.authorization-table th,
.authorization-table td {
height: 46px;
}
.device-text {
overflow: hidden;
text-overflow: ellipsis;
}
.device-text {
max-width: 260px;
}
.device-actions-head {
width: 190px;
}
.device-actions-cell {
width: 190px;
min-width: 190px;
}
.device-actions {
display: grid;
grid-template-columns: repeat(2, minmax(82px, 1fr));
gap: 6px;
min-width: 178px;
white-space: normal;
}
.device-actions .btn {
width: 100%;
justify-content: center;
}
.operation-row {
display: grid;
grid-template-columns: repeat(2, minmax(280px, 1fr));
gap: 10px;
}
.operation-box {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 10px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
}
.operation-title {
display: flex;
width: 100%;
align-items: center;
gap: 6px;
font-weight: 800;
}
.checkline {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
}
.checkline input {
width: auto;
height: auto;
}
.alert {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 9px 10px;
color: #8a251d;
background: #fff2f0;
border: 1px solid #efb4ad;
border-radius: 8px;
}
.json-block {
max-height: 520px;
overflow: auto;
margin: 0;
padding: 12px;
color: #d8e6f0;
background: #141a22;
border: 1px solid #2a3542;
border-radius: 8px;
font-size: 12px;
}
.raw-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.loading-line {
min-height: 80px;
display: grid;
place-items: center;
color: var(--muted);
}
.empty-panel {
display: grid;
min-height: 92px;
place-items: center;
color: var(--muted);
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
}

View file

@ -0,0 +1,261 @@
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: center;
padding: 24px;
background: rgba(17, 24, 39, 0.52);
}
.modal {
width: min(760px, 100%);
max-height: min(820px, calc(100vh - 48px));
overflow: hidden;
padding: 0;
background: #ffffff;
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.command-modal {
display: flex;
flex-direction: column;
}
.modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 18px 12px;
border-bottom: 1px solid var(--line);
}
.icon-btn {
display: grid;
width: 30px;
height: 30px;
place-items: center;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 7px;
cursor: pointer;
}
.command-steps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.command-body {
display: grid;
gap: 12px;
overflow: auto;
padding: 14px 18px;
}
.command-step {
display: flex;
min-height: 38px;
align-items: center;
gap: 8px;
padding: 0 10px;
color: var(--muted);
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 8px;
}
.command-step span {
display: grid;
width: 20px;
height: 20px;
place-items: center;
background: #ffffff;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 11px;
font-weight: 800;
}
.command-step.active {
color: var(--brand);
border-color: #a9d8ce;
}
.command-step.done {
color: var(--good);
border-color: #b9dcc7;
}
.form-field {
display: grid;
gap: 6px;
}
.form-field span,
.form-stack span {
color: #4b5563;
font-weight: 800;
}
.command-preview {
display: grid;
gap: 8px;
}
.command-preview .json-block {
max-height: 150px;
}
.preview-head,
.result-title {
display: flex;
align-items: center;
gap: 7px;
color: #344054;
font-weight: 800;
}
.result-box {
display: grid;
gap: 8px;
padding: 10px;
background: #fbfcfd;
border: 1px solid var(--line);
border-radius: 8px;
}
.result-line {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 8px;
}
.result-line span {
color: var(--muted);
}
.result-line strong {
overflow-wrap: anywhere;
}
.result-message {
color: #344054;
}
.modal-actions {
justify-content: flex-end;
padding: 12px 18px;
background: #ffffff;
border-top: 1px solid var(--line);
}
.login-page {
display: grid;
min-height: 100vh;
place-items: center;
padding: 24px;
background: var(--bg);
}
.login-panel {
display: grid;
width: min(420px, 100%);
gap: 18px;
padding: 22px;
background: #ffffff;
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.login-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.login-chip {
display: inline-flex;
min-height: 24px;
align-items: center;
padding: 0 8px;
color: var(--brand);
background: #edf7f4;
border: 1px solid #c9e2dc;
border-radius: 999px;
font-size: 12px;
}
.login-copy h1 {
margin: 0;
font-size: 22px;
}
.login-copy p {
margin: 8px 0 0;
color: var(--muted);
}
.form-stack {
display: grid;
gap: 12px;
}
.form-stack label {
display: grid;
gap: 6px;
}
.form-stack input {
width: 100%;
}
.boot-screen {
display: grid;
min-height: 100vh;
place-items: center;
align-content: center;
gap: 18px;
}
.loader-bar {
width: 180px;
height: 4px;
overflow: hidden;
background: #d7dde4;
border-radius: 999px;
}
.loader-bar::before {
display: block;
width: 42%;
height: 100%;
content: "";
background: var(--brand);
animation: load 1s infinite ease-in-out;
}
.spin {
animation: spin 0.8s linear infinite;
}
@keyframes load {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(260%);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View file

@ -0,0 +1,78 @@
@media (max-width: 1120px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
}
.nav-list {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.sidebar-status {
display: none;
}
.overview-band,
.split-layout,
.operation-row,
.raw-grid,
.message-selector-grid,
.message-selector-grid.single {
grid-template-columns: 1fr;
}
.action-dock {
position: static;
}
}
@media (max-width: 760px) {
.content,
.topbar {
padding-left: 14px;
padding-right: 14px;
}
.command-grid,
.work-strip,
.overview-metrics,
.metric-row,
.summary-grid,
.command-steps {
grid-template-columns: 1fr;
}
.sidebar {
gap: 12px;
padding: 14px;
}
.nav-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.topbar,
.page-title-row,
.entity-head {
align-items: flex-start;
flex-direction: column;
}
input,
.searchbox {
width: 100%;
}
.toolbar {
align-items: stretch;
}
.picker-row,
.selected-entity {
grid-template-columns: 1fr;
}
}

View file

@ -0,0 +1,222 @@
export type AccountRow = {
ID: number;
Phone: string;
Username: string;
FirstName: string;
LastName: string;
CreatedAt: string;
UpdatedAt: string;
Frozen: boolean;
Reason: string;
Verified: boolean;
PremiumUntil: number;
LastActiveAt: string;
DeviceCount: number;
};
export type RestrictionRow = {
Frozen: boolean;
Reason: string;
Actor: string;
CommandID: string;
UpdatedAt: string;
};
export type AuthorizationRow = {
AuthKeyID: number;
Hash: number;
Layer: number;
DeviceModel: string;
Platform: string;
SystemVersion: string;
APIID: number;
AppVersion: string;
IP: string;
PasswordPending: boolean;
CreatedAt: string;
ActiveAt: string;
};
export type AuditLogRow = {
ID: number;
CommandID: string;
Actor: string;
Action: string;
DryRun: boolean;
Reason: string;
Status: string;
Error: string;
Result: string;
CreatedAt: string;
};
export type AccountDetail = {
Account: AccountRow;
About: string;
LastSeenAt: number;
Verified: boolean;
Support: boolean;
Bot: boolean;
Restriction: RestrictionRow;
HasRestriction: boolean;
Authorizations: AuthorizationRow[];
AuditLogs: AuditLogRow[];
};
export type ChannelRow = {
ID: number;
AccessHash: number;
CreatorUserID: number;
Title: string;
About: string;
Username: string;
Broadcast: boolean;
Megagroup: boolean;
Forum: boolean;
Monoforum: boolean;
Verified: boolean;
Deleted: boolean;
ParticipantsCount: number;
AdminsCount: number;
KickedCount: number;
BannedCount: number;
TopMessageID: number;
PinnedMessageID: number;
PTS: number;
Date: number;
CreatedAt: string;
UpdatedAt: string;
};
export type ChannelDetail = {
Channel: ChannelRow;
ChannelJSON: string;
AuditLogs: AuditLogRow[];
};
export type MessageRow = {
OwnerUserID: number;
BoxID: number;
PrivateMessageID: number;
MessageSenderID: number;
PeerID: number;
FromUserID: number;
Date: number;
Outgoing: boolean;
Body: string;
PTS: number;
Deleted: boolean;
Media: string;
};
export type GroupMessageRow = {
ChannelID: number;
ID: number;
SenderUserID: number;
FromPeerType: string;
FromPeerID: number;
Date: number;
Post: boolean;
Body: string;
PTS: number;
Deleted: boolean;
Media: string;
ViewsCount: number;
EditDate: number;
Pinned: boolean;
};
export type UpdateEventRow = {
PTS: number;
PTSCount: number;
Type: string;
Date: number;
JSON: string;
};
export type ChannelUpdateEventRow = {
PTS: number;
PTSCount: number;
Type: string;
MessageID: number;
Date: number;
SenderUserID: number;
JSON: string;
};
export type OutboxRow = {
ID: number;
TargetUserID: number;
PTS: number;
EventType: string;
Status: string;
Attempts: number;
CreatedAt: string;
UpdatedAt: string;
};
export type MessageDetail = {
Message: MessageRow;
MessageJSON: string;
DialogJSON: string;
PrivateJSON: string;
UpdateEvents: UpdateEventRow[];
Outbox: OutboxRow[];
};
export type GroupMessageDetail = {
Message: GroupMessageRow;
MessageJSON: string;
ChannelJSON: string;
UpdateEvents: ChannelUpdateEventRow[];
};
export type CommandResult = {
command_id: string;
action: string;
status: string;
already_executed: boolean;
dry_run: boolean;
target_user_id?: number;
target_peer?: unknown;
message: string;
details?: Record<string, unknown>;
error?: string;
};
export type AccountListResponse = {
query: string;
limit: number;
rows: AccountRow[];
has_more: boolean;
next_before_id: number;
next_before_active_us: number;
listing: boolean;
};
export type ChannelListResponse = {
query: string;
limit: number;
rows: ChannelRow[];
has_more: boolean;
next_before_id: number;
next_before_updated_us: number;
listing: boolean;
};
export type MessageListResponse = {
owner_user_id: number;
peer_id: number;
before_date: number;
before_id: number;
limit: number;
rows: MessageRow[];
};
export type GroupMessageListResponse = {
channel_id: number;
before_date: number;
before_id: number;
limit: number;
rows: GroupMessageRow[];
};